Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions sdk/src/__tests__/stream-interruption.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, expect, it } from 'bun:test'

import { APICallError } from 'ai'

import {
classifyStreamEndRecovery,
classifyThrownStreamRecovery,
Expand Down Expand Up @@ -201,4 +203,64 @@ describe('classifyThrownStreamRecovery', () => {
}),
).toBeNull()
})

it('recovers a provider-reported 500 that arrived mid-stream', () => {
// The openai-compatible shim enqueues a provider 5xx as an error part
// carrying an APICallError — the same transient event as a severed body,
// so it takes the same capped recovery path instead of ending the run.
const recovery = classifyThrownStreamRecovery({
aborted: false,
error: apiError(500, 'Internal Server Error'),
})
expect(recovery?.source).toBe('stream-interrupted')
expect(recovery?.message).toContain('HTTP 500')
})

it('recovers a provider-reported 429 that arrived mid-stream', () => {
const recovery = classifyThrownStreamRecovery({
aborted: false,
error: apiError(429, 'Too Many Requests'),
})
expect(recovery?.source).toBe('stream-interrupted')
expect(recovery?.message).toContain('HTTP 429')
})

it('recovers a wrapped provider 503 behind a RetryError cause chain', () => {
const error = new Error('Failed after 4 attempts', {
cause: apiError(503, 'Service Unavailable'),
})
expect(
classifyThrownStreamRecovery({ aborted: false, error })?.source,
).toBe('stream-interrupted')
})

it('leaves client-error statuses fatal', () => {
for (const statusCode of [400, 401, 402, 403, 404]) {
expect(
classifyThrownStreamRecovery({
aborted: false,
error: apiError(statusCode, `HTTP ${statusCode}`),
}),
).toBeNull()
}
})

it('does not recover a provider 5xx after user cancellation', () => {
expect(
classifyThrownStreamRecovery({
aborted: true,
error: apiError(500, 'Internal Server Error'),
}),
).toBeNull()
})
})

function apiError(statusCode: number, message: string): APICallError {
return new APICallError({
message,
url: 'https://openrouter.ai/api/v1/chat/completions',
requestBodyValues: { prompt: 'x' },
statusCode,
isRetryable: statusCode === 429 || statusCode >= 500,
})
}
24 changes: 21 additions & 3 deletions sdk/src/impl/stream-interruption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,10 @@
*/

import type { StreamRecoverySource } from '@codebuff/common/types/contracts/llm'
import { isTransientNetworkError } from '@codebuff/common/util/error'
import {
extractApiErrorDetails,
isTransientNetworkError,
} from '@codebuff/common/util/error'

export interface StreamFinishInfo {
finishReason: string
Expand Down Expand Up @@ -134,11 +137,26 @@ export function classifyStreamEndRecovery(params: {
* `ConnectionClosed` / `ECONNRESET`) instead of the graceful-but-incomplete
* stream ending handled by {@link classifyStreamEndRecovery}. Both represent
* the same recoverable condition to the agent loop.
*
* A provider-reported 5xx/429 that arrives mid-stream — the openai-compatible
* shim enqueues it as an `error` part with `finishReason='error'` — is the
* same transient event as a severed body: the upstream had a bad moment, and
* the retry the agent loop forces (capped) is the response either way. A
* client-error status (400/401/402/403) is deterministic — retrying cannot
* help — so it stays fatal and propagates to the run's error handling.
*/
export function classifyThrownStreamRecovery(params: {
aborted: boolean
error: unknown
}): StreamEndRecovery | null {
if (params.aborted || !isTransientNetworkError(params.error)) return null
return STREAM_INTERRUPTED_RECOVERY
if (params.aborted) return null
if (isTransientNetworkError(params.error)) return STREAM_INTERRUPTED_RECOVERY
const { statusCode } = extractApiErrorDetails(params.error)
if (statusCode === 429 || (statusCode !== undefined && statusCode >= 500)) {
return {
source: 'stream-interrupted',
message: `The provider reported a temporary failure (HTTP ${statusCode}) while the response was streaming, so the output above may be cut off mid-thought. Continue from where it left off (or start the step over if nothing useful arrived).`,
}
}
return null
}
Loading