diff --git a/src/provider-request.ts b/src/provider-request.ts index d606e14..50fc1bd 100644 --- a/src/provider-request.ts +++ b/src/provider-request.ts @@ -94,8 +94,12 @@ export async function requestJsonBody( return responseJson(resp, label, settings); } +// Bare `unknown`/`unrecognized` were intentionally dropped: they false-positive on +// model-name errors ("unknown model", "unrecognized model ID") and trigger a wasted +// fallback retry. The specific feature tokens + bare `schema` cover real structured- +// output rejections (e.g. "Unknown field: responseSchema" still matches `schema`). const STRUCTURED_OUTPUT_REJECTION_KEYWORDS = - /response_format|json_schema|responseJsonSchema|responseMimeType|tools?|tool_choice|unsupported|unrecognized|unknown|schema/i; + /response_format|json_schema|responseJsonSchema|responseMimeType|tools?|tool_choice|unsupported|schema/i; const STRUCTURED_OUTPUT_FALLBACK_STATUSES = new Set([400, 404, 422]); export function shouldRetryWithoutStructuredOutput(error: unknown): boolean { diff --git a/src/streaming.ts b/src/streaming.ts index 883a47a..5b24325 100644 --- a/src/streaming.ts +++ b/src/streaming.ts @@ -42,13 +42,14 @@ export function streamErrorMessage(json: Record): string | null if (typeof err.type === 'string' && err.type) return err.type; return null; }; + // Anthropic: an explicit error event is an error even if its details are sparse. if (json.type === 'error') { return messageFromError(json.error) ?? 'Provider returned a streaming error'; } - if (json.error) { - return messageFromError(json.error) ?? 'Provider returned a streaming error'; - } - return null; + // OpenAI-compatible: { error: { message, type, code } }. Only treat it as an error + // when it actually carries a message/type, so a stray empty `error: {}` (or a + // code-only object) in an otherwise-normal chunk does not abort the stream. + return messageFromError(json.error); } export type DeltaExtractor = (json: Record) => string; diff --git a/tests/direct-providers.test.js b/tests/direct-providers.test.js index e36aa00..316e1c1 100644 --- a/tests/direct-providers.test.js +++ b/tests/direct-providers.test.js @@ -118,6 +118,16 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup') shouldRetryWithoutStructuredOutput(new ProviderApiError('bad request', 400, 'missing required field: model')), false, ); + // Model-name errors ("unknown model" / "unrecognized model ID") must NOT trigger a + // wasted fallback retry — bare unknown/unrecognized keywords were dropped for this. + assert.strictEqual( + shouldRetryWithoutStructuredOutput(new ProviderApiError('bad request', 400, 'unknown model: gpt-5')), + false, + ); + assert.strictEqual( + shouldRetryWithoutStructuredOutput(new ProviderApiError('bad request', 400, 'unrecognized model ID: x')), + false, + ); // Legacy path: a plain Error carrying the English template still works (back-compat). assert.strictEqual( shouldRetryWithoutStructuredOutput(new Error('OpenAI API returned HTTP 400: response_format not supported')), diff --git a/tests/direct-streaming.test.js b/tests/direct-streaming.test.js index 51f8632..c799422 100644 --- a/tests/direct-streaming.test.js +++ b/tests/direct-streaming.test.js @@ -85,10 +85,10 @@ const { assert, requireBundledModule, cleanup } = require('./direct-test-setup') ); assert.strictEqual(streaming.streamErrorMessage({ type: 'error' }), 'Provider returned a streaming error'); assert.strictEqual(streaming.streamErrorMessage({ error: { message: 'Quota exceeded' } }), 'Quota exceeded'); - assert.strictEqual( - streaming.streamErrorMessage({ error: { code: 'insufficient_quota' } }), - 'Provider returned a streaming error', - ); + // OpenAI-style error object without a message/type is NOT treated as an error + // (avoids aborting a normal chunk that carries a stray empty/code-only `error`). + assert.strictEqual(streaming.streamErrorMessage({ error: { code: 'insufficient_quota' } }), null); + assert.strictEqual(streaming.streamErrorMessage({ error: {} }), null); assert.strictEqual(streaming.streamErrorMessage({ choices: [{ delta: { content: 'hi' } }] }), null); assert.strictEqual(streaming.streamErrorMessage({ type: 'content_block_delta', delta: { text: 'x' } }), null); assert.strictEqual(streaming.streamErrorMessage({ error: null }), null);