diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/affinity/egress_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/affinity/egress_test.ts index eea0a81206..98e7306e14 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/affinity/egress_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/affinity/egress_test.ts @@ -208,6 +208,31 @@ describe('OpenAI Responses affinity egress', () => { expect(output).toEqual([eventFrame({ type: 'response.failed', response: response([], 'failed') })]); }); + test.each([ + { item: { type: 'reasoning' as const, id: 'rs_complete', summary: [], encrypted_content: 'opaque' }, shift: 0 }, + { item: { type: 'message' as const, id: 'msg_complete', role: 'assistant' as const, status: 'completed', content: [{ type: 'output_text' as const, text: 'answer', annotations: [] }] }, shift: 2 }, + ])('preserves the error and following failed response after a $item.type item', async ({ item, shift }) => { + const error = { type: 'error' as const, sequence_number: 3, code: null, param: 'tools', message: 'native failure', vendor: { retryable: false } }; + const failed = { ...response([item], 'failed'), error: { code: 'vendor_failure', message: error.message }, usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 } }; + const output: OpenAIResponsesStreamEventEx[] = []; + for await (const frame of wrapOpenAIResponsesAffinityEgress(frames([ + eventFrame({ type: 'response.created', sequence_number: 0, response: response([], 'in_progress') }), + eventFrame({ type: 'response.output_item.added', sequence_number: 1, output_index: 0, item }), + eventFrame({ type: 'response.output_item.done', sequence_number: 2, output_index: 0, item }), + eventFrame(error), + eventFrame({ type: 'response.failed', sequence_number: 4, response: failed }), + ]), { codec: immediateCodec, affinity })) { + if (frame.type === 'event') output.push(frame.event); + } + + expect(output.map(event => event.sequence_number)).toEqual(Array.from({ length: 5 + shift }, (_, index) => index)); + expect(output.find(event => event.type === 'error')).toEqual({ ...error, sequence_number: 3 + shift }); + const terminal = output.at(-1); + if (terminal?.type !== 'response.failed') throw new Error('expected response.failed'); + const finalizedItems = output.flatMap(event => event.type === 'response.output_item.done' ? [event.item] : []); + expect(terminal.response).toEqual({ ...failed, output: finalizedItems }); + }); + test('wraps compaction_summary as a natural carrier without inserting a prefix', async () => { const item = { type: 'compaction_summary', id: 'cmp_upstream', encrypted_content: 'opaque' } as unknown as OpenAIResponsesOutputItemEx; const output: ProtocolFrame[] = []; diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/server-tool-shim_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/server-tool-shim_test.ts index fab9d21a0c..8f29a5827c 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/server-tool-shim_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/server-tool-shim_test.ts @@ -2773,42 +2773,31 @@ test('next-turn echo retains original arguments while execution repairs a traili assertEquals((backend.calls[0].request as WebSearchProviderRequest).query, 'q'); }); -test('upstream sends bare `error` frame BEFORE any response.created: shim throws from the events iterator (no synthetic response.failed with empty identity)', async () => { - // A bare `{type:'error'}` arriving before any `response.created` - // produces no captured identity (id / model). Synthesizing a - // `response.failed` envelope would require those fields. The - // synthesizer throws instead of inventing values; the throw - // escapes the events generator and is surfaced upward by the - // source responder. The unreachable-in-practice path stays - // honest about the upstream protocol violation rather than - // silently lying about the served identity. +test('upstream sends an error event before response.created: shim forwards it without inventing a response', async () => { makeStubDeps(); const shim = withOpenAIResponsesWebSearchShim; const inv = makeInvocation(); + const error = { + type: 'error' as const, + message: 'upstream dropped before response shell', + code: null, + param: null, + }; const errorOnlyTurn: ScriptedTurn = [ - eventFrame({ - type: 'error', - message: 'upstream dropped before response shell', - }), + eventFrame(error), ]; const script = scriptedRun([errorOnlyTurn]); const result = await shim(inv, makeGatewayCtx(), script.run); assert(result.type === 'events'); - let thrown: unknown = undefined; - try { - for await (const _ of result.events) { /* drain */ } - } catch (e) { - thrown = e; - } - assert(thrown instanceof Error); + const events = eventPayloads(await collectFrames(result.events)); + assertEquals(events.length, 1); + const { sequence_number: sequenceNumber, ...forwarded } = events[0] as typeof error & { sequence_number: number }; + assertEquals(forwarded, error); + assertEquals(sequenceNumber, 0); }); -test('upstream sends bare `error` frame AFTER response.created: shim emits response.failed with synthesized id + last-seen model', async () => { - // When model is captured before the bare error arrives, the shim - // can synthesize a wire-valid `response.failed` envelope. The - // synthesized id is the shim's own (`resp_shim_`), and the - // model is the last-seen upstream model. +test('upstream sends an error event after response.created: shim forwards both events without synthesizing a terminal resource', async () => { makeStubDeps(); const shim = withOpenAIResponsesWebSearchShim; const inv = makeInvocation(); @@ -2838,16 +2827,12 @@ test('upstream sends bare `error` frame AFTER response.created: shim emits respo const result = await shim(inv, makeGatewayCtx(), script.run); assert(result.type === 'events'); const events = eventPayloads(await collectFrames(result.events)); - const failed = events[events.length - 1] as Extract; - assertEquals(failed.type, 'response.failed'); - assert(failed.response.id.startsWith('resp_shim_')); - assertEquals(failed.response.model, 'gpt-5.4-2025-01-20'); - assertEquals(failed.response.status, 'failed'); - assertEquals(failed.response.error?.message, 'mid-stream upstream blew up'); - // Upstream-supplied code carries through verbatim. - assertEquals(failed.response.error?.code, 'server_error'); - assert(failed.response.error?.provider_specific_fields === diagnostics); - assertFalse('type' in (failed.response.error as object)); + assertEquals(events.map(event => event.type), ['response.created', 'error']); + const error = events[1] as Extract; + assert('message' in error); + assertEquals(error.message, 'mid-stream upstream blew up'); + assertEquals(error.code, 'server_error'); + assert(error.provider_specific_fields === diagnostics); }); test('upstream iterator rejects before yielding any frame: shim surfaces the throw through the events iterator', async () => { @@ -4351,139 +4336,42 @@ test('turn 1 pure-text response streams BEFORE upstream terminal (no TTFT regres while (!(await iter.next()).done) { /* drain */ } }); -test('mid-stream upstream error yields response.failed and closes the SSE stream', async () => { - // After turn 1 emits a web_search call, turn 2's run() returns an - // upstream-error envelope. The shim must yield a single terminal - // response.failed frame and end the iterator — switching the outer - // envelope to upstream-error is impossible: the outer envelope shape - // is locked once the first frame is yielded. - makeStubDeps(); - const shim = withOpenAIResponsesWebSearchShim; - const inv = makeInvocation(); - - let runCalls = 0; - const run = async (): Promise>> => { - runCalls += 1; - if (runCalls === 1) { - const frames: AsyncIterable> = (async function* () { - for (const f of searchCallTurn(0, 'call_1', 'q1')) yield f; - })(); - return { - type: 'events', - events: frames, - modelIdentity: testTelemetryModelIdentity, - }; - } - return { - type: 'api-error', - source: 'upstream', - status: 503, - headers: new Headers({ 'content-type': 'application/json' }), - body: new TextEncoder().encode('{"error":"upstream temporarily unavailable"}'), - }; - }; - - const result = await shim(inv, makeGatewayCtx(), run); - assert(result.type === 'events'); - const events = eventPayloads(await collectFrames(result.events)); - - const terminal = events[events.length - 1]; - assertEquals(terminal.type, 'response.failed'); - const failedEv = terminal as Extract; - assertEquals(failedEv.response.status, 'failed'); - assert(failedEv.response.error !== undefined); - // Pass-through code: the shim quotes upstream's HTTP status - // (`upstream_`) when the body has no OpenAI-shaped error - // envelope. No normalization to a spec enum value. - assertEquals(failedEv.response.error?.code, 'upstream_503'); - assert(failedEv.response.error?.message.includes('503')); - assertFalse(events.some(e => e.type === 'response.completed')); - assertEquals(runCalls, 2); -}); - -test('mid-stream 429 pass-through: code reflects upstream HTTP status (no spec-enum normalization)', async () => { - // The shim no longer normalizes upstream error codes to the - // OpenAPI `OpenAIResponsesErrorCode` enum. A 429 with no OpenAI-shaped - // body falls back to `upstream_429` — the HTTP status is the most - // honest signal, and downstream clients pattern-matching on - // upstream's actual code see it directly. - makeStubDeps(); - const shim = withOpenAIResponsesWebSearchShim; - const inv = makeInvocation(); - - let runCalls = 0; - const run = async (): Promise>> => { - runCalls += 1; - if (runCalls === 1) { - const frames: AsyncIterable> = (async function* () { - for (const f of searchCallTurn(0, 'call_1', 'q1')) yield f; - })(); - return { type: 'events', events: frames, modelIdentity: testTelemetryModelIdentity }; - } - return { - type: 'api-error', - source: 'upstream', - status: 429, - headers: new Headers({ 'content-type': 'text/plain' }), - body: new TextEncoder().encode('rate limited'), - }; - }; - - const result = await shim(inv, makeGatewayCtx(), run); - assert(result.type === 'events'); - const events = eventPayloads(await collectFrames(result.events)); - const failed = events[events.length - 1] as Extract; - assertEquals(failed.response.error?.code, 'upstream_429'); -}); - -test('mid-stream 400 with non-OpenAI body falls back to upstream_400 code (no spec-enum normalization)', async () => { - // Without an OpenAI-shaped `error.code` in the body, the shim - // synthesizes `upstream_` rather than mapping to a spec - // enum value. The body excerpt and status live in `error.message` - // so clients still see the upstream detail. - makeStubDeps(); - const shim = withOpenAIResponsesWebSearchShim; - const inv = makeInvocation(); - - let runCalls = 0; - const run = async (): Promise>> => { - runCalls += 1; - if (runCalls === 1) { - const frames: AsyncIterable> = (async function* () { - for (const f of searchCallTurn(0, 'call_1', 'q1')) yield f; - })(); - return { type: 'events', events: frames, modelIdentity: testTelemetryModelIdentity }; - } - return { - type: 'api-error', - source: 'upstream', - status: 400, - headers: new Headers({ 'content-type': 'text/plain' }), - body: new TextEncoder().encode('bad request'), - }; - }; - - const result = await shim(inv, makeGatewayCtx(), run); - assert(result.type === 'events'); - const events = eventPayloads(await collectFrames(result.events)); - const failed = events[events.length - 1] as Extract; - assertEquals(failed.response.error?.code, 'upstream_400'); - // The HTTP status is preserved in the diagnostic message so clients - // pattern-matching for 4xx see it. - assert(failed.response.error?.message.includes('400')); - assert(failed.response.error?.message.includes('bad request')); - assertFalse('type' in (failed.response.error as object)); -}); - -test('mid-stream upstream error with OpenAI-shaped JSON body forwards code/type/message verbatim', async () => { - // When upstream is OpenAI-compatible and gives us a standard error - // envelope, pass it through unchanged so SDKs see the same vocabulary - // they'd see hitting upstream directly. +test.each([ + { + body: { + type: 'error', + message: 'You exceeded your current quota.', + code: null, + param: 'tools', + provider_specific_fields: { request_id: 'req_flat', retryable: true }, + }, + responseError: null, + }, + { + body: { + type: 'error', + error: { + message: 'You exceeded your current quota.', + type: 'insufficient_quota', + code: 'insufficient_quota', + param: null, + provider_specific_fields: { request_id: 'req_nested', retryable: true }, + }, + trace_id: 'trace_nested', + }, + responseError: { + message: 'You exceeded your current quota.', + type: 'insufficient_quota', + code: 'insufficient_quota', + param: null, + provider_specific_fields: { request_id: 'req_nested', retryable: true }, + }, + }, +] as const)('mid-stream HTTP error body becomes a lossless error event followed by response.failed: $body', async ({ body, responseError }) => { makeStubDeps(); const shim = withOpenAIResponsesWebSearchShim; const inv = makeInvocation(); - const diagnostics = { name: 'RateLimitError', cause: { limit: 'images' }, target_api: 'openaiResponses' }; let runCalls = 0; const run = async (): Promise>> => { runCalls += 1; @@ -4498,26 +4386,26 @@ test('mid-stream upstream error with OpenAI-shaped JSON body forwards code/type/ source: 'upstream', status: 429, headers: new Headers({ 'content-type': 'application/json' }), - body: new TextEncoder().encode(JSON.stringify({ - error: { - message: 'You exceeded your current quota.', - type: 'insufficient_quota', - code: 'insufficient_quota', - param: null, - provider_specific_fields: diagnostics, - }, - })), + body: new TextEncoder().encode(JSON.stringify(body)), }; }; const result = await shim(inv, makeGatewayCtx(), run); assert(result.type === 'events'); const events = eventPayloads(await collectFrames(result.events)); + const errorIndex = events.findIndex(event => event.type === 'error'); + assert(errorIndex >= 0); + const error = events[errorIndex] as Extract; + const { sequence_number: sequenceNumber, ...unstamped } = error; + assertEquals(unstamped, body); + assertEquals(typeof sequenceNumber, 'number'); + const failed = events[events.length - 1] as Extract; - assertEquals(failed.response.error?.code, 'insufficient_quota'); - assertEquals(failed.response.error?.type, 'insufficient_quota'); - assertEquals(failed.response.error?.message, 'You exceeded your current quota.'); - assertEquals(failed.response.error?.provider_specific_fields, diagnostics); + assertEquals(failed.type, 'response.failed'); + assertEquals(failed.response.status, 'failed'); + assertEquals(failed.response.error, responseError); + assertFalse(events.some(event => event.type === 'response.completed')); + assertEquals(runCalls, 2); }); test('upstream response.incomplete forwards as response.incomplete with the same incomplete_details.reason', async () => { @@ -6478,112 +6366,65 @@ test('consumeTurn surfaces upstream response.incomplete as terminalStatus.incomp assertEquals(result.summary.terminalStatus.kind, 'incomplete'); }); -test('consumeTurn surfaces bare `error` event as terminalStatus.failed with a synthesized envelope', async () => { - const state = createMergeState(); - const result = await consumeTurn( - framesOf( - mkResponseCreated(), - eventFrame({ - type: 'error', - message: 'upstream blew up', - code: 'server_error', - }), - ), - state, - true, - ); - assertEquals(result.summary.terminalStatus.kind, 'failed'); - const ts = result.summary.terminalStatus as Extract; - assertEquals(ts.response.status, 'failed'); - assertEquals(ts.response.error?.message, 'upstream blew up'); - assertEquals(ts.response.error?.code, 'server_error'); - // Synthesized envelope's id is the shim-synthesized response id - // — upstream's id is not exposed downstream. - assertEquals(ts.response.id, state.synthesizedResponseId); -}); - -test('consumeTurn defaults missing `error.code` to spec-defined `server_error` (no synthetic `unknown_upstream_error` literal)', async () => { - // A bare upstream `{type: 'error'}` frame without a `code` field - // falls back to the OpenAPI `OpenAIResponsesErrorCode` enum value - // `'server_error'`. The previous `'unknown_upstream_error'` - // synthetic literal is not in the enum and typed SDKs (openai-python - // `Literal[...]` with strict Pydantic validation, openai-node - // literal union, openai-go `OpenAIResponsesErrorCode string` named type) - // reject unknown values at parse time. Reference: - // https://github.com/openai/openai-openapi/blob/master/openapi.yaml +test.each([ + { type: 'error' as const, message: 'upstream blew up', code: null, param: 'tools', vendor: { retryable: false } }, + { type: 'error' as const, message: 'upstream stated an empty code', code: '', param: null }, +])('consumeTurn forwards a standalone error event without rewriting it: $message', async error => { const state = createMergeState(); const result = await consumeTurn( framesOf( mkResponseCreated(), - eventFrame({ - type: 'error', - message: 'upstream blew up without a code', - }), + eventFrame(error), ), state, true, ); - const ts = result.summary.terminalStatus as Extract; - assertEquals(ts.response.error?.code, 'server_error'); - // No synthetic `type` — the spec's OpenAIResponsesError schema defines - // only `{code, message}` and the upstream `error` frame doesn't - // carry a `type` field on the wire either. - assertFalse('type' in (ts.response.error as object)); -}); - -test('consumeTurn treats empty-string `error.code` as missing (same fallback as undefined)', async () => { - // `??` only handles null/undefined; an explicit `code: ''` would - // survive and reach the wire as the synthesized response.failed's - // `error.code`, where typed SDKs reject the empty string the same - // way they reject any non-enum value. Mirror `parseUpstreamErrorBody`'s - // `length > 0` guard for shape parity between the bare-error path - // and the upstream-error HTTP body path. + assertEquals(result.summary.terminalStatus, { kind: 'error', event: error }); + const forwarded = eventPayloads(result.downstreamFrames); + assertEquals(forwarded.map(event => event.type), ['response.created', 'error']); + const { sequence_number: _sequenceNumber, ...forwardedError } = forwarded[1] as typeof error & { sequence_number: number }; + assertEquals(forwardedError, error); +}); + +test('consumeTurn keeps an error notification and the following native failed resource', async () => { const state = createMergeState(); + const error = { type: 'error' as const, message: 'upstream blew up', code: null, param: 'tools' }; + const failed: OpenAIResponsesResultEx = { + id: 'upstream_failed', object: 'response', model: 'test-model', output: [], status: 'failed', + error: { code: 'rate_limit_exceeded', message: error.message }, incomplete_details: null, + usage: { input_tokens: 4, output_tokens: 1, total_tokens: 5 }, + }; const result = await consumeTurn( framesOf( mkResponseCreated(), - eventFrame({ - type: 'error', - message: 'upstream blew up with empty code', - code: '', - }), + eventFrame(error), + eventFrame({ type: 'response.failed', response: failed }), ), state, true, ); const ts = result.summary.terminalStatus as Extract; - assertEquals(ts.response.error?.code, 'server_error'); -}); - -test('consumeTurn surfaces bare `error` event arriving BEFORE response.created as terminalStatus `bare-error-pre-shell` (no synthesized response)', async () => { - // The success-path "no fallback" contract refuses to synthesize a - // wire-valid response envelope that lies about the served identity. - // The bare-error path's job is the opposite: surface upstream's - // failure verbatim. But when the failure happens before any - // identity is captured (truncated TLS, transport drop, intermediate - // proxy injection), we cannot synthesize a wire-valid - // `OpenAIResponsesResult` (id and model are required, not nullable). Use - // a distinct terminal status `bare-error-pre-shell` so the shim's - // outer loop can short-circuit to a non-events `upstream-error` - // result instead of fabricating empty-string identity fields. + assertEquals(ts.response, failed); + assertEquals(result.summary.turnUsage, { input_tokens: 4, output_tokens: 1, total_tokens: 5 }); + const forwarded = eventPayloads(result.downstreamFrames); + assertEquals(forwarded.map(event => event.type), ['response.created', 'error']); + const { sequence_number: _sequenceNumber, ...forwardedError } = forwarded.at(-1) as typeof error & { sequence_number: number }; + assertEquals(forwardedError, error); +}); + +test('consumeTurn returns a pre-shell error event without inventing a response resource', async () => { const state = createMergeState(); + const error = { type: 'error' as const, message: 'upstream dropped before response shell', code: null, param: null }; const result = await consumeTurn( - framesOf( - eventFrame({ - type: 'error', - message: 'upstream dropped before response shell', - }), - ), + framesOf(eventFrame(error)), state, true, ); - assertEquals(result.summary.terminalStatus.kind, 'bare-error-pre-shell'); - const ts = result.summary.terminalStatus as Extract; - assertEquals(ts.error.message, 'upstream dropped before response shell'); - // Spec-defined fallback (no `code` on upstream's error frame). See - // the matching test above for the in-shell path; both default to - // `server_error` from the `OpenAIResponsesErrorCode` enum. - assertEquals(ts.error.code, 'server_error'); + assertEquals(result.summary.terminalStatus, { kind: 'error', event: error }); + const forwarded = eventPayloads(result.downstreamFrames); + assertEquals(forwarded.length, 1); + const { sequence_number: _sequenceNumber, ...forwardedError } = forwarded[0] as typeof error & { sequence_number: number }; + assertEquals(forwardedError, error); }); test('consumeTurnStreaming yields forwarded frames before upstream completes', async () => { @@ -7567,8 +7408,8 @@ test('server-tool loop preserves internal failure diagnostics on the failed reso test('consumeTurn retains namespaced diagnostics before a response shell is announced', async () => { const diagnostics = { name: 'UpstreamError', stack: 'UpstreamError: no response shell', cause: { connection: 'closed' } }; - const result = await consumeTurn(framesOf(eventFrame({ type: 'error', error: { message: 'No response shell', code: 'connection_error', provider_specific_fields: diagnostics } })), createMergeState(), true); - assert(result.summary.terminalStatus.kind === 'bare-error-pre-shell'); - assertEquals(result.summary.terminalStatus.error.code, 'connection_error'); - assert(result.summary.terminalStatus.error.provider_specific_fields === diagnostics); + const error = { type: 'error' as const, error: { message: 'No response shell', code: 'connection_error', provider_specific_fields: diagnostics } }; + const result = await consumeTurn(framesOf(eventFrame(error)), createMergeState(), true); + assert(result.summary.terminalStatus.kind === 'error'); + assertEquals(result.summary.terminalStatus.event, error); }); diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/items/output_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/items/output_test.ts index e34ba1d4d5..7f83abd62d 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/items/output_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/items/output_test.ts @@ -278,6 +278,37 @@ test('client output persists a completed item before forwarding an error event', expect(await repo.openaiResponsesSnapshots.lookup('key-a', 'resp_public', 0)).toBeNull(); }); +test('client output preserves an error notification and the following failed response without committing a snapshot', async () => { + const { repo, store } = memoryOutputHarness(); + const error = { type: 'error' as const, sequence_number: 3, code: null, message: 'upstream failed', param: 'tools', vendor: { retryable: false } }; + const response = { + ...responseFor([completedReasoningItem]), + status: 'failed' as const, + error: { code: 'vendor_failure', message: error.message }, + usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 }, + }; + const input = async function* (): AsyncIterable> { + yield eventFrame({ type: 'response.created', sequence_number: 0, response: { ...response, status: 'in_progress', output: [], error: null } }); + yield eventFrame({ type: 'response.output_item.added', sequence_number: 1, output_index: 0, item: completedReasoningItem }); + yield eventFrame({ type: 'response.output_item.done', sequence_number: 2, output_index: 0, item: completedReasoningItem }); + yield eventFrame(error); + yield eventFrame({ type: 'response.failed', sequence_number: 4, response }); + }; + const output: ProtocolFrame[] = []; + for await (const frame of wrapOpenAIResponsesClientOutput(input(), { store, responseId: 'resp_public' })) { + output.push(frame); + if (frame.type === 'event' && frame.event.type === 'error') { + expect(await repo.openaiResponsesItems.lookupMany('key-a', ['rs_upstream'], 0)).toHaveLength(1); + } + } + + expect(output.slice(-2)).toEqual([ + eventFrame(error), + eventFrame({ type: 'response.failed', sequence_number: 4, response: { ...response, id: 'resp_public' } }), + ]); + expect(await repo.openaiResponsesSnapshots.lookup('key-a', 'resp_public', 0)).toBeNull(); +}); + test('client output does not persist a partial item without output_item.done', async () => { const { repo, store } = memoryOutputHarness(); const item = completedReasoningItem; diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/respond_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/respond_test.ts index eafa41d188..fa3c127827 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/respond_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/respond_test.ts @@ -4,12 +4,13 @@ import { beforeEach, expect, test } from 'vitest'; import { respondOpenAIResponses, respondOpenAIResponsesFailure } from '../../../../src/data-plane/chat/openai-responses/respond.ts'; import { OPENAI_RESPONSES_REASONING_INCLUDED_HEADER } from '../../../../src/data-plane/chat/openai-responses/response-headers.ts'; import { initRepo } from '../../../../src/repo/index.ts'; +import { tokenCountsFromUsage } from '../../../../src/repo/usage-metrics.ts'; import { InMemoryRepo } from '../../../repo/memory.ts'; import { mockChatGatewayCtx } from '../../../test-utils/gateway-ctx.ts'; -import { eventFrame } from '@floway-dev/protocols/common'; +import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; import { collectOpenAIResponsesProtocolEventsToResult, parseOpenAIResponsesStream, type OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; -import { eventResult, internalErrorResult, toInternalDebugError } from '@floway-dev/provider'; -import { testTelemetryModelIdentity } from '@floway-dev/test-utils'; +import { eventResult, internalErrorResult, toInternalDebugError, type EventResultMetadata } from '@floway-dev/provider'; +import { mockPerfTelemetryContext, testTelemetryModelIdentity } from '@floway-dev/test-utils'; beforeEach(() => { initRepo(new InMemoryRepo()); }); @@ -80,7 +81,143 @@ test('Responses SSE standalone error and failed response carry identical complet expect(failed.response.error).toEqual((error as { error: unknown }).error); const terminalOnly = await collectOpenAIResponsesProtocolEventsToResult((async function* () { yield eventFrame(failed); })()); expect(terminalOnly.error).toEqual(failed.response.error); - await expect(collectOpenAIResponsesProtocolEventsToResult(parseOpenAIResponsesStream(new Response(wire).body!))).rejects.toThrow('Upstream SSE error'); + await expect(collectOpenAIResponsesProtocolEventsToResult(parseOpenAIResponsesStream(new Response(wire).body!))).rejects.toMatchObject({ message: 'broken', cause: error }); +}); + +test.each([false, true])('Responses HTTP consumes an upstream error followed by its failed resource when stream=%s', async wantsStream => { + const item = { type: 'reasoning' as const, id: 'rs_complete', summary: [] }; + const error = { type: 'error' as const, sequence_number: 3, code: null, message: 'upstream failed', param: null, vendor: { detail: 'native' } }; + const failed = { + id: 'resp_upstream', object: 'response' as const, model: 'm', status: 'failed' as const, + output: [item], error: { code: 'vendor_failure', message: error.message }, incomplete_details: null, + usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 }, + }; + const frames = async function* () { + yield eventFrame({ type: 'response.created', sequence_number: 0, response: { ...failed, status: 'in_progress', output: [], error: null } }); + yield eventFrame({ type: 'response.output_item.added', sequence_number: 1, output_index: 0, item }); + yield eventFrame({ type: 'response.output_item.done', sequence_number: 2, output_index: 0, item }); + yield eventFrame(error); + yield eventFrame({ type: 'response.failed', sequence_number: 4, response: failed }); + }; + const app = new Hono().get('/', c => respondOpenAIResponses(c, eventResult(frames(), testTelemetryModelIdentity), wantsStream, mockChatGatewayCtx(), { model: 'm', input: [] })); + const response = await app.request('/'); + expect(response.status).toBe(wantsStream ? 200 : 502); + if (!wantsStream) { + const { sequence_number: _sequenceNumber, ...body } = error; + expect(await response.json()).toEqual(body); + return; + } + const events = []; + for await (const frame of parseOpenAIResponsesStream(response.body!)) if (frame.type === 'event') events.push(frame.event); + expect(events.map(event => event.type)).toEqual(['response.created', 'response.output_item.added', 'response.output_item.done', 'error', 'response.failed']); + expect(events[3]).toEqual(error); + expect(events.map(event => event.sequence_number)).toEqual([0, 1, 2, 3, 4]); + const terminal = events.at(-1); + if (terminal?.type !== 'response.failed') throw new Error('Missing failed response'); + const created = events[0]; + if (created.type !== 'response.created') throw new Error('Missing created response'); + expect(terminal.response.id).toBe(created.response.id); + expect(terminal.response).toMatchObject({ + status: 'failed', output: [item], error: failed.error, + usage: { ...failed.usage, input_tokens_details: { cached_tokens: 0 }, output_tokens_details: { reasoning_tokens: 0 } }, + }); + expect(terminal.response.id).not.toBe(failed.id); +}); + +test.each([ + { type: 'error' as const, sequence_number: 0, code: null, message: 'native failure', param: null, vendor: { detail: 'flat' } }, + { type: 'error' as const, sequence_number: 0, error: { code: 'vendor_failure', message: 'native failure', param: 'tools', vendor: { detail: 'nested' } }, trace: 'native-trace' }, +])('Responses HTTP preserves a standalone upstream error event %j', async error => { + for (const wantsStream of [false, true]) { + const frames = async function* () { yield eventFrame(error); yield doneFrame(); }; + const app = new Hono().get('/', c => respondOpenAIResponses(c, eventResult(frames(), testTelemetryModelIdentity), wantsStream, mockChatGatewayCtx(), { model: 'm', input: [] })); + const response = await app.request('/'); + expect(response.status).toBe(wantsStream ? 200 : 502); + if (wantsStream) { + const wire = await response.text(); + expect(wire.split('data: [DONE]').length - 1).toBe(1); + const events = []; + for await (const frame of parseOpenAIResponsesStream(new Response(wire).body!)) if (frame.type === 'event') events.push(frame.event); + expect(events).toEqual([error]); + } else { + const { sequence_number: _sequenceNumber, ...body } = error; + expect(await response.json()).toEqual(body); + } + } +}); + +test.each([ + { error: { type: 'error' as const, sequence_number: 0, error: { type: 'invalid_request_error', code: null, message: 'invalid tools', param: 'tools' } }, status: 400 }, + { error: { type: 'error' as const, sequence_number: 0, code: 'rate_limit_exceeded', message: 'slow down', param: null }, status: 429 }, + { error: { type: 'error' as const, sequence_number: 0, code: null, message: 'bad key', param: null, provider_specific_fields: { type: 'authentication_error' } }, status: 401 }, + { error: { type: 'error' as const, sequence_number: 0, code: 'server_error', message: 'failed', param: null }, status: 500 }, + { error: { type: 'error' as const, sequence_number: 0, code: 'vendor_failure', message: 'failed', param: null }, status: 502 }, +] as const)('Responses non-streaming error classification selects HTTP $status', async ({ error, status }) => { + const failed = { id: 'resp_failed', object: 'response', model: 'm', status: 'failed' as const, output: [], error: null, incomplete_details: null }; + const frames = async function* () { + yield eventFrame(error); + yield eventFrame({ type: 'response.failed', sequence_number: 1, response: failed }); + }; + const app = new Hono().get('/', c => respondOpenAIResponses(c, eventResult(frames(), testTelemetryModelIdentity), false, mockChatGatewayCtx(), { model: 'm', input: [] })); + const response = await app.request('/'); + + expect(response.status).toBe(status); + const { sequence_number: _sequenceNumber, ...body } = error; + expect(await response.json()).toEqual(body); +}); + +test.each([ + { type: 'error' as const, sequence_number: 3, code: null, param: 'tools', message: 'rate limited', vendor: { retryable: true } }, + { type: 'error' as const, sequence_number: 3, error: { code: null, param: null, message: 'rate limited', vendor: { retryable: true } }, trace: 'native-trace' }, +])('Responses non-streaming failure drains the failed resource, preserves error fields, and records usage once: %j', async error => { + const repo = new InMemoryRepo(); + initRepo(repo); + const background: Promise[] = []; + const modelIdentity = { ...testTelemetryModelIdentity, upstream: 'continuation-upstream' }; + const telemetry = mockPerfTelemetryContext({ keyId: 'key_test', upstream: modelIdentity.upstream, runtimeLocation: 'TEST' }); + let resolveMetadata!: (metadata: EventResultMetadata) => void; + const finalMetadata = new Promise(resolve => { resolveMetadata = resolve; }); + let readFailedResource = false; + const item = { type: 'reasoning' as const, id: 'rs_complete', summary: [] }; + const failed = { + id: 'resp_aggregate', object: 'response' as const, model: 'm', status: 'failed' as const, + output: [item], error: null, incomplete_details: null, + usage: { input_tokens: 15, output_tokens: 2, total_tokens: 17 }, + }; + const frames = async function* () { + try { + yield eventFrame({ type: 'response.created', sequence_number: 0, response: { ...failed, status: 'in_progress', output: [] } }); + yield eventFrame({ type: 'response.output_item.added', sequence_number: 1, output_index: 0, item }); + yield eventFrame({ type: 'response.output_item.done', sequence_number: 2, output_index: 0, item }); + yield eventFrame(error); + readFailedResource = true; + yield eventFrame({ type: 'response.failed', sequence_number: 4, response: failed }); + } finally { + resolveMetadata({ modelIdentity, performance: telemetry, billableUsage: { input: 10, cacheRead: 3, cacheWrite: 2, cacheWrite1h: 0, output: 2 } }); + } + }; + const ctx = mockChatGatewayCtx({ + backgroundScheduler: promise => { background.push(promise); }, + attempt: { timing: { upstreamCallStartedAt: 0, firstOutputTokenAt: 1 }, telemetry }, + }); + const result = eventResult(frames(), testTelemetryModelIdentity, { + performance: telemetry, + finalMetadata, + }); + const app = new Hono().get('/', c => respondOpenAIResponses(c, result, false, ctx, { model: 'm', input: [] })); + const response = await app.request('/'); + expect(response.status).toBe(502); + expect(readFailedResource).toBe(true); + const { sequence_number: _sequenceNumber, ...body } = error; + expect(await response.json()).toEqual(body); + await Promise.all(background); + const usage = await repo.usage.listAll(); + expect(usage).toHaveLength(1); + expect(usage[0]).toMatchObject({ requests: 1, upstream: modelIdentity.upstream }); + expect(tokenCountsFromUsage(usage[0])).toEqual({ input: 10, input_cache_read: 3, input_cache_write: 2, output: 2 }); + const performance = await repo.performance.listAll(); + expect(performance).toHaveLength(1); + expect(performance[0]).toMatchObject({ requests: 1, errorsWithOutput: 1, errorsNoOutput: 0, ttftSamplesOk: 0, tpotSamples: 1 }); }); test('Responses collect failures preserve nested causes in the HTTP error namespace', async () => { diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/websocket_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/websocket_test.ts index e73158b5fd..eef9808a16 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/websocket_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/websocket_test.ts @@ -1279,6 +1279,7 @@ test('OpenAI Responses WebSocket evicts a failed continuation target so the next test('OpenAI Responses WebSocket evicts a continuation that failed through a streamed terminal event', async () => { const { apiKey } = await setupAppTest(); let responseCalls = 0; + const upstreamError = { type: 'error', sequence_number: 1, code: null, param: 'tools', message: 'the upstream gave up mid-turn', vendor: { retryable: false } }; await withMockedFetch( async request => { @@ -1304,7 +1305,8 @@ test('OpenAI Responses WebSocket evicts a continuation that failed through a str }; return sseResponse([ { event: 'response.created', data: { type: 'response.created', response: { ...failing, status: 'in_progress', error: null }, sequence_number: 0 } }, - { event: 'response.failed', data: { type: 'response.failed', response: failing, sequence_number: 1 } }, + { event: 'error', data: upstreamError }, + { event: 'response.failed', data: { type: 'response.failed', response: failing, sequence_number: 2 } }, { data: '[DONE]' }, ]); } @@ -1345,6 +1347,10 @@ test('OpenAI Responses WebSocket evicts a continuation that failed through a str }, })); const failedMessages = await failedTurn; + assertEquals(failedMessages.map(message => message.type), ['response.created', 'error', 'response.in_progress', 'response.failed']); + assertEquals(failedMessages[1], { ...upstreamError, event_id: 'evt_streamed_failure' }); + assertEquals(failedMessages.map(message => message.sequence_number), [0, 1, 2, 3]); + assert(failedMessages.every(message => message.event_id === 'evt_streamed_failure')); assertEquals(failedMessages.at(-1)?.type, 'response.failed'); const evicted = waitForMessages(client, messages => messages.some(message => message.type === 'error')); @@ -1375,6 +1381,64 @@ test('OpenAI Responses WebSocket evicts a continuation that failed through a str ); }); +test.each([ + { type: 'error', code: null, param: null, message: 'native failure', vendor: { detail: 'flat' } }, + { type: 'error', error: { code: 'vendor_failure', param: 'tools', message: 'native failure', vendor: { detail: 'nested' } }, trace: 'native-trace' }, +].flatMap(error => [false, true].map(announced => ({ error, announced }))))('OpenAI Responses WebSocket preserves a standalone upstream error and accepts the next turn %j', async ({ error, announced }) => { + const { apiKey, repo } = await setupAppTest(); + await repo.apiKeys.save({ ...apiKey, dumpRetentionSeconds: 3600 }); + const dumps = installDumpStubs(initDumpStore, initDumpBroker); + let responseCalls = 0; + const nativeError = { ...error, sequence_number: announced ? 1 : 0 }; + await withMockedFetch( + async request => { + const url = new URL(request.url); + if (url.hostname === 'update.code.visualstudio.com') return jsonResponse(['1.110.1']); + if (url.pathname === '/copilot_internal/v2/token') { + return jsonResponse({ token: 'copilot-access-token', expires_at: 4102444800, refresh_in: 3600, endpoints: { api: 'https://api.individual.githubcopilot.com' } }); + } + if (url.pathname === '/models') { + return jsonResponse(copilotModels([{ id: 'gpt-direct-responses', supported_endpoints: ['/responses'] }])); + } + if (url.pathname === '/responses') { + responseCalls += 1; + if (responseCalls === 1) return sseResponse([ + ...(announced ? [{ event: 'response.created', data: { type: 'response.created', sequence_number: 0, response: { id: 'resp_error', object: 'response', model: 'gpt-direct-responses', status: 'in_progress', output: [], error: null, incomplete_details: null } } }] : []), + { event: 'error', data: nativeError }, + { data: '[DONE]' }, + ]); + return sseOpenAIResponsesResponse({ id: 'resp_after_error', object: 'response', model: 'gpt-direct-responses', status: 'completed', output: [] }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => await withWorkerWebSocketRuntime(async () => { + const client = await connectOpenAIResponsesWebSocket(apiKey.key); + const raw = recordRawMessages(client); + try { + const received = waitForMessages(client, messages => messages.some(message => message.type === 'error')); + client.send(JSON.stringify({ type: 'response.create', event_id: 'evt_standalone', response: { model: 'gpt-direct-responses', input: 'hello' } })); + const messages = await received; + assertEquals(messages.map(message => message.type), announced ? ['response.created', 'error'] : ['error']); + assertEquals(messages.at(-1), { ...nativeError, event_id: 'evt_standalone' }); + await vi.waitFor(() => assertEquals(dumps.stored.length, 1)); + assertEquals(raw.messages.map(message => JSON.parse(message)), messages); + assertEquals(dumps.stored[0]?.record.meta.responseBytes, raw.messages.reduce((total, message) => total + new TextEncoder().encode(message).byteLength, 0)); + const nextTurn = waitForMessages(client, messages => messages.some(isTerminalResponseEvent)); + if (announced) { + const created = messages[0] as { response: { id: string } }; + client.send(JSON.stringify({ type: 'response.interrupt', event_id: 'evt_late_interrupt', response_id: created.response.id, mode: 'discard_partial_items' })); + } + client.send(JSON.stringify({ type: 'response.create', event_id: 'evt_after_error', response: { model: 'gpt-direct-responses', input: 'next turn' } })); + assert((await nextTurn).every(message => message.event_id === 'evt_after_error')); + assertEquals(responseCalls, 2); + } finally { + raw.stop(); + client.close(); + } + }), + ); +}); + test('OpenAI Responses WebSocket store:true durable snapshots can chain through local session cache', async () => { const { apiKey, repo } = await setupAppTest(); let turn = 0; diff --git a/packages/gateway/src/data-plane/chat/openai-responses/affinity/egress.ts b/packages/gateway/src/data-plane/chat/openai-responses/affinity/egress.ts index 2fb1ef007e..65d19159fa 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/affinity/egress.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/affinity/egress.ts @@ -94,7 +94,6 @@ const wrapNaturalOpenAIResponsesAffinity = async function* ( continue; } yield frame; - if (event.type === 'error') return; } }; @@ -291,7 +290,6 @@ const wrapOpenAIResponsesFirstCarrier = async function* ( } yield eventFrame(shifted(event)); - if (event.type === 'error') return; } }; diff --git a/packages/gateway/src/data-plane/chat/openai-responses/interceptors/server-tool-shim.ts b/packages/gateway/src/data-plane/chat/openai-responses/interceptors/server-tool-shim.ts index 38c3ac1871..a27dac0c99 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/interceptors/server-tool-shim.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/interceptors/server-tool-shim.ts @@ -1,11 +1,10 @@ import { jsonrepair } from 'jsonrepair'; import type { OpenAIResponsesInterceptor, OpenAIResponsesInvocation } from './types.ts'; -import { truncatePreservingCodePoints } from '../../../shared/text.ts'; import type { ChatGatewayCtx } from '../../shared/gateway-ctx.ts'; import { SidecarDomain } from '../../shared/sidecar-codec.ts'; import type { InterceptorRun } from '@floway-dev/interceptor'; -import { eventFrame, isJsonObject, sumBillableUsage, type ProtocolFrame } from '@floway-dev/protocols/common'; +import { eventFrame, isJsonObject, parseJSONWithRawNumbers, sumBillableUsage, type ProtocolFrame } from '@floway-dev/protocols/common'; import { createRandomOpenAIResponsesItemId, isOpenAIResponsesAssistantInputItem, @@ -19,6 +18,7 @@ import { type OpenAIResponsesOutputItemEx, type OpenAIResponsesResultEx, type OpenAIResponsesErrorEx, + type OpenAIResponsesErrorPayload, type OpenAIResponsesStreamEventEx, type OpenAIResponsesTool, type OpenAIResponsesToolChoice, @@ -152,14 +152,14 @@ type ActiveServerTool = Extract & { // How a single upstream turn ended, as observed while consuming its // stream. Carries the raw upstream `response` for failed/incomplete so -// the loop can lift the upstream `error` / `incomplete_details`, plus a -// `bare-error-pre-shell` variant for an `error` event that arrived -// before any `response.created` (no model known yet). Distinct from +// the loop can lift its error and usage. Error notifications remain separate +// from a response resource, including when no resource follows. Distinct from // `SynthesizedTerminal`, which is the shim's own outgoing terminal. export type UpstreamTerminal = | { kind: 'completed' } | { kind: 'failed'; response: OpenAIResponsesResultEx } | { kind: 'incomplete'; response: OpenAIResponsesResultEx } + | { kind: 'error'; event: Extract } | { kind: 'bare-error-pre-shell'; error: OpenAIResponsesErrorEx }; export interface TurnSummary { @@ -693,6 +693,7 @@ export const consumeTurnStreaming = async function* ( let sawClientToolCall = false; let turnUsage: MergeUsage = {}; let terminalStatus: UpstreamTerminal | undefined = undefined; + let errorEvent: Extract | undefined; const openItems = new Map(); const openItemIds = new Map(); @@ -748,26 +749,8 @@ export const consumeTurnStreaming = async function* ( } if (event.type === 'error') { - const e = 'error' in event ? event.error : event; - const code = typeof e.code === 'string' && e.code.length > 0 ? e.code : 'server_error'; - const failure: OpenAIResponsesErrorEx = { message: e.message, code, ...(e.provider_specific_fields === undefined ? {} : { provider_specific_fields: e.provider_specific_fields }) }; - if (merge.lastSeenModel === null) { - terminalStatus = { kind: 'bare-error-pre-shell', error: failure }; - } else { - terminalStatus = { - kind: 'failed', - response: { - id: merge.synthesizedResponseId, - object: 'response', - model: ensureModel(), - output: [], - status: 'failed', - error: failure, - incomplete_details: null, - }, - }; - } - turnUsage = {}; + errorEvent = event; + yield stamp(event); continue; } @@ -924,7 +907,9 @@ export const consumeTurnStreaming = async function* ( } if (terminalStatus === undefined) { - if (merge.lastSeenModel === null) { + if (errorEvent !== undefined) { + terminalStatus = { kind: 'error', event: errorEvent }; + } else if (merge.lastSeenModel === null) { terminalStatus = { kind: 'bare-error-pre-shell', error: { message: 'Upstream stream ended without a terminal event (no response.created observed)', code: 'server_error' }, @@ -948,7 +933,7 @@ export const consumeTurnStreaming = async function* ( if (interceptedByUpstreamIndex.size > 0) { const unmatched = [...interceptedByUpstreamIndex.keys()]; const priorKind = terminalStatus.kind; - const priorLabel = priorKind === 'bare-error-pre-shell' ? 'a pre-shell bare error' : `response.${priorKind}`; + const priorLabel = priorKind === 'error' ? 'an error event' : priorKind === 'bare-error-pre-shell' ? 'a pre-shell bare error' : `response.${priorKind}`; terminalStatus = { kind: 'failed', response: { @@ -969,36 +954,23 @@ export const consumeTurnStreaming = async function* ( return { dispatchedSlots, sawServerToolCall, sawClientToolCall, turnUsage, terminalStatus }; }; -const MAX_BODY_EXCERPT_CHARS = 512; - -const buildErrorFromResult = ( +const errorEventFromResult = ( result: Exclude, { type: 'events' }>, -): NonNullable => { - if (result.type === 'internal-error') return { message: result.error.message, code: 'server_error', provider_specific_fields: internalDebugErrorFields(result.error) }; - const decoded = new TextDecoder('utf-8', { fatal: false }).decode(result.body); - let parsed: unknown = undefined; - try { - parsed = JSON.parse(decoded); - } catch { - parsed = undefined; - } - const err = typeof parsed === 'object' && parsed !== null ? (parsed as { error?: unknown }).error : undefined; - if (typeof err === 'object' && err !== null) { - const e = err as Record; - const out: NonNullable = { - message: typeof e.message === 'string' ? e.message : `Upstream returned HTTP ${result.status}`, - code: typeof e.code === 'string' ? e.code : `upstream_${result.status}`, - }; - if (typeof e.type === 'string') out.type = e.type; - if (e.provider_specific_fields !== undefined) out.provider_specific_fields = e.provider_specific_fields as Record; - return out; - } - const truncated = truncatePreservingCodePoints(decoded, MAX_BODY_EXCERPT_CHARS); - const excerpt = truncated.length === decoded.length ? decoded : `${truncated}...`; - return { - message: excerpt.length > 0 ? `Upstream returned HTTP ${result.status}: ${excerpt}` : `Upstream returned HTTP ${result.status}`, - code: `upstream_${result.status}`, +): Extract => { + if (result.type === 'internal-error') return { + type: 'error', code: 'server_error', message: result.error.message, param: null, + provider_specific_fields: internalDebugErrorFields(result.error), }; + const body = parseJSONWithRawNumbers(new TextDecoder().decode(result.body)) as Extract; + return { ...body, type: 'error' }; +}; + +const responseErrorFromEvent = ( + event: Extract, +): OpenAIResponsesErrorEx | null => { + const { type: _type, sequence_number: _sequenceNumber, ...fields } = event; + const payload = ('error' in fields && isJsonObject(fields.error) ? fields.error : fields) as OpenAIResponsesErrorPayload; + return typeof payload.code === 'string' ? { ...payload, code: payload.code, message: payload.message } : null; }; const invalidRequestEnvelope = ( @@ -1159,6 +1131,10 @@ async function* runMultiTurnLoop(args: { yield synthesizeTerminalEnvelope(merge, { kind: 'incomplete', incompleteDetails: turn.terminalStatus.response.incomplete_details }, active); return; } + if (turn.terminalStatus.kind === 'error') { + if (turn.sawServerToolCall) yield* materializeServerToolItems(turn.dispatchedSlots, merge, replayCodec); + return; + } if (turn.terminalStatus.kind === 'bare-error-pre-shell') { yield synthesizeTerminalEnvelope(merge, { kind: 'failed', @@ -1211,7 +1187,12 @@ async function* runMultiTurnLoop(args: { const nextResult = await run(); if (nextResult.type !== 'events') { - yield synthesizeTerminalEnvelope(merge, { kind: 'failed', error: buildErrorFromResult(nextResult) }, active); + const event = { ...errorEventFromResult(nextResult), sequence_number: merge.sequenceNumber++ }; + yield eventFrame(event); + yield synthesizeTerminalEnvelope(merge, { + kind: 'failed', + error: responseErrorFromEvent(event), + }, active); return; } metadata.modelIdentity = nextResult.modelIdentity; @@ -1228,13 +1209,18 @@ async function* runMultiTurnLoop(args: { midStreamError = error; throw error; } + const failure = { + type: 'error' as const, + code: 'server_error', + message: `Upstream stream failed mid-response: ${error instanceof Error ? error.message : String(error)}`, + param: null, + provider_specific_fields: internalDebugErrorFields(toInternalDebugError(error)), + sequence_number: merge.sequenceNumber++, + }; + yield eventFrame(failure); yield synthesizeTerminalEnvelope(merge, { kind: 'failed', - error: { - code: 'server_error', - message: `Upstream stream failed mid-response: ${error instanceof Error ? error.message : String(error)}`, - provider_specific_fields: internalDebugErrorFields(toInternalDebugError(error)), - }, + error: responseErrorFromEvent(failure), }, active); } finally { if (midStreamError === undefined) resolveFinalMetadata(metadata); diff --git a/packages/gateway/src/data-plane/chat/openai-responses/items/output.ts b/packages/gateway/src/data-plane/chat/openai-responses/items/output.ts index 7c1d3e374c..88dd463c59 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/items/output.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/items/output.ts @@ -7,7 +7,7 @@ import { isOpenAIResponsesCompactionItem, openaiResponsesResultToEvents, type Op // Complete output items become reusable at their first done frame, so each row // commits before that frame is yielded. Later done frames remain // visible but cannot replace the durable item. The response snapshot commits -// separately before a successful terminal frame. Failed/error terminals keep +// separately before a successful terminal frame. Failed responses keep // completed item rows but never a snapshot. // // One client response can span several upstream calls behind the server-tool @@ -95,11 +95,6 @@ export const wrapOpenAIResponsesClientOutput = async function* ( yield eventFrame({ ...event, response: clientEnvelope(event.response) }); return; } - if (event.type === 'error') { - yield frame; - return; - } - yield frame; } }; diff --git a/packages/gateway/src/data-plane/chat/openai-responses/respond.ts b/packages/gateway/src/data-plane/chat/openai-responses/respond.ts index 64e1f5e9bb..fc8250be84 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/respond.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/respond.ts @@ -10,9 +10,9 @@ import { settle } from '../../shared/telemetry/settle.ts'; import { tokenUsageFromBillableUsage } from '../../shared/telemetry/usage.ts'; import { forwardUpstreamHeaders, mergeForwardedUpstreamHeaders } from '../../shared/upstream-response.ts'; import { SourceStreamState, eventResultMetadata, plainResultToResponse } from '../shared/respond.ts'; -import { doneFrame, eventFrame, type ProtocolFrame, sseCommentFrame, sseFrame } from '@floway-dev/protocols/common'; +import { doneFrame, eventFrame, isJsonObject, type ProtocolFrame, sseCommentFrame, sseFrame } from '@floway-dev/protocols/common'; import { openaiResponsesProtocolFrameToSSEFrame, OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE, collectOpenAIResponsesProtocolEventsToResult } from '@floway-dev/protocols/openai-responses'; -import { isOpenAIResponsesTerminalEvent, type CanonicalOpenAIResponsesPayload, type ClientResponseResource, type ClientOpenAIResponsesStreamEvent, type OpenAIResponsesStreamEventEx, type OpenAIResponsesErrorEx } from '@floway-dev/protocols/openai-responses'; +import { isOpenAIResponsesErrorEvent, isOpenAIResponsesTerminalEvent, type CanonicalOpenAIResponsesPayload, type ClientResponseResource, type ClientOpenAIResponsesStreamEvent, type OpenAIResponsesStreamEventEx, type OpenAIResponsesErrorEx } from '@floway-dev/protocols/openai-responses'; import { type ExecuteResult, type PlainResult, type InternalDebugError, internalDebugErrorFields, toInternalDebugError } from '@floway-dev/provider'; import { apiErrorToResponse } from '@floway-dev/provider'; @@ -68,8 +68,16 @@ export const respondOpenAIResponses = async ( headers.set(OPENAI_RESPONSES_REASONING_INCLUDED_HEADER, OPENAI_RESPONSES_REASONING_INCLUDED_VALUE); return Response.json(response, { headers }); } catch (error) { - recordFailedRequest(ctx, result.performance); ctx.dump?.failed(error); + if (error instanceof Error && isOpenAIResponsesErrorEvent(error.cause)) { + const metadata = await eventResultMetadata(result); + const usage = tokenUsageFromBillableUsage(metadata.billableUsage); + ctx.dump?.success(metadata.modelIdentity, usage); + settle(ctx, metadata.performance, metadata.modelIdentity, usage, true); + const { sequence_number: _sequenceNumber, ...body } = error.cause; + return Response.json(body, { status: openAIResponsesErrorHttpStatus(error.cause) }); + } + recordFailedRequest(ctx, result.performance); return internalOpenAIResponsesErrorResponse(502, toInternalDebugError(error)); } } @@ -100,6 +108,36 @@ export const respondOpenAIResponses = async ( // --- error rendering --- +const ERROR_HTTP_STATUS = new Map([ + ['invalid_request_error', 400], + ['invalid_prompt', 400], + ['authentication_error', 401], + ['invalid_api_key', 401], + ['permission_error', 403], + ['not_found_error', 404], + ['conflict_error', 409], + ['unprocessable_entity_error', 422], + ['rate_limit_error', 429], + ['rate_limit_exceeded', 429], + ['insufficient_quota', 429], + ['server_error', 500], + ['internal_error', 500], +]); + +// OpenAI SDKs classify API failures by these HTTP categories. +// https://github.com/openai/openai-node/blob/b579bcb3e586d22269ffe6fe504653a0e05acbe8/src/core/error.ts#L60-L94 +const openAIResponsesErrorHttpStatus = (event: Extract): number => { + const { type: _type, sequence_number: _sequenceNumber, ...fields } = event; + const payload = ('error' in fields && isJsonObject(fields.error) ? fields.error : fields) as Readonly>; + const details = isJsonObject(payload.provider_specific_fields) ? payload.provider_specific_fields : undefined; + for (const classification of [payload.code, payload.type, details?.code, details?.type]) { + if (typeof classification !== 'string') continue; + const status = ERROR_HTTP_STATUS.get(classification); + if (status !== undefined) return status; + } + return 502; +}; + const internalOpenAIResponsesErrorResponse = (status: number, error: InternalDebugError): Response => Response.json({ error: { @@ -111,17 +149,18 @@ const internalOpenAIResponsesErrorResponse = (status: number, error: InternalDeb // --- frame observation --- -const isOpenAIResponsesTerminalFrame = (frame: ProtocolFrame) => frame.type === 'event' && isOpenAIResponsesTerminalEvent(frame.event); - const observeOpenAIResponsesFrames = async function* (frames: AsyncIterable>, state: SourceStreamState, ctx: GatewayCtx) { for await (const frame of frames) { ctx.dump?.frame(frame); - const failed = frame.type === 'event' && (frame.event.type === 'error' || frame.event.type === 'response.failed'); + if (frame.type === 'done') continue; + const failed = frame.event.type === 'error' || frame.event.type === 'response.failed'; + const terminal = isOpenAIResponsesTerminalEvent(frame.event); if (failed) state.failed = true; - if (isOpenAIResponsesTerminalFrame(frame) && !failed) state.completed = true; + if (terminal && !failed) state.completed = true; yield frame; - if (isOpenAIResponsesTerminalFrame(frame)) return; + if (terminal) return; } + if (state.failed) return; throw new Error(OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE); }; diff --git a/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts b/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts index 094feb111c..7776ead410 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts @@ -638,16 +638,17 @@ const respondOpenAIResponsesWebSocket = async (input: { } } - if (terminalEvent === undefined) { + if (terminalEvent === undefined && !state.failed) { throw new Error(OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE); } // Renumbered here rather than where it was buffered: keep-alives can still // fire while the generator drains behind the terminal event, and each of // those takes a slot that has to land before the terminal event's own. - if (!sendOpenAIResponsesEvent(socket, sequence.renumber(terminalEvent), eventId, ctx.dump)) { + if (terminalEvent !== undefined && !sendOpenAIResponsesEvent(socket, sequence.renumber(terminalEvent), eventId, ctx.dump)) { completion = 'cancel'; return; } + turn.terminal = true; completion = 'eof'; } catch (error) { if ((signal.aborted && !turn.interrupted) || isClosed()) { @@ -662,7 +663,7 @@ const respondOpenAIResponsesWebSocket = async (input: { const failed = state.failedAfter(completion); if (failed) { // `fail` cannot carry the eviction for every failed turn: one that - // streamed an `error` or `response.failed` terminal answered the client + // streamed an `error` notification or `response.failed` answered the client // with an event rather than an error envelope, and one the client // abandoned before the terminal event answered with nothing at all. // Both settle here. For the abandoned turn the eviction is inert — the diff --git a/packages/protocols/__tests__/openai-responses/reassemble_test.ts b/packages/protocols/__tests__/openai-responses/reassemble_test.ts index 77a933f88d..4e8d652198 100644 --- a/packages/protocols/__tests__/openai-responses/reassemble_test.ts +++ b/packages/protocols/__tests__/openai-responses/reassemble_test.ts @@ -4,14 +4,7 @@ import type { OpenAIResponsesOutputItemEx, OpenAIResponsesResultEx, OpenAIRespon import { reassembleOpenAIResponsesEvents } from '../../src/openai-responses/reassemble.ts'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; -type OpenAIResponsesReassembleEvent = - | OpenAIResponsesStreamEventEx - | { - type: 'error'; - message?: string; - }; - -function makeEvents(chunks: Array<{ event?: string; data: unknown }>): AsyncIterable { +function makeEvents(chunks: Array<{ event?: string; data: unknown }>): AsyncIterable { return (async function* () { for (const chunk of chunks) { if (typeof chunk.data === 'string') continue; @@ -95,10 +88,39 @@ test('reassembleOpenAIResponsesEvents handles incomplete event', async () => { assertEquals(result.status, 'incomplete'); }); -test('reassembleOpenAIResponsesEvents throws on error event', async () => { - const body = makeEvents([{ event: 'error', data: { type: 'error', message: 'bad request' } }]); +test.each([ + { type: 'error', code: null, param: 'model', message: 'bad request', sequence_number: 1, request_id: 'req_flat' }, + { type: 'error', code: null, param: null, message: 'bad request', error: null, sequence_number: 1, request_id: 'req_extension' }, + { type: 'error', error: { code: 'rate_limit_exceeded', param: null, message: 'bad request', request_id: 'req_nested' }, sequence_number: 1 }, +] as const)('reassembleOpenAIResponsesEvents preserves standalone errors as the original cause', async error => { + const body = makeEvents([{ data: error }]); + const caught: unknown = await reassembleOpenAIResponsesEvents(body).catch((caught: unknown) => caught); + + expect(caught).toBeInstanceOf(Error); + expect(caught).toMatchObject({ message: 'bad request' }); + expect((caught as Error).cause).toBe(error); +}); + +test('reassembleOpenAIResponsesEvents drains through response.failed before returning the original error', async () => { + const failed: OpenAIResponsesResultEx = { + id: 'resp_failed', object: 'response', model: 'gpt-test', status: 'failed', + output: [{ type: 'reasoning', id: 'rs_complete', summary: [{ type: 'summary_text', text: 'Work before failure' }] }], + error: { code: 'rate_limit_exceeded', message: 'Limit reached' }, + incomplete_details: null, + usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 }, + }; + const error = { type: 'error', code: null, param: 'model', message: 'Limit reached', sequence_number: 1 } satisfies OpenAIResponsesStreamEventEx; + let emittedFailed = false; + const events = async function* (): AsyncIterable { + yield { type: 'response.created', sequence_number: 0, response: { ...failed, status: 'in_progress', output: [], error: null, usage: null } }; + yield error; + emittedFailed = true; + yield { type: 'response.failed', sequence_number: 2, response: failed }; + }; + const caught: unknown = await reassembleOpenAIResponsesEvents(events()).catch((caught: unknown) => caught); - await assertRejects(() => reassembleOpenAIResponsesEvents(body), Error, 'bad request'); + expect(emittedFailed).toBe(true); + expect(caught).toMatchObject({ message: error.message, cause: error }); }); test('reassembleOpenAIResponsesEvents throws when stream ends without terminal event', async () => { diff --git a/packages/protocols/__tests__/openai-responses/stream_test.ts b/packages/protocols/__tests__/openai-responses/stream_test.ts index ce9454e391..8a578e59f8 100644 --- a/packages/protocols/__tests__/openai-responses/stream_test.ts +++ b/packages/protocols/__tests__/openai-responses/stream_test.ts @@ -344,7 +344,7 @@ test('parseOpenAIResponsesStream fast-paths response.incomplete terminal', async assertEquals(eventTypes.includes('response.output_item.added'), true); }); -test('parseOpenAIResponsesStream passes raw error terminal through (no .response to expand)', async () => { +test('parseOpenAIResponsesStream passes raw error notifications through', async () => { const frames = await collect(parse( sseFrame(JSON.stringify({ message: 'connection reset', code: 'ECONNRESET' }), 'error'), sseFrame('[DONE]'), @@ -356,6 +356,26 @@ test('parseOpenAIResponsesStream passes raw error terminal through (no .response assertEquals(errorEvent.type === 'event' && (errorEvent.event as { message?: string }).message, 'connection reset'); }); +test('parseOpenAIResponsesStream preserves error notifications and expands the following failed resource', async () => { + const error = { type: 'error', code: null, param: 'model', message: 'Limit reached', sequence_number: 1, request_id: 'req_failed' }; + const failed = makeResponse('failed', { error: { code: 'rate_limit_exceeded', message: 'Limit reached' } }); + const frames = await collect(parse( + sseFrame(JSON.stringify({ type: 'response.created', response: { ...failed, status: 'in_progress', output: [], error: null }, sequence_number: 0 })), + sseFrame(JSON.stringify(error), 'error'), + sseFrame(JSON.stringify({ type: 'response.failed', response: failed, sequence_number: 2 })), + sseFrame('[DONE]'), + )); + + assertEquals(frames[1], { type: 'event', event: error }); + assertEquals(frames.map(frame => frame.type === 'event' ? frame.event.type : frame.type), [ + 'response.created', 'error', 'response.in_progress', 'response.output_item.added', + 'response.content_part.added', 'response.output_text.delta', 'response.output_text.done', + 'response.content_part.done', 'response.output_item.done', 'response.failed', 'done', + ]); + const terminal = frames.at(-2); + assertEquals(terminal?.type === 'event' && 'response' in terminal.event && terminal.event.response, failed); +}); + test('parseOpenAIResponsesStream fast-paths when ping interleaves the wrappers', async () => { const completed = makeResponse('completed'); const frames = await collect(parse( diff --git a/packages/protocols/__tests__/openai-responses/to-result_test.ts b/packages/protocols/__tests__/openai-responses/to-result_test.ts index d58507c4df..f38d112484 100644 --- a/packages/protocols/__tests__/openai-responses/to-result_test.ts +++ b/packages/protocols/__tests__/openai-responses/to-result_test.ts @@ -1,6 +1,6 @@ -import { test } from 'vitest'; +import { expect, test } from 'vitest'; -import { eventFrame } from '../../src/common/index.ts'; +import { doneFrame, eventFrame } from '../../src/common/index.ts'; import { openaiResponsesResultToEvents, type OpenAIResponsesResultEx, type OpenAIResponsesStreamEventEx } from '../../src/openai-responses/index.ts'; import { collectOpenAIResponsesProtocolEventsToResult } from '../../src/openai-responses/to-result.ts'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; @@ -51,3 +51,38 @@ test('collectOpenAIResponsesProtocolEventsToResult rejects streams without termi await assertRejects(async () => await collectOpenAIResponsesProtocolEventsToResult(events()), Error, 'OpenAI Responses stream ended without a terminal event.'); }); + +test('collectOpenAIResponsesProtocolEventsToResult drains response.failed and returns the original error', async () => { + const failed: OpenAIResponsesResultEx = { + id: 'resp_failed', object: 'response', model: 'gpt-test', status: 'failed', + output: [{ type: 'reasoning', id: 'rs_complete', summary: [{ type: 'summary_text', text: 'Work before failure' }] }], + error: { code: 'rate_limit_exceeded', message: 'Limit reached' }, + incomplete_details: null, + usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 }, + }; + let emittedFailed = false; + async function* events() { + yield eventFrame({ type: 'response.created', sequence_number: 0, response: { ...failed, status: 'in_progress', output: [], error: null, usage: null } } satisfies OpenAIResponsesStreamEventEx); + yield eventFrame({ type: 'error', code: null, param: 'model', message: 'Limit reached', sequence_number: 1 } satisfies OpenAIResponsesStreamEventEx); + emittedFailed = true; + yield eventFrame({ type: 'response.failed', sequence_number: 2, response: failed } satisfies OpenAIResponsesStreamEventEx); + throw new Error('Read beyond the failed response'); + } + + const caught: unknown = await collectOpenAIResponsesProtocolEventsToResult(events()).catch((caught: unknown) => caught); + expect(emittedFailed).toBe(true); + expect(caught).toMatchObject({ + message: 'Limit reached', + cause: { type: 'error', code: null, param: 'model', message: 'Limit reached', sequence_number: 1 }, + }); +}); + +test('collectOpenAIResponsesProtocolEventsToResult preserves an error notification when its stream ends', async () => { + const error = { type: 'error', code: null, param: 'model', message: 'Limit reached', sequence_number: 1, request_id: 'req_only' } as const; + async function* events() { + yield eventFrame(error); + yield doneFrame(); + } + + await expect(collectOpenAIResponsesProtocolEventsToResult(events())).rejects.toMatchObject({ message: error.message, cause: error }); +}); diff --git a/packages/protocols/src/openai-responses/index.ts b/packages/protocols/src/openai-responses/index.ts index 6cc0c353f8..0d48683a5a 100644 --- a/packages/protocols/src/openai-responses/index.ts +++ b/packages/protocols/src/openai-responses/index.ts @@ -1425,7 +1425,7 @@ type OpenAIResponsesStreamEventVariant = // is identical aside from the type tag's role. export type OpenAIResponsesReasoningItem = OpenAIResponsesInputReasoning | OpenAIResponsesOutputReasoning; -export { isOpenAIResponsesTerminalEvent, openaiResponsesResultFromStreamEvent } from './terminal-event.ts'; +export { isOpenAIResponsesTerminalEvent, isOpenAIResponsesErrorEvent, openaiResponsesResultFromStreamEvent } from './terminal-event.ts'; export { isOpenAIResponsesAssistantInputItem } from './assistant-items.ts'; export { WEB_SEARCH_HOSTED_TYPE_NAMES, collectOpenAIResponsesToolEntries, collectOpenAIResponsesTools, mapOpenAIResponsesTools } from './tools.ts'; diff --git a/packages/protocols/src/openai-responses/reassemble.ts b/packages/protocols/src/openai-responses/reassemble.ts index 419b463ddb..e18a117c8b 100644 --- a/packages/protocols/src/openai-responses/reassemble.ts +++ b/packages/protocols/src/openai-responses/reassemble.ts @@ -1,26 +1,23 @@ import type { OpenAIResponsesResultEx, OpenAIResponsesStreamEventEx } from './index.ts'; -type OpenAIResponsesReassembleEvent = - | OpenAIResponsesStreamEventEx - | { - type: 'error'; - message?: string; - }; +export const OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE = 'OpenAI Responses stream ended without a terminal event.'; -export async function reassembleOpenAIResponsesEvents(events: AsyncIterable): Promise { +export async function reassembleOpenAIResponsesEvents(events: AsyncIterable): Promise { + let error: Extract | undefined; for await (const event of events) { - const rawEvent = event as unknown as Record; - const type = rawEvent.type as string; - - if (type === 'error') { - const message = (rawEvent.message as string | undefined) ?? JSON.stringify(event); - throw new Error(`Upstream SSE error: ${message}`); + if (event.type === 'error') { + error = event; } - if (type === 'response.completed' || type === 'response.incomplete' || type === 'response.failed') { - return rawEvent.response as OpenAIResponsesResultEx; + if (event.type === 'response.completed' || event.type === 'response.incomplete' || event.type === 'response.failed') { + if (event.type === 'response.failed' && error !== undefined) throw new Error(errorMessage(error), { cause: error }); + return event.response; } } - throw new Error('SSE stream ended without a terminal response event'); + if (error !== undefined) throw new Error(errorMessage(error), { cause: error }); + throw new Error(OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE); } + +const errorMessage = (error: Extract): string => + 'message' in error ? error.message : error.error.message; diff --git a/packages/protocols/src/openai-responses/stream.ts b/packages/protocols/src/openai-responses/stream.ts index c9db7e95d4..9ba5938314 100644 --- a/packages/protocols/src/openai-responses/stream.ts +++ b/packages/protocols/src/openai-responses/stream.ts @@ -7,15 +7,14 @@ export interface ParseOpenAIResponsesStreamOptions { signal?: AbortSignal; } -// Deny-list: anything that is not a wrapper (`response.queued` / -// `response.created` / `response.in_progress`) and not terminal is treated as -// content-bearing. Future OpenAI Responses event types fall through as structured by -// default, which is safer than missing an allow-list entry and incorrectly -// triggering the fast-path expansion below. +// Lifecycle wrappers, resource terminals, and error notifications carry no +// content deltas. Unknown event types count as structured content so a new +// content event cannot accidentally trigger duplicate fast-path expansion. const isStructuredOpenAIResponsesEvent = (event: { type: string }): boolean => event.type !== 'response.queued' && event.type !== 'response.created' && event.type !== 'response.in_progress' + && event.type !== 'error' && !isOpenAIResponsesTerminalEvent(event as OpenAIResponsesStreamEventEx); // Some OpenAI Responses upstreams emit the event type only via the SSE `event:` @@ -49,9 +48,8 @@ const sequencer = () => { // a terminal `response.completed` / `response.incomplete` / `response.failed`, // skipping every content-bearing structured event. This parser expands the // terminal in place via `openaiResponsesResultToEvents` so downstream consumers -// always observe one canonical full event sequence. `error` terminals carry -// no `response` payload, so we cannot expand them; they continue to surface -// as their original frame for downstream handlers. +// always observe one canonical full event sequence. Error notifications pass +// through before the terminal response and do not suppress its expansion. export const parseOpenAIResponsesStream = ( body: ReadableStream, options: ParseOpenAIResponsesStreamOptions = {}, diff --git a/packages/protocols/src/openai-responses/terminal-event.ts b/packages/protocols/src/openai-responses/terminal-event.ts index ef06d6dda6..460ca5e405 100644 --- a/packages/protocols/src/openai-responses/terminal-event.ts +++ b/packages/protocols/src/openai-responses/terminal-event.ts @@ -1,7 +1,15 @@ import type { OpenAIResponsesResultEx, OpenAIResponsesStreamEventEx } from './index.ts'; +// Streaming errors are followed by the failed response resource. +// https://github.com/openresponses/openresponses/blob/92c12d96d7b61d6d15e2214daa5e9c6000ab6e1c/src/specifications/2026-04-24.mdx#L419-L444 export const isOpenAIResponsesTerminalEvent = (event: Pick): boolean => - event.type === 'response.completed' || event.type === 'response.incomplete' || event.type === 'response.failed' || event.type === 'error'; + event.type === 'response.completed' || event.type === 'response.incomplete' || event.type === 'response.failed'; + +export const isOpenAIResponsesErrorEvent = (value: unknown): value is Extract => { + if (value === null || typeof value !== 'object' || !('type' in value) || value.type !== 'error') return false; + return ('message' in value && typeof value.message === 'string') + || ('error' in value && value.error !== null && typeof value.error === 'object' && 'message' in value.error && typeof value.error.message === 'string'); +}; // Typed accessor for the `response` payload carried on lifecycle envelopes // (`response.queued`, `response.created`, `response.in_progress`, `response.completed`, diff --git a/packages/protocols/src/openai-responses/to-result.ts b/packages/protocols/src/openai-responses/to-result.ts index 1d173f7a0c..857e4505a6 100644 --- a/packages/protocols/src/openai-responses/to-result.ts +++ b/packages/protocols/src/openai-responses/to-result.ts @@ -1,10 +1,9 @@ import type { ClientResponseResource, ClientOpenAIResponsesStreamEvent } from './client-resource.ts'; import type { OpenAIResponsesResultEx, OpenAIResponsesStreamEventEx } from './index.ts'; import { reassembleOpenAIResponsesEvents } from './reassemble.ts'; -import { isOpenAIResponsesTerminalEvent } from './terminal-event.ts'; import { type ProtocolFrame } from '../common/index.ts'; -export const OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE = 'OpenAI Responses stream ended without a terminal event.'; +export { OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE } from './reassemble.ts'; // Reassembly copies each response object through, so a stream whose frames // already carry the completed client resource collects into a completed @@ -20,10 +19,7 @@ export async function collectOpenAIResponsesProtocolEventsToResult(frames: Async if (frame.type === 'done') continue; yield frame.event; - if (isOpenAIResponsesTerminalEvent(frame.event)) return; } - - throw new Error(OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE); }; return await reassembleOpenAIResponsesEvents(events()); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-source/context-window-error_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/context-window-error_test.ts deleted file mode 100644 index e5e266c303..0000000000 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-source/context-window-error_test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { test } from 'vitest'; - -import { rewriteContextExceededToPromptTooLong } from '../../../../src/protocols/anthropic-messages/as-source/context-window-error.ts'; -import { buildPromptTooLongBody, PROMPT_TOO_LONG_MESSAGE } from '@floway-dev/protocols/anthropic-messages'; -import { assert, assertEquals } from '@floway-dev/test-utils'; - -test('buildPromptTooLongBody — Anthropic envelope with load-bearing prefix', () => { - const body = new TextDecoder().decode(buildPromptTooLongBody()); - const parsed = JSON.parse(body) as { type: string; error: { type: string; message: string } }; - assertEquals(parsed.type, 'error'); - assertEquals(parsed.error.type, 'invalid_request_error'); - assertEquals(parsed.error.message, PROMPT_TOO_LONG_MESSAGE); - assert(parsed.error.message.startsWith('prompt is too long:')); -}); - -test('rewriteContextExceededToPromptTooLong — rewrites Copilot OpenAI Responses shape', () => { - const upstream = { - status: 400, - headers: new Headers({ 'x-copilot-request-id': 'abc' }), - body: new TextEncoder().encode(JSON.stringify({ - error: { code: 'model_max_prompt_tokens_exceeded', message: 'prompt token count of 1000000 exceeds the limit of 128000' }, - })), - }; - - const result = rewriteContextExceededToPromptTooLong(upstream.status, upstream.headers, upstream.body); - assert(result !== undefined); - assertEquals(result!.status, 400); - assertEquals(result!.headers.get('content-type'), 'application/json'); - const parsed = result.body as { error: { message: string } }; - assert(parsed.error.message.startsWith('prompt is too long:')); -}); - -test('rewriteContextExceededToPromptTooLong — rewrites Codex OpenAI Responses shape', () => { - const upstream = { - status: 400, - headers: new Headers(), - body: new TextEncoder().encode(JSON.stringify({ - error: { code: 'context_length_exceeded', message: 'Your input exceeds the context window of this model. Please adjust your input and try again.' }, - })), - }; - const result = rewriteContextExceededToPromptTooLong(upstream.status, upstream.headers, upstream.body); - assert(result !== undefined); -}); - -test('rewriteContextExceededToPromptTooLong — recognizes bodies whose signal is in the message substring only', () => { - const upstream = { - status: 400, - headers: new Headers(), - body: new TextEncoder().encode(JSON.stringify({ - error: { message: "This model's maximum context length is 4097 tokens. However, your messages resulted in 4498 tokens." }, - })), - }; - assert(rewriteContextExceededToPromptTooLong(upstream.status, upstream.headers, upstream.body) !== undefined); -}); - -test('rewriteContextExceededToPromptTooLong — passes unrelated bodies through unchanged', () => { - const upstream = { - status: 401, - headers: new Headers(), - body: new TextEncoder().encode(JSON.stringify({ error: { code: 'invalid_api_key', message: 'bad token' } })), - }; - assertEquals(rewriteContextExceededToPromptTooLong(upstream.status, upstream.headers, upstream.body), undefined); -}); - -test('rewriteContextExceededToPromptTooLong — falls back to plain-text substring when the body is not JSON', () => { - const matching = { - status: 502, - headers: new Headers(), - body: new TextEncoder().encode('some upstream prose: exceeds the context window of this model'), - }; - assert(rewriteContextExceededToPromptTooLong(matching.status, matching.headers, matching.body) !== undefined); - - const unrelated = { - status: 502, - headers: new Headers(), - body: new TextEncoder().encode('Bad Gateway'), - }; - assertEquals(rewriteContextExceededToPromptTooLong(unrelated.status, unrelated.headers, unrelated.body), undefined); -}); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-source/error-body_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/error-body_test.ts new file mode 100644 index 0000000000..47379896a5 --- /dev/null +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-source/error-body_test.ts @@ -0,0 +1,123 @@ +import { test } from 'vitest'; + +import { restateAnthropicMessagesError } from '../../../../src/protocols/anthropic-messages/as-source/error-body.ts'; +import { anthropicMessagesErrorFromIR } from '../../../../src/protocols/anthropic-messages/as-source/error.ts'; +import { errorPayloadFromWire } from '../../../../src/shared/api-error.ts'; +import { buildPromptTooLongBody, PROMPT_TOO_LONG_MESSAGE } from '@floway-dev/protocols/anthropic-messages'; +import { assert, assertEquals } from '@floway-dev/test-utils'; + +const decoded = (result: { body: unknown } | undefined) => result!.body as { + type: string; + error: { type: string; message: string }; +}; +const upstreamOf = (status: number, body: unknown, headers: Record = {}) => [ + status, + new Headers({ 'x-upstream': 'a', ...headers }), + new TextEncoder().encode(typeof body === 'string' ? body : JSON.stringify(body)), +] as const; + +test('buildPromptTooLongBody — Anthropic envelope with load-bearing prefix', () => { + const body = new TextDecoder().decode(buildPromptTooLongBody()); + const parsed = JSON.parse(body) as { type: string; error: { type: string; message: string } }; + assertEquals(parsed.type, 'error'); + assertEquals(parsed.error.type, 'invalid_request_error'); + assertEquals(parsed.error.message, PROMPT_TOO_LONG_MESSAGE); + assert(parsed.error.message.startsWith('prompt is too long:')); +}); + +test.each([ + ['Copilot Responses', { error: { code: 'model_max_prompt_tokens_exceeded', message: 'prompt token count of 1000000 exceeds the limit of 128000' } }], + ['Codex Responses', { error: { code: 'context_length_exceeded', message: 'Your input exceeds the context window of this model. Please adjust your input and try again.' } }], + ['message-only signal', { error: { message: "This model's maximum context length is 4097 tokens. However, your messages resulted in 4498 tokens." } }], +])('restates a context overflow from the %s as the prompt-too-long wording', (_name, body) => { + const result = restateAnthropicMessagesError(...upstreamOf(502, body)); + // Use our 400 invalid_request_error mapping and the prompt-too-long compatibility prefix. + // Claude Code recognizes that substring without requiring this complete message or HTTP status. + assertEquals(result!.status, 400); + assertEquals(result!.headers.get('content-type'), 'application/json'); + const parsed = decoded(result); + assertEquals(parsed.error.type, 'invalid_request_error'); + assert(parsed.error.message.startsWith('prompt is too long:')); +}); + +test('restates a recognized failure in the Messages envelope and keeps the upstream status', () => { + const result = restateAnthropicMessagesError(...upstreamOf(401, { error: { code: 'invalid_api_key', message: 'bad token' } })); + assertEquals(result!.status, 401); + assertEquals(decoded(result).error.message, 'bad token'); +}); + +test('keeps the upstream headers a client reads', () => { + const result = restateAnthropicMessagesError(...upstreamOf(429, { error: { message: 'slow down' } }, { 'retry-after': '30', 'request-id': 'req_1' })); + assertEquals(result!.headers.get('retry-after'), '30'); + assertEquals(result!.headers.get('request-id'), 'req_1'); + assertEquals(result!.headers.get('x-upstream'), 'a'); +}); + +test('passes an unrecognized body through untouched', () => { + assertEquals(restateAnthropicMessagesError(...upstreamOf(502, 'Bad Gateway')), undefined); + assertEquals(restateAnthropicMessagesError(...upstreamOf(502, 'exceeds the context window of this model')), undefined); + assertEquals(restateAnthropicMessagesError(...upstreamOf(500, { detail: 'something broke' })), undefined); + assertEquals(restateAnthropicMessagesError(...upstreamOf(500, {})), undefined); +}); + +test('reads a message-only envelope the same way its stream does', () => { + const result = restateAnthropicMessagesError(...upstreamOf(529, { error: { message: 'Overloaded' } })); + + assertEquals(result!.status, 529); + assertEquals(decoded(result).error.message, 'Overloaded'); +}); + +test('carries the fields the target protocol states that the IR does not name', () => { + const result = restateAnthropicMessagesError(...upstreamOf(400, { + error: { message: 'bad request', code: 'invalid', safety_violations: [{ category: 'hate' }], headers: { 'x-trace': 'abc' } }, + })); + + const error = decoded(result).error as Record; + assertEquals(error.provider_specific_fields, { + code: 'invalid', + safety_violations: [{ category: 'hate' }], + headers: { 'x-trace': 'abc' }, + }); +}); + +test('preserves colliding loose error fields beneath the upstream container priority', () => { + const result = restateAnthropicMessagesError(...upstreamOf(400, { + error: { message: 'bad request', provider_specific_fields: { stack: 'theirs' }, stack: 'legacy', retry_after: 10, code: 'invalid_value', param: 'temperature' }, + })); + + assertEquals((decoded(result).error as Record).provider_specific_fields, { + stack: 'theirs', stack_1: 'legacy', retry_after: 10, code: 'invalid_value', param: 'temperature', + }); +}); + +test('preserves the same failure fields before and during a stream', () => { + const error = { + message: 'bad field', type: 'invalid_request_error', code: 'invalid_value', param: 'temperature', + provider_specific_fields: { request_id: 'req_1' }, retry_after: 10, + }; + const result = restateAnthropicMessagesError(...upstreamOf(400, { error })); + const expected = { + type: 'invalid_request_error', message: 'bad field', + provider_specific_fields: { code: 'invalid_value', param: 'temperature', request_id: 'req_1', retry_after: 10 }, + }; + assertEquals(decoded(result).error, expected); + assertEquals(anthropicMessagesErrorFromIR(errorPayloadFromWire(error)), expected); +}); + +test('normalizes a wire msg alias before treating the object as an IR error', () => { + const details = { request_id: 'req_alias' }; + const wire = { msg: 'Alias message', code: 'invalid_value', param: 'temperature', provider_specific_fields: details }; + const error = errorPayloadFromWire({ error: wire }); + assertEquals(error.message, wire.msg); + assertEquals(error.code, wire.code); + assertEquals(error.param, wire.param); + assert(error.provider_specific_fields === details); + assertEquals(anthropicMessagesErrorFromIR(error).message, wire.msg); + const beforeStream = restateAnthropicMessagesError(...upstreamOf(400, { error: wire })); + assertEquals(decoded(beforeStream).error, anthropicMessagesErrorFromIR(error)); +}); + +test('a canonical wire error keeps its original object', () => { + const wire = { message: 'Canonical message', code: 'invalid_value' }; + assert(errorPayloadFromWire({ error: wire }) === wire); +}); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts index f3d783b4e0..d2295824ac 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts @@ -163,6 +163,7 @@ test('translateToSourceEvents translates OpenAI Responses failed terminal to Ant error: { type: 'api_error', message: 'upstream failed', + provider_specific_fields: { code: 'server_error', type: 'server_error' }, }, } satisfies AnthropicMessagesStreamEventEx), ]); @@ -193,6 +194,7 @@ test('translateToSourceEvents translates OpenAI Responses error terminal to Anth error: { type: 'api_error', message: 'upstream overloaded', + provider_specific_fields: { code: 'overloaded_error' }, }, } satisfies AnthropicMessagesStreamEventEx), ]); diff --git a/packages/translate/__tests__/protocols/chat-reasoning-round-trip_test.ts b/packages/translate/__tests__/protocols/chat-reasoning-round-trip_test.ts index bc12299fe5..aa6194444b 100644 --- a/packages/translate/__tests__/protocols/chat-reasoning-round-trip_test.ts +++ b/packages/translate/__tests__/protocols/chat-reasoning-round-trip_test.ts @@ -4,7 +4,7 @@ import { requireTrip } from './require-trip.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsAssistantDeltaEx, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; -import { reassembleOpenAIResponsesEvents, toOpenAIResponsesInputItems } from '@floway-dev/protocols/openai-responses'; +import { toOpenAIResponsesInputItems, reassembleOpenAIResponsesEvents } from '@floway-dev/protocols/openai-responses'; import { routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; const chunk = (delta: OpenAIChatCompletionsAssistantDeltaEx, finish_reason: 'stop' | null = null) => eventFrame({ diff --git a/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts b/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts new file mode 100644 index 0000000000..bcc53febfc --- /dev/null +++ b/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts @@ -0,0 +1,245 @@ +import { expect, test } from 'vitest'; + +import { requireTrip } from './require-trip.ts'; +import type { IRErrorPayload } from '../../src/ir/output/error.ts'; +import { readAnthropicMessagesEvents } from '../../src/protocols/anthropic-messages/as-target/events.ts'; +import { readOpenAIChatCompletionsEvents } from '../../src/protocols/openai-chat-completions/as-target/events.ts'; +import { readOpenAIResponsesEvents } from '../../src/protocols/openai-responses/as-target/events.ts'; +import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { createRoundTripReaderCapture } from '../round-trip/test-helpers.ts'; +import { collect, iterate } from '../shared/iterables.ts'; +import { PROMPT_TOO_LONG_MESSAGE } from '@floway-dev/protocols/anthropic-messages'; +import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; +import { + routeAnthropicMessagesViaOpenAIChatCompletions, + routeAnthropicMessagesViaOpenAIResponses, + routeGeminiGenerateContentViaAnthropicMessages, + routeGeminiGenerateContentViaOpenAIChatCompletions, + routeGeminiGenerateContentViaOpenAIResponses, + routeOpenAIChatCompletionsViaAnthropicMessages, + routeOpenAIChatCompletionsViaOpenAIResponses, + routeOpenAIResponsesViaAnthropicMessages, + routeOpenAIResponsesViaOpenAIChatCompletions, + type TranslateInputError, + type TranslateTrip, +} from '@floway-dev/translate'; + +const options = () => ({ model: 'm', sidecarCodec: createTestSidecarCodec('error-routes') }); +const chat = { model: 'm', messages: [{ role: 'user' as const, content: 'hello' }] }; +const responses = { model: 'm', input: 'hello' }; +const messages = { model: 'm', max_tokens: 16, messages: [{ role: 'user' as const, content: 'hello' }] }; +const gemini = { contents: [{ role: 'user' as const, parts: [{ text: 'hello' }] }] }; + +const failure = { + message: 'bad field', type: 'invalid_request_error', code: 'invalid_prompt', param: 'temperature', + status: 'UPSTREAM_STATUS', msg: 'secondary message', retry_after: 7, + provider_specific_fields: { request_id: 'req_1', retry_after: 9 }, +}; +const chatEvent = (error: IRErrorPayload) => ({ + id: 'error', object: 'chat.completion.chunk' as const, created: 0, model: 'm', choices: [], error, +}); +const nestedEvent = (error: typeof failure) => ({ type: 'error' as const, error }); + +const expectedChat = { + error: { + message: failure.message, type: failure.type, code: failure.code, param: failure.param, + provider_specific_fields: { status: failure.status, msg: failure.msg, request_id: 'req_1', retry_after: 9, retry_after_1: 7 }, + }, +}; +const expectedResponses = { + type: 'error', message: failure.message, code: failure.code, param: failure.param, + provider_specific_fields: { type: failure.type, status: failure.status, msg: failure.msg, request_id: 'req_1', retry_after: 9, retry_after_1: 7 }, +}; +const expectedMessages = { + type: 'error', error: { + type: failure.type, message: failure.message, + provider_specific_fields: { code: failure.code, param: failure.param, status: failure.status, msg: failure.msg, request_id: 'req_1', retry_after: 9, retry_after_1: 7 }, + }, +}; +const expectedGemini = { + error: { + code: 400, status: 'INVALID_ARGUMENT', message: failure.message, + details: [{ + '@type': 'type.googleapis.com/google.protobuf.Struct', value: { + type: failure.type, code: failure.code, param: failure.param, status: failure.status, + msg: failure.msg, request_id: 'req_1', retry_after: 9, retry_after_1: 7, + }, + }], + }, +}; + +const verifyPhases = async ( + pending: Promise | TranslateInputError>, + event: Target, + expected: unknown, +) => { + const trip = await requireTrip(pending); + const headers = new Headers({ + 'content-type': 'text/plain', 'content-length': '123', 'content-encoding': 'gzip', + 'retry-after': '5', 'x-request-id': 'req_1', + }); + const http = trip.error(400, headers, new TextEncoder().encode(JSON.stringify({ error: failure }))); + expect(http?.status).toBe(400); + expect(http?.body).toEqual(expected); + expect(http?.headers.get('retry-after')).toBe('5'); + expect(http?.headers.get('x-request-id')).toBe('req_1'); + expect(http?.headers.get('content-type')).toBe('application/json'); + expect(http?.headers.has('content-length')).toBe(false); + expect(http?.headers.has('content-encoding')).toBe(false); + expect(headers.get('content-type')).toBe('text/plain'); + const frames = await collect(trip.events(iterate([eventFrame(event)]))); + expect(frames).toHaveLength(1); + const [frame] = frames; + if (frame.type !== 'event') throw new Error('Expected the translated error event'); + const source = frame.event as Record; + const { sequence_number: _sequenceNumber, ...body } = source; + expect(body).toEqual(expected); +}; + +const cases = [ + ['Chat via Responses', () => verifyPhases(routeOpenAIChatCompletionsViaOpenAIResponses(chat, options()), nestedEvent(failure), expectedChat)], + ['Chat via Messages', () => verifyPhases(routeOpenAIChatCompletionsViaAnthropicMessages(chat, { ...options(), loadRemoteImage: async () => null, fallbackMaxOutputTokens: 16 }), nestedEvent(failure), expectedChat)], + ['Responses via Chat', () => verifyPhases(routeOpenAIResponsesViaOpenAIChatCompletions(responses, options()), chatEvent(failure), expectedResponses)], + ['Responses via Messages', () => verifyPhases(routeOpenAIResponsesViaAnthropicMessages(responses, { ...options(), loadRemoteImage: async () => null, fallbackMaxOutputTokens: 16 }), nestedEvent(failure), expectedResponses)], + ['Messages via Chat', () => verifyPhases(routeAnthropicMessagesViaOpenAIChatCompletions(messages, options()), chatEvent(failure), expectedMessages)], + ['Messages via Responses', () => verifyPhases(routeAnthropicMessagesViaOpenAIResponses(messages, options()), nestedEvent(failure), expectedMessages)], + ['Gemini via Chat', () => verifyPhases(routeGeminiGenerateContentViaOpenAIChatCompletions(gemini, options()), chatEvent(failure), expectedGemini)], + ['Gemini via Responses', () => verifyPhases(routeGeminiGenerateContentViaOpenAIResponses(gemini, options()), nestedEvent(failure), expectedGemini)], + ['Gemini via Messages', () => verifyPhases(routeGeminiGenerateContentViaAnthropicMessages(gemini, { ...options(), fallbackMaxOutputTokens: 16 }), nestedEvent(failure), expectedGemini)], +] as const; + +test.each(cases)('%s preserves the error fields in both phases', async (_name, run) => { await run(); }); + +test.each([ + { message: 'failed' }, + { message: 'failed', code: null, param: null }, + { message: 'failed', type: 'classification' }, + { message: 'failed', type: 'classification', code: null, param: null }, +])('OpenAI errors preserve absent and nullable fields independently: %j', async error => { + const chatTrip = await requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses(chat, options())); + const responsesTrip = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(responses, options())); + const encoded = new TextEncoder().encode(JSON.stringify({ error })); + expect(chatTrip.error(502, new Headers(), encoded)?.body).toEqual({ error }); + const expected = { + type: 'error', message: error.message, + ...('code' in error ? { code: error.code } : {}), + ...('param' in error ? { param: error.param } : {}), + ...('type' in error ? { provider_specific_fields: { type: error.type } } : {}), + }; + expect(responsesTrip.error(502, new Headers(), encoded)?.body).toEqual(expected); + expect(await collect(chatTrip.events(iterate([eventFrame({ type: 'error', error } as const)])))).toEqual([eventFrame({ error })]); + expect(await collect(responsesTrip.events(iterate([eventFrame(chatEvent(error))])))).toEqual([eventFrame({ ...expected, sequence_number: 0 })]); +}); + +test('Responses flat errors remove transport discriminants before restatement', async () => { + const trip = await requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses(chat, options())); + const error = { type: 'error' as const, sequence_number: 17, message: 'failed', code: null, param: null }; + const frames = await collect(trip.events(iterate([eventFrame(error)]))); + expect(trip.error(502, new Headers(), new TextEncoder().encode(JSON.stringify(error)))?.body).toEqual({ + error: { message: 'failed', code: null, param: null }, + }); + expect(frames).toEqual([eventFrame({ error: { message: 'failed', code: null, param: null } })]); +}); + +test('Responses nested error envelopes preserve request_id in both phases', async () => { + const trip = await requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses(chat, options())); + const envelope = { + type: 'error' as const, sequence_number: 17, request_id: 'req_1', error: { message: 'failed', code: null }, + }; + const expected = { error: { message: 'failed', code: null, provider_specific_fields: { request_id: 'req_1' } } }; + const http = trip.error(502, new Headers(), new TextEncoder().encode(JSON.stringify(envelope))); + expect(http?.body).toEqual(expected); + expect(await collect(trip.events(iterate([eventFrame(envelope)])))).toEqual([eventFrame(expected)]); +}); + +test('Responses nested error envelopes preserve every collision and explicit suffix in both phases', async () => { + const trip = await requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses(chat, options())); + const error = { + message: 'failed', type: 'upstream_type', code: null, param: null, + request_id: 'inner_loose', provider_specific_fields: { request_id: 'inner_bag', retry_after: 7 }, + }; + const envelope = { + type: 'error' as const, sequence_number: 17, error, + request_id: 'outer_loose', request_id_1: 'original suffix', trace: { region: 'us' }, + provider_specific_fields: { request_id: 'outer_bag', outer_detail: true }, + }; + const expected = { + error: { + message: 'failed', type: 'upstream_type', code: null, param: null, + provider_specific_fields: { + request_id: 'inner_bag', retry_after: 7, trace: { region: 'us' }, outer_detail: true, + request_id_1: 'original suffix', request_id_2: 'inner_loose', request_id_3: 'outer_bag', request_id_4: 'outer_loose', + }, + }, + }; + const http = trip.error(502, new Headers(), new TextEncoder().encode(JSON.stringify(envelope))); + expect(http?.body).toEqual(expected); + expect(await collect(trip.events(iterate([eventFrame(envelope)])))).toEqual([eventFrame(expected)]); +}); + +test('Responses nested error envelopes coalesce equal JSON metadata in both phases', async () => { + const trip = await requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses(chat, options())); + const envelope = { + type: 'error' as const, + error: { + message: 'failed', trace: { region: 'us', attempts: [1, false, null] }, + provider_specific_fields: { trace: { attempts: [1, false, null], region: 'us' } }, + }, + trace: { attempts: [1, false, null], region: 'us' }, + provider_specific_fields: { trace: { region: 'us', attempts: [1, false, null] } }, + }; + const expected = { + error: { message: 'failed', provider_specific_fields: { trace: { region: 'us', attempts: [1, false, null] } } }, + }; + expect(trip.error(502, new Headers(), new TextEncoder().encode(JSON.stringify(envelope)))?.body).toEqual(expected); + expect(await collect(trip.events(iterate([eventFrame(envelope)])))).toEqual([eventFrame(expected)]); +}); + +test('Messages projects a canonical code once when outer metadata states the same value', async () => { + const trip = await requireTrip(routeAnthropicMessagesViaOpenAIResponses(messages, options())); + const envelope = { + type: 'error' as const, code: 'source-code', + error: { message: 'failed', code: 'source-code', provider_specific_fields: { code: 'container-code' } }, + }; + const expected = { + type: 'error', error: { + type: 'api_error', message: 'failed', + provider_specific_fields: { code: 'container-code', code_1: 'source-code' }, + }, + }; + expect(trip.error(502, new Headers(), new TextEncoder().encode(JSON.stringify(envelope)))?.body).toEqual(expected); + expect(await collect(trip.events(iterate([eventFrame(envelope)])))).toEqual([eventFrame(expected)]); +}); + +test('native target errors retain canonical object identity', async () => { + const chatRecords = await collect(readOpenAIChatCompletionsEvents(iterate([eventFrame(chatEvent(failure))]), { + roundTrip: createRoundTripReaderCapture<'openaiChatCompletions'>().roundTrip, + })); + const messagesRecords = await collect(readAnthropicMessagesEvents(iterate([eventFrame(nestedEvent(failure))]), { + roundTrip: createRoundTripReaderCapture<'anthropicMessages'>().roundTrip, + })); + const responsesRecords = await collect(readOpenAIResponsesEvents(iterate([eventFrame(nestedEvent(failure))]), { + roundTrip: createRoundTripReaderCapture<'openaiResponses'>().roundTrip, + })); + for (const records of [chatRecords, messagesRecords, responsesRecords]) { + const record = records.find(value => value.type === 'error'); + expect(record?.error).toBe(failure); + } +}); + +test.each([ + ['Messages', () => routeAnthropicMessagesViaOpenAIResponses(messages, options())], + ['Gemini', () => routeGeminiGenerateContentViaOpenAIResponses(gemini, options())], +] as const)('%s context overflow keeps the same recognized semantics in both phases', async (protocol, create) => { + const trip = await create(); + if (trip.type === 'input-error') throw new Error(trip.message); + const error = { message: 'too long', code: 'context_length_exceeded' }; + const http = trip.error(502, new Headers(), new TextEncoder().encode(JSON.stringify({ error }))); + expect(http?.status).toBe(400); + const frames: ProtocolFrame[] = []; + for await (const frame of trip.events(iterate([eventFrame({ type: 'error', error } as const)]))) frames.push(frame); + expect(frames).toEqual([eventFrame(http?.body)]); + expect(http?.body).toMatchObject(protocol === 'Messages' + ? { error: { type: 'invalid_request_error', message: PROMPT_TOO_LONG_MESSAGE } } + : { error: { code: 400, status: 'INVALID_ARGUMENT', message: 'too long' } }); +}); diff --git a/packages/translate/__tests__/protocols/edge-cases_test.ts b/packages/translate/__tests__/protocols/edge-cases_test.ts index 74f8cb5a42..35caec5395 100644 --- a/packages/translate/__tests__/protocols/edge-cases_test.ts +++ b/packages/translate/__tests__/protocols/edge-cases_test.ts @@ -34,7 +34,7 @@ for (const [code, category] of [['bio_policy', 'bio'], ['cyber_policy', 'cyber'] test.each(['The model failed.', 'The safety policy service is unavailable.'])('a server error stays an error regardless of message keywords: %s', async message => { const frames = await collect(generateContentViaResponses(iterate([eventFrame({ type: 'response.failed', response: { id: 'resp_failed', model: 'served', output: [], status: 'failed', error: { code: 'server_error', message } } })] as any), createTestSidecarCodec())); - expect(frames).toEqual([eventFrame({ error: { code: 500, status: 'INTERNAL', message } })]); + expect(frames).toEqual([eventFrame({ error: { code: 500, status: 'INTERNAL', message, details: [{ '@type': 'type.googleapis.com/google.protobuf.Struct', value: { code: 'server_error' } }] } })]); }); test('a failed Responses stream without an error description still fails with a message', async () => { @@ -44,7 +44,7 @@ test('a failed Responses stream without an error description still fails with a test('rate limiting becomes a native GenerateContent error', async () => { const frames = await collect(generateContentViaResponses(iterate([eventFrame({ type: 'error', code: 'rate_limit_exceeded', message: 'Try later.' })] as any), createTestSidecarCodec())); - expect(frames).toEqual([eventFrame({ error: { code: 429, status: 'RESOURCE_EXHAUSTED', message: 'Try later.' } })]); + expect(frames).toEqual([eventFrame({ error: { code: 429, status: 'RESOURCE_EXHAUSTED', message: 'Try later.', details: [{ '@type': 'type.googleapis.com/google.protobuf.Struct', value: { code: 'rate_limit_exceeded' } }] } })]); }); test('ChatCompletions ignores empty identity frames and starts when a model appears', async () => { diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/error-body_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/error-body_test.ts new file mode 100644 index 0000000000..8b2ecb5dc8 --- /dev/null +++ b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/error-body_test.ts @@ -0,0 +1,71 @@ +import { test } from 'vitest'; + +import { restateGeminiGenerateContentError } from '../../../../src/protocols/gemini-generate-content/as-source/error-body.ts'; +import { geminiGenerateContentErrorFromIR } from '../../../../src/protocols/gemini-generate-content/as-source/error.ts'; +import { errorPayloadFromWire } from '../../../../src/shared/api-error.ts'; +import { assertEquals } from '@floway-dev/test-utils'; + +const decoded = (result: { body: unknown } | undefined) => result!.body as { + error: { code: number; status: string; message: string }; +}; + +const upstreamOf = (status: number, body: unknown, headers: Record = {}) => [ + status, + new Headers({ 'x-upstream': 'a', ...headers }), + new TextEncoder().encode(typeof body === 'string' ? body : JSON.stringify(body)), +] as const; + +test.each([502, 503])('keeps upstream HTTP %i with its native RPC status', status => { + const result = restateGeminiGenerateContentError(...upstreamOf(status, { error: { message: 'upstream unavailable' } })); + assertEquals(result!.status, status); + assertEquals(decoded(result).error.code, status); + assertEquals(decoded(result).error.status, 'UNAVAILABLE'); + assertEquals(decoded(result).error.message, 'upstream unavailable'); +}); + +test('keeps a status the inferred table would have flattened', () => { + const result = restateGeminiGenerateContentError(...upstreamOf(402, { error: { message: 'payment required' } })); + + assertEquals(result!.status, 402); + assertEquals(decoded(result).error.code, 402); + assertEquals(decoded(result).error.status, 'UNKNOWN'); +}); + +test('keeps the upstream headers a client reads', () => { + const result = restateGeminiGenerateContentError(...upstreamOf(429, { error: { message: 'slow down' } }, { 'retry-after': '12', 'x-request-id': 'req_9' })); + + assertEquals(result!.headers.get('retry-after'), '12'); + assertEquals(result!.headers.get('x-request-id'), 'req_9'); + assertEquals(result!.headers.get('content-type'), 'application/json'); +}); + +test('passes an unrecognized body through untouched', () => { + assertEquals(restateGeminiGenerateContentError(...upstreamOf(500, 'internal blew up')), undefined); + assertEquals(restateGeminiGenerateContentError(...upstreamOf(500, { detail: 'nope' })), undefined); +}); + +test('preserves the upstream failure fields beside the native RPC code', () => { + const error = { + message: 'bad field', type: 'invalid_request_error', code: 'invalid_value', param: 'temperature', + provider_specific_fields: { request_id: 'req_1' }, retry_after: 10, + }; + const result = restateGeminiGenerateContentError(...upstreamOf(400, { error })); + const expected = { + code: 400, status: 'INVALID_ARGUMENT', message: 'bad field', + details: [{ + '@type': 'type.googleapis.com/google.protobuf.Struct', + value: { type: 'invalid_request_error', code: 'invalid_value', param: 'temperature', request_id: 'req_1', retry_after: 10 }, + }], + }; + assertEquals(decoded(result).error, expected); + assertEquals(geminiGenerateContentErrorFromIR(errorPayloadFromWire(error), 400), expected); +}); + +test('context overflow determines both the HTTP status and body code', () => { + const error = { message: 'too long', code: 'context_length_exceeded' }; + const result = restateGeminiGenerateContentError(...upstreamOf(502, { error })); + assertEquals(result!.status, 400); + assertEquals(decoded(result).error.code, 400); + assertEquals(decoded(result).error.status, 'INVALID_ARGUMENT'); + assertEquals(decoded(result).error, geminiGenerateContentErrorFromIR(error)); +}); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts index 667d1a7eeb..fcdc09e3ba 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts @@ -376,7 +376,6 @@ test('translateToSourceEvents translates OpenAI Responses error events to OpenAI if (frames[0].type !== 'event') throw new Error('expected event frame'); assertEquals((frames[0].event as unknown as Record).error, { message: 'upstream overloaded', - type: 'overloaded_error', code: 'overloaded_error', }); }); @@ -409,7 +408,7 @@ test('translateToSourceEvents translates OpenAI Responses failed terminal events }); }); -test.each(['flat error', 'nested error', 'failed response'])('translateToSourceEvents preserves the error provider namespace without copying (%s)', async path => { +test.each(['flat error', 'nested error', 'failed response'])('translateToSourceEvents preserves the error provider namespace and colliding loose fields (%s)', async path => { const provider_specific_fields = { name: 'TranslateInputException', stack: 'translator stack', @@ -432,11 +431,13 @@ test.each(['flat error', 'nested error', 'failed response'])('translateToSourceE const translated = (frames[0].event as unknown as { error: Record }).error; expect(translated).toEqual({ message: error.message, - type: path === 'flat error' ? error.code : error.type, + ...(path === 'flat error' ? {} : { type: error.type }), code: error.code, - provider_specific_fields, + provider_specific_fields: { + ...provider_specific_fields, + name_1: error.name, stack_1: error.stack, cause_1: error.cause, target_api_1: error.target_api, + }, }); - expect(translated.provider_specific_fields).toBe(provider_specific_fields); const sse = writeChatFrame(frames[0], includeUsageChunk); if (sse === null) throw new Error('expected error SSE frame'); expect(JSON.parse(sse.data)).toEqual({ error: translated }); diff --git a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts b/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts index c47c4cd04e..1e177c38f0 100644 --- a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts @@ -60,7 +60,7 @@ test('translateToSourceEvents translates Anthropic Messages error terminal and s eventFrame({ type: 'error', message: 'upstream overloaded', - code: 'overloaded_error', + provider_specific_fields: { type: 'overloaded_error' }, sequence_number: 0, }), ); diff --git a/packages/translate/__tests__/shared/api-error_test.ts b/packages/translate/__tests__/shared/api-error_test.ts new file mode 100644 index 0000000000..53ad7b7095 --- /dev/null +++ b/packages/translate/__tests__/shared/api-error_test.ts @@ -0,0 +1,132 @@ +import { expect, test, vi } from 'vitest'; + +import { routeOpenAIChatCompletionsViaOpenAIResponses } from '../../src/index.ts'; +import { errorPayloadFromWire, irErrorPayloadFromBody } from '../../src/shared/api-error.ts'; +import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; + +const createTrip = async () => { + const trip = await routeOpenAIChatCompletionsViaOpenAIResponses({ + model: 'm', stream: true, messages: [{ role: 'user', content: 'hello' }], + }, { model: 'm', sidecarCodec: createTestSidecarCodec() }); + if (trip.type !== 'trip') throw new Error(trip.message); + return trip; +}; + +test('error restatement retains nullable fields, unused aliases and outer metadata', async () => { + const trip = await createTrip(); + const result = trip.error(400, new Headers({ 'retry-after': '5' }), new TextEncoder().encode(JSON.stringify({ + error: { message: 'bad field', code: null, param: null, status: 'INVALID_ARGUMENT' }, request_id: 'req_1', + }))); + expect(result?.body).toEqual({ + error: { + message: 'bad field', code: null, param: null, + provider_specific_fields: { status: 'INVALID_ARGUMENT', request_id: 'req_1' }, + }, + }); + expect(result?.status).toBe(400); + expect(result?.headers.get('retry-after')).toBe('5'); +}); + +test('unrecognized encoding and nonobject JSON leave the upstream body untouched', async () => { + const trip = await createTrip(); + const invalid = new Uint8Array([ + ...new TextEncoder().encode('{"error":{"message":"'), 255, ...new TextEncoder().encode('"}}'), + ]); + expect(trip.error(500, new Headers(), invalid)).toBeUndefined(); + for (const body of ['null', '[]', '502 Bad Gateway', 'some upstream prose: exceeds the context window of this model']) { + expect(trip.error(500, new Headers(), new TextEncoder().encode(body))).toBeUndefined(); + } +}); + +test('an internal error from JSON parsing propagates with its original cause', async () => { + const trip = await createTrip(); + const failure = new Error('internal parser failure', { cause: new Error('underlying failure') }); + const json = JSON as typeof JSON & { rawJSON: (text: string) => unknown }; + const spy = vi.spyOn(json, 'rawJSON').mockImplementation(() => { throw failure; }); + try { + let caught: unknown; + try { + trip.error(500, new Headers(), new TextEncoder().encode('{"error":{"message":"bad","retry_after":1.0}}')); + } catch (error) { + caught = error; + } + expect(spy).toHaveBeenCalledOnce(); + expect(caught).toBe(failure); + expect(failure.cause).toBeInstanceOf(Error); + expect((caught as Error).cause).toBe(failure.cause); + } finally { + spy.mockRestore(); + } +}); + +test('consumes msg only when it supplies the canonical message', () => { + const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value)); + expect(irErrorPayloadFromBody(encode({ error: { message: 'primary', msg: 'secondary' } }))).toEqual({ + message: 'primary', provider_specific_fields: { msg: 'secondary' }, + }); + expect(irErrorPayloadFromBody(encode({ error: { msg: 'alias', trace_id: 'trace' } }))).toEqual({ + message: 'alias', provider_specific_fields: { trace_id: 'trace' }, + }); + expect(errorPayloadFromWire({ msg: 'alias', trace_id: 'trace' })).toEqual({ message: 'alias', trace_id: 'trace' }); +}); + +test('canonical wire errors retain object identity', () => { + const error = { message: 'failed', type: 'upstream_type', code: 'upstream_code', param: null, request_id: 'req_1' }; + expect(errorPayloadFromWire(error)).toBe(error); + expect(errorPayloadFromWire({ error })).toBe(error); + expect(errorPayloadFromWire({ type: 'error', sequence_number: 1, error })).toBe(error); + const nativeCategory = { ...error, type: 'error' }; + expect(errorPayloadFromWire(nativeCategory)).toBe(nativeCategory); +}); + +test('envelope transport fields stay outside the error metadata', () => { + const body = new TextEncoder().encode(JSON.stringify({ + type: 'error', sequence_number: 9, request_id: 'req_1', error: { message: 'failed', code: null }, + })); + expect(irErrorPayloadFromBody(body)).toEqual({ + message: 'failed', code: null, provider_specific_fields: { request_id: 'req_1' }, + }); +}); + +test('error metadata collisions reserve original suffix keys across every namespace', () => { + const body = new TextEncoder().encode(JSON.stringify({ + error: { + message: 'failed', trace: 'inner loose', + provider_specific_fields: { trace: 'inner container' }, + }, + trace: 'outer loose', trace_1: 'original suffix', + provider_specific_fields: { trace: 'outer container' }, + })); + expect(irErrorPayloadFromBody(body)).toEqual({ + message: 'failed', + provider_specific_fields: { + trace: 'inner container', trace_1: 'original suffix', + trace_2: 'inner loose', trace_3: 'outer container', trace_4: 'outer loose', + }, + }); +}); + +test('equal values at one metadata key coalesce across namespaces without consuming explicit suffix keys', () => { + const body = new TextEncoder().encode(JSON.stringify({ + error: { + message: 'failed', trace: { region: 'us', attempts: [1, false, null] }, + provider_specific_fields: { trace: 'preferred' }, + }, + trace: { attempts: [1, false, null], region: 'us' }, + trace_1: { region: 'us', attempts: [1, false, null] }, + provider_specific_fields: { trace: { region: 'us', attempts: [1, false, null] } }, + })); + expect(irErrorPayloadFromBody(body)).toEqual({ + message: 'failed', + provider_specific_fields: { + trace: 'preferred', trace_1: { region: 'us', attempts: [1, false, null] }, + trace_2: { region: 'us', attempts: [1, false, null] }, + }, + }); +}); + +test('metadata equality preserves distinct raw number tokens and coalesces identical tokens', () => { + const body = new TextEncoder().encode('{"error":{"message":"failed","amount":9007199254740993,"same":{"amount":1.00},"provider_specific_fields":{"amount":9007199254740992,"same":{"amount":1.00}}}}'); + const payload = irErrorPayloadFromBody(body); + expect(JSON.stringify(payload)).toBe('{"message":"failed","provider_specific_fields":{"amount":9007199254740992,"same":{"amount":1.00},"amount_1":9007199254740993}}'); +}); diff --git a/packages/translate/src/ir/output/error.ts b/packages/translate/src/ir/output/error.ts index edd52c87e6..9afe1b8922 100644 --- a/packages/translate/src/ir/output/error.ts +++ b/packages/translate/src/ir/output/error.ts @@ -1,5 +1,6 @@ // The error fields upstream protocols agree on; each writer picks the ones its protocol defines. export interface IRErrorPayload { + readonly [field: string]: unknown; readonly message: string; readonly type?: string; readonly code?: string | null; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/context-window-error.ts b/packages/translate/src/protocols/anthropic-messages/as-source/context-window-error.ts deleted file mode 100644 index d4d4a50239..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/as-source/context-window-error.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { isContextExceededError } from '../../../shared/context-window-error.ts'; -import type { ApiErrorTranslator } from '../../../types.ts'; -import { PROMPT_TOO_LONG_MESSAGE } from '@floway-dev/protocols/anthropic-messages'; - -const isContextExceededErrorText = (text: string): boolean => { - try { - const parsed = JSON.parse(text) as { error?: unknown }; - return parsed !== null && typeof parsed === 'object' && isContextExceededError(parsed.error); - } catch { - return isContextExceededError({ message: text }); - } -}; - -// Shared `TranslateTrip.error` implementation for the `anthropic-messages-via-*` -// pairs. Rewrites an upstream context-exceeded body into an Anthropic -// prompt-too-long envelope; passes anything else through unchanged. -export const rewriteContextExceededToPromptTooLong: ApiErrorTranslator = (_status, _headers, body) => { - if (!isContextExceededErrorText(new TextDecoder().decode(body))) return undefined; - return { - status: 400, - headers: new Headers({ 'content-type': 'application/json' }), - body: { type: 'error', error: { type: 'invalid_request_error', message: PROMPT_TOO_LONG_MESSAGE } }, - }; -}; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/error-body.ts b/packages/translate/src/protocols/anthropic-messages/as-source/error-body.ts new file mode 100644 index 0000000000..6ca5ff6f0f --- /dev/null +++ b/packages/translate/src/protocols/anthropic-messages/as-source/error-body.ts @@ -0,0 +1,11 @@ +import { anthropicMessagesErrorEnvelope } from './error.ts'; +import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; +import type { ApiErrorTranslator } from '../../../types.ts'; + +/** Reuse the stream error envelope for HTTP failures; unrecognized bodies remain verbatim upstream responses. */ +export const restateAnthropicMessagesError: ApiErrorTranslator = (upstreamStatus, headers, upstreamBody) => { + const error = irErrorPayloadFromBody(upstreamBody); + if (error === undefined) return undefined; + const { body, status } = anthropicMessagesErrorEnvelope(error); + return restateError(status ?? upstreamStatus, headers, body); +}; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/error.ts b/packages/translate/src/protocols/anthropic-messages/as-source/error.ts new file mode 100644 index 0000000000..f202d2f124 --- /dev/null +++ b/packages/translate/src/protocols/anthropic-messages/as-source/error.ts @@ -0,0 +1,37 @@ +import type { IRErrorPayload } from '../../../ir/output/error.ts'; +import { errorDetailsOf } from '../../../shared/api-error.ts'; +import { isContextExceededError } from '../../../shared/context-window-error.ts'; +import { PROMPT_TOO_LONG_MESSAGE, type AnthropicMessagesErrorEventEx } from '@floway-dev/protocols/anthropic-messages'; + +// https://platform.claude.com/docs/en/api/errors#http-errors +const MESSAGES_ERROR_TYPES = [ + 'invalid_request_error', + 'authentication_error', + 'permission_error', + 'not_found_error', + 'rate_limit_error', + 'api_error', + 'overloaded_error', +]; + +/** HTTP and SSE share the Messages error object. Context overflow uses the shared prompt-too-long compatibility message. */ +const messagesError = (error: IRErrorPayload, contextExceeded: boolean): AnthropicMessagesErrorEventEx['error'] => { + const type = contextExceeded + ? 'invalid_request_error' + : error.type !== undefined && MESSAGES_ERROR_TYPES.includes(error.type) ? error.type : 'api_error'; + const details = errorDetailsOf(error, type === error.type ? ['message', 'type'] : ['message']); + const extension = details === undefined ? {} : { provider_specific_fields: details }; + return { type, message: contextExceeded ? PROMPT_TOO_LONG_MESSAGE : error.message, ...extension }; +}; + +export const anthropicMessagesErrorFromIR = (error: IRErrorPayload): AnthropicMessagesErrorEventEx['error'] => + messagesError(error, isContextExceededError(error)); + +/** Classify overflow once so its compatibility message and our chosen 400 mapping stay consistent. */ +export const anthropicMessagesErrorEnvelope = (error: IRErrorPayload): { readonly body: unknown; readonly status: number | undefined } => { + const contextExceeded = isContextExceededError(error); + return { + body: { type: 'error', error: messagesError(error, contextExceeded) }, + status: contextExceeded ? 400 : undefined, + }; +}; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/events.ts b/packages/translate/src/protocols/anthropic-messages/as-source/events.ts index 9c694e9b0a..0799b039f8 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/events.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/events.ts @@ -1,9 +1,9 @@ +import { anthropicMessagesErrorFromIR } from './error.ts'; import { collectAnthropicMessagesTextPlacements, computeAnthropicMessagesTurnDigest, createAnthropicMessagesSidecarBlock } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; import type { IRJSONObject } from '../../../ir/common/json.ts'; import { IRAbsent } from '../../../ir/output/absent.ts'; import type { IRCollector } from '../../../ir/output/collector.ts'; -import type { IRErrorPayload } from '../../../ir/output/error.ts'; import { isIRItemPath, isIRPartPath, irPathKey } from '../../../ir/output/operation-paths.ts'; import type { IRPath, IRRecord } from '../../../ir/output/records.ts'; import { consumeIRRecords } from '../../../ir/output/stream.ts'; @@ -13,7 +13,6 @@ import { createIRUnitQueue } from '../../../ir/output/unit-queue.ts'; import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/provenance.ts'; import { appendEmittedField, createEmittedFieldState, type EmittedFieldState, type EmittedFieldStates } from '../../../round-trip/reference/reference-builder.ts'; -import { isContextExceededError } from '../../../shared/context-window-error.ts'; import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; import { parseJSONObjectUnlessTruncated } from '../../../shared/json/completion.ts'; import { parseJSONObject, requireJSONObject } from '../../../shared/json/object.ts'; @@ -21,10 +20,8 @@ import type { AnthropicMessagesInputAssistantTurnState, AnthropicMessagesInputAs import { generateAnthropicId, createAnthropicMessagesEventAccumulator, - PROMPT_TOO_LONG_MESSAGE, type AnthropicMessagesAssistantContentBlock, type AnthropicMessagesContentBlockDeltaEvent, - type AnthropicMessagesErrorEventEx, type AnthropicMessagesResult, type AnthropicMessagesStreamEventEx, type AnthropicMessagesUsage, @@ -32,20 +29,6 @@ import { import { isJsonObject } from '@floway-dev/protocols/common'; import { eventFrame, type EventFrame } from '@floway-dev/protocols/common'; -const MESSAGES_ERROR_TYPES = [ - 'invalid_request_error', - 'authentication_error', - 'permission_error', - 'not_found_error', - 'rate_limit_error', - 'api_error', - 'overloaded_error', -]; - -const irMessagesError = (error: IRErrorPayload): AnthropicMessagesErrorEventEx['error'] => isContextExceededError(error) - ? { type: 'invalid_request_error', message: PROMPT_TOO_LONG_MESSAGE } - : { type: error.type !== undefined && MESSAGES_ERROR_TYPES.includes(error.type) ? error.type : 'api_error', message: error.message }; - interface MessagesUnit { readonly path: IRPath; readonly kind: 'text' | 'refusal' | 'audio' | 'image' | 'reasoning' | 'function_call'; @@ -223,7 +206,7 @@ export const writeAnthropicMessagesEvents = async function* ( for await (const { record, collector } of consumeIRRecords(records)) { const { state } = collector; - if (record.type === 'error') { yield emit({ type: 'error', error: irMessagesError(record.error) }); return; } + if (record.type === 'error') { yield emit({ type: 'error', error: anthropicMessagesErrorFromIR(record.error) }); return; } if (record.type === 'write' && record.path[0] === 'model' && typeof record.value === 'string') metadata.model = record.value; if (record.type === 'start') { if (state.model === undefined) throw new TypeError('Messages output requires an IR model before start'); diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/translate.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/translate.ts index b491772328..6bd9c0ade8 100644 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/translate.ts +++ b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/translate.ts @@ -2,7 +2,7 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import type { Translator } from '../../../types.ts'; -import { rewriteContextExceededToPromptTooLong } from '../as-source/context-window-error.ts'; +import { restateAnthropicMessagesError } from '../as-source/error-body.ts'; import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; @@ -15,7 +15,7 @@ export const translate: Translator< type: 'trip', target, events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), - error: rewriteContextExceededToPromptTooLong, + error: restateAnthropicMessagesError, }; } catch (error) { if (error instanceof TranslateInputException) return error.failure; diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/translate.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/translate.ts index bdec21ae2e..83581fc501 100644 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/translate.ts +++ b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/translate.ts @@ -2,7 +2,7 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import type { Translator } from '../../../types.ts'; -import { rewriteContextExceededToPromptTooLong } from '../as-source/context-window-error.ts'; +import { restateAnthropicMessagesError } from '../as-source/error-body.ts'; import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; @@ -14,7 +14,7 @@ export const translate: Translator< type: 'trip', target: await buildTargetRequest(src, ctx.sidecarCodec), events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), - error: rewriteContextExceededToPromptTooLong, + error: restateAnthropicMessagesError, }; } catch (error) { if (error instanceof TranslateInputException) return error.failure; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/error-body.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/error-body.ts new file mode 100644 index 0000000000..bb8995b3c9 --- /dev/null +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/error-body.ts @@ -0,0 +1,14 @@ +import { geminiGenerateContentErrorFromIR } from './error.ts'; +import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; +import type { ApiErrorTranslator } from '../../../types.ts'; + +/** + * Restate recognized failures as RPC error envelopes using the native error constructor's status. + * Unrecognized bodies pass through verbatim. + */ +export const restateGeminiGenerateContentError: ApiErrorTranslator = (upstreamStatus, headers, upstreamBody) => { + const error = irErrorPayloadFromBody(upstreamBody); + if (error === undefined) return undefined; + const restated = geminiGenerateContentErrorFromIR(error, upstreamStatus); + return restateError(restated.code, headers, { error: restated }); +}; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts new file mode 100644 index 0000000000..81b9456626 --- /dev/null +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts @@ -0,0 +1,21 @@ +import type { IRErrorPayload } from '../../../ir/output/error.ts'; +import { errorDetailsOf } from '../../../shared/api-error.ts'; +import { isContextExceededError } from '../../../shared/context-window-error.ts'; +import { geminiGenerateContentStatusForHttpStatus, type GeminiGenerateContentErrorResponse } from '@floway-dev/protocols/gemini-generate-content'; + +/** + * HTTP failures supply an upstream status; streaming failures derive a code from the error kind. + * Context overflow selects our local 400 code. + */ +export const geminiGenerateContentErrorFromIR = (error: IRErrorPayload, httpStatus?: number): GeminiGenerateContentErrorResponse['error'] => { + const details = errorDetailsOf(error, ['message']); + // Preserve unmodeled diagnostic fields in a chosen Struct Any inside details. + // https://protobuf.dev/reference/protobuf/google.protobuf/#struct + const extension = details === undefined ? {} : { details: [{ '@type': 'type.googleapis.com/google.protobuf.Struct', value: details }] }; + const kind = error.code ?? error.type; + const code = httpStatus !== undefined && !isContextExceededError(error) ? httpStatus + : kind === 'rate_limit_exceeded' || kind === 'rate_limit_error' ? 429 + : kind === 'invalid_prompt' || kind === 'invalid_request_error' || isContextExceededError(error) ? 400 + : kind === 'overloaded_error' ? 503 : 500; + return { code, status: geminiGenerateContentStatusForHttpStatus(code) ?? 'UNKNOWN', message: error.message, ...extension }; +}; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts index 97d6616732..69591f76e0 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts @@ -1,9 +1,9 @@ +import { geminiGenerateContentErrorFromIR } from './error.ts'; import { collectGeminiGenerateContentTextPlacements, computeGeminiGenerateContentTurnDigest, createGeminiGenerateContentSidecarPart } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; import type { IRJSONObject } from '../../../ir/common/json.ts'; import { IRAbsent } from '../../../ir/output/absent.ts'; import type { IRCollector } from '../../../ir/output/collector.ts'; -import type { IRErrorPayload } from '../../../ir/output/error.ts'; import { irItemPath, irPartPath, irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/output/operation-paths.ts'; import type { IRPath, IRRecord } from '../../../ir/output/records.ts'; import { consumeIRRecords } from '../../../ir/output/stream.ts'; @@ -12,7 +12,6 @@ import { createIRUnitQueue, type IRUnitQueue } from '../../../ir/output/unit-que import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/provenance.ts'; import { appendEmittedField, createEmittedFieldState, type EmittedFieldStates, type EmittedFieldState } from '../../../round-trip/reference/reference-builder.ts'; -import { isContextExceededError } from '../../../shared/context-window-error.ts'; import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; import { parseJSONObjectUnlessTruncated } from '../../../shared/json/completion.ts'; import { isCompleteJSONObject } from '../../../shared/json/object.ts'; @@ -21,7 +20,6 @@ import type { GeminiGenerateContentInputAssistantTurnState, GeminiGenerateConten import { eventFrame, FAST_SERVICE_TIER, type EventFrame } from '@floway-dev/protocols/common'; import { type GeminiGenerateContentCandidate, - type GeminiGenerateContentErrorResponse, type GeminiGenerateContentGroundingChunk, type GeminiGenerateContentPart, type GeminiGenerateContentResult, @@ -29,14 +27,6 @@ import { createGeminiGenerateContentEventAccumulator, } from '@floway-dev/protocols/gemini-generate-content'; -const irGenerateContentError = (error: IRErrorPayload): GeminiGenerateContentErrorResponse['error'] => { - const code = error.code ?? error.type; - const [status, statusCode] = code === 'rate_limit_exceeded' || code === 'rate_limit_error' ? ['RESOURCE_EXHAUSTED', 429] - : code === 'invalid_prompt' || code === 'invalid_request_error' || isContextExceededError(error) ? ['INVALID_ARGUMENT', 400] - : code === 'overloaded_error' ? ['UNAVAILABLE', 503] : ['INTERNAL', 500]; - return { code: statusCode, status, message: error.message }; -}; - interface GenerateContentUnit { readonly path: IRPath; readonly kind: 'text' | 'refusal' | 'audio' | 'image' | 'reasoning' | 'function_call'; @@ -357,7 +347,7 @@ export const writeGeminiGenerateContentEvents = async function* ( for await (const { record, collector } of consumeIRRecords(records)) { const { state } = collector; - if (record.type === 'error') { yield eventFrame({ error: irGenerateContentError(record.error) }); return; } + if (record.type === 'error') { yield eventFrame({ error: geminiGenerateContentErrorFromIR(record.error) }); return; } if (record.type === 'write' && record.path[0] === 'model' && typeof record.value === 'string') metadata.model = record.value; if (record.type === 'start') { if (state.model === undefined) throw new TypeError('GenerateContent output requires an IR model before start'); diff --git a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/translate.ts b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/translate.ts index bbd30e7ffc..c2fd71fb32 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/translate.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/translate.ts @@ -2,6 +2,7 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import type { Translator } from '../../../types.ts'; +import { restateGeminiGenerateContentError } from '../as-source/error-body.ts'; import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; @@ -12,7 +13,7 @@ export const translate: Translator< try { return { type: 'trip', - error: () => undefined, + error: restateGeminiGenerateContentError, target: await buildTargetRequest(src, ctx.model, ctx.sidecarCodec, { fallbackMaxOutputTokens: ctx.fallbackMaxOutputTokens }), events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), }; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/translate.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/translate.ts index b95fd0c535..5772e145a0 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/translate.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/translate.ts @@ -2,6 +2,7 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import type { Translator } from '../../../types.ts'; +import { restateGeminiGenerateContentError } from '../as-source/error-body.ts'; import type { GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; @@ -10,7 +11,7 @@ export const translate: Translator< > = async (src, ctx) => { try { const target = await buildTargetRequest(src, ctx.model, ctx.sidecarCodec); - return { type: 'trip', error: () => undefined, target, events: frames => translateToSourceEvents(frames, ctx.sidecarCodec) }; + return { type: 'trip', error: restateGeminiGenerateContentError, target, events: frames => translateToSourceEvents(frames, ctx.sidecarCodec) }; } catch (error) { if (error instanceof TranslateInputException) return error.failure; throw error; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/translate.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/translate.ts index 240d232135..f483efe36f 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/translate.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/translate.ts @@ -2,6 +2,7 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import type { Translator } from '../../../types.ts'; +import { restateGeminiGenerateContentError } from '../as-source/error-body.ts'; import type { GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; @@ -11,7 +12,7 @@ export const translate: Translator< try { return { type: 'trip', - error: () => undefined, + error: restateGeminiGenerateContentError, target: await buildTargetRequest(src, ctx.model, ctx.sidecarCodec), events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), }; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/error-body.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/error-body.ts new file mode 100644 index 0000000000..4018a309b8 --- /dev/null +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/error-body.ts @@ -0,0 +1,10 @@ +import { openaiChatCompletionsErrorFromIR } from './error.ts'; +import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; +import type { ApiErrorTranslator } from '../../../types.ts'; + +/** Reuse the stream error object, preserve upstream HTTP status, and pass unrecognized bodies through unchanged. */ +export const restateOpenAIChatCompletionsError: ApiErrorTranslator = (upstreamStatus, headers, upstreamBody) => { + const error = irErrorPayloadFromBody(upstreamBody); + if (error === undefined) return undefined; + return restateError(upstreamStatus, headers, { error: openaiChatCompletionsErrorFromIR(error) }); +}; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/error.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/error.ts new file mode 100644 index 0000000000..d3b5f34c72 --- /dev/null +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/error.ts @@ -0,0 +1,13 @@ +import type { IRErrorPayload } from '../../../ir/output/error.ts'; +import { errorDetailsOf } from '../../../shared/api-error.ts'; + +export const openaiChatCompletionsErrorFromIR = (error: IRErrorPayload) => { + const details = errorDetailsOf(error, ['message', 'type', 'code', 'param']); + return { + message: error.message, + ...(error.type === undefined ? {} : { type: error.type }), + ...(error.code === undefined ? {} : { code: error.code }), + ...(error.param === undefined ? {} : { param: error.param }), + ...(details === undefined ? {} : { provider_specific_fields: details }), + }; +}; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts index 255b34cfb8..d7d7a67877 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts @@ -1,7 +1,7 @@ +import { openaiChatCompletionsErrorFromIR } from './error.ts'; import { collectOpenAIChatCompletionsTextPlacements, computeOpenAIChatCompletionsTurnDigest, createOpenAIChatCompletionsSidecarDetails } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; import { IRAbsent } from '../../../ir/output/absent.ts'; -import type { IRErrorPayload } from '../../../ir/output/error.ts'; import { irItemPath, irPartPath, irPathKey } from '../../../ir/output/operation-paths.ts'; import type { IRRecord } from '../../../ir/output/records.ts'; import { consumeIRRecords } from '../../../ir/output/stream.ts'; @@ -23,14 +23,6 @@ import { createOpenAIChatCompletionsEventAccumulator, } from '@floway-dev/protocols/openai-chat-completions'; -const irChatError = (error: IRErrorPayload): IRErrorPayload => ({ - message: error.message, - type: typeof error.type === 'string' && error.type !== 'error' ? error.type : error.code ?? 'server_error', - ...(error.code === undefined ? {} : { code: error.code }), - ...(error.param === undefined ? {} : { param: error.param }), - ...(error.provider_specific_fields === undefined ? {} : { provider_specific_fields: error.provider_specific_fields }), -}); - interface OpenAIChatCompletionsEventWriterOptions extends SourceEventWriterOptions<'openaiChatCompletions'> { // Replay identity belongs to the newly emitted target audio, not the source IR content. audioMetadata?: (choice: number) => { id: string; expires_at: number }; @@ -176,7 +168,7 @@ export const writeOpenAIChatCompletionsEvents = async function* ( for await (const { record, collector } of consumeIRRecords(records)) { const { state } = collector; if (record.type === 'error') { - yield eventFrame({ error: irChatError(record.error) } as unknown as OpenAIChatCompletionsStreamEvent); + yield eventFrame({ error: openaiChatCompletionsErrorFromIR(record.error) } as unknown as OpenAIChatCompletionsStreamEvent); return; } if (record.type === 'write' && record.path[0] === 'model' && typeof record.value === 'string') metadata.model = record.value; diff --git a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/translate.ts b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/translate.ts index 588339a6bf..864f6867c6 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/translate.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/translate.ts @@ -2,6 +2,7 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import type { RemoteImageLoader, Translator } from '../../../types.ts'; +import { restateOpenAIChatCompletionsError } from '../as-source/error-body.ts'; import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; @@ -13,7 +14,7 @@ export const translate: Translator< const streamOptions = { ...src.stream_options }; return { type: 'trip', - error: () => undefined, + error: restateOpenAIChatCompletionsError, target: await buildTargetRequest(src, ctx.sidecarCodec, { fallbackMaxOutputTokens: ctx.fallbackMaxOutputTokens, loadRemoteImage: ctx.loadRemoteImage, diff --git a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/translate.ts b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/translate.ts index cbd5549db7..27fbb53a80 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/translate.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/translate.ts @@ -2,6 +2,7 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import type { Translator } from '../../../types.ts'; +import { restateOpenAIChatCompletionsError } from '../as-source/error-body.ts'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; @@ -12,7 +13,7 @@ export const translate: Translator< const streamOptions = { ...src.stream_options }; return { type: 'trip', - error: () => undefined, + error: restateOpenAIChatCompletionsError, target: await buildTargetRequest(src, ctx.sidecarCodec), events: frames => translateToSourceEvents(frames, streamOptions, ctx.sidecarCodec), }; diff --git a/packages/translate/src/protocols/openai-responses/as-source/error-body.ts b/packages/translate/src/protocols/openai-responses/as-source/error-body.ts new file mode 100644 index 0000000000..196dbc7dc5 --- /dev/null +++ b/packages/translate/src/protocols/openai-responses/as-source/error-body.ts @@ -0,0 +1,13 @@ +import { openaiResponsesErrorEventOf } from './error.ts'; +import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; +import type { ApiErrorTranslator } from '../../../types.ts'; + +/** + * Before a response resource exists, render recognized failures as Responses error events. + * Preserve upstream status and unrecognized bodies. + */ +export const restateOpenAIResponsesError: ApiErrorTranslator = (upstreamStatus, headers, upstreamBody) => { + const error = irErrorPayloadFromBody(upstreamBody); + if (error === undefined) return undefined; + return restateError(upstreamStatus, headers, openaiResponsesErrorEventOf(error)); +}; diff --git a/packages/translate/src/protocols/openai-responses/as-source/error.ts b/packages/translate/src/protocols/openai-responses/as-source/error.ts new file mode 100644 index 0000000000..6078ab9c22 --- /dev/null +++ b/packages/translate/src/protocols/openai-responses/as-source/error.ts @@ -0,0 +1,14 @@ +import type { IRErrorPayload } from '../../../ir/output/error.ts'; +import { errorDetailsOf } from '../../../shared/api-error.ts'; +import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; + +export const openaiResponsesErrorEventOf = (error: IRErrorPayload): OpenAIResponsesStreamEventEx => { + const details = errorDetailsOf(error, ['message', 'code', 'param']); + return { + type: 'error', + message: error.message, + ...(error.code === undefined ? {} : { code: error.code }), + ...(error.param === undefined ? {} : { param: error.param }), + ...(details === undefined ? {} : { provider_specific_fields: details }), + }; +}; diff --git a/packages/translate/src/protocols/openai-responses/as-source/events.ts b/packages/translate/src/protocols/openai-responses/as-source/events.ts index aea752d90a..73a5c06f87 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/events.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/events.ts @@ -1,3 +1,4 @@ +import { openaiResponsesErrorEventOf } from './error.ts'; import { restoreNamespaceOutputItem, type NamespaceToolNames } from './namespace-tools.ts'; import { createRandomOpenAIResponsesResponseId } from './response-id.ts'; import { collectOpenAIResponsesTextPlacements, computeOpenAIResponsesTurnDigest, createOpenAIResponsesSidecarItem } from './round-trip.ts'; @@ -615,12 +616,7 @@ export const writeOpenAIResponsesEvents = async function* ( for await (const { record, collector } of consumeIRRecords(records)) { const { state } = collector; if (record.type === 'error') { - yield emit({ - type: 'error', - message: record.error.message, - code: record.error.code ?? record.error.type, - ...(record.error.param === undefined ? {} : { param: record.error.param }), - }); + yield emit(openaiResponsesErrorEventOf(record.error)); return; } if (record.type === 'write' && record.path[0] === 'model' && typeof record.value === 'string') metadata.model = record.value; diff --git a/packages/translate/src/protocols/openai-responses/as-target/events.ts b/packages/translate/src/protocols/openai-responses/as-target/events.ts index 008e841c7e..24ca5559a4 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/events.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/events.ts @@ -5,6 +5,7 @@ import type { IRRecord } from '../../../ir/output/records.ts'; import type { IROutputSourceCitation } from '../../../ir/output/types.ts'; import type { RoundTripReader } from '../../../round-trip/reader.ts'; import { appendReadField, createReadFieldState, invalidateReadField, type ReadFieldStates } from '../../../round-trip/reference/reference-builder.ts'; +import { errorPayloadFromEvent, errorPayloadFromWire } from '../../../shared/api-error.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { @@ -542,10 +543,14 @@ export const readOpenAIResponsesEvents = async function* ( for await (const frame of frames) { if (frame.type === 'done') throw new Error('Responses done arrived without a terminal response'); const e = frame.event; - if (e.type === 'error') { ir.event({ type: 'error', error: 'error' in e ? e.error ?? e : e }); yield* ir.drain(); return; } + if (e.type === 'error') { + ir.event({ type: 'error', error: errorPayloadFromEvent(e) }); + yield* ir.drain(); + return; + } if (e.type === 'response.failed' && e.response.error?.code !== 'bio_policy' && e.response.error?.code !== 'cyber_policy') { // `error` is nullable on a Response, so a failure may carry no description. - ir.event({ type: 'error', error: e.response.error ?? { message: 'Response failed due to unknown error.' } }); + ir.event({ type: 'error', error: errorPayloadFromWire(e.response.error ?? { message: 'Response failed due to unknown error.' }) }); yield* ir.drain(); return; } diff --git a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/translate.ts b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/translate.ts index 7f71c1c4ed..4c8618bc35 100644 --- a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/translate.ts +++ b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/translate.ts @@ -2,6 +2,7 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import type { RemoteImageLoader, Translator } from '../../../types.ts'; +import { restateOpenAIResponsesError } from '../as-source/error-body.ts'; import { restoreNamespaceEvents } from '../as-source/namespace-tools.ts'; import { createRandomOpenAIResponsesResponseId } from '../as-source/response-id.ts'; import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; @@ -23,7 +24,7 @@ export const translate: Translator< return { type: 'trip', - error: () => undefined, + error: restateOpenAIResponsesError, target, events: frames => restoreNamespaceEvents(translateToSourceEvents(frames, responseId, customToolNames, ctx.sidecarCodec, namespaceToolNames), namespaceToolNames), }; diff --git a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/translate.ts b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/translate.ts index 4ff7f977ec..b349d18130 100644 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/translate.ts +++ b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/translate.ts @@ -2,6 +2,7 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import type { Translator } from '../../../types.ts'; +import { restateOpenAIResponsesError } from '../as-source/error-body.ts'; import { restoreNamespaceEvents } from '../as-source/namespace-tools.ts'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; @@ -13,7 +14,7 @@ export const translate: Translator< const { target, customToolNames, namespaceToolNames } = await buildTargetRequest(src, ctx.sidecarCodec); return { type: 'trip', - error: () => undefined, + error: restateOpenAIResponsesError, target, events: frames => restoreNamespaceEvents( translateToSourceEvents(frames, customToolNames, ctx.sidecarCodec, namespaceToolNames), diff --git a/packages/translate/src/shared/api-error.ts b/packages/translate/src/shared/api-error.ts new file mode 100644 index 0000000000..e39942a1f1 --- /dev/null +++ b/packages/translate/src/shared/api-error.ts @@ -0,0 +1,181 @@ +import { rawJSON } from './json/raw-json.ts'; +import type { IRErrorPayload } from '../ir/output/error.ts'; +import { parseJSONWithRawNumbers } from '@floway-dev/protocols/common'; + +const recordOf = (value: unknown): Readonly> | undefined => + typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Readonly> : undefined; + +const stringOf = (value: unknown): string | undefined => typeof value === 'string' ? value : undefined; + +/** + * Recognize structured upstream error envelopes for cross-protocol rendering. Undefined includes + * malformed JSON, invalid UTF-8 and unrecognized bodies, preserving the original response untouched. + */ +export const irErrorPayloadFromBody = (body: Uint8Array): IRErrorPayload | undefined => { + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(body); + } catch (error) { + if (!(error instanceof TypeError)) throw error; + return undefined; + } + let parsed: unknown; + try { + parsed = parseJSONWithRawNumbers(text); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + return undefined; + } + const envelopeRecord = recordOf(parsed); + if (envelopeRecord === undefined) return undefined; + const { source, details } = errorEnvelopeOf(envelopeRecord.type === 'error' ? errorEventPayloadOf(envelopeRecord) : envelopeRecord); + return errorPayloadOf(source, details); +}; + +/** Restatement requires a message; preserve every unrepresented field in diagnostic details. */ +export const errorPayloadOf = ( + error: Readonly>, + envelopeDetails: readonly Readonly>[] = [], +): IRErrorPayload | undefined => { + const message = stringOf(error.message) ?? stringOf(error.msg); + if (message === undefined) return undefined; + const type = stringOf(error.type); + const code = error.code === null ? null : stringOf(error.code) ?? stringOf(error.status); + const param = error.param === null ? null : stringOf(error.param); + const represented = new Set([ + typeof error.message === 'string' ? 'message' : 'msg', + ...(type === undefined ? [] : ['type']), + ...(code === undefined ? [] : [error.code === null || typeof error.code === 'string' ? 'code' : 'status']), + ...(param === undefined ? [] : ['param']), + ]); + const extra = extensionOf(error, represented, envelopeDetails); + return { + message, + ...(type === undefined ? {} : { type }), + ...(code === undefined ? {} : { code }), + ...(param === undefined ? {} : { param }), + ...(extra === undefined ? {} : { provider_specific_fields: extra }), + }; +}; + +const errorEnvelopeOf = (envelope: Readonly>) => { + const nested = recordOf(envelope.error); + const transportFields = envelope.type === 'error' ? ['type', 'sequence_number'] : []; + if (nested !== undefined) return { + source: nested, + details: extensionNamespacesOf(envelope, new Set(['error', ...transportFields])), + }; + return { source: envelope, details: [] }; +}; + +// After merging, loose fields live only in the container so source projection cannot add them again. +const withEnvelopeDetails = (payload: IRErrorPayload, details: readonly Readonly>[]): IRErrorPayload => + details.length === 0 ? payload : { + message: payload.message, + ...(payload.type === undefined ? {} : { type: payload.type }), + ...(payload.code === undefined ? {} : { code: payload.code }), + ...(payload.param === undefined ? {} : { param: payload.param }), + provider_specific_fields: extensionOf(payload, new Set(['message', 'type', 'code', 'param']), details), + }; + +const errorEventPayloadOf = (event: Readonly>) => { + const { type: _type, sequence_number: _sequenceNumber, ...payload } = event; + return payload; +}; + +export const errorPayloadFromEvent = (event: Readonly>): IRErrorPayload => + errorPayloadFromWire(errorEventPayloadOf(event)); + +/** Target adapters own wire shaping; source writers own extension-field projection. */ +// Canonical wire payloads retain identity when their envelope adds no metadata. +export const errorPayloadFromWire = (wire: unknown): IRErrorPayload => { + const { source, details } = errorEnvelopeOf(recordOf(wire) ?? {}); + if (hasErrorMessage(source)) return withEnvelopeDetails(source, details); + const aliasMessage = stringOf(source.msg); + if (aliasMessage !== undefined) { + const { msg: _msg, ...fields } = source; + return withEnvelopeDetails({ ...fields, message: aliasMessage }, details); + } + // Failures without message text still need an error frame; use an empty message. + const extra = extensionOf(source, NAMED_ERROR_FIELDS, details); + return { message: '', ...(extra === undefined ? {} : { provider_specific_fields: extra }) }; +}; + +const hasErrorMessage = (value: Readonly>): value is Readonly> & { readonly message: string } => + stringOf(value.message) !== undefined; +const NAMED_ERROR_FIELDS: ReadonlySet = new Set(['message', 'msg', 'type', 'code', 'status', 'param']); + +const extensionNamespacesOf = (error: Readonly>, representedFields: ReadonlySet) => { + const own = recordOf(error.provider_specific_fields); + const rest = Object.fromEntries(Object.entries(error).filter(([key]) => !representedFields.has(key) && key !== 'provider_specific_fields')); + return [...(own === undefined ? [] : [own]), ...(Object.keys(rest).length === 0 ? [] : [rest])]; +}; + +const sameJSONValue = (left: unknown, right: unknown): boolean => { + if (left === right) return true; + if (rawJSON.isRawJSON(left) || rawJSON.isRawJSON(right)) return JSON.stringify(left) === JSON.stringify(right); + if (Array.isArray(left)) return Array.isArray(right) && left.length === right.length && left.every((value, index) => sameJSONValue(value, right[index])); + const leftObject = recordOf(left); + const rightObject = recordOf(right); + if (leftObject === undefined || rightObject === undefined) return false; + const keys = Object.keys(leftObject); + return keys.length === Object.keys(rightObject).length && keys.every(key => Object.hasOwn(rightObject, key) && sameJSONValue(leftObject[key], rightObject[key])); +}; + +// Inner fields precede outer fields, and each upstream container precedes its loose fields. +// Reserve every original key before suffix allocation so generated names cannot take explicit names. +const extensionOf = ( + error: Readonly>, + representedFields: ReadonlySet, + envelopeDetails: readonly Readonly>[] = [], +): Readonly> | undefined => { + const namespaces = [...extensionNamespacesOf(error, representedFields), ...envelopeDetails]; + if (namespaces.length === 0) return undefined; + const values = new Map(); + for (const namespace of namespaces) { + for (const [key, value] of Object.entries(namespace)) { + // A named field already carries this value; another source writer can project it once. + if (representedFields.has(key) && sameJSONValue(error[key], value)) continue; + const previous = values.get(key); + if (previous === undefined) values.set(key, [value]); + else if (!previous.some(existing => sameJSONValue(existing, value))) previous.push(value); + } + } + if (namespaces.length === 1 && values.size === Object.keys(namespaces[0]).length) return namespaces[0]; + const merged: Record = Object.fromEntries([...values].map(([key, entries]) => [key, entries[0]])); + for (const [key, entries] of values) { + for (const value of entries.slice(1)) { + let suffix = 1; + while (Object.hasOwn(merged, `${key}_${suffix}`)) suffix++; + merged[`${key}_${suffix}`] = value; + } + } + return merged; +}; + +/** Each source writer lists fields represented in its envelope; all remaining values stay in diagnostic extensions. */ +export const errorDetailsOf = (error: IRErrorPayload, representedFields: readonly string[]): Readonly> | undefined => + extensionOf(error, new Set(representedFields)); + +/** + * Return a JSON envelope without transport encoding. Preserve semantic upstream headers while + * replacing the media type and removing length and encoding headers tied to the replaced body. + */ +export const restateError = ( + status: number, + headers: Headers, + body: unknown, +) => ({ + status, + headers: restatedHeaders(headers), + body, +}); + +const restatedHeaders = (headers: Headers): Headers => { + const kept = new Headers(headers); + kept.set('content-type', 'application/json'); + // A replacement body can change length and encoding, so remove both upstream body headers. + kept.delete('content-length'); + kept.delete('content-encoding'); + return kept; +};