From dfb82f8cff6ccc4c82fe033e0ac9abc90fd4a0b7 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 04:40:27 +0800 Subject: [PATCH 01/15] refactor(translate): expose prepared translation trips Replace fluent protocol subpaths with named root translators returning plain trips or input failures. Keep request builders and event adapters intact while containing expected input exceptions at public operations. Consume translation failures at gateway attempt and payload boundaries, and encode rewritten JSON errors at the transport boundary. Preserve the current model context and remote-image loader requirements for the pairwise request backend. Validate named translation and gateway suites, owning TypeScript projects, and changed lint targets. --- .../chat/anthropic-messages/errors_test.ts | 17 +- .../openai-chat-completions/errors_test.ts | 19 ++- .../chat/openai-responses/errors_test.ts | 21 ++- .../interceptors/agent-message-shim_test.ts | 35 ++-- .../interceptors/server-tool-shim_test.ts | 11 +- .../items/agent-message-content_test.ts | 15 +- ...se_test.ts => execute-translation_test.ts} | 151 +++++++++++------- .../data-plane/chat/shared/require-trip.ts | 7 + .../chat/anthropic-messages/attempt.ts | 78 +++++---- .../chat/anthropic-messages/errors.ts | 11 +- .../chat/anthropic-messages/http.ts | 16 +- .../chat/gemini-generate-content/attempt.ts | 101 +++++++----- .../chat/gemini-generate-content/errors.ts | 22 ++- .../chat/gemini-generate-content/http.ts | 9 +- .../chat/openai-chat-completions/attempt.ts | 88 +++++----- .../chat/openai-chat-completions/errors.ts | 11 +- .../chat/openai-chat-completions/http.ts | 14 +- .../chat/openai-responses/attempt.ts | 21 +-- .../chat/openai-responses/errors.ts | 6 +- .../data-plane/chat/openai-responses/http.ts | 25 +-- .../interceptors/agent-message-shim.ts | 28 ++-- .../items/agent-message-content.ts | 8 +- .../chat/openai-responses/websocket.ts | 25 +-- .../src/data-plane/chat/shared/errors.ts | 6 + ...ate-traverse.ts => execute-translation.ts} | 60 +++---- packages/gateway/src/dump/accumulator.ts | 4 +- packages/gateway/src/dump/types.ts | 2 +- packages/translate/AGENTS.md | 6 +- .../__tests__/ir/continuous-usage_test.ts | 9 +- .../as-source/context-window-error_test.ts | 14 +- .../as-source/native-reassembly_test.ts | 7 +- .../protocols/chat-legacy-round-trip_test.ts | 27 ++-- .../chat-reasoning-round-trip_test.ts | 7 +- .../as-source/native-reassembly_test.ts | 7 +- .../round-trip-request_test.ts | 9 +- .../via-openai-responses/events_test.ts | 2 +- .../via-openai-responses/request_test.ts | 4 +- .../as-source/allowed-tools_test.ts | 8 +- .../as-source/namespace-tools_test.ts | 12 +- .../canonicalize-payload_test.ts | 30 ++-- .../protocols/public-boundary_test.ts | 60 +++++++ .../__tests__/protocols/require-trip.ts | 7 + .../protocols/round-trip-receive_test.ts | 44 +++-- .../__tests__/protocols/routes_test.ts | 69 ++++---- packages/translate/package.json | 9 +- packages/translate/src/index.ts | 15 +- .../as-source/context-window-error.ts | 12 +- .../as-target/tool-input-schema.ts | 4 +- .../src/protocols/anthropic-messages/index.ts | 1 - .../src/protocols/anthropic-messages/route.ts | 8 - .../via-openai-chat-completions/request.ts | 16 +- .../via-openai-chat-completions/translate.ts | 23 ++- .../via-openai-responses/request.ts | 16 +- .../via-openai-responses/translate.ts | 21 ++- .../as-source/schema.ts | 10 +- .../gemini-generate-content/index.ts | 1 - .../gemini-generate-content/route.ts | 10 -- .../via-anthropic-messages/request.ts | 10 +- .../via-anthropic-messages/translate.ts | 20 ++- .../via-openai-chat-completions/request.ts | 12 +- .../via-openai-chat-completions/translate.ts | 14 +- .../via-openai-responses/request.ts | 10 +- .../via-openai-responses/translate.ts | 20 ++- .../openai-chat-completions/index.ts | 1 - .../openai-chat-completions/route.ts | 8 - .../via-anthropic-messages/request.ts | 18 +-- .../via-anthropic-messages/translate.ts | 28 ++-- .../via-openai-responses/content.ts | 4 +- .../via-openai-responses/request.ts | 8 +- .../via-openai-responses/translate.ts | 22 ++- .../as-source/allowed-tools.ts | 14 +- .../as-source/namespace-tools.ts | 18 +-- .../as-source/programmatic-tooling.ts | 8 +- .../openai-responses/canonicalize-payload.ts | 24 ++- .../src/protocols/openai-responses/index.ts | 2 - .../src/protocols/openai-responses/route.ts | 8 - .../via-anthropic-messages/request.ts | 46 +++--- .../via-anthropic-messages/translate.ts | 36 +++-- .../via-openai-chat-completions/content.ts | 6 +- .../via-openai-chat-completions/request.ts | 22 +-- .../via-openai-chat-completions/translate.ts | 28 ++-- packages/translate/src/protocols/routes.ts | 9 ++ packages/translate/src/shared/input-error.ts | 14 ++ .../translate/src/translator-input-error.ts | 19 --- packages/translate/src/types.ts | 82 +++------- 85 files changed, 957 insertions(+), 833 deletions(-) rename packages/gateway/__tests__/data-plane/chat/shared/{translate-traverse_test.ts => execute-translation_test.ts} (59%) create mode 100644 packages/gateway/__tests__/data-plane/chat/shared/require-trip.ts rename packages/gateway/src/data-plane/chat/shared/{translate-traverse.ts => execute-translation.ts} (61%) create mode 100644 packages/translate/__tests__/protocols/public-boundary_test.ts create mode 100644 packages/translate/__tests__/protocols/require-trip.ts delete mode 100644 packages/translate/src/protocols/anthropic-messages/index.ts delete mode 100644 packages/translate/src/protocols/anthropic-messages/route.ts delete mode 100644 packages/translate/src/protocols/gemini-generate-content/index.ts delete mode 100644 packages/translate/src/protocols/gemini-generate-content/route.ts delete mode 100644 packages/translate/src/protocols/openai-chat-completions/index.ts delete mode 100644 packages/translate/src/protocols/openai-chat-completions/route.ts delete mode 100644 packages/translate/src/protocols/openai-responses/index.ts delete mode 100644 packages/translate/src/protocols/openai-responses/route.ts create mode 100644 packages/translate/src/protocols/routes.ts create mode 100644 packages/translate/src/shared/input-error.ts delete mode 100644 packages/translate/src/translator-input-error.ts diff --git a/packages/gateway/__tests__/data-plane/chat/anthropic-messages/errors_test.ts b/packages/gateway/__tests__/data-plane/chat/anthropic-messages/errors_test.ts index bd739593e1..67385d36cf 100644 --- a/packages/gateway/__tests__/data-plane/chat/anthropic-messages/errors_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/anthropic-messages/errors_test.ts @@ -1,17 +1,16 @@ import { test } from 'vitest'; -import { translatorInputErrorResult } from '../../../../src/data-plane/chat/anthropic-messages/errors.ts'; +import { inputErrorResult } from '../../../../src/data-plane/chat/anthropic-messages/errors.ts'; import type { ApiErrorResult } from '@floway-dev/provider'; import { assert, assertEquals } from '@floway-dev/test-utils'; -import { TranslatorInputError } from '@floway-dev/translate'; -const apiErrorOf = (result: ReturnType): ApiErrorResult => result as ApiErrorResult; -const bodyOf = (result: ReturnType): Record => +const apiErrorOf = (result: ReturnType): ApiErrorResult => result as ApiErrorResult; +const bodyOf = (result: ReturnType): Record => JSON.parse(new TextDecoder().decode(apiErrorOf(result).body)); -test('translatorInputErrorResult renders an Anthropic 400 invalid_request_error envelope with top-level request_id', () => { - const result = translatorInputErrorResult( - new TranslatorInputError("Invalid 'image_url' content part in system or developer message. Only 'text' content parts are supported in system messages on this model."), +test('inputErrorResult renders an Anthropic 400 invalid_request_error envelope with top-level request_id', () => { + const result = inputErrorResult( + { message: "Invalid 'image_url' content part in system or developer message. Only 'text' content parts are supported in system messages on this model." }, ); const apiError = apiErrorOf(result); @@ -29,8 +28,8 @@ test('translatorInputErrorResult renders an Anthropic 400 invalid_request_error assert(typeof body.request_id === 'string' && /^req_[A-Za-z0-9]{24}$/.test(body.request_id), `request_id ${String(body.request_id)} must match Anthropic-shape req_<24 opaque chars>`); }); -test('translatorInputErrorResult preserves Anthropic key order: type, error, request_id', () => { - const result = translatorInputErrorResult(new TranslatorInputError('whatever')); +test('inputErrorResult preserves Anthropic key order: type, error, request_id', () => { + const result = inputErrorResult({ message: 'whatever' }); const raw = new TextDecoder().decode(apiErrorOf(result).body); // Key-order is load-bearing for byte-faithfulness with Anthropic-direct. assert(/^\{"type":"error","error":\{"type":"invalid_request_error","message":"whatever"\},"request_id":"req_[A-Za-z0-9]{24}"\}$/.test(raw), `body ${raw} must match Anthropic-direct byte shape`); diff --git a/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/errors_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/errors_test.ts index a2c24ebc05..ecc3a30973 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/errors_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/errors_test.ts @@ -1,17 +1,16 @@ import { test } from 'vitest'; -import { translatorInputErrorResult } from '../../../../src/data-plane/chat/openai-chat-completions/errors.ts'; +import { inputErrorResult } from '../../../../src/data-plane/chat/openai-chat-completions/errors.ts'; import type { ApiErrorResult } from '@floway-dev/provider'; import { assertEquals } from '@floway-dev/test-utils'; -import { TranslatorInputError } from '@floway-dev/translate'; -const apiErrorOf = (result: ReturnType): ApiErrorResult => result as ApiErrorResult; -const bodyOf = (result: ReturnType): unknown => +const apiErrorOf = (result: ReturnType): ApiErrorResult => result as ApiErrorResult; +const bodyOf = (result: ReturnType): unknown => JSON.parse(new TextDecoder().decode(apiErrorOf(result).body)); -test('translatorInputErrorResult renders an OpenAI 400 invalid_request_error envelope with default `messages` param', () => { - const result = translatorInputErrorResult( - new TranslatorInputError("Missing required field 'tool_call_id' on a 'tool' role message."), +test('inputErrorResult renders an OpenAI 400 invalid_request_error envelope with default `messages` param', () => { + const result = inputErrorResult( + { message: "Missing required field 'tool_call_id' on a 'tool' role message." }, ); const apiError = apiErrorOf(result); @@ -28,9 +27,9 @@ test('translatorInputErrorResult renders an OpenAI 400 invalid_request_error env }); }); -test('translatorInputErrorResult honors an explicit param from the translator', () => { - const result = translatorInputErrorResult( - new TranslatorInputError('content part not supported', { param: 'messages[2].content[1]' }), +test('inputErrorResult honors an explicit param from the translator', () => { + const result = inputErrorResult( + { message: 'content part not supported', param: 'messages[2].content[1]' }, ); assertEquals(bodyOf(result), { diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/errors_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/errors_test.ts index 94e23fba8a..59bd78b67c 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/errors_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/errors_test.ts @@ -1,10 +1,9 @@ import { test } from 'vitest'; -import { openaiResponsesInputErrorResult, type OpenAIResponsesServeFailure } from '../../../../src/data-plane/chat/openai-responses/errors.ts'; +import { inputErrorResult, type OpenAIResponsesServeFailure } from '../../../../src/data-plane/chat/openai-responses/errors.ts'; import { throwChatServeFailure, tryCatchChatServeFailure } from '../../../../src/data-plane/chat/shared/errors.ts'; import type { ApiErrorResult } from '@floway-dev/provider'; import { assertEquals, assertThrows } from '@floway-dev/test-utils'; -import { TranslatorInputError } from '@floway-dev/translate'; const bodyOf = (result: ApiErrorResult): unknown => JSON.parse(new TextDecoder().decode(result.body)); @@ -15,9 +14,9 @@ test('round-trips the OpenAI-Responses-only item-not-found failure through throw assertEquals(tryCatchChatServeFailure(error), failure); }); -test('openaiResponsesInputErrorResult renders an OpenAI 400 invalid_request_error envelope with default `input` param', () => { - const result = openaiResponsesInputErrorResult( - new TranslatorInputError("Invalid input item type 'image_generation_call'."), +test('inputErrorResult renders an OpenAI 400 invalid_request_error envelope with default `input` param', () => { + const result = inputErrorResult( + { message: "Invalid input item type 'image_generation_call'." }, ); assertEquals(result.type, 'api-error'); assertEquals(result.source, 'gateway'); @@ -32,9 +31,9 @@ test('openaiResponsesInputErrorResult renders an OpenAI 400 invalid_request_erro }); }); -test('openaiResponsesInputErrorResult carries an explicit error code into the envelope', () => { - const result = openaiResponsesInputErrorResult( - new TranslatorInputError("Missing required parameter: 'model'.", { param: 'model', code: 'missing_required_parameter' }), +test('inputErrorResult carries an explicit error code into the envelope', () => { + const result = inputErrorResult( + { message: "Missing required parameter: 'model'.", param: 'model', code: 'missing_required_parameter' }, ); assertEquals(bodyOf(result), { @@ -47,9 +46,9 @@ test('openaiResponsesInputErrorResult carries an explicit error code into the en }); }); -test('openaiResponsesInputErrorResult honors an explicit input param', () => { - const result = openaiResponsesInputErrorResult( - new TranslatorInputError('content block not supported', { param: 'input[1].content[0]' }), +test('inputErrorResult honors an explicit input param', () => { + const result = inputErrorResult( + { message: 'content block not supported', param: 'input[1].content[0]' }, ); assertEquals(bodyOf(result), { diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/agent-message-shim_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/agent-message-shim_test.ts index 4da0e89ac5..fcbbc959f0 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/agent-message-shim_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/agent-message-shim_test.ts @@ -6,8 +6,7 @@ import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import { doneFrame } from '@floway-dev/protocols/common'; import type { OpenAIResponsesInputAgentMessageItem, CanonicalOpenAIResponsesInputItem } from '@floway-dev/protocols/openai-responses'; import { eventResult, type FlagId } from '@floway-dev/provider'; -import { assert, assertEquals, assertRejects, stubModelCandidate, testTelemetryModelIdentity } from '@floway-dev/test-utils'; -import { TranslatorInputError } from '@floway-dev/translate'; +import { assert, assertEquals, stubModelCandidate, testTelemetryModelIdentity } from '@floway-dev/test-utils'; const gatewayCtx = mockChatGatewayCtx(); const okEvents = () => Promise.resolve(eventResult((async function* () { yield doneFrame(); })(), testTelemetryModelIdentity)); @@ -20,7 +19,7 @@ const lower = async ( enabledFlags?: ReadonlySet; targetApi?: OpenAIResponsesInvocation['targetApi']; } = {}, -): Promise<{ before: OpenAIResponsesInvocation['payload']; seen: OpenAIResponsesInvocation['payload'] }> => { +) => { const invocation: OpenAIResponsesInvocation = { payload: { model: 'test-model', input }, candidate: stubModelCandidate({ enabledFlags }), @@ -30,11 +29,11 @@ const lower = async ( }; const before = invocation.payload; let seen: OpenAIResponsesInvocation['payload'] | undefined; - await withOpenAIResponsesAgentMessageShim(invocation, gatewayCtx, () => { + const result = await withOpenAIResponsesAgentMessageShim(invocation, gatewayCtx, () => { seen = invocation.payload; return okEvents(); }); - return { before, seen: seen! }; + return { before, seen, result }; }; const newTask: OpenAIResponsesInputAgentMessageItem = { @@ -78,6 +77,7 @@ test('lowers agent_message items in place to framed user messages when the flag { type: 'function_call', call_id: 'call_1', name: 'shell', arguments: '{}', status: 'completed' }, reply, ], { enabledFlags: FLAG }); + assert(seen !== undefined); assertEquals(seen.input, [ { type: 'message', role: 'developer', content: 'You are an agent in a team of agents.' }, @@ -103,6 +103,7 @@ test('lowers agent_message items in place to framed user messages when the flag test('forwards agent_message verbatim to a native OpenAI Responses target without the flag', async () => { const { before, seen } = await lower([newTask]); + assert(seen !== undefined); assert(seen === before); assertEquals(seen.input, [newTask]); }); @@ -111,6 +112,7 @@ test.each(['anthropicMessages', 'openaiChatCompletions'] as const)( 'lowers agent_message at a %s target even without the flag', async targetApi => { const { seen } = await lower([newTask], { targetApi }); + assert(seen !== undefined); assertEquals(seen.input, [framedNewTask]); }, ); @@ -119,19 +121,20 @@ test('leaves a payload without agent_message as the same object', async () => { const input: CanonicalOpenAIResponsesInputItem[] = [{ type: 'message', role: 'user', content: 'hi' }]; for (const options of [{ enabledFlags: FLAG }, {}, { targetApi: 'anthropicMessages' as const }]) { const { before, seen } = await lower(input, options); + assert(seen !== undefined); assert(seen === before); } }); -test('rejects an agent_message whose content cannot be lowered', async () => { - await assertRejects( - () => lower([{ - type: 'agent_message', - author: '/root', - recipient: '/root/worker', - content: [{ type: 'encrypted_content', encrypted_content: 'opaque' }], - }], { enabledFlags: FLAG }), - TranslatorInputError, - "Invalid value: 'encrypted_content' for 'agent_message.content[0].type'.", - ); +test('returns an input error without running downstream when agent content cannot be lowered', async () => { + const { result, seen } = await lower([{ + type: 'agent_message', author: '/root', recipient: '/root/worker', + content: [{ type: 'encrypted_content', encrypted_content: 'opaque' }], + }], { enabledFlags: FLAG }); + assertEquals(seen, undefined); + assert(result.type === 'api-error'); + assertEquals(result.status, 400); + const body = JSON.parse(new TextDecoder().decode(result.body)) as { error: { message: string; param: string } }; + assertEquals(body.error.message, "Invalid value: 'encrypted_content' for 'agent_message.content[0].type'."); + assertEquals(body.error.param, 'agent_message.content[0].type'); }); 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 51c4ab6ce9..fab9d21a0c 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 @@ -36,6 +36,7 @@ import type { import { getRepo, initRepo } from '../../../../../src/repo/index.ts'; import { InMemoryRepo } from '../../../../repo/memory.ts'; import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; +import { requireTrip } from '../../shared/require-trip.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { decodeCanonicalBase64, encodeBase64, eventFrame } from '@floway-dev/protocols/common'; import type { BillableUsage, ProtocolFrame } from '@floway-dev/protocols/common'; @@ -55,7 +56,7 @@ import type { } from '@floway-dev/protocols/openai-responses'; import { eventResult, type EventResult, type ExecuteResult, type FlagId } from '@floway-dev/provider'; import { assert, assertEquals, assertFalse, assertRejects, stubModelCandidate } from '@floway-dev/test-utils'; -import { routeOpenAIResponses } from '@floway-dev/translate/openai-responses'; +import { routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; const withOpenAIResponsesWebSearchShim = withOpenAIResponsesServerToolShim([webSearchServerTool]); @@ -7350,7 +7351,7 @@ for (const targetApi of ['openaiChatCompletions', 'anthropicMessages', 'openaiRe ]]).run(); } if (targetApi === 'openaiChatCompletions') { - const trip = await routeOpenAIResponses(inv.payload).viaOpenAIChatCompletions({ model: 'm', sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }); + const trip = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(inv.payload, { model: 'm', sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) })); const client = trip.target.tools?.[1]; assert(client?.type === 'function'); const events = (async function* (): AsyncGenerator> { @@ -7359,7 +7360,7 @@ for (const targetApi of ['openaiChatCompletions', 'anthropicMessages', 'openaiRe })(); return eventResult(trip.events(events), testTelemetryModelIdentity); } - const trip = await routeOpenAIResponses(inv.payload).viaAnthropicMessages({ model: 'm', sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), loadRemoteImage: async () => { throw new Error('Unexpected image'); } }); + const trip = await requireTrip(routeOpenAIResponsesViaAnthropicMessages(inv.payload, { model: 'm', sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), loadRemoteImage: async () => { throw new Error('Unexpected image'); } })); const tool = trip.target.tools?.[1]; const name = tool !== undefined && 'name' in tool ? tool.name : undefined; assert(typeof name === 'string'); @@ -7416,8 +7417,8 @@ for (const target of ['openaiChatCompletions', 'anthropicMessages'] as const) { choices.push(structuredClone(inv.payload.tool_choice)); const before = structuredClone(inv.payload); const trip = target === 'openaiChatCompletions' - ? await routeOpenAIResponses(inv.payload).viaOpenAIChatCompletions({ model: 'm', sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }) - : await routeOpenAIResponses(inv.payload).viaAnthropicMessages({ model: 'm', sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), loadRemoteImage: async () => { throw new Error('Unexpected image'); } }); + ? await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(inv.payload, { model: 'm', sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) })) + : await requireTrip(routeOpenAIResponsesViaAnthropicMessages(inv.payload, { model: 'm', sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), loadRemoteImage: async () => { throw new Error('Unexpected image'); } })); const wire = JSON.parse(JSON.stringify(trip.target)) as { tools: Array<{ name?: string; function?: { name: string } }> }; assert(wire.tools.some(tool => (tool.function?.name ?? tool.name) === alias), 'the translated subset must declare the helper alias'); assertEquals(inv.payload, before, 'repeated translation must preserve the outer loop payload'); diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/items/agent-message-content_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/items/agent-message-content_test.ts index 601b58e041..7d8da1b20d 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/items/agent-message-content_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/items/agent-message-content_test.ts @@ -1,6 +1,7 @@ import { test } from 'vitest'; import { agentMessageContent } from '../../../../../src/data-plane/chat/openai-responses/items/agent-message-content.ts'; +import { tryCatchChatServeFailure, type InputErrorDetails } from '../../../../../src/data-plane/chat/shared/errors.ts'; import type { OpenAIResponsesInputAgentMessageItem } from '@floway-dev/protocols/openai-responses'; import { assertEquals, assertThrows } from '@floway-dev/test-utils'; @@ -61,9 +62,12 @@ test('agentMessageContent reports the exact path for unknown beta content', () = const error = assertThrows( () => agentMessageContent(agentMessage([{ type: 'future_agent_part', value: 1 }])), Error, - "Invalid value: 'future_agent_part'", ); - assertEquals((error as Error & { param?: string }).param, 'agent_message.content[0].type'); + assertEquals(tryCatchChatServeFailure(error), { + kind: 'invalid-input', + message: "Invalid value: 'future_agent_part' for 'agent_message.content[0].type'.", + param: 'agent_message.content[0].type', + }); }); test('agentMessageContent reports the exact path for malformed text', () => { @@ -72,7 +76,10 @@ test('agentMessageContent reports the exact path for malformed text', () => { { type: 'input_text', text: 42 } as unknown as OpenAIResponsesInputAgentMessageItem['content'][number], ])), Error, - "Invalid type for 'agent_message.content[0].text'", ); - assertEquals((error as Error & { param?: string }).param, 'agent_message.content[0].text'); + assertEquals(tryCatchChatServeFailure(error), { + kind: 'invalid-input', + message: "Invalid type for 'agent_message.content[0].text': expected a string.", + param: 'agent_message.content[0].text', + }); }); diff --git a/packages/gateway/__tests__/data-plane/chat/shared/translate-traverse_test.ts b/packages/gateway/__tests__/data-plane/chat/shared/execute-translation_test.ts similarity index 59% rename from packages/gateway/__tests__/data-plane/chat/shared/translate-traverse_test.ts rename to packages/gateway/__tests__/data-plane/chat/shared/execute-translation_test.ts index e467fc77d1..0e20777312 100644 --- a/packages/gateway/__tests__/data-plane/chat/shared/translate-traverse_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/shared/execute-translation_test.ts @@ -1,18 +1,20 @@ import { describe, expect, test } from 'vitest'; -import { traverseTranslation } from '../../../../src/data-plane/chat/shared/translate-traverse.ts'; +import { executeTranslation } from '../../../../src/data-plane/chat/shared/execute-translation.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; -import type { ExecuteResult } from '@floway-dev/provider'; +import { toInternalDebugError, type ExecuteResult } from '@floway-dev/provider'; +import { testTelemetryModelIdentity } from '@floway-dev/test-utils'; +import type { TranslateTrip } from '@floway-dev/translate'; // Fake event types — no real protocol types needed. +type SrcPayload = { model: string; stream?: boolean }; type SrcEvent = { kind: 'src'; value: string }; type TgtEvent = { kind: 'tgt'; value: string }; -const translate = async (payload: string): Promise<{ - target: string; - events: (e: AsyncIterable>) => AsyncIterable>; -}> => ({ - target: `translated(${payload})`, +const translate = async (payload: SrcPayload): Promise> => ({ + type: 'trip', + error: () => undefined, + target: `translated(${payload.model})`, async *events(frames) { for await (const frame of frames) { if (frame.type === 'done') { @@ -35,17 +37,37 @@ const collectFrames = async (iterable: AsyncIterable>): return out; }; -const fakeModelIdentity = { modelId: 'fake' } as never; +describe('executeTranslation', () => { + test('asks the upstream for a stream whatever the caller requested', async () => { + const innerAttempt = async (_translated: string): Promise>> => ({ + type: 'events', + events: makeTgtFrames([]), + modelIdentity: testTelemetryModelIdentity, + }); + + // A client that asked for a single body still reaches a trip, whose only + // output is a stream. The caller's preference is a rendering decision made + // from the returned events, so it never reaches the source protocol. + const source = { model: 'm', stream: false }; + let seen: unknown; + await executeTranslation(source, async (payload: typeof source) => { + seen = payload; + return await translate(payload); + }, innerAttempt); + + expect(seen).toEqual({ model: 'm', stream: true }); + // The caller's own payload is not mutated. + expect(source).toEqual({ model: 'm', stream: false }); + }); -describe('traverseTranslation', () => { test('events inner — outer wraps with trip.events', async () => { const innerAttempt = async (_translated: string): Promise>> => ({ type: 'events', events: makeTgtFrames(['a', 'b']), - modelIdentity: fakeModelIdentity, + modelIdentity: testTelemetryModelIdentity, }); - const result = await traverseTranslation('payload', translate, innerAttempt); + const result = await executeTranslation({ model: 'payload' }, translate, innerAttempt); expect(result.type).toBe('events'); if (result.type !== 'events') throw new Error('unreachable'); @@ -56,7 +78,7 @@ describe('traverseTranslation', () => { { type: 'event', event: { kind: 'src', value: 'b' } }, { type: 'done' }, ]); - expect(result.modelIdentity).toBe(fakeModelIdentity); + expect(result.modelIdentity).toBe(testTelemetryModelIdentity); }); test('result inner — outer returns the result unchanged', async () => { @@ -69,7 +91,7 @@ describe('traverseTranslation', () => { }; const innerAttempt = async (_translated: string): Promise>> => inner; - const result = await traverseTranslation('payload', translate, innerAttempt); + const result = await executeTranslation({ model: 'payload' }, translate, innerAttempt); expect(result).toBe(inner); }); @@ -78,16 +100,16 @@ describe('traverseTranslation', () => { const inner: ExecuteResult> = { type: 'internal-error', status: 500, - error: new Error('boom') as never, + error: toInternalDebugError(new Error('boom')), }; const innerAttempt = async (_translated: string): Promise>> => inner; - const result = await traverseTranslation('payload', translate, innerAttempt); + const result = await executeTranslation({ model: 'payload' }, translate, innerAttempt); expect(result).toBe(inner); }); - test('upstream api-error with trip.apiError rewrite — outer returns the rewritten envelope', async () => { + test('upstream api-error with trip.error — outer returns the restated envelope', async () => { const inner: ExecuteResult> = { type: 'api-error', source: 'upstream', @@ -95,26 +117,22 @@ describe('traverseTranslation', () => { headers: new Headers({ 'x-upstream-request-id': 'abc' }), body: new TextEncoder().encode('raw upstream'), }; - const translateWithRewrite = async (payload: string): Promise<{ - target: string; - events: (e: AsyncIterable>) => AsyncIterable>; - apiError: (upstream: { status: number; headers: Headers; body: Uint8Array }) => { status: number; headers: Headers; body: Uint8Array }; - }> => ({ + const translateWithRewrite = async (payload: SrcPayload): Promise> => ({ ...(await translate(payload)), - apiError: () => ({ status: 400, headers: new Headers({ 'content-type': 'application/json' }), body: new TextEncoder().encode('rewritten') }), + error: () => ({ status: 400, headers: new Headers({ 'content-type': 'application/json' }), body: { error: { message: 'rewritten' } } }), }); - const result = await traverseTranslation('payload', translateWithRewrite, async () => inner); + const result = await executeTranslation({ model: 'payload' }, translateWithRewrite, async () => inner); expect(result.type).toBe('api-error'); if (result.type !== 'api-error') throw new Error('unreachable'); - expect(new TextDecoder().decode(result.body)).toBe('rewritten'); + expect(JSON.parse(new TextDecoder().decode(result.body))).toEqual({ error: { message: 'rewritten' } }); expect(result.headers.get('content-type')).toBe('application/json'); expect(result.headers.get('x-upstream-request-id')).toBeNull(); // Fields not touched by the rewriter (source, performance, upstream) survive. expect(result.source).toBe('upstream'); }); - test('upstream api-error with trip.apiError returning undefined — outer passes the envelope through', async () => { + test('upstream api-error with trip.error returning undefined — outer passes the envelope through', async () => { const inner: ExecuteResult> = { type: 'api-error', source: 'upstream', @@ -122,17 +140,13 @@ describe('traverseTranslation', () => { headers: new Headers(), body: new TextEncoder().encode('bad gateway'), }; - const translateWithRewrite = async (payload: string): Promise<{ - target: string; - events: (e: AsyncIterable>) => AsyncIterable>; - apiError: () => undefined; - }> => ({ ...(await translate(payload)), apiError: () => undefined }); + const translateWithRewrite = async (payload: SrcPayload): Promise> => ({ ...(await translate(payload)), error: () => undefined }); - const result = await traverseTranslation('payload', translateWithRewrite, async () => inner); + const result = await executeTranslation({ model: 'payload' }, translateWithRewrite, async () => inner); expect(result).toBe(inner); }); - test('gateway-sourced api-error — trip.apiError is NOT invoked', async () => { + test('gateway-sourced api-error — trip.error is NOT invoked', async () => { const inner: ExecuteResult> = { type: 'api-error', source: 'gateway', @@ -141,19 +155,15 @@ describe('traverseTranslation', () => { body: new TextEncoder().encode('synthesized'), }; let called = false; - const translateWithRewrite = async (payload: string): Promise<{ - target: string; - events: (e: AsyncIterable>) => AsyncIterable>; - apiError: () => { status: number; headers: Headers; body: Uint8Array }; - }> => ({ + const translateWithRewrite = async (payload: SrcPayload): Promise> => ({ ...(await translate(payload)), - apiError: () => { + error: () => { called = true; - return { status: 999, headers: new Headers(), body: new Uint8Array() }; + return { status: 999, headers: new Headers(), body: { error: { message: 'unused' } } }; }, }); - const result = await traverseTranslation('payload', translateWithRewrite, async () => inner); + const result = await executeTranslation({ model: 'payload' }, translateWithRewrite, async () => inner); expect(called).toBe(false); expect(result).toBe(inner); }); @@ -161,14 +171,14 @@ describe('traverseTranslation', () => { // The pre-translation capture hook sees the ORIGINAL target-protocol frames // (events path) and the verbatim upstream api-error envelope (api-error path), -// before `trip.events` / `trip.apiError` rewrite them. `capture === undefined` +// before `trip.events` / `trip.error` restate them. `capture === undefined` // (api key opts out of dumps) skips all capture work. -describe('traverseTranslation upstream capture', () => { +describe('executeTranslation upstream capture', () => { test('events path — capture receives each original target frame, in order, before translation', async () => { const innerAttempt = async (_translated: string): Promise>> => ({ type: 'events', events: makeTgtFrames(['a', 'b']), - modelIdentity: fakeModelIdentity, + modelIdentity: testTelemetryModelIdentity, }); const captured: ProtocolFrame[] = []; const capture = { @@ -176,7 +186,7 @@ describe('traverseTranslation upstream capture', () => { upstreamApiError: () => { throw new Error('should not be called on events path'); }, }; - const result = await traverseTranslation('payload', translate, innerAttempt, capture); + const result = await executeTranslation({ model: 'payload' }, translate, innerAttempt, capture); expect(result.type).toBe('events'); if (result.type !== 'events') throw new Error('unreachable'); // Drain the translated stream so the tee pulls the inner frames. @@ -194,11 +204,11 @@ describe('traverseTranslation upstream capture', () => { const innerAttempt = async (_translated: string): Promise>> => ({ type: 'events', events: makeTgtFrames(['a']), - modelIdentity: fakeModelIdentity, + modelIdentity: testTelemetryModelIdentity, }); const capture = { upstreamFrame: () => {}, upstreamApiError: () => {} }; - const result = await traverseTranslation('payload', translate, innerAttempt, capture); + const result = await executeTranslation({ model: 'payload' }, translate, innerAttempt, capture); if (result.type !== 'events') throw new Error('unreachable'); const frames = await collectFrames(result.events); expect(frames).toEqual([ @@ -220,16 +230,12 @@ describe('traverseTranslation upstream capture', () => { upstreamFrame: () => { throw new Error('should not be called on api-error path'); }, upstreamApiError: (error: { status: number; headers: Headers; body: Uint8Array }) => captured.push(error), }; - const translateWithRewrite = async (payload: string): Promise<{ - target: string; - events: (e: AsyncIterable>) => AsyncIterable>; - apiError: () => { status: number; headers: Headers; body: Uint8Array }; - }> => ({ + const translateWithRewrite = async (payload: SrcPayload): Promise> => ({ ...(await translate(payload)), - apiError: () => ({ status: 400, headers: new Headers(), body: new TextEncoder().encode('rewritten') }), + error: () => ({ status: 400, headers: new Headers(), body: { error: { message: 'rewritten' } } }), }); - const result = await traverseTranslation('payload', translateWithRewrite, async () => inner, capture); + const result = await executeTranslation({ model: 'payload' }, translateWithRewrite, async () => inner, capture); expect(result.type).toBe('api-error'); if (result.type !== 'api-error') throw new Error('unreachable'); // Capture saw the ORIGINAL envelope, not the rewrite. @@ -238,10 +244,10 @@ describe('traverseTranslation upstream capture', () => { expect(captured[0].headers.get('x-upstream')).toBe('yes'); expect(new TextDecoder().decode(captured[0].body)).toBe('raw upstream body'); // Downstream got the rewrite. - expect(new TextDecoder().decode(result.body)).toBe('rewritten'); + expect(JSON.parse(new TextDecoder().decode(result.body))).toEqual({ error: { message: 'rewritten' } }); }); - test('api-error path — capture fires even when trip.apiError is undefined (no rewrite)', async () => { + test('api-error path — capture fires even when trip.error is undefined (no restatement)', async () => { const inner: ExecuteResult> = { type: 'api-error', source: 'upstream', @@ -255,7 +261,7 @@ describe('traverseTranslation upstream capture', () => { upstreamApiError: (error: { status: number; headers: Headers; body: Uint8Array }) => captured.push({ status: error.status }), }; - await traverseTranslation('payload', translate, async () => inner, capture); + await executeTranslation({ model: 'payload' }, translate, async () => inner, capture); expect(captured).toEqual([{ status: 502 }]); }); @@ -263,7 +269,7 @@ describe('traverseTranslation upstream capture', () => { const inner: ExecuteResult> = { type: 'internal-error', status: 500, - error: new Error('boom') as never, + error: toInternalDebugError(new Error('boom')), }; let called = false; const capture = { @@ -271,7 +277,7 @@ describe('traverseTranslation upstream capture', () => { upstreamApiError: () => { called = true; }, }; - const result = await traverseTranslation('payload', translate, async () => inner, capture); + const result = await executeTranslation({ model: 'payload' }, translate, async () => inner, capture); expect(called).toBe(false); expect(result).toBe(inner); }); @@ -290,7 +296,7 @@ describe('traverseTranslation upstream capture', () => { upstreamApiError: () => { called = true; }, }; - await traverseTranslation('payload', translate, async () => inner, capture); + await executeTranslation({ model: 'payload' }, translate, async () => inner, capture); expect(called).toBe(false); }); @@ -298,10 +304,10 @@ describe('traverseTranslation upstream capture', () => { const innerAttempt = async (_translated: string): Promise>> => ({ type: 'events', events: makeTgtFrames(['a']), - modelIdentity: fakeModelIdentity, + modelIdentity: testTelemetryModelIdentity, }); - const result = await traverseTranslation('payload', translate, innerAttempt, undefined); + const result = await executeTranslation({ model: 'payload' }, translate, innerAttempt, undefined); if (result.type !== 'events') throw new Error('unreachable'); const frames = await collectFrames(result.events); expect(frames).toEqual([ @@ -310,3 +316,26 @@ describe('traverseTranslation upstream capture', () => { ]); }); }); + +test('returns a raw translation input error before calling the inner attempt', async () => { + const failure = { type: 'input-error' as const, message: 'unsupported content', param: 'messages[0]', code: 'unsupported_content' }; + let called = false; + const result = await executeTranslation({ model: 'payload' }, async () => failure, async () => { + called = true; + return { type: 'events', events: makeTgtFrames([]), modelIdentity: testTelemetryModelIdentity }; + }); + expect(result).toBe(failure); + expect(called).toBe(false); +}); + +test('propagates unexpected translator errors with their identity and cause intact', async () => { + const cause = new Error('codec'); + const failure = new Error('translation failed', { cause }); + let called = false; + await expect(executeTranslation({ model: 'payload' }, async () => { throw failure; }, async () => { + called = true; + return { type: 'events', events: makeTgtFrames([]), modelIdentity: testTelemetryModelIdentity }; + })).rejects.toBe(failure); + expect(failure.cause).toBe(cause); + expect(called).toBe(false); +}); diff --git a/packages/gateway/__tests__/data-plane/chat/shared/require-trip.ts b/packages/gateway/__tests__/data-plane/chat/shared/require-trip.ts new file mode 100644 index 0000000000..e9200a26bb --- /dev/null +++ b/packages/gateway/__tests__/data-plane/chat/shared/require-trip.ts @@ -0,0 +1,7 @@ +import type { TranslateInputError } from '@floway-dev/translate'; + +export const requireTrip = async (result: Promise): Promise => { + const trip = await result; + if (trip.type === 'input-error') throw new Error(trip.message); + return trip; +}; diff --git a/packages/gateway/src/data-plane/chat/anthropic-messages/attempt.ts b/packages/gateway/src/data-plane/chat/anthropic-messages/attempt.ts index 43d6c7dfcb..838fbd4f72 100644 --- a/packages/gateway/src/data-plane/chat/anthropic-messages/attempt.ts +++ b/packages/gateway/src/data-plane/chat/anthropic-messages/attempt.ts @@ -1,3 +1,4 @@ +import { inputErrorResult } from './errors.ts'; import { anthropicMessagesInterceptors, anthropicMessagesCountTokensInterceptors } from './interceptors/index.ts'; import type { AnthropicMessagesInvocation } from './interceptors/types.ts'; import { createAnthropicMessagesBillableUsageReader } from './usage.ts'; @@ -5,18 +6,18 @@ import { buildUpstreamCallOptions } from '../../shared/upstream-call-options.ts' import { openaiChatCompletionsAttempt } from '../openai-chat-completions/attempt.ts'; import { openaiResponsesAttempt } from '../openai-responses/attempt.ts'; import { applyRulesToUpstreamAnthropicMessages } from '../shared/alias-rules.ts'; +import { captureFromDump, executeTranslation } from '../shared/execute-translation.ts'; import type { ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import { providerStreamResultToExecuteResult } from '../shared/provider-stream-result.ts'; import { plainResultFromResponse } from '../shared/respond.ts'; import { SidecarDomain } from '../shared/sidecar-codec.ts'; import { chatTargetPicker } from '../shared/target-picker.ts'; -import { captureFromDump, traverseTranslation } from '../shared/translate-traverse.ts'; import { runInterceptors } from '@floway-dev/interceptor'; import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { ModelCandidate, ExecuteResult, AnthropicMessagesUpstreamCallOptions, PlainResult } from '@floway-dev/provider'; import { providerModelOf } from '@floway-dev/provider'; -import { routeAnthropicMessages } from '@floway-dev/translate/anthropic-messages'; +import { routeAnthropicMessagesViaOpenAIChatCompletions, routeAnthropicMessagesViaOpenAIResponses, type TranslateInputError } from '@floway-dev/translate'; // `/v1/messages` generate prefers a native Anthropic Messages target, then the // translated OpenAI Responses path, then the translated OpenAI Chat Completions path. @@ -59,38 +60,8 @@ export const anthropicMessagesAttempt = { anthropicBeta, }; return await runInterceptors(invocation, ctx, anthropicMessagesInterceptors, async () => { - if (targetApi === 'anthropicMessages') { - if (candidate.rules !== undefined) applyRulesToUpstreamAnthropicMessages(invocation.payload, candidate.rules); - const { model: _model, ...body } = invocation.payload; - const providerResult = await candidate.provider.instance.callAnthropicMessages( - providerModelOf(candidate), - body, - ctx.abortSignal, - buildAnthropicMessagesUpstreamCallOptions(candidate, ctx, invocation.headers, invocation.anthropicBeta), - ); - return await providerStreamResultToExecuteResult(providerResult, candidate, targetApi, ctx, createAnthropicMessagesBillableUsageReader()); - } - if (targetApi === 'openaiResponses') { - return await traverseTranslation( - invocation.payload, - p => routeAnthropicMessages(p).viaOpenAIResponses({ model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), - translated => openaiResponsesAttempt.generate({ - payload: translated, ctx, candidate, headers: invocation.headers, - }), - captureFromDump(ctx.dump, targetApi), - ); - } - if (targetApi === 'openaiChatCompletions') { - return await traverseTranslation( - invocation.payload, - p => routeAnthropicMessages(p).viaOpenAIChatCompletions({ model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), - translated => openaiChatCompletionsAttempt.generate({ - payload: translated, ctx, candidate, headers: invocation.headers, - }), - captureFromDump(ctx.dump, targetApi), - ); - } - throw new Error(`anthropicMessagesAttempt.generate: unexpected targetApi '${targetApi as string}'`); + const result = await dispatchAnthropicMessages(invocation, ctx); + return result.type === 'input-error' ? inputErrorResult(result, ctx.attempt.telemetry) : result; }); }, @@ -124,3 +95,42 @@ export const anthropicMessagesAttempt = { return await plainResultFromResponse(response, candidate.provider.upstreamId); }, }; + +const dispatchAnthropicMessages = async ( + invocation: AnthropicMessagesInvocation, + ctx: ChatGatewayCtx, +): Promise> | TranslateInputError> => { + const { candidate, targetApi } = invocation; + if (targetApi === 'anthropicMessages') { + if (candidate.rules !== undefined) applyRulesToUpstreamAnthropicMessages(invocation.payload, candidate.rules); + const { model: _model, ...body } = invocation.payload; + const providerResult = await candidate.provider.instance.callAnthropicMessages( + providerModelOf(candidate), + body, + ctx.abortSignal, + buildAnthropicMessagesUpstreamCallOptions(candidate, ctx, invocation.headers, invocation.anthropicBeta), + ); + return await providerStreamResultToExecuteResult(providerResult, candidate, targetApi, ctx, createAnthropicMessagesBillableUsageReader()); + } + if (targetApi === 'openaiResponses') { + return await executeTranslation( + invocation.payload, + p => routeAnthropicMessagesViaOpenAIResponses(p, { model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), + translated => openaiResponsesAttempt.generate({ + payload: translated, ctx, candidate, headers: invocation.headers, + }), + captureFromDump(ctx.dump, targetApi), + ); + } + if (targetApi === 'openaiChatCompletions') { + return await executeTranslation( + invocation.payload, + p => routeAnthropicMessagesViaOpenAIChatCompletions(p, { model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), + translated => openaiChatCompletionsAttempt.generate({ + payload: translated, ctx, candidate, headers: invocation.headers, + }), + captureFromDump(ctx.dump, targetApi), + ); + } + throw new Error(`anthropicMessagesAttempt.generate: unexpected targetApi '${targetApi as string}'`); +}; diff --git a/packages/gateway/src/data-plane/chat/anthropic-messages/errors.ts b/packages/gateway/src/data-plane/chat/anthropic-messages/errors.ts index d76830d291..1bc87472f4 100644 --- a/packages/gateway/src/data-plane/chat/anthropic-messages/errors.ts +++ b/packages/gateway/src/data-plane/chat/anthropic-messages/errors.ts @@ -1,9 +1,8 @@ import { appendFailedUpstreams } from '../../shared/failed-upstreams.ts'; -import type { ChatServeFailure } from '../shared/errors.ts'; +import type { ChatServeFailure, InputErrorDetails } from '../shared/errors.ts'; import { generateAnthropicId, type AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { ExecuteResult, PerformanceTelemetryContext } from '@floway-dev/provider'; -import type { TranslatorInputError } from '@floway-dev/translate'; // Anthropic Messages error envelope used to render pre-stream // `ChatServeFailure`s. These are gateway-synthesized rather than received @@ -33,10 +32,10 @@ const anthropicErrorResult = ( // disallowed role, missing required field, etc.). Render as a 400 // invalid_request_error so the caller sees a protocol-shaped failure // instead of the internal-error 502 envelope. `performance` carries the -// throwing candidate's telemetry attribution when the throw fired -// mid-attempt (see AttemptState.telemetry). -export const translatorInputErrorResult = ( - error: TranslatorInputError, +// candidate's telemetry attribution when validation failed +// during an attempt (see AttemptState.telemetry). +export const inputErrorResult = ( + error: InputErrorDetails, performance?: PerformanceTelemetryContext, ): ExecuteResult> => anthropicErrorResult(400, 'invalid_request_error', error.message, performance); diff --git a/packages/gateway/src/data-plane/chat/anthropic-messages/http.ts b/packages/gateway/src/data-plane/chat/anthropic-messages/http.ts index 2489a30d3f..60ba4bf852 100644 --- a/packages/gateway/src/data-plane/chat/anthropic-messages/http.ts +++ b/packages/gateway/src/data-plane/chat/anthropic-messages/http.ts @@ -1,4 +1,3 @@ -import { translatorInputErrorResult } from './errors.ts'; import { respondAnthropicMessages } from './respond.ts'; import { anthropicMessagesServe } from './serve.ts'; import type { AuthedContext } from '../../../middleware/auth.ts'; @@ -11,7 +10,6 @@ import { createChatGatewayCtxFromHono, type ChatGatewayCtx } from '../shared/gat import { providerModelsUnavailableResponse } from '../shared/upstream-models-error.ts'; import type { AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; import { internalErrorResult, toInternalDebugError } from '@floway-dev/provider'; -import { TranslatorInputError } from '@floway-dev/translate'; // Reject `anthropic_beta` / `betas` in the body; the Anthropic Messages protocol carries // them via the `anthropic-beta` HTTP header. @@ -52,16 +50,6 @@ const respondWithInternalError = async (c: AuthedContext, error: unknown, reques return finalizeGatewayResponse(effectiveCtx, response); }; -// Pre-stream caller-input failure raised by a translator → Anthropic-Messages-shaped -// 400 invalid_request_error envelope. Anything else falls through to the -// internal-error 502 path. -const respondToThrow = async (c: AuthedContext, error: unknown, requestBody: RequestBody, ctx?: GatewayCtx): Promise => { - if (!(error instanceof TranslatorInputError)) return await respondWithInternalError(c, error, requestBody, ctx); - const effectiveCtx = ctx ?? createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); - const response = await respondAnthropicMessages(c, translatorInputErrorResult(error, effectiveCtx.attempt.telemetry), false, effectiveCtx); - return finalizeGatewayResponse(effectiveCtx, response); -}; - const parsePayload = (requestBody: RequestBody): AnthropicMessagesPayload => JSON.parse(new TextDecoder().decode(requestBody.bytes)) as AnthropicMessagesPayload; @@ -80,7 +68,7 @@ export const anthropicMessagesHttp = { const response = await respondAnthropicMessages(c, result, wantsStream, ctx); return finalizeGatewayResponse(ctx, response); } catch (error) { - return await respondToThrow(c, error, requestBody, ctx); + return await respondWithInternalError(c, error, requestBody, ctx); } }, @@ -97,7 +85,7 @@ export const anthropicMessagesHttp = { const response = await respondAnthropicMessages(c, result, false, ctx); return finalizeGatewayResponse(ctx, response); } catch (error) { - return await respondToThrow(c, error, requestBody, ctx); + return await respondWithInternalError(c, error, requestBody, ctx); } }, }; diff --git a/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts b/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts index 7a945525d5..a38b0366ec 100644 --- a/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts +++ b/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts @@ -1,19 +1,19 @@ -import { geminiGenerateContentInternalErrorPayload, geminiGenerateContentStatusForHttpStatus } from './errors.ts'; +import { inputErrorResult, geminiGenerateContentInternalErrorPayload, geminiGenerateContentStatusForHttpStatus } from './errors.ts'; import { geminiGenerateContentCountTokensInterceptors, geminiGenerateContentInterceptors } from './interceptors/index.ts'; import { stripUnsupportedPartFieldsFromPayload } from './interceptors/strip-unsupported-part-fields.ts'; import { stripUnsupportedToolsFromPayload } from './interceptors/strip-unsupported-tools.ts'; import { anthropicMessagesAttempt } from '../anthropic-messages/attempt.ts'; import { openaiChatCompletionsAttempt } from '../openai-chat-completions/attempt.ts'; import { openaiResponsesAttempt } from '../openai-responses/attempt.ts'; +import { captureFromDump, executeTranslation } from '../shared/execute-translation.ts'; import type { ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import { SidecarDomain } from '../shared/sidecar-codec.ts'; import { chatTargetPicker } from '../shared/target-picker.ts'; -import { captureFromDump, traverseTranslation } from '../shared/translate-traverse.ts'; import { runInterceptors } from '@floway-dev/interceptor'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; import { type ModelCandidate, plainResult, toInternalDebugError, type ExecuteResult, type GeminiGenerateContentInvocation, type PlainResult } from '@floway-dev/provider'; -import { routeGeminiGenerateContent } from '@floway-dev/translate/gemini-generate-content'; +import { routeGeminiGenerateContentViaAnthropicMessages, routeGeminiGenerateContentViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIResponses, type TranslateInputError } from '@floway-dev/translate'; // Gemini generateContent has no native upstream target in the provider API; prefer OpenAI Chat Completions // Completions, then Anthropic Messages, then OpenAI Responses for generate. countTokens has @@ -43,46 +43,8 @@ export const geminiGenerateContentAttempt = { const targetApi = geminiGenerateContentGenerateTarget.pick(candidate.model.endpoints); const invocation: GeminiGenerateContentInvocation = { payload, candidate, targetApi, headers }; return await runInterceptors(invocation, ctx, geminiGenerateContentInterceptors, async () => { - // Gemini generateContent has no native upstream target today — every targetApi we - // pick is reached via translation. The dispatch threads each branch - // through `traverseTranslation` so each inner attempt owns its own - // interceptor chain and rewrite. - const transCtx = { - model: candidate.model.id, - sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), - fallbackMaxOutputTokens: candidate.model.limits.max_output_tokens, - }; - if (targetApi === 'anthropicMessages') { - return await traverseTranslation( - invocation.payload, - p => routeGeminiGenerateContent(p).viaAnthropicMessages(transCtx), - translated => anthropicMessagesAttempt.generate({ - payload: translated, ctx, candidate, headers: invocation.headers, anthropicBeta: [], - }), - captureFromDump(ctx.dump, targetApi), - ); - } - if (targetApi === 'openaiResponses') { - return await traverseTranslation( - invocation.payload, - p => routeGeminiGenerateContent(p).viaOpenAIResponses(transCtx), - translated => openaiResponsesAttempt.generate({ - payload: translated, ctx, candidate, headers: invocation.headers, - }), - captureFromDump(ctx.dump, targetApi), - ); - } - if (targetApi === 'openaiChatCompletions') { - return await traverseTranslation( - invocation.payload, - p => routeGeminiGenerateContent(p).viaOpenAIChatCompletions(transCtx), - translated => openaiChatCompletionsAttempt.generate({ - payload: translated, ctx, candidate, headers: invocation.headers, - }), - captureFromDump(ctx.dump, targetApi), - ); - } - throw new Error(`geminiGenerateContentAttempt.generate: unexpected targetApi '${targetApi as string}'`); + const result = await dispatchGeminiGenerateContent(invocation, ctx); + return result.type === 'input-error' ? inputErrorResult(result, ctx.attempt.telemetry) : result; }); }, @@ -110,7 +72,11 @@ export const geminiGenerateContentAttempt = { stripUnsupportedPartFieldsFromPayload(cleaned); stripUnsupportedToolsFromPayload(cleaned); delete cleaned.safetySettings; - const trip = await routeGeminiGenerateContent(cleaned).viaAnthropicMessages(transCtx); + const trip = await routeGeminiGenerateContentViaAnthropicMessages(cleaned, transCtx); + if (trip.type === 'input-error') { + const failure = inputErrorResult(trip, ctx.attempt.telemetry); + return plainResult(failure.status, failure.headers, failure.body); + } const { stream: _stream, ...target } = trip.target; const anthropicMessagesResult = await anthropicMessagesAttempt.countTokens({ payload: target, ctx, candidate, headers: invocation.headers, anthropicBeta: [], @@ -165,3 +131,50 @@ const geminiGenerateContentInternalPlainResult = (status: number, error: Error): new Headers({ 'content-type': 'application/json' }), new TextEncoder().encode(JSON.stringify(geminiGenerateContentInternalErrorPayload(status, toInternalDebugError(error)))), ); + +const dispatchGeminiGenerateContent = async ( + invocation: GeminiGenerateContentInvocation, + ctx: ChatGatewayCtx, +): Promise> | TranslateInputError> => { + const { candidate, targetApi } = invocation; + // Gemini generateContent has no native upstream target today — every targetApi we + // pick is reached via translation. The dispatch threads each branch + // through `executeTranslation` so each inner attempt owns its own + // interceptor chain and rewrite. + const transCtx = { + model: candidate.model.id, + sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), + fallbackMaxOutputTokens: candidate.model.limits.max_output_tokens, + }; + if (targetApi === 'anthropicMessages') { + return await executeTranslation( + invocation.payload, + p => routeGeminiGenerateContentViaAnthropicMessages(p, transCtx), + translated => anthropicMessagesAttempt.generate({ + payload: translated, ctx, candidate, headers: invocation.headers, anthropicBeta: [], + }), + captureFromDump(ctx.dump, targetApi), + ); + } + if (targetApi === 'openaiResponses') { + return await executeTranslation( + invocation.payload, + p => routeGeminiGenerateContentViaOpenAIResponses(p, transCtx), + translated => openaiResponsesAttempt.generate({ + payload: translated, ctx, candidate, headers: invocation.headers, + }), + captureFromDump(ctx.dump, targetApi), + ); + } + if (targetApi === 'openaiChatCompletions') { + return await executeTranslation( + invocation.payload, + p => routeGeminiGenerateContentViaOpenAIChatCompletions(p, transCtx), + translated => openaiChatCompletionsAttempt.generate({ + payload: translated, ctx, candidate, headers: invocation.headers, + }), + captureFromDump(ctx.dump, targetApi), + ); + } + throw new Error(`geminiGenerateContentAttempt.generate: unexpected targetApi '${targetApi as string}'`); +}; diff --git a/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts b/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts index ec96327089..edc87687c8 100644 --- a/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts +++ b/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts @@ -1,9 +1,7 @@ import { appendFailedUpstreams } from '../../shared/failed-upstreams.ts'; -import type { ChatServeFailure } from '../shared/errors.ts'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { GeminiGenerateContentStreamEvent, GeminiGenerateContentErrorResponse } from '@floway-dev/protocols/gemini-generate-content'; -import { internalDebugErrorFields, type ExecuteResult, type PerformanceTelemetryContext, type InternalDebugError } from '@floway-dev/provider'; -import type { TranslatorInputError } from '@floway-dev/translate'; +import type { ChatServeFailure, InputErrorDetails } from '../shared/errors.ts'; +import type { GeminiGenerateContentErrorResponse } from '@floway-dev/protocols/gemini-generate-content'; +import { internalDebugErrorFields, type ApiErrorResult, type PerformanceTelemetryContext, type InternalDebugError } from '@floway-dev/provider'; // Google RPC Status envelope, used by Gemini's `error` channel everywhere // (HTTP body, SSE-tunnelled error event). @@ -47,7 +45,7 @@ export const geminiGenerateContentInternalErrorPayload = (status: number, error: }; }; -const geminiGenerateContentRpcErrorResult = (status: number, message: string, performance?: PerformanceTelemetryContext): ExecuteResult> => ({ +const geminiGenerateContentRpcErrorResult = (status: number, message: string, performance?: PerformanceTelemetryContext): ApiErrorResult => ({ type: 'api-error', source: 'gateway', status, @@ -62,12 +60,12 @@ const geminiGenerateContentRpcErrorResult = (status: number, message: string, pe // disallowed role, missing required field, etc.). Render as a 400 // INVALID_ARGUMENT envelope so the caller sees a Gemini-generateContent-shaped failure // instead of the internal-error 500 envelope. `performance` carries the -// throwing candidate's telemetry attribution when the throw fired -// mid-attempt (see AttemptState.telemetry). -export const translatorInputErrorResult = ( - error: TranslatorInputError, +// candidate's telemetry attribution when validation failed +// during an attempt (see AttemptState.telemetry). +export const inputErrorResult = ( + error: InputErrorDetails, performance?: PerformanceTelemetryContext, -): ExecuteResult> => +): ApiErrorResult => geminiGenerateContentRpcErrorResult(400, error.message, performance); // `endpoint` selects between `:generateContent` and `:countTokens` only in @@ -75,7 +73,7 @@ export const translatorInputErrorResult = ( export const renderGeminiGenerateContentFailure = ( failure: ChatServeFailure, endpoint: 'generate' | 'countTokens', -): ExecuteResult> => { +): ApiErrorResult => { switch (failure.kind) { case 'routing-unavailable': return geminiGenerateContentRpcErrorResult(400, failure.message); diff --git a/packages/gateway/src/data-plane/chat/gemini-generate-content/http.ts b/packages/gateway/src/data-plane/chat/gemini-generate-content/http.ts index 6836b3ee0f..5eff42994d 100644 --- a/packages/gateway/src/data-plane/chat/gemini-generate-content/http.ts +++ b/packages/gateway/src/data-plane/chat/gemini-generate-content/http.ts @@ -1,4 +1,3 @@ -import { translatorInputErrorResult } from './errors.ts'; import { geminiGenerateContentInternalRpcErrorResponse, geminiGenerateContentRpcErrorResponse, respondGeminiGenerateContent } from './respond.ts'; import { geminiGenerateContentServe } from './serve.ts'; import type { AuthedContext } from '../../../middleware/auth.ts'; @@ -10,7 +9,6 @@ import { createNonOpenAIResponsesSourceStore } from '../openai-responses/items/s import { createChatGatewayCtxFromHono, type ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import type { GeminiCountTokensPayload, GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; import { internalErrorResult, ProviderModelsUnavailableError, toInternalDebugError } from '@floway-dev/provider'; -import { TranslatorInputError } from '@floway-dev/translate'; interface GeminiGenerateContentModelAction { readonly model: string; @@ -50,8 +48,7 @@ const parseGeminiGenerateContentBodyBytes = (requestBody: RequestBody, projec // Surfaces a pre-stream throw as a Gemini-generateContent-RPC envelope, routing through // `respondGeminiGenerateContent` so the dump records the failure exactly as the sibling -// HTTP handlers do. `TranslatorInputError` renders a 400 INVALID_ARGUMENT -// envelope (caller-input violation). A `ProviderModelsUnavailableError` +// HTTP handlers do. A `ProviderModelsUnavailableError` // carrying an upstream HTTP body relays that body through the `api-error` // path with `source: 'upstream'`; everything else collapses to an // `internal-error` result rendered as the Gemini generateContent internal-error envelope @@ -65,10 +62,6 @@ const respondWithGeminiGenerateContentError = async ( ctx: ChatGatewayCtx, wantsStream: boolean, ): Promise => { - if (error instanceof TranslatorInputError) { - const response = await respondGeminiGenerateContent(c, translatorInputErrorResult(error, ctx.attempt.telemetry), wantsStream, ctx); - return finalizeGatewayResponse(ctx, response); - } if (error instanceof ProviderModelsUnavailableError && error.httpResponse) { const { status, headers, body } = error.httpResponse; const apiErrorResult = { diff --git a/packages/gateway/src/data-plane/chat/openai-chat-completions/attempt.ts b/packages/gateway/src/data-plane/chat/openai-chat-completions/attempt.ts index 5e8995655d..3c642d6313 100644 --- a/packages/gateway/src/data-plane/chat/openai-chat-completions/attempt.ts +++ b/packages/gateway/src/data-plane/chat/openai-chat-completions/attempt.ts @@ -1,3 +1,4 @@ +import { inputErrorResult } from './errors.ts'; import { openaiChatCompletionsInterceptors } from './interceptors/index.ts'; import type { OpenAIChatCompletionsInvocation } from './interceptors/types.ts'; import { billableUsageFromOpenAIChatCompletionsEvent } from './usage.ts'; @@ -5,17 +6,17 @@ import { buildUpstreamCallOptions } from '../../shared/upstream-call-options.ts' import { anthropicMessagesAttempt } from '../anthropic-messages/attempt.ts'; import { openaiResponsesAttempt } from '../openai-responses/attempt.ts'; import { applyRulesToUpstreamOpenAIChatCompletions } from '../shared/alias-rules.ts'; +import { captureFromDump, executeTranslation } from '../shared/execute-translation.ts'; import { createExternalImageLoader } from '../shared/external-image-loader.ts'; import type { ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import { providerStreamResultToExecuteResult } from '../shared/provider-stream-result.ts'; import { SidecarDomain } from '../shared/sidecar-codec.ts'; import { chatTargetPicker } from '../shared/target-picker.ts'; -import { captureFromDump, traverseTranslation } from '../shared/translate-traverse.ts'; import { runInterceptors } from '@floway-dev/interceptor'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; import { type ModelCandidate, type ExecuteResult, providerModelOf } from '@floway-dev/provider'; -import { routeOpenAIChatCompletions } from '@floway-dev/translate/openai-chat-completions'; +import { routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses, type TranslateInputError } from '@floway-dev/translate'; // `/v1/chat/completions` generate prefers a native OpenAI Chat Completions target, // then the translated Anthropic Messages path, then the translated OpenAI Responses path. @@ -41,43 +42,52 @@ export const openaiChatCompletionsAttempt = { headers, }; return await runInterceptors(invocation, ctx, openaiChatCompletionsInterceptors, async () => { - if (targetApi === 'openaiChatCompletions') { - if (candidate.rules !== undefined) applyRulesToUpstreamOpenAIChatCompletions(invocation.payload, candidate.rules); - const { model: _model, ...body } = invocation.payload; - const providerResult = await candidate.provider.instance.callOpenAIChatCompletions( - providerModelOf(candidate), - body, - ctx.abortSignal, - buildUpstreamCallOptions(candidate, ctx, invocation.headers), - ); - return await providerStreamResultToExecuteResult(providerResult, candidate, 'openaiChatCompletions', ctx, billableUsageFromOpenAIChatCompletionsEvent); - } - if (targetApi === 'anthropicMessages') { - return await traverseTranslation( - invocation.payload, - p => routeOpenAIChatCompletions(p).viaAnthropicMessages({ - model: candidate.model.id, - sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), - fallbackMaxOutputTokens: candidate.model.limits.max_output_tokens, - loadRemoteImage: createExternalImageLoader(ctx.abortSignal), - }), - translated => anthropicMessagesAttempt.generate({ - payload: translated, ctx, candidate, headers: invocation.headers, anthropicBeta: [], - }), - captureFromDump(ctx.dump, targetApi), - ); - } - if (targetApi === 'openaiResponses') { - return await traverseTranslation( - invocation.payload, - p => routeOpenAIChatCompletions(p).viaOpenAIResponses({ model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), - translated => openaiResponsesAttempt.generate({ - payload: translated, ctx, candidate, headers: invocation.headers, - }), - captureFromDump(ctx.dump, targetApi), - ); - } - throw new Error(`openaiChatCompletionsAttempt.generate: unexpected targetApi '${targetApi as string}'`); + const result = await dispatchOpenAIChatCompletions(invocation, ctx); + return result.type === 'input-error' ? inputErrorResult(result, ctx.attempt.telemetry) : result; }); }, }; + +const dispatchOpenAIChatCompletions = async ( + invocation: OpenAIChatCompletionsInvocation, + ctx: ChatGatewayCtx, +): Promise> | TranslateInputError> => { + const { candidate, targetApi } = invocation; + if (targetApi === 'openaiChatCompletions') { + if (candidate.rules !== undefined) applyRulesToUpstreamOpenAIChatCompletions(invocation.payload, candidate.rules); + const { model: _model, ...body } = invocation.payload; + const providerResult = await candidate.provider.instance.callOpenAIChatCompletions( + providerModelOf(candidate), + body, + ctx.abortSignal, + buildUpstreamCallOptions(candidate, ctx, invocation.headers), + ); + return await providerStreamResultToExecuteResult(providerResult, candidate, 'openaiChatCompletions', ctx, billableUsageFromOpenAIChatCompletionsEvent); + } + if (targetApi === 'anthropicMessages') { + return await executeTranslation( + invocation.payload, + p => routeOpenAIChatCompletionsViaAnthropicMessages(p, { + model: candidate.model.id, + sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), + fallbackMaxOutputTokens: candidate.model.limits.max_output_tokens, + loadRemoteImage: createExternalImageLoader(ctx.abortSignal), + }), + translated => anthropicMessagesAttempt.generate({ + payload: translated, ctx, candidate, headers: invocation.headers, anthropicBeta: [], + }), + captureFromDump(ctx.dump, targetApi), + ); + } + if (targetApi === 'openaiResponses') { + return await executeTranslation( + invocation.payload, + p => routeOpenAIChatCompletionsViaOpenAIResponses(p, { model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), + translated => openaiResponsesAttempt.generate({ + payload: translated, ctx, candidate, headers: invocation.headers, + }), + captureFromDump(ctx.dump, targetApi), + ); + } + throw new Error(`openaiChatCompletionsAttempt.generate: unexpected targetApi '${targetApi as string}'`); +}; diff --git a/packages/gateway/src/data-plane/chat/openai-chat-completions/errors.ts b/packages/gateway/src/data-plane/chat/openai-chat-completions/errors.ts index 8f4d29575c..c28736f571 100644 --- a/packages/gateway/src/data-plane/chat/openai-chat-completions/errors.ts +++ b/packages/gateway/src/data-plane/chat/openai-chat-completions/errors.ts @@ -1,19 +1,18 @@ import { appendFailedUpstreams } from '../../shared/failed-upstreams.ts'; -import { openAiErrorResult, type ChatServeFailure } from '../shared/errors.ts'; +import { openAiErrorResult, type ChatServeFailure, type InputErrorDetails } from '../shared/errors.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; import type { ExecuteResult, PerformanceTelemetryContext } from '@floway-dev/provider'; -import type { TranslatorInputError } from '@floway-dev/translate'; // Translator surfaced a caller-input violation. Render as a 400 // invalid_request_error so the caller sees a protocol-shaped failure // instead of the internal-error 502 envelope. `param` falls back to // `messages` (the OpenAI Chat Completions canonical messages field) when the // translator did not carry a more specific path. `performance` carries the -// throwing candidate's telemetry attribution when the throw fired -// mid-attempt (see AttemptState.telemetry). -export const translatorInputErrorResult = ( - error: TranslatorInputError, +// candidate's telemetry attribution when validation failed +// during an attempt (see AttemptState.telemetry). +export const inputErrorResult = ( + error: InputErrorDetails, performance?: PerformanceTelemetryContext, ): ExecuteResult> => openAiErrorResult(400, error.message, { param: error.param ?? 'messages', code: null }, performance); diff --git a/packages/gateway/src/data-plane/chat/openai-chat-completions/http.ts b/packages/gateway/src/data-plane/chat/openai-chat-completions/http.ts index a1d936b749..32b39d3a20 100644 --- a/packages/gateway/src/data-plane/chat/openai-chat-completions/http.ts +++ b/packages/gateway/src/data-plane/chat/openai-chat-completions/http.ts @@ -1,4 +1,3 @@ -import { translatorInputErrorResult } from './errors.ts'; import { respondOpenAIChatCompletions } from './respond.ts'; import { openaiChatCompletionsServe } from './serve.ts'; import type { AuthedContext } from '../../../middleware/auth.ts'; @@ -11,7 +10,6 @@ import { createChatGatewayCtxFromHono, type ChatGatewayCtx } from '../shared/gat import { providerModelsUnavailableResponse } from '../shared/upstream-models-error.ts'; import type { OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; import { internalErrorResult, toInternalDebugError } from '@floway-dev/provider'; -import { TranslatorInputError } from '@floway-dev/translate'; // Surfaces a pre-stream throw (malformed JSON body, an interceptor crash, // etc.) as an OpenAI Chat Completions-shaped 502 with the same internal-error @@ -32,16 +30,6 @@ const respondWithInternalError = async (c: AuthedContext, error: unknown, reques return finalizeGatewayResponse(effectiveCtx, response); }; -// Pre-stream caller-input failure raised by a translator → an OpenAI Chat -// Completions-shaped 400 invalid_request_error envelope. Anything else -// falls through to the internal-error 502 path. -const respondToThrow = async (c: AuthedContext, error: unknown, requestBody: RequestBody, ctx?: GatewayCtx): Promise => { - if (!(error instanceof TranslatorInputError)) return await respondWithInternalError(c, error, requestBody, ctx); - const effectiveCtx = ctx ?? createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); - const response = await respondOpenAIChatCompletions(c, translatorInputErrorResult(error, effectiveCtx.attempt.telemetry), false, false, effectiveCtx); - return finalizeGatewayResponse(effectiveCtx, response); -}; - export const openaiChatCompletionsHttp = { generate: async (c: AuthedContext): Promise => { const requestBody = await readRequestBody(c); @@ -60,7 +48,7 @@ export const openaiChatCompletionsHttp = { const response = await respondOpenAIChatCompletions(c, result, wantsStream, includeUsageChunk, ctx); return finalizeGatewayResponse(ctx, response); } catch (error) { - return await respondToThrow(c, error, requestBody, ctx); + return await respondWithInternalError(c, error, requestBody, ctx); } }, }; diff --git a/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts b/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts index 18a0d75359..4da1ac83f0 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts @@ -1,5 +1,6 @@ import { klona } from 'klona/json'; +import { inputErrorResult } from './errors.ts'; import { openaiResponsesInterceptors } from './interceptors/index.ts'; import type { OpenAIResponsesAttemptResult, OpenAIResponsesInvocation } from './interceptors/types.ts'; import { normalizeAssistantInputText } from './items/normalize-assistant-content.ts'; @@ -11,17 +12,17 @@ import { buildUpstreamCallOptions } from '../../shared/upstream-call-options.ts' import { anthropicMessagesAttempt } from '../anthropic-messages/attempt.ts'; import { openaiChatCompletionsAttempt } from '../openai-chat-completions/attempt.ts'; import { applyRulesToUpstreamOpenAIResponses } from '../shared/alias-rules.ts'; +import { captureFromDump, executeTranslation } from '../shared/execute-translation.ts'; import { createExternalImageLoader } from '../shared/external-image-loader.ts'; import type { ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import { providerStreamResultToExecuteResult } from '../shared/provider-stream-result.ts'; import { SidecarDomain } from '../shared/sidecar-codec.ts'; import { chatTargetPicker } from '../shared/target-picker.ts'; -import { captureFromDump, traverseTranslation } from '../shared/translate-traverse.ts'; import { runInterceptors } from '@floway-dev/interceptor'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import { collectOpenAIResponsesProtocolEventsToResult, type CanonicalOpenAIResponsesPayload, type OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; import { type ModelCandidate, eventResult, readUpstreamApiError, providerModelOf, type ChatTargetApi, type ExecuteResult, type ProviderOpenAIResponsesResult, type OpenAIResponsesAction } from '@floway-dev/provider'; -import { routeOpenAIResponses } from '@floway-dev/translate/openai-responses'; +import { routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions, type TranslateInputError } from '@floway-dev/translate'; // `/v1/responses` generate prefers the native OpenAI Responses target, then the // translated Anthropic Messages path, then the translated OpenAI Chat Completions path. The @@ -95,8 +96,10 @@ export const openaiResponsesAttempt = { targetApi, headers, }; - const chainResult = await runInterceptors(invocation, ctx, openaiResponsesInterceptors, async () => - await dispatchOpenAIResponses(invocation, ctx)); + const chainResult = await runInterceptors(invocation, ctx, openaiResponsesInterceptors, async () => { + const result = await dispatchOpenAIResponses(invocation, ctx); + return result.type === 'input-error' ? inputErrorResult(result, ctx.attempt.telemetry) : result; + }); if (chainResult.type !== 'events') return chainResult; @@ -131,7 +134,7 @@ export const openaiResponsesAttempt = { const dispatchOpenAIResponses = async ( invocation: OpenAIResponsesInvocation, ctx: ChatGatewayCtx, -): Promise>> => { +): Promise> | TranslateInputError> => { const { candidate, targetApi } = invocation; switch (targetApi) { case 'openaiResponses': { @@ -168,9 +171,9 @@ const dispatchOpenAIResponses = async ( // unexpected-input-item guard. throw new Error(`openaiResponsesAttempt: action='compact' reached dispatch on targetApi='anthropicMessages' — the openai-responses-compact-shim must engage and pivot the action`); } - return await traverseTranslation( + return await executeTranslation( invocation.payload, - p => routeOpenAIResponses(p).viaAnthropicMessages({ + p => routeOpenAIResponsesViaAnthropicMessages(p, { model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), fallbackMaxOutputTokens: candidate.model.limits.max_output_tokens, @@ -185,9 +188,9 @@ const dispatchOpenAIResponses = async ( if (invocation.action === 'compact') { throw new Error(`openaiResponsesAttempt: action='compact' reached dispatch on targetApi='openaiChatCompletions' — the openai-responses-compact-shim must engage and pivot the action`); } - return await traverseTranslation( + return await executeTranslation( invocation.payload, - p => routeOpenAIResponses(p).viaOpenAIChatCompletions({ model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), + p => routeOpenAIResponsesViaOpenAIChatCompletions(p, { model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), translated => openaiChatCompletionsAttempt.generate({ payload: translated, ctx, candidate, headers: invocation.headers, }), diff --git a/packages/gateway/src/data-plane/chat/openai-responses/errors.ts b/packages/gateway/src/data-plane/chat/openai-responses/errors.ts index 55af640934..c77960f733 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/errors.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/errors.ts @@ -1,5 +1,5 @@ import { appendFailedUpstreams } from '../../shared/failed-upstreams.ts'; -import { openAiErrorResult, type ChatServeFailure } from '../shared/errors.ts'; +import { openAiErrorResult, type ChatServeFailure, type InputErrorDetails } from '../shared/errors.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; import type { ApiErrorResult, ExecuteResult, PerformanceTelemetryContext } from '@floway-dev/provider'; @@ -9,8 +9,8 @@ export type OpenAIResponsesServeFailure = ChatServeFailure | { readonly kind: 'i // Caller-input violations discovered by translation or the source affinity // membrane share the OpenAI Responses 400 envelope. `performance` retains candidate // attribution when validation fires after attempt dispatch. -export const openaiResponsesInputErrorResult = ( - error: { readonly message: string; readonly param?: string; readonly code?: string }, +export const inputErrorResult = ( + error: InputErrorDetails, performance?: PerformanceTelemetryContext, ): ApiErrorResult => openAiErrorResult(400, error.message, { param: error.param ?? 'input', code: error.code ?? null }, performance); diff --git a/packages/gateway/src/data-plane/chat/openai-responses/http.ts b/packages/gateway/src/data-plane/chat/openai-responses/http.ts index 46423989a3..d6f198ef0a 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/http.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/http.ts @@ -1,4 +1,4 @@ -import { openaiResponsesInputErrorResult } from './errors.ts'; +import { inputErrorResult } from './errors.ts'; import { createOpenAIResponsesHttpStore } from './items/store.ts'; import { respondOpenAIResponses, respondOpenAIResponsesFailure } from './respond.ts'; import { PreviousResponseNotFoundError } from './serve-prep.ts'; @@ -11,10 +11,9 @@ import { readRequestBody, takeRequestBody, type RequestBody } from '../../shared import { settle } from '../../shared/telemetry/settle.ts'; import { createChatGatewayCtxFromHono, type ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import { providerModelsUnavailableResponse } from '../shared/upstream-models-error.ts'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; +import type { OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; import { internalErrorResult, toInternalDebugError } from '@floway-dev/provider'; -import { TranslatorInputError } from '@floway-dev/translate'; -import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/translate/openai-responses'; +import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/translate'; // OpenAI's verbatim previous_response_not_found envelope. Codex compares this // body byte-for-byte against upstream — see the cross-references on @@ -51,8 +50,7 @@ const respondWithInternalError = async (c: AuthedContext, error: unknown, reques return finalizeGatewayResponse(effectiveCtx, response); }; -// Pre-stream throw dispatcher. `PreviousResponseNotFoundError` and the -// translator-input case render protocol-shaped 400s; anything else falls +// Pre-stream throw dispatcher. The previous-response case renders its protocol envelope; anything else falls // through to the internal-error 502 path. const respondToThrow = async (c: AuthedContext, error: unknown, requestBody: RequestBody, ctx?: GatewayCtx): Promise => { if (error instanceof PreviousResponseNotFoundError) { @@ -60,15 +58,10 @@ const respondToThrow = async (c: AuthedContext, error: unknown, requestBody: Req ctx?.dump?.error('gateway'); return ctx ? finalizeGatewayResponse(ctx, response) : response; } - if (error instanceof TranslatorInputError) { - const effectiveCtx = ctx ?? createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); - const response = respondOpenAIResponsesFailure(openaiResponsesInputErrorResult(error, effectiveCtx.attempt.telemetry), effectiveCtx); - return finalizeGatewayResponse(effectiveCtx, response); - } return await respondWithInternalError(c, error, requestBody, ctx); }; -const parsePayload = (requestBody: RequestBody): CanonicalOpenAIResponsesPayload => +const parsePayload = (requestBody: RequestBody) => canonicalizeOpenAIResponsesPayload(JSON.parse(new TextDecoder().decode(requestBody.bytes)) as OpenAIResponsesRequestPayloadEx); export const openaiResponsesHttp = { @@ -77,6 +70,10 @@ export const openaiResponsesHttp = { let ctx: ChatGatewayCtx | undefined; try { const payload = parsePayload(requestBody); + if (!('input' in payload)) { + const errorCtx = createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); + return finalizeGatewayResponse(errorCtx, respondOpenAIResponsesFailure(inputErrorResult(payload), errorCtx)); + } const wantsStream = payload.stream === true; ctx = createChatGatewayCtxFromHono(c, { wantsStream, requestBody: takeRequestBody(requestBody), model: payload.model, backgroundScheduler: backgroundSchedulerFromContext(c) }, (apiKey, requestStartedAt) => createOpenAIResponsesHttpStore(apiKey, requestStartedAt, payload.store ?? undefined)); const result = await openaiResponsesServe.generate({ payload, ctx, headers: inboundHeaders(c) }); @@ -92,6 +89,10 @@ export const openaiResponsesHttp = { let ctx: ChatGatewayCtx | undefined; try { const payload = parsePayload(requestBody); + if (!('input' in payload)) { + const errorCtx = createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); + return finalizeGatewayResponse(errorCtx, respondOpenAIResponsesFailure(inputErrorResult(payload), errorCtx)); + } ctx = createChatGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), model: payload.model, backgroundScheduler: backgroundSchedulerFromContext(c) }, (apiKey, requestStartedAt) => createOpenAIResponsesHttpStore(apiKey, requestStartedAt, payload.store ?? undefined)); const result = await openaiResponsesServe.compact({ payload, ctx, headers: inboundHeaders(c) }); if (result.type === 'result') { diff --git a/packages/gateway/src/data-plane/chat/openai-responses/interceptors/agent-message-shim.ts b/packages/gateway/src/data-plane/chat/openai-responses/interceptors/agent-message-shim.ts index 70c26a62cd..ac0a605812 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/interceptors/agent-message-shim.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/interceptors/agent-message-shim.ts @@ -1,4 +1,6 @@ import type { OpenAIResponsesInterceptor } from './types.ts'; +import { tryCatchChatServeFailure, type InputErrorDetails } from '../../shared/errors.ts'; +import { inputErrorResult } from '../errors.ts'; import { agentMessageContent } from '../items/agent-message-content.ts'; import { providerModelOf } from '@floway-dev/provider'; @@ -14,16 +16,22 @@ import { providerModelOf } from '@floway-dev/provider'; // `openai-responses-agent-message-shim` on a native target, which otherwise // receives the item untouched. // https://github.com/openai/codex/blob/0a2eb4696c26ac33204bcd255721ab30220a4774/codex-rs/protocol/src/models.rs#L1036-L1046 -export const withOpenAIResponsesAgentMessageShim: OpenAIResponsesInterceptor = (ctx, _gatewayCtx, run) => { +export const withOpenAIResponsesAgentMessageShim: OpenAIResponsesInterceptor = async (ctx, gatewayCtx, run) => { if (ctx.targetApi === 'openaiResponses' - && !providerModelOf(ctx.candidate).enabledFlags.has('openai-responses-agent-message-shim')) return run(); - if (!ctx.payload.input.some(item => item.type === 'agent_message')) return run(); + && !providerModelOf(ctx.candidate).enabledFlags.has('openai-responses-agent-message-shim')) return await run(); + if (!ctx.payload.input.some(item => item.type === 'agent_message')) return await run(); - ctx.payload = { - ...ctx.payload, - input: ctx.payload.input.map(item => item.type === 'agent_message' - ? { type: 'message', role: 'user', content: agentMessageContent(item) } - : item), - }; - return run(); + try { + ctx.payload = { + ...ctx.payload, + input: ctx.payload.input.map(item => item.type === 'agent_message' + ? { type: 'message', role: 'user', content: agentMessageContent(item) } + : item), + }; + } catch (error) { + const failure = tryCatchChatServeFailure(error); + if (failure === null) throw error; + return inputErrorResult(failure, gatewayCtx.attempt.telemetry); + } + return await run(); }; diff --git a/packages/gateway/src/data-plane/chat/openai-responses/items/agent-message-content.ts b/packages/gateway/src/data-plane/chat/openai-responses/items/agent-message-content.ts index 9603bfd535..ace3f38c2a 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/items/agent-message-content.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/items/agent-message-content.ts @@ -1,5 +1,5 @@ +import { throwChatServeFailure } from '../../shared/errors.ts'; import type { OpenAIResponsesInputAgentMessageItem, OpenAIResponsesInputContent, OpenAIResponsesInputImage } from '@floway-dev/protocols/openai-responses'; -import { TranslatorInputError } from '@floway-dev/translate'; interface AgentContentFields { type: string; @@ -12,14 +12,14 @@ interface AgentContentFields { const requiredString = (value: unknown, path: string): string => { if (typeof value !== 'string') { - throw new TranslatorInputError(`Invalid type for '${path}': expected a string.`, { param: path }); + return throwChatServeFailure({ kind: 'invalid-input', message: `Invalid type for '${path}': expected a string.`, param: path }); } return value; }; const nullableString = (value: unknown, path: string): string | null | undefined => { if (value !== undefined && value !== null && typeof value !== 'string') { - throw new TranslatorInputError(`Invalid type for '${path}': expected a string or null.`, { param: path }); + return throwChatServeFailure({ kind: 'invalid-input', message: `Invalid type for '${path}': expected a string or null.`, param: path }); } return value; }; @@ -107,7 +107,7 @@ export const agentMessageContent = ( }); break; default: - throw new TranslatorInputError(`Invalid value: '${part.type}' for '${path}.type'.`, { param: `${path}.type` }); + return throwChatServeFailure({ kind: 'invalid-input', message: `Invalid value: '${part.type}' for '${path}.type'.`, param: `${path}.type` }); } } 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 da8055d5df..fa073da736 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts @@ -21,10 +21,8 @@ import type { BackgroundScheduler } from '@floway-dev/platform'; import { eventFrame, isJsonMediaType, type ProtocolFrame } from '@floway-dev/protocols/common'; import { OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE } from '@floway-dev/protocols/openai-responses'; import { isOpenAIResponsesTerminalEvent, type CanonicalOpenAIResponsesPayload, type ClientOpenAIResponsesStreamEvent, type OpenAIResponsesOutputItemEx, type OpenAIResponsesRequestPayloadEx, type OpenAIResponsesResultEx, type OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; -import type { ExecuteResult } from '@floway-dev/provider'; -import { internalDebugErrorFields, toInternalDebugError } from '@floway-dev/provider'; -import { TranslatorInputError } from '@floway-dev/translate'; -import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/translate/openai-responses'; +import { type ExecuteResult, internalDebugErrorFields, toInternalDebugError } from '@floway-dev/provider'; +import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/translate'; interface WorkerWebSocket extends WebSocket { accept(): void; @@ -365,7 +363,11 @@ const handleClientMessage = async ( const source = message.response && typeof message.response === 'object' ? message.response : Object.fromEntries(Object.entries(message).filter(([key]) => key !== 'type' && key !== 'event_id')); - const payload = openaiResponsesPayloadFromClientSource(source); + const payload = canonicalizeOpenAIResponsesPayload({ ...source, stream: true }); + if (!('input' in payload)) { + turnFailure.fail(400, { type: 'invalid_request_error', code: payload.code ?? 'invalid_request_error', message: payload.message, param: payload.param }); + return; + } previousResponseId = payload.previous_response_id ?? undefined; ctx = createChatGatewayCtxFromHono(c, { wantsStream: true, @@ -404,15 +406,6 @@ const handleClientMessage = async ( await respondOpenAIResponsesWebSocket({ socket, eventId, signal, isClosed, result, ctx, payload, turnFailure, turn }); } catch (error) { if (signal.aborted || isClosed()) return; - if (error instanceof TranslatorInputError) { - turnFailure.fail(400, { - type: 'invalid_request_error', - code: error.code ?? 'invalid_request_error', - message: error.message, - param: error.param, - }); - return; - } if (error instanceof WebSocketClientMessageError) { turnFailure.fail(400, { type: 'invalid_request_error', @@ -451,10 +444,6 @@ const validateClientMessage = (parsed: unknown): OpenAIResponsesWebSocketClientE return parsed as OpenAIResponsesWebSocketClientEvent; }; -// The transport always streams, whatever the client sent. -const openaiResponsesPayloadFromClientSource = (source: object): CanonicalOpenAIResponsesPayload => - ({ ...canonicalizeOpenAIResponsesPayload(source as OpenAIResponsesRequestPayloadEx), stream: true }); - const respondOpenAIResponsesWebSocket = async (input: { readonly socket: OpenAIResponsesWebSocketSocket; readonly eventId: string | undefined; diff --git a/packages/gateway/src/data-plane/chat/shared/errors.ts b/packages/gateway/src/data-plane/chat/shared/errors.ts index 51629a9f89..98812f5f8f 100644 --- a/packages/gateway/src/data-plane/chat/shared/errors.ts +++ b/packages/gateway/src/data-plane/chat/shared/errors.ts @@ -10,6 +10,12 @@ export type ChatServeFailure = | { readonly kind: 'model-unsupported'; readonly model: string; readonly failedUpstreams: readonly string[] } | { readonly kind: 'routing-unavailable'; readonly message: string }; +export interface InputErrorDetails { + readonly message: string; + readonly param?: string; + readonly code?: string; +} + class ChatServeFailureError extends Error { readonly failure: TFailure; diff --git a/packages/gateway/src/data-plane/chat/shared/translate-traverse.ts b/packages/gateway/src/data-plane/chat/shared/execute-translation.ts similarity index 61% rename from packages/gateway/src/data-plane/chat/shared/translate-traverse.ts rename to packages/gateway/src/data-plane/chat/shared/execute-translation.ts index b60fac01a2..fe8f2b31ed 100644 --- a/packages/gateway/src/data-plane/chat/shared/translate-traverse.ts +++ b/packages/gateway/src/data-plane/chat/shared/execute-translation.ts @@ -1,10 +1,10 @@ import type { DumpAccumulator } from '../../../dump/accumulator.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { ChatTargetApi, ExecuteResult } from '@floway-dev/provider'; -import type { TranslateTripResult } from '@floway-dev/translate'; +import type { TranslateTrip, TranslateInputError } from '@floway-dev/translate'; // Protocol-agnostic capture hook for the pre-translation (target-protocol) -// upstream view. `traverseTranslation` invokes it as it relays the inner +// upstream view. `executeTranslation` invokes it as it relays the inner // attempt's frames/bytes through `trip.events`; the call site constructs one // from its `(ctx.dump, targetApi)` via `captureFromDump`. The target protocol // is statically known at every call site (the enclosing `if`/`case` branch), @@ -16,7 +16,7 @@ export interface TranslationUpstreamCapture { } // `dump` is null when the api key opts out of dumps; returns `undefined` so -// `traverseTranslation` skips all upstream capture work (no tee, no api-error +// `executeTranslation` skips all upstream capture work (no tee, no api-error // copy) — zero per-request cost on that path. `targetApi` is stamped eagerly so // `meta.targetApi` is set even when the upstream stream produces zero frames. export const captureFromDump = ( @@ -25,35 +25,35 @@ export const captureFromDump = ( ): TranslationUpstreamCapture | undefined => { if (dump === null) return undefined; dump.setUpstreamTargetApi(targetApi); - const upstreamFrame = (frame: ProtocolFrame) => dump.upstreamFrame(frame); - const upstreamApiError = (error: { status: number; headers: Headers; body: Uint8Array }) => dump.upstreamApiError(error); - return { upstreamFrame, upstreamApiError }; + return { + upstreamFrame: frame => dump.upstreamFrame(frame), + upstreamApiError: error => dump.upstreamApiError(error), + }; }; -// Threads a translate trip around an inner attempt. The trip itself is async -// (the real `@floway-dev/translate` route methods resolve a `Promise`), so -// `translate` returns the trip object behind a promise. This helper's -// `translate` parameter stays unary so each caller closes over its own -// `ctx` (`p => routeSource(p).viaTarget(ctx)`). -// -// On an upstream api-error the trip's optional `apiError` hook is invoked so +// On an upstream api-error, `trip.error` translates the source envelope so // the pair can rewrite the body into the source protocol's envelope — the // canonical case is `anthropic-messages-via-*` translating an upstream context-window // error into the Anthropic `prompt is too long:` shape Claude Code recognizes -// for auto-compaction. Pairs that don't set `apiError` (or return `undefined`) -// pass the upstream body through verbatim. +// for auto-compaction. Returning `undefined` passes the upstream body through verbatim. // // When `capture` is provided, the ORIGINAL target-protocol frames (events // path) or the verbatim upstream api-error envelope (api-error path) are tee'd // to the dump BEFORE translation/rewrite, so the dump records what the upstream // actually sent independent of what Floway translated it into. -export const traverseTranslation = async ( +export const executeTranslation = async ( payload: SP, - translate: (p: SP) => Promise>, + translator: (p: Omit & { readonly stream: true }) => Promise | TranslateInputError>, innerAttempt: (translated: TP) => Promise>>, capture?: TranslationUpstreamCapture, -): Promise>> => { - const trip = await translate(payload); +): Promise> | TranslateInputError> => { + // A translation trip only produces a stream, so the upstream is always asked + // for one. Whether the caller wanted a stream is a rendering decision made + // after the trip returns; asking the upstream for a single body here would + // leave the trip with an answer it has no path for. A source protocol whose + // request states no stream field ignores the added key. + const trip = await translator({ ...payload, stream: true as const }); + if (trip.type === 'input-error') return trip; const inner = await innerAttempt(trip.target); if (inner.type === 'events') { const events = capture === undefined @@ -61,16 +61,22 @@ export const traverseTranslation = async ( : teeUpstreamFrames(inner.events, capture.upstreamFrame); return { ...inner, events: trip.events(events) }; } - // Capture the ORIGINAL upstream api-error envelope before the optional - // `trip.apiError` rewrite. Fires for EVERY upstream api-error — even when no - // rewrite happens (trip.apiError undefined or returns undefined), because the - // verbatim target-protocol body is still the pre-translation view operators - // need to diagnose upstream behavior. + // Capture the ORIGINAL upstream api-error envelope before the source + // protocol restates it. Fires for EVERY upstream api-error — even when the + // trip states none — because the verbatim target-protocol body is still the + // pre-translation view operators need to diagnose upstream behavior. if (inner.type === 'api-error' && inner.source === 'upstream') { capture?.upstreamApiError({ status: inner.status, headers: inner.headers, body: inner.body }); - if (trip.apiError !== undefined) { - const rewritten = trip.apiError({ status: inner.status, headers: inner.headers, body: inner.body }); - if (rewritten !== undefined) return { ...inner, status: rewritten.status, headers: rewritten.headers, body: rewritten.body }; + const rewritten = trip.error(inner.status, inner.headers, inner.body); + // A restated failure is a JSON envelope, not bytes: the transport encodes + // it here, the only place a response body becomes wire form. + if (rewritten !== undefined) { + return { + ...inner, + status: rewritten.status, + headers: rewritten.headers, + body: new TextEncoder().encode(JSON.stringify(rewritten.body)), + }; } } return inner; diff --git a/packages/gateway/src/dump/accumulator.ts b/packages/gateway/src/dump/accumulator.ts index 9344efa314..87c15943c1 100644 --- a/packages/gateway/src/dump/accumulator.ts +++ b/packages/gateway/src/dump/accumulator.ts @@ -111,7 +111,7 @@ export class DumpAccumulator { private errorMeta: DumpErrorMeta | null = null; private readonly preparedRequestBody: Promise; // Pre-translation (target-protocol) view. Populated only on translated turns - // via `traverseTranslation`'s capture hook; stays empty on native turns. + // via `executeTranslation`'s capture hook; stays empty on native turns. private readonly upstreamEvents: DumpStreamEvent[] = []; private upstreamTargetApi: ChatTargetApi | null = null; private upstreamApiErrorEnvelope: { status: number; headers: Array<[string, string]>; body: Uint8Array } | null = null; @@ -155,7 +155,7 @@ export class DumpAccumulator { this.events.push({ frame, ts: Date.now() - this.startedAt }); } - // --- pre-translation upstream hooks (called from `traverseTranslation`) --- + // --- pre-translation upstream hooks (called from `executeTranslation`) --- // Stamped eagerly at capture construction so `meta.targetApi` is set even // when the upstream stream produces zero frames (e.g. immediate done). diff --git a/packages/gateway/src/dump/types.ts b/packages/gateway/src/dump/types.ts index 6c68476141..39621828c6 100644 --- a/packages/gateway/src/dump/types.ts +++ b/packages/gateway/src/dump/types.ts @@ -76,7 +76,7 @@ export type StoredDumpResponseBody = | { type: 'none' }; // The pre-translation (target-protocol) view of what the upstream returned. -// Present only for turns that traversed a translation (`traverseTranslation`) +// Present only for turns that traversed a translation (`executeTranslation`) // and only when the inner attempt produced events or an upstream api-error. // Native turns and gateway-synthesized errors have no upstream view. export interface StoredDumpUpstreamResponse { diff --git a/packages/translate/AGENTS.md b/packages/translate/AGENTS.md index 8a7031c696..0f0319c1e0 100644 --- a/packages/translate/AGENTS.md +++ b/packages/translate/AGENTS.md @@ -2,8 +2,8 @@ `src/protocols//` owns translation entrypoints and protocol-specific code. `__tests__/protocols/` mirrors production ownership. Package callers -import the source protocol's subpath and call -`route(input).via(context)` to create an independent trip. +import the package root and call +`routeVia(input, options)` to create an independent trip. ## Categories @@ -27,7 +27,7 @@ import the source protocol's subpath and call validation, and native stream coordination under `round-trip/`. Source replay inspection and carriers belong in `as-source/round-trip.ts`; native assistant-turn shapes and output-to-input converters belong in `as-target/assistant-turn.ts`. - Transport encoding is injected through `TranslationContext.sidecarCodec`. + Transport encoding is injected through `TranslateOptions.sidecarCodec`. 7. **Protocol-independent utility** — keep it in `shared/`, with tests under `__tests__/shared/`. Protocol behavior belongs in its owning protocol directory. diff --git a/packages/translate/__tests__/ir/continuous-usage_test.ts b/packages/translate/__tests__/ir/continuous-usage_test.ts index a64739675a..055a0efa2e 100644 --- a/packages/translate/__tests__/ir/continuous-usage_test.ts +++ b/packages/translate/__tests__/ir/continuous-usage_test.ts @@ -2,9 +2,10 @@ import { expect, test } from 'vitest'; import { collect, iterate } from './helpers.ts'; import { fixtureFrames } from './translation-cases.ts'; -import { routeOpenAIChatCompletions } from '../../src/protocols/openai-chat-completions/index.ts'; +import { requireTrip } from '../protocols/require-trip.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import type { OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; +import { routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses } from '@floway-dev/translate'; const protocols = ['anthropic-messages', 'openai-responses'] as const; const flags = [undefined, false, true]; @@ -13,8 +14,8 @@ test.each(protocols)('ChatCompletions via %s captures continuous usage preferenc for (const include_usage of flags) for (const continuous_usage_stats of flags) { const src: OpenAIChatCompletionsPayloadEx = { model: 'model', messages: [], stream: true, stream_options: { include_usage, continuous_usage_stats } }; const trip = protocol === 'anthropic-messages' - ? await routeOpenAIChatCompletions(src).viaAnthropicMessages({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'model', fallbackMaxOutputTokens: 100, loadRemoteImage: async () => { throw new Error('Unexpected image'); } }) - : await routeOpenAIChatCompletions(src).viaOpenAIResponses({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'model' }); + ? await requireTrip(routeOpenAIChatCompletionsViaAnthropicMessages(src, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'model', fallbackMaxOutputTokens: 100, loadRemoteImage: async () => { throw new Error('Unexpected image'); } })) + : await requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses(src, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'model' })); src.stream_options!.include_usage = include_usage !== true; src.stream_options!.continuous_usage_stats = continuous_usage_stats !== true; const usage = protocol === 'anthropic-messages' ? { input_tokens: 10, output_tokens: 4 } : { input_tokens: 10, output_tokens: 4, total_tokens: 14 }; @@ -31,7 +32,7 @@ test.each(protocols)('ChatCompletions via %s captures continuous usage preferenc }); test('ChatCompletions continuous usage reflects reported Responses counters without extra progress chunks', async () => { - const trip = await routeOpenAIChatCompletions({ model: 'model', messages: [], stream_options: { include_usage: true, continuous_usage_stats: true } }).viaOpenAIResponses({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'model' }); + const trip = await requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses({ model: 'model', messages: [], stream_options: { include_usage: true, continuous_usage_stats: true } }, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'model' })); const response = { id: 'resp', model: 'model', created_at: 1, status: 'in_progress', output: [], usage: null }; const frames = [ { type: 'response.created', response }, 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 index b796be5d69..e5e266c303 100644 --- 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 @@ -22,11 +22,11 @@ test('rewriteContextExceededToPromptTooLong — rewrites Copilot OpenAI Response })), }; - const result = rewriteContextExceededToPromptTooLong(upstream); + 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 = JSON.parse(new TextDecoder().decode(result!.body)) as { error: { message: string } }; + const parsed = result.body as { error: { message: string } }; assert(parsed.error.message.startsWith('prompt is too long:')); }); @@ -38,7 +38,7 @@ test('rewriteContextExceededToPromptTooLong — rewrites Codex OpenAI Responses 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); + const result = rewriteContextExceededToPromptTooLong(upstream.status, upstream.headers, upstream.body); assert(result !== undefined); }); @@ -50,7 +50,7 @@ test('rewriteContextExceededToPromptTooLong — recognizes bodies whose signal i error: { message: "This model's maximum context length is 4097 tokens. However, your messages resulted in 4498 tokens." }, })), }; - assert(rewriteContextExceededToPromptTooLong(upstream) !== undefined); + assert(rewriteContextExceededToPromptTooLong(upstream.status, upstream.headers, upstream.body) !== undefined); }); test('rewriteContextExceededToPromptTooLong — passes unrelated bodies through unchanged', () => { @@ -59,7 +59,7 @@ test('rewriteContextExceededToPromptTooLong — passes unrelated bodies through headers: new Headers(), body: new TextEncoder().encode(JSON.stringify({ error: { code: 'invalid_api_key', message: 'bad token' } })), }; - assertEquals(rewriteContextExceededToPromptTooLong(upstream), undefined); + assertEquals(rewriteContextExceededToPromptTooLong(upstream.status, upstream.headers, upstream.body), undefined); }); test('rewriteContextExceededToPromptTooLong — falls back to plain-text substring when the body is not JSON', () => { @@ -68,12 +68,12 @@ test('rewriteContextExceededToPromptTooLong — falls back to plain-text substri headers: new Headers(), body: new TextEncoder().encode('some upstream prose: exceeds the context window of this model'), }; - assert(rewriteContextExceededToPromptTooLong(matching) !== undefined); + 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), undefined); + assertEquals(rewriteContextExceededToPromptTooLong(unrelated.status, unrelated.headers, unrelated.body), undefined); }); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-source/native-reassembly_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/native-reassembly_test.ts index d5ce4aa386..9eda683e9a 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-source/native-reassembly_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-source/native-reassembly_test.ts @@ -2,9 +2,10 @@ import { expect, test, vi } from 'vitest'; import { computeAnthropicMessagesTurnDigest } from '../../../../src/protocols/anthropic-messages/as-source/round-trip.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { requireTrip } from '../../require-trip.ts'; import { createAnthropicMessagesEventAccumulator, reassembleAnthropicMessagesEvents } from '@floway-dev/protocols/anthropic-messages'; import { openaiResponsesResultToEvents } from '@floway-dev/protocols/openai-responses'; -import { routeAnthropicMessages } from '@floway-dev/translate/anthropic-messages'; +import { routeAnthropicMessagesViaOpenAIResponses } from '@floway-dev/translate'; vi.mock('@floway-dev/protocols/anthropic-messages', async importOriginal => { const actual = await importOriginal(); @@ -21,9 +22,9 @@ test.each(['complete', 'truncated'] as const)('reads only the current native blo vi.mocked(createAnthropicMessagesEventAccumulator).mockClear(); const count = 32; const codec = createTestSidecarCodec(); - const trip = await routeAnthropicMessages({ + const trip = await requireTrip(routeAnthropicMessagesViaOpenAIResponses({ model: 'source', max_tokens: 4096, messages: [{ role: 'user', content: 'question' }], - }).viaOpenAIResponses({ model: 'target', sidecarCodec: codec }); + }, { model: 'target', sidecarCodec: codec })); const frames = async function* () { yield* openaiResponsesResultToEvents({ id: 'response', object: 'response', model: 'target', diff --git a/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts b/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts index 0e54d49260..63edd51909 100644 --- a/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts +++ b/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts @@ -1,5 +1,6 @@ import { expect, test } from 'vitest'; +import { requireTrip } from './require-trip.ts'; import { toOpenAIResponsesInputAssistantTurn } from '../../src/protocols/openai-responses/as-target/assistant-turn.ts'; import { iterate } from '../ir/helpers.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; @@ -8,9 +9,7 @@ import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols import { collectGeminiGenerateContentProtocolEventsToResult, type GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIChatCompletionsAssistantDeltaEx, OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; import { collectOpenAIResponsesProtocolEventsToResult, type OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; -import { routeAnthropicMessages } from '@floway-dev/translate/anthropic-messages'; -import { routeGeminiGenerateContent } from '@floway-dev/translate/gemini-generate-content'; -import { routeOpenAIResponses } from '@floway-dev/translate/openai-responses'; +import { routeAnthropicMessagesViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIChatCompletions, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; const nativeFrames = (mode: 'legacy' | 'modern' | 'mixed', name: string, arguments_ = '{"x":1}'): ProtocolFrame[] => { const chunk = (delta: OpenAIChatCompletionsAssistantDeltaEx, finish_reason: 'function_call' | 'tool_calls' | null = null) => eventFrame({ @@ -52,13 +51,13 @@ test('Messages returns matching legacy function results while modern calls remai tools: [{ name: 'lookup', input_schema: { type: 'object', properties: { x: { type: 'integer' } } } }], }; for (const [index, mode] of (['legacy', 'modern', 'legacy'] as const).entries()) { - const trip = await routeAnthropicMessages(payload).viaOpenAIChatCompletions(context); + const trip = await requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)); const result = await collectAnthropicMessagesProtocolEventsToResult(trip.events(iterate(nativeFrames(mode, 'lookup')))); const call = result.content.find(block => block.type === 'tool_use'); if (call === undefined) throw new Error('Expected a public Messages tool invocation'); payload.messages.push({ role: 'assistant', content: result.content }, { role: 'user', content: [{ type: 'tool_result', tool_use_id: call.id, content: `result-${index}` }] }); } - const replay = await routeAnthropicMessages(payload).viaOpenAIChatCompletions(context); + const replay = await requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)); assertNativeHistory(replay.target, 'lookup', ['result-0', 'result-1', 'result-2']); }); @@ -70,7 +69,7 @@ test('Responses binds public namespaced call IDs to the native legacy function n }; let nativeName = ''; for (const [index, mode] of (['legacy', 'modern', 'legacy'] as const).entries()) { - const trip = await routeOpenAIResponses(payload).viaOpenAIChatCompletions(context); + const trip = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(payload, context)); const declaration = trip.target.tools?.find(tool => tool.type === 'function'); if (declaration?.type !== 'function') throw new Error('Expected a native function declaration'); nativeName = declaration.function.name; @@ -83,7 +82,7 @@ test('Responses binds public namespaced call IDs to the native legacy function n if (!Array.isArray(payload.input)) throw new Error('Expected item-array history'); payload.input.push(...toOpenAIResponsesInputAssistantTurn(result.output), { type: 'function_call_output', call_id: call.call_id, output: `result-${index}` }); } - const replay = await routeOpenAIResponses(payload).viaOpenAIChatCompletions(context); + const replay = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(payload, context)); assertNativeHistory(replay.target, nativeName, ['result-0', 'result-1', 'result-2']); }); @@ -94,7 +93,7 @@ test('Gemini matches ID-less legacy responses by public name and explicit modern tools: [{ functionDeclarations: [{ name: 'lookup', parameters: { type: 'OBJECT', properties: { x: { type: 'INTEGER' } } } }] }], }; for (const [index, mode] of (['legacy', 'modern', 'legacy'] as const).entries()) { - const trip = await routeGeminiGenerateContent(payload).viaOpenAIChatCompletions(context); + const trip = await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(payload, context)); const result = await collectGeminiGenerateContentProtocolEventsToResult(trip.events(iterate(nativeFrames(mode, 'lookup')))); const content = result.candidates?.[0]?.content; const call = content?.parts?.find(part => part.functionCall !== undefined)?.functionCall; @@ -102,20 +101,20 @@ test('Gemini matches ID-less legacy responses by public name and explicit modern expect(call.id).toBe(mode === 'modern' ? 'native-modern' : undefined); payload.contents.push(content, { role: 'user', parts: [{ functionResponse: { name: call.name, ...(call.id === undefined ? {} : { id: call.id }), response: { result: index } } }] }); } - const replay = await routeGeminiGenerateContent(payload).viaOpenAIChatCompletions(context); + const replay = await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(payload, context)); assertNativeHistory(replay.target, 'lookup', ['{"result":0}', '{"result":1}', '{"result":2}']); }); test('Gemini keeps modern IDs separate when a vendor stream includes both call formats', async () => { const context = { model: 'native-model', sidecarCodec: createTestSidecarCodec() }; const source: GeminiGenerateContentPayload = { contents: [{ role: 'user', parts: [{ text: 'Call lookup' }] }] }; - const trip = await routeGeminiGenerateContent(source).viaOpenAIChatCompletions(context); + const trip = await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(source, context)); const result = await collectGeminiGenerateContentProtocolEventsToResult(trip.events(iterate(nativeFrames('mixed', 'lookup')))); const content = result.candidates?.[0]?.content; if (content === undefined) throw new Error('Expected a public Gemini assistant content'); const calls = content.parts?.flatMap(part => part.functionCall === undefined ? [] : [part.functionCall]); expect(calls).toHaveLength(2); - const replay = await routeGeminiGenerateContent({ + const replay = await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions({ contents: [ ...source.contents ?? [], content, { @@ -125,7 +124,7 @@ test('Gemini keeps modern IDs separate when a vendor stream includes both call f ], }, ], - }).viaOpenAIChatCompletions(context); + }, context)); const native = replay.target.messages.find(message => message.role === 'assistant'); expect(native?.function_call).toEqual({ name: 'lookup', arguments: '{"x":1}' }); expect(native?.tool_calls).toEqual([{ id: 'native-modern', type: 'function', function: { name: 'lookup', arguments: '{"x":1}' } }]); @@ -137,13 +136,13 @@ test('Gemini keeps modern IDs separate when a vendor stream includes both call f test('restored legacy results retain the ordinary Responses input_file rejection', async () => { const context = { model: 'native-model', sidecarCodec: createTestSidecarCodec() }; - const trip = await routeOpenAIResponses({ model: 'public-model', input: 'Call lookup' }).viaOpenAIChatCompletions(context); + const trip = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'public-model', input: 'Call lookup' }, context)); const result = await collectOpenAIResponsesProtocolEventsToResult(trip.events(iterate(nativeFrames('legacy', 'lookup')))); const call = result.output.find(item => item.type === 'function_call'); if (call === undefined) throw new Error('Expected a public Responses invocation'); const output = { type: 'function_call_output' as const, call_id: call.call_id, output: [{ type: 'input_file' as const, file_id: 'unsupported-file' }] }; const input = toOpenAIResponsesInputAssistantTurn(result.output); for (const history of [input, input.filter(item => item.type !== 'reasoning')]) { - await expect(routeOpenAIResponses({ model: 'public-model', input: [...history, output] }).viaOpenAIChatCompletions(context)).rejects.toThrow('Cannot translate input_file tool output to OpenAI Chat Completions.'); + await expect(requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'public-model', input: [...history, output] }, context))).rejects.toThrow('Cannot translate input_file tool output to OpenAI Chat Completions.'); } }); 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 faae6a5b09..16f1a9b44a 100644 --- a/packages/translate/__tests__/protocols/chat-reasoning-round-trip_test.ts +++ b/packages/translate/__tests__/protocols/chat-reasoning-round-trip_test.ts @@ -1,11 +1,12 @@ import { expect, test } from 'vitest'; +import { requireTrip } from './require-trip.ts'; import { toOpenAIResponsesInputAssistantTurn } from '../../src/protocols/openai-responses/as-target/assistant-turn.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 } from '@floway-dev/protocols/openai-responses'; -import { routeOpenAIResponses } from '@floway-dev/translate/openai-responses'; +import { routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; const chunk = (delta: OpenAIChatCompletionsAssistantDeltaEx, finish_reason: 'stop' | null = null) => eventFrame({ id: 'native', model: 'target', object: 'chat.completion.chunk', created: 1, choices: [{ index: 0, delta, finish_reason }], @@ -13,7 +14,7 @@ const chunk = (delta: OpenAIChatCompletionsAssistantDeltaEx, finish_reason: 'sto test('round-trips native Chat reasoning fragments through Responses without corrupting detail identity', async () => { const context = { model: 'target', sidecarCodec: createTestSidecarCodec() }; - const trip = await routeOpenAIResponses({ model: 'source', input: [{ role: 'user', content: 'question' }] }).viaOpenAIChatCompletions(context); + const trip = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'source', input: [{ role: 'user', content: 'question' }] }, context)); const upstream = async function* () { yield chunk({ role: 'assistant', reasoning_details: [{ type: 'reasoning.summary', summary: ' reading', id: 'summary', index: 0, format: 'openai-responses-v1' }] }); yield chunk({ reasoning_details: [{ type: 'reasoning.summary', summary: ' it.', id: 'summary', index: 0, format: 'openai-responses-v1' }] }); @@ -25,7 +26,7 @@ test('round-trips native Chat reasoning fragments through Responses without corr for await (const frame of trip.events(upstream())) if (frame.type === 'event') yield frame.event; }; const result = await reassembleOpenAIResponsesEvents(responseEvents()); - const restored = await routeOpenAIResponses({ model: 'source', input: toOpenAIResponsesInputAssistantTurn(result.output) }).viaOpenAIChatCompletions(context); + const restored = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'source', input: toOpenAIResponsesInputAssistantTurn(result.output) }, context)); expect(restored.target.messages).toEqual([{ role: 'assistant', content: 'answer', refusal: null, provider_specific_fields: { trace: 'native' }, reasoning_details: [ diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/native-reassembly_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/native-reassembly_test.ts index f8c64a1543..8dbd1ed9df 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/native-reassembly_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/native-reassembly_test.ts @@ -2,10 +2,11 @@ import { expect, test, vi } from 'vitest'; import { computeGeminiGenerateContentTurnDigest } from '../../../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { requireTrip } from '../../require-trip.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { createGeminiGenerateContentEventAccumulator, reassembleGeminiGenerateContentEvents } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; -import { routeGeminiGenerateContent } from '@floway-dev/translate/gemini-generate-content'; +import { routeGeminiGenerateContentViaOpenAIChatCompletions } from '@floway-dev/translate'; vi.mock('@floway-dev/protocols/gemini-generate-content', async importOriginal => { const actual = await importOriginal(); @@ -33,9 +34,9 @@ const chatFrames = async function* (count: number): AsyncGenerator { vi.mocked(createGeminiGenerateContentEventAccumulator).mockClear(); const codec = createTestSidecarCodec(); - const trip = await routeGeminiGenerateContent({ + const trip = await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions({ contents: [{ role: 'user', parts: [{ text: 'question' }] }], generationConfig: { candidateCount: count }, - }).viaOpenAIChatCompletions({ model: 'target', sidecarCodec: codec }); + }, { model: 'target', sidecarCodec: codec })); const events = async function* () { for await (const frame of trip.events(chatFrames(count))) if (frame.type === 'event') yield frame.event; }; diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/round-trip-request_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/round-trip-request_test.ts index 994004902a..25708c0be3 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/round-trip-request_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/round-trip-request_test.ts @@ -3,8 +3,9 @@ import { expect, test } from 'vitest'; import { computeGeminiGenerateContentTurnDigest } from '../../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; import { encodeRoundTripSidecar } from '../../../src/round-trip/sidecar.ts'; import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; +import { requireTrip } from '../require-trip.ts'; import type { GeminiGenerateContentContent } from '@floway-dev/protocols/gemini-generate-content'; -import { routeGeminiGenerateContent } from '@floway-dev/translate/gemini-generate-content'; +import { routeGeminiGenerateContentViaAnthropicMessages, routeGeminiGenerateContentViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIResponses } from '@floway-dev/translate'; const targetTurn = (name: string): GeminiGenerateContentContent[] => [{ role: 'model', parts: [{ functionCall: { name, args: { query: 'docs' } } }] }]; const replayedContents = (name: string, carrier: string): GeminiGenerateContentContent[] => [ @@ -18,7 +19,7 @@ test.each(['lookup', '__proto__'])('Gemini via Chat uses restored call IDs for i const codec = createTestSidecarCodec(); const native = { role: 'assistant' as const, tool_calls: [{ id: 'native-chat-call', type: 'function' as const, function: { name, arguments: '{"query":"docs"}' } }] }; const carrier = await encodeRoundTripSidecar(codec, { v: 1, source: 'geminiGenerateContent', target: 'openaiChatCompletions', targetTurn: native, sourceTurnDigest: await computeGeminiGenerateContentTurnDigest(targetTurn(name)) }); - const { target: request } = await routeGeminiGenerateContent({ contents: replayedContents(name, carrier) }).viaOpenAIChatCompletions({ model: 'test', sidecarCodec: createTestSidecarCodec('test-codec-scope') }); + const { target: request } = await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions({ contents: replayedContents(name, carrier) }, { model: 'test', sidecarCodec: createTestSidecarCodec('test-codec-scope') })); expect(request.messages).toEqual([ { role: 'user', content: 'question' }, native, @@ -31,7 +32,7 @@ test.each(['lookup', '__proto__'])('Gemini via Messages uses restored call IDs w const codec = createTestSidecarCodec(); const native = { role: 'assistant' as const, content: [{ type: 'tool_use' as const, id: 'native-messages-call', name, input: { query: 'docs' } }] }; const carrier = await encodeRoundTripSidecar(codec, { v: 1, source: 'geminiGenerateContent', target: 'anthropicMessages', targetTurn: native, sourceTurnDigest: await computeGeminiGenerateContentTurnDigest(targetTurn(name)) }); - const { target: request } = await routeGeminiGenerateContent({ contents: replayedContents(name, carrier) }).viaAnthropicMessages({ model: 'test', sidecarCodec: createTestSidecarCodec('test-codec-scope'), fallbackMaxOutputTokens: undefined }); + const { target: request } = await requireTrip(routeGeminiGenerateContentViaAnthropicMessages({ contents: replayedContents(name, carrier) }, { model: 'test', sidecarCodec: createTestSidecarCodec('test-codec-scope'), fallbackMaxOutputTokens: undefined })); expect(request.messages[1]).toEqual(native); expect(request.messages[2]).toEqual({ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'native-messages-call', content: '{"answer":42}' }] }); expect(request.messages[3]).toEqual({ role: 'assistant', content: [{ type: 'tool_use', id: 'gemini_call_3_0', name: 'other', input: {}, cache_control: { type: 'ephemeral' } }] }); @@ -41,7 +42,7 @@ test.each(['lookup', '__proto__'])('Gemini via Responses uses restored call IDs const codec = createTestSidecarCodec(); const native = [{ type: 'function_call' as const, call_id: 'native-responses-call', name, arguments: '{"query":"docs"}', status: 'completed' as const }]; const carrier = await encodeRoundTripSidecar(codec, { v: 1, source: 'geminiGenerateContent', target: 'openaiResponses', targetTurn: native, sourceTurnDigest: await computeGeminiGenerateContentTurnDigest(targetTurn(name)) }); - const { target: request } = await routeGeminiGenerateContent({ contents: replayedContents(name, carrier) }).viaOpenAIResponses({ model: 'test', sidecarCodec: createTestSidecarCodec('test-codec-scope') }); + const { target: request } = await requireTrip(routeGeminiGenerateContentViaOpenAIResponses({ contents: replayedContents(name, carrier) }, { model: 'test', sidecarCodec: createTestSidecarCodec('test-codec-scope') })); expect(request.input).toEqual([ { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'question' }] }, ...native, 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 beafe30ffa..811f2cec7d 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 @@ -411,7 +411,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 => { const provider_specific_fields = { - name: 'TranslatorInputError', + name: 'TranslateInputException', stack: 'translator stack', cause: { message: 'source failed', cause: { code: 17, details: [false, null, { value: 'nested' }] } }, target_api: 'openai-responses', diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/request_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/request_test.ts index 8aac03d24e..826b190ded 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/request_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/request_test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; import { buildTargetRequest as buildTargetRequestWithSidecar } from '../../../../src/protocols/openai-chat-completions/via-openai-responses/request.ts'; -import { TranslatorInputError } from '../../../../src/translator-input-error.ts'; +import { TranslateInputException } from '../../../../src/shared/input-error.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; import type { OpenAIChatCompletionsPayload, OpenAIChatCompletionsMessage } from '@floway-dev/protocols/openai-chat-completions'; import { assertEquals, assertFalse, assertRejects } from '@floway-dev/test-utils'; @@ -259,7 +259,7 @@ test.each([null, undefined])('buildTargetRequest omits unset reasoning_effort=%j }); test('rejects a json_schema request with no schema instead of removing constraints', async () => { - await assertRejects(() => buildWithTestSidecar({ model: 'gpt-test', messages: [], response_format: { type: 'json_schema', json_schema: { name: 'shape' } } }), TranslatorInputError, 'without a schema'); + await assertRejects(() => buildWithTestSidecar({ model: 'gpt-test', messages: [], response_format: { type: 'json_schema', json_schema: { name: 'shape' } } }), TranslateInputException, 'without a schema'); }); test('allowed_tools retains both native callable kinds', async () => { diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/allowed-tools_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/allowed-tools_test.ts index 1927534f24..58a9a2d950 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/allowed-tools_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/allowed-tools_test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import { buildTargetRequest as buildMessages } from '../../../../src/protocols/openai-responses/via-anthropic-messages/request.ts'; import { buildTargetRequest as buildChat } from '../../../../src/protocols/openai-responses/via-openai-chat-completions/request.ts'; -import { TranslatorInputError } from '../../../../src/translator-input-error.ts'; +import { TranslateInputException } from '../../../../src/shared/input-error.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; import type { CanonicalOpenAIResponsesInputItem, OpenAIResponsesRequestPayloadEx, OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; @@ -56,7 +56,7 @@ for (const target of ['chat', 'messages'] as const) { tools: [{ type: 'function', name: 'web_search', parameters: { type: 'object' } }], input: [item], }; - await assertRejects(() => build(payload), TranslatorInputError, "distinct callable kinds sharing 'web_search'"); + await assertRejects(() => build(payload), TranslateInputException, "distinct callable kinds sharing 'web_search'"); }); } @@ -69,7 +69,7 @@ for (const target of ['chat', 'messages'] as const) { test(`${target} request rejects a flat selector that would erase a declaration namespace`, async () => { const payload = source({ type: 'allowed_tools', mode: 'auto', tools: [{ type: 'function', name: 'read' }] }); payload.tools![0] = { ...payload.tools![0], namespace: 'files' } as unknown as OpenAIResponsesTool; - await assertRejects(() => build(payload), TranslatorInputError, 'allowed_tools'); + await assertRejects(() => build(payload), TranslateInputException, 'allowed_tools'); }); for (const [label, choice] of [ @@ -87,7 +87,7 @@ for (const target of ['chat', 'messages'] as const) { test(`${target} request typed-rejects ${label} instead of widening allowed_tools`, async () => { const payload = source(choice as unknown as OpenAIResponsesToolChoice); payload.tools!.push({ type: 'custom', name: 'read' }); - await assertRejects(() => build(payload), TranslatorInputError, 'allowed_tools'); + await assertRejects(() => build(payload), TranslateInputException, 'allowed_tools'); }); } } diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-tools_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-tools_test.ts index b909c5bf69..e33c749b2c 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-tools_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-tools_test.ts @@ -1,14 +1,16 @@ import { expect, test } from 'vitest'; import { flattenNamespaceTools, restoreNamespaceEvents, restoreNamespaceOutputItem } from '../../../../src/protocols/openai-responses/as-source/namespace-tools.ts'; +import { requireCanonicalOpenAIResponsesPayload } from '../../../../src/protocols/openai-responses/canonicalize-payload.ts'; import { buildTargetRequest as buildMessagesRequest } from '../../../../src/protocols/openai-responses/via-anthropic-messages/request.ts'; import { buildTargetRequest as buildChatRequest } from '../../../../src/protocols/openai-responses/via-openai-chat-completions/request.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { requireTrip } from '../../require-trip.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesOutputItemEx, OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; -import { routeOpenAIResponses, canonicalizeOpenAIResponsesPayload } from '@floway-dev/translate/openai-responses'; +import { routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; const payload = (): OpenAIResponsesRequestPayloadEx => ({ model: 'm', @@ -105,7 +107,7 @@ test('forced namespace choices use the declaration mapping', async () => { }); test('restores function and custom calls across item events and terminal snapshots', async () => { - const prepared = flattenNamespaceTools(canonicalizeOpenAIResponsesPayload(payload())); + const prepared = flattenNamespaceTools(requireCanonicalOpenAIResponsesPayload(payload())); const output = [ { type: 'function_call' as const, name: 'agents_spawn_2', call_id: 'spawn', arguments: '{"name":"agents_spawn_2"}', status: 'completed' }, { type: 'custom_tool_call' as const, name: 'agents_audit', call_id: 'audit', input: 'agents_audit' }, @@ -127,7 +129,7 @@ test('restores function and custom calls across item events and terminal snapsho }); test('restores all function/custom output item type combinations without retaining the replaced payload field', () => { - const names = flattenNamespaceTools(canonicalizeOpenAIResponsesPayload(payload())).names; + const names = flattenNamespaceTools(requireCanonicalOpenAIResponsesPayload(payload())).names; const functionName = names.sourceToTarget.get('agents.spawn'); const customName = names.sourceToTarget.get('agents.audit'); if (functionName === undefined || customName === undefined) throw new Error('Expected flattened namespace tool names'); @@ -172,7 +174,7 @@ test('rejects function/custom ambiguity and distinct tuples with the same qualif }); test('the complete Chat Completions trip restores the namespace after target tool calls', async () => { - const trip = await routeOpenAIResponses(payload()).viaOpenAIChatCompletions({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' }); + const trip = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(payload(), { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' })); const frames = (async function* (): AsyncGenerator> { yield eventFrame({ id: 'chat1', object: 'chat.completion.chunk', model: 'm', created: 0, choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call1', type: 'function', function: { name: 'agents_spawn_2', arguments: '{}' } }] }, finish_reason: null }] }); yield eventFrame({ id: 'chat1', object: 'chat.completion.chunk', model: 'm', created: 0, choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] }); @@ -185,7 +187,7 @@ test('the complete Chat Completions trip restores the namespace after target too }); test('the complete Anthropic Messages trip restores the namespace after target tool calls', async () => { - const trip = await routeOpenAIResponses(payload()).viaAnthropicMessages({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm', loadRemoteImage: async () => { throw new Error('Unexpected remote image'); } }); + const trip = await requireTrip(routeOpenAIResponsesViaAnthropicMessages(payload(), { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm', loadRemoteImage: async () => { throw new Error('Unexpected remote image'); } })); const frames = (async function* (): AsyncGenerator> { yield eventFrame({ type: 'message_start', message: { container: null, diagnostics: null, stop_details: null, id: 'msg1', type: 'message', model: 'm', role: 'assistant', content: [], stop_reason: null, stop_sequence: null, usage: { cache_creation: null, cache_creation_input_tokens: null, cache_read_input_tokens: null, inference_geo: null, output_tokens_details: null, server_tool_use: null, service_tier: null, input_tokens: 1, output_tokens: 0 } } }); yield eventFrame({ type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'call1', name: 'agents_spawn_2', input: {} } }); diff --git a/packages/translate/__tests__/protocols/openai-responses/canonicalize-payload_test.ts b/packages/translate/__tests__/protocols/openai-responses/canonicalize-payload_test.ts index 5e093764bc..c3e7fa5bfd 100644 --- a/packages/translate/__tests__/protocols/openai-responses/canonicalize-payload_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/canonicalize-payload_test.ts @@ -1,9 +1,7 @@ import { test } from 'vitest'; -import { TranslatorInputError } from '../../../src/translator-input-error.ts'; -import type { OpenAIResponsesPayloadEx } from '@floway-dev/protocols/openai-responses'; -import { assertEquals, assertThrows } from '@floway-dev/test-utils'; -import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/translate/openai-responses'; +import { assert, assertEquals } from '@floway-dev/test-utils'; +import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/translate'; test('canonicalizes string and implicit-message wire inputs', () => { assertEquals(canonicalizeOpenAIResponsesPayload({ model: 'gpt-test', input: 'hello' }), { @@ -62,13 +60,9 @@ test('rejects a payload without a usable model at the canonical boundary', () => { model: 42, input: 'hello' }, { model: null, input: 'hello' }, ]) { - const error = assertThrows( - () => canonicalizeOpenAIResponsesPayload(payload), - TranslatorInputError, - "Missing required parameter: 'model'.", - ) as TranslatorInputError; - assertEquals(error.param, 'model'); - assertEquals(error.code, 'missing_required_parameter'); + assertEquals(canonicalizeOpenAIResponsesPayload(payload), { + type: 'input-error', message: "Missing required parameter: 'model'.", param: 'model', code: 'missing_required_parameter', + }); } }); @@ -84,15 +78,9 @@ test('rejects malformed untyped input items at the canonical boundary', () => { { role: 'user', content: [{ type: 'input_text', text: 'invalid breakpoint', prompt_cache_breakpoint: {} }] }, { role: 'user', content: 'invalid phase', phase: 42 }, ]) { - const error = assertThrows( - () => canonicalizeOpenAIResponsesPayload({ - model: 'gpt-test', - input: [malformed] as unknown as OpenAIResponsesPayloadEx['input'], - }), - TranslatorInputError, - 'valid role and content', - ) as TranslatorInputError; - assertEquals(error.param, 'input[0]'); + assertEquals(canonicalizeOpenAIResponsesPayload({ model: 'gpt-test', input: [malformed] }), { + type: 'input-error', message: 'Untyped OpenAI Responses input items require a valid role and content.', param: 'input[0]', + }); } }); @@ -102,6 +90,7 @@ test('canonicalizeOpenAIResponsesPayload preserves reasoning.context verbatim, i input: [{ type: 'message', role: 'user', content: 'hi' }], reasoning: { effort: 'high', context: 'current_turn' }, }); + assert('input' in canonicalCurrent); assertEquals(canonicalCurrent.reasoning, { effort: 'high', context: 'current_turn' }); // An unknown/future context string rides through the wire→canonical boundary @@ -111,5 +100,6 @@ test('canonicalizeOpenAIResponsesPayload preserves reasoning.context verbatim, i input: 'hi', reasoning: { context: 'future_mode' }, }); + assert('input' in canonicalFuture); assertEquals(canonicalFuture.reasoning, { context: 'future_mode' }); }); diff --git a/packages/translate/__tests__/protocols/public-boundary_test.ts b/packages/translate/__tests__/protocols/public-boundary_test.ts new file mode 100644 index 0000000000..511e9c6bee --- /dev/null +++ b/packages/translate/__tests__/protocols/public-boundary_test.ts @@ -0,0 +1,60 @@ +import { expect, test } from 'vitest'; + +import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { + canonicalizeOpenAIResponsesPayload, + routeGeminiGenerateContentViaOpenAIChatCompletions, + routeOpenAIChatCompletionsViaAnthropicMessages, + routeOpenAIChatCompletionsViaOpenAIResponses, + routeOpenAIResponsesViaOpenAIChatCompletions, +} from '@floway-dev/translate'; + +const options = { model: 'resolved-model', sidecarCodec: createTestSidecarCodec('public-boundary') }; + +test('public translators return ordinary input-failure data before preparing a trip', async () => { + const failure = await routeOpenAIChatCompletionsViaOpenAIResponses({ + model: 'source-model', messages: [], + response_format: { type: 'json_schema', json_schema: { name: 'shape' } }, + }, options); + expect(failure).toEqual({ type: 'input-error', message: expect.stringContaining('without a schema') }); + expect(Object.getPrototypeOf(failure)).toBe(Object.prototype); + + const canonicalFailure = await routeOpenAIResponsesViaOpenAIChatCompletions({ model: '', input: [] }, options); + expect(canonicalFailure).toEqual({ + type: 'input-error', message: "Missing required parameter: 'model'.", param: 'model', code: 'missing_required_parameter', + }); +}); + +test('public operations preserve unexpected errors and their causes', async () => { + const cause = new Error('transport'); + const failure = new Error('image loader', { cause }); + await expect(routeOpenAIChatCompletionsViaAnthropicMessages({ + model: 'source-model', messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://example.com/image.png' } }] }], + }, { ...options, fallbackMaxOutputTokens: 32, loadRemoteImage: async () => { throw failure; } })).rejects.toBe(failure); + + expect(() => canonicalizeOpenAIResponsesPayload({ get model() { throw failure; } })).toThrow(failure); + expect(failure.cause).toBe(cause); +}); + +test('Messages translation retains the injected remote-image loader', async () => { + const urls: string[] = []; + const trip = await routeOpenAIChatCompletionsViaAnthropicMessages({ + model: 'source-model', messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://example.com/image.png' } }] }], + }, { + ...options, fallbackMaxOutputTokens: 32, + loadRemoteImage: async url => { urls.push(url); return { mediaType: 'image/png', data: new Uint8Array([1, 2, 3]) }; }, + }); + expect(trip.type).toBe('trip'); + if (trip.type !== 'trip') throw new Error(trip.message); + expect(urls).toEqual(['https://example.com/image.png']); + expect(trip.target.messages[0].content).toEqual([{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AQID' }, cache_control: { type: 'ephemeral' } }]); + expect(trip.target.max_tokens).toBe(32); + expect(trip.error(418, new Headers(), new Uint8Array())).toBeUndefined(); +}); + +test('Gemini translation retains the resolved model supplied by the gateway', async () => { + const trip = await routeGeminiGenerateContentViaOpenAIChatCompletions({ contents: [{ role: 'user', parts: [{ text: 'hello' }] }] }, options); + expect(trip.type).toBe('trip'); + if (trip.type !== 'trip') throw new Error(trip.message); + expect(trip.target.model).toBe('resolved-model'); +}); diff --git a/packages/translate/__tests__/protocols/require-trip.ts b/packages/translate/__tests__/protocols/require-trip.ts new file mode 100644 index 0000000000..e9200a26bb --- /dev/null +++ b/packages/translate/__tests__/protocols/require-trip.ts @@ -0,0 +1,7 @@ +import type { TranslateInputError } from '@floway-dev/translate'; + +export const requireTrip = async (result: Promise): Promise => { + const trip = await result; + if (trip.type === 'input-error') throw new Error(trip.message); + return trip; +}; diff --git a/packages/translate/__tests__/protocols/round-trip-receive_test.ts b/packages/translate/__tests__/protocols/round-trip-receive_test.ts index ef74461ed8..6a19f827b5 100644 --- a/packages/translate/__tests__/protocols/round-trip-receive_test.ts +++ b/packages/translate/__tests__/protocols/round-trip-receive_test.ts @@ -1,5 +1,6 @@ import { expect, test } from 'vitest'; +import { requireTrip } from './require-trip.ts'; import type { IRJSONObject } from '../../src/ir/types.ts'; import { computeAnthropicMessagesTurnDigest } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; import { computeGeminiGenerateContentTurnDigest } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; @@ -16,10 +17,7 @@ import type { AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-m import type { GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; -import { routeAnthropicMessages } from '@floway-dev/translate/anthropic-messages'; -import { routeGeminiGenerateContent } from '@floway-dev/translate/gemini-generate-content'; -import { routeOpenAIChatCompletions } from '@floway-dev/translate/openai-chat-completions'; -import { routeOpenAIResponses } from '@floway-dev/translate/openai-responses'; +import { routeAnthropicMessagesViaOpenAIChatCompletions, routeAnthropicMessagesViaOpenAIResponses, routeGeminiGenerateContentViaAnthropicMessages, routeGeminiGenerateContentViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIResponses, routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses, routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; const context = { model: 'target', sidecarCodec: createTestSidecarCodec('test-codec-scope'), loadRemoteImage: async () => null, fallbackMaxOutputTokens: undefined }; const chat = (carrier: string, text = 'answer'): OpenAIChatCompletionsPayloadEx => { @@ -46,15 +44,15 @@ const nativeTurn = (target: NativeProtocol, text = 'restored'): AssistantTurnFor : [{ type: 'message', role: 'assistant', content: text }]; const cases = [ - { source: 'openaiChatCompletions', target: 'anthropicMessages', run: (carrier: string) => routeOpenAIChatCompletions(chat(carrier)).viaAnthropicMessages(context) }, - { source: 'openaiChatCompletions', target: 'openaiResponses', run: (carrier: string) => routeOpenAIChatCompletions(chat(carrier)).viaOpenAIResponses(context) }, - { source: 'anthropicMessages', target: 'openaiChatCompletions', run: (carrier: string) => routeAnthropicMessages(messages(carrier)).viaOpenAIChatCompletions(context) }, - { source: 'anthropicMessages', target: 'openaiResponses', run: (carrier: string) => routeAnthropicMessages(messages(carrier)).viaOpenAIResponses(context) }, - { source: 'openaiResponses', target: 'anthropicMessages', run: (carrier: string) => routeOpenAIResponses(responses(carrier)).viaAnthropicMessages(context) }, - { source: 'openaiResponses', target: 'openaiChatCompletions', run: (carrier: string) => routeOpenAIResponses(responses(carrier)).viaOpenAIChatCompletions(context) }, - { source: 'geminiGenerateContent', target: 'anthropicMessages', run: (carrier: string) => routeGeminiGenerateContent(gemini(carrier)).viaAnthropicMessages(context) }, - { source: 'geminiGenerateContent', target: 'openaiChatCompletions', run: (carrier: string) => routeGeminiGenerateContent(gemini(carrier)).viaOpenAIChatCompletions(context) }, - { source: 'geminiGenerateContent', target: 'openaiResponses', run: (carrier: string) => routeGeminiGenerateContent(gemini(carrier)).viaOpenAIResponses(context) }, + { source: 'openaiChatCompletions', target: 'anthropicMessages', run: (carrier: string) => requireTrip(routeOpenAIChatCompletionsViaAnthropicMessages(chat(carrier), context)) }, + { source: 'openaiChatCompletions', target: 'openaiResponses', run: (carrier: string) => requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses(chat(carrier), context)) }, + { source: 'anthropicMessages', target: 'openaiChatCompletions', run: (carrier: string) => requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions(messages(carrier), context)) }, + { source: 'anthropicMessages', target: 'openaiResponses', run: (carrier: string) => requireTrip(routeAnthropicMessagesViaOpenAIResponses(messages(carrier), context)) }, + { source: 'openaiResponses', target: 'anthropicMessages', run: (carrier: string) => requireTrip(routeOpenAIResponsesViaAnthropicMessages(responses(carrier), context)) }, + { source: 'openaiResponses', target: 'openaiChatCompletions', run: (carrier: string) => requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(responses(carrier), context)) }, + { source: 'geminiGenerateContent', target: 'anthropicMessages', run: (carrier: string) => requireTrip(routeGeminiGenerateContentViaAnthropicMessages(gemini(carrier), context)) }, + { source: 'geminiGenerateContent', target: 'openaiChatCompletions', run: (carrier: string) => requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(gemini(carrier), context)) }, + { source: 'geminiGenerateContent', target: 'openaiResponses', run: (carrier: string) => requireTrip(routeGeminiGenerateContentViaOpenAIResponses(gemini(carrier), context)) }, ] as const; test.each(cases)('receives recognized $source history via $target', async ({ source, target, run }) => { @@ -91,7 +89,7 @@ test('hydrates a native JSON field from a replayed GenerateContent argument cand referencedContentHashes: [await hashRoundTripContent(JSON.stringify(args))], sourceTurnDigest: await hashRoundTripContent(JSON.stringify(checkMaterial)), }); - const trip = await routeGeminiGenerateContent({ contents: [{ role: 'model', parts: [{ functionCall: { name: 'lookup', args }, thoughtSignature: carrier }] }] }).viaAnthropicMessages(context); + const trip = await requireTrip(routeGeminiGenerateContentViaAnthropicMessages({ contents: [{ role: 'model', parts: [{ functionCall: { name: 'lookup', args }, thoughtSignature: carrier }] }] }, context)); expect(trip.target.messages).toEqual([{ role: 'assistant', content: [{ type: 'tool_use', id: 'native-call', name: 'native-tool', input: args }] }]); }); @@ -119,7 +117,7 @@ test.each(['different codec scope', 'changed sidecar handle', 'changed replay'] const encoded = await encodeRoundTripSidecar(codec, { v: 1, source: 'openaiChatCompletions', target: 'anthropicMessages', targetTurn: native, sourceTurnDigest: await turnDigest('openaiChatCompletions') }); const carrier = failure === 'changed sidecar handle' ? `${encoded.startsWith('A') ? 'B' : 'A'}${encoded.slice(1)}` : encoded; const text = failure === 'changed replay' ? 'edited' : 'answer'; - const trip = await routeOpenAIChatCompletions(chat(carrier, text)).viaAnthropicMessages(context); + const trip = await requireTrip(routeOpenAIChatCompletionsViaAnthropicMessages(chat(carrier, text), context)); expect(trip.target.messages).toEqual([{ role: 'assistant', content: [{ type: 'text', text, cache_control: { type: 'ephemeral' } }] }]); }); @@ -128,11 +126,11 @@ test.each([{ input: 'text' }, { input: null }, { input: [1] }])('preserves ordin model: 'source', max_tokens: 16, messages: [{ role: 'assistant', content: [{ type: 'tool_use', id: 'call', name: 'echo', input }] }], }; - const chatTrip = await routeAnthropicMessages(payload).viaOpenAIChatCompletions(context); + const chatTrip = await requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)); expect(chatTrip.target.messages).toMatchObject([ { role: 'assistant', tool_calls: [{ id: 'call', function: { name: 'echo', arguments: JSON.stringify(input) } }] }, ]); - const responsesTrip = await routeAnthropicMessages(payload).viaOpenAIResponses(context); + const responsesTrip = await requireTrip(routeAnthropicMessagesViaOpenAIResponses(payload, context)); expect(responsesTrip.target.input).toMatchObject([ { type: 'function_call', call_id: 'call', name: 'echo', arguments: JSON.stringify(input) }, ]); @@ -140,15 +138,15 @@ test.each([{ input: 'text' }, { input: null }, { input: [1] }])('preserves ordin test('preserves ordinary GenerateContent null-argument handling', async () => { const payload: GeminiGenerateContentPayload = JSON.parse('{"contents":[{"role":"model","parts":[{"functionCall":{"name":"lookup","args":null}}]}]}'); - const chatTrip = await routeGeminiGenerateContent(payload).viaOpenAIChatCompletions(context); + const chatTrip = await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(payload, context)); expect(chatTrip.target.messages).toMatchObject([ { role: 'assistant', tool_calls: [{ function: { name: 'lookup', arguments: 'null' } }] }, ]); - const responsesTrip = await routeGeminiGenerateContent(payload).viaOpenAIResponses(context); + const responsesTrip = await requireTrip(routeGeminiGenerateContentViaOpenAIResponses(payload, context)); expect(responsesTrip.target.input).toMatchObject([ { type: 'function_call', name: 'lookup', arguments: 'null' }, ]); - const messagesTrip = await routeGeminiGenerateContent(payload).viaAnthropicMessages(context); + const messagesTrip = await requireTrip(routeGeminiGenerateContentViaAnthropicMessages(payload, context)); expect(messagesTrip.target.messages).toMatchObject([ { role: 'assistant', content: [{ type: 'tool_use', name: 'lookup', input: null }] }, ]); @@ -163,7 +161,7 @@ test('exposes a recognized malformed assistant envelope as an internal error', a targetTurn: { role: 'user', content: [] }, sourceTurnDigest: await turnDigest('openaiChatCompletions'), }); - await expect(routeOpenAIChatCompletions(chat(carrier)).viaAnthropicMessages(context)).rejects.toThrow('Messages assistant turn is invalid'); + await expect(requireTrip(routeOpenAIChatCompletionsViaAnthropicMessages(chat(carrier), context))).rejects.toThrow('Messages assistant turn is invalid'); }); test('accepts incomplete Responses call history and requires its replayed status to match', async () => { @@ -179,9 +177,9 @@ test('accepts incomplete Responses call history and requires its replayed status sourceTurnDigest: await hashRoundTripContent(JSON.stringify(expected)), }); const reasoning = { type: 'reasoning' as const, id: 'carrier', summary: [], encrypted_content: carrier }; - const replayed = await routeOpenAIResponses({ model: 'source', input: [call, reasoning] }).viaAnthropicMessages(context); + const replayed = await requireTrip(routeOpenAIResponsesViaAnthropicMessages({ model: 'source', input: [call, reasoning] }, context)); expect(replayed.target.messages).toEqual([native]); - const changed = await routeOpenAIResponses({ model: 'source', input: [{ ...call, status: 'completed' }, reasoning] }).viaAnthropicMessages(context); + const changed = await requireTrip(routeOpenAIResponsesViaAnthropicMessages({ model: 'source', input: [{ ...call, status: 'completed' }, reasoning] }, context)); expect(changed.target.messages).not.toEqual([native]); expect(changed.target.messages).toEqual(expect.arrayContaining([ expect.objectContaining({ content: expect.arrayContaining([expect.objectContaining({ type: 'tool_use', id: 'call', name: 'lookup', input: { x: 1 } })]) }), diff --git a/packages/translate/__tests__/protocols/routes_test.ts b/packages/translate/__tests__/protocols/routes_test.ts index 129b7a61c5..319c6c3ba7 100644 --- a/packages/translate/__tests__/protocols/routes_test.ts +++ b/packages/translate/__tests__/protocols/routes_test.ts @@ -1,5 +1,6 @@ import { test } from 'vitest'; +import { requireTrip } from './require-trip.ts'; import { partitionAnthropicMessagesTurns } from '../../src/protocols/anthropic-messages/as-source/conversation-turns.ts'; import { findAnthropicMessagesSidecarCarriers } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; import { findGeminiGenerateContentSidecarCarriers } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; @@ -19,10 +20,7 @@ import { collectGeminiGenerateContentProtocolEventsToResult, type GeminiGenerate import { collectOpenAIChatCompletionsProtocolEventsToResult, type OpenAIChatCompletionsAssistantMessageEx, type OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; import { collectOpenAIResponsesProtocolEventsToResult, type CanonicalOpenAIResponsesInputItem, type CanonicalOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; import { assert, assertEquals } from '@floway-dev/test-utils'; -import { routeAnthropicMessages } from '@floway-dev/translate/anthropic-messages'; -import { routeGeminiGenerateContent } from '@floway-dev/translate/gemini-generate-content'; -import { routeOpenAIChatCompletions } from '@floway-dev/translate/openai-chat-completions'; -import { routeOpenAIResponses } from '@floway-dev/translate/openai-responses'; +import { routeAnthropicMessagesViaOpenAIChatCompletions, routeAnthropicMessagesViaOpenAIResponses, routeGeminiGenerateContentViaAnthropicMessages, routeGeminiGenerateContentViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIResponses, routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses, routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; const schema = { type: 'object' as const, properties: { x: { type: 'string' } } }; const formatSchema = { type: 'object', properties: { y: { type: 'string' } } }; @@ -77,21 +75,16 @@ const objectsIn = (value: unknown): Set => { return objects; }; -const responsesRoute = routeOpenAIResponses(responses); -const chatRoute = routeOpenAIChatCompletions(chat); -const anthropicRoute = routeAnthropicMessages(anthropic); -const geminiRoute = routeGeminiGenerateContent(gemini); - const translations: Array<{ name: string; source: unknown; translate: () => Promise }> = [ - { name: 'Responses to Chat Completions', source: responses, translate: async () => (await responsesRoute.viaOpenAIChatCompletions({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' })).target }, - { name: 'Responses to Anthropic Messages', source: responses, translate: async () => (await responsesRoute.viaAnthropicMessages({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm', loadRemoteImage: async () => { throw new Error('Unexpected remote image'); } })).target }, - { name: 'Chat Completions to Responses', source: chat, translate: async () => (await chatRoute.viaOpenAIResponses({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' })).target }, - { name: 'Chat Completions to Anthropic Messages', source: chat, translate: async () => (await chatRoute.viaAnthropicMessages({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm', loadRemoteImage: async () => { throw new Error('Unexpected remote image'); } })).target }, - { name: 'Anthropic Messages to Responses', source: anthropic, translate: async () => (await anthropicRoute.viaOpenAIResponses({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' })).target }, - { name: 'Anthropic Messages to Chat Completions', source: anthropic, translate: async () => (await anthropicRoute.viaOpenAIChatCompletions({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' })).target }, - { name: 'Gemini to Responses', source: gemini, translate: async () => (await geminiRoute.viaOpenAIResponses({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' })).target }, - { name: 'Gemini to Chat Completions', source: gemini, translate: async () => (await geminiRoute.viaOpenAIChatCompletions({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' })).target }, - { name: 'Gemini to Anthropic Messages', source: gemini, translate: async () => (await geminiRoute.viaAnthropicMessages({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm', fallbackMaxOutputTokens: 16 })).target }, + { name: 'Responses to Chat Completions', source: responses, translate: async () => (await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(responses, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' }))).target }, + { name: 'Responses to Anthropic Messages', source: responses, translate: async () => (await requireTrip(routeOpenAIResponsesViaAnthropicMessages(responses, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm', loadRemoteImage: async () => { throw new Error('Unexpected remote image'); } }))).target }, + { name: 'Chat Completions to Responses', source: chat, translate: async () => (await requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses(chat, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' }))).target }, + { name: 'Chat Completions to Anthropic Messages', source: chat, translate: async () => (await requireTrip(routeOpenAIChatCompletionsViaAnthropicMessages(chat, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm', loadRemoteImage: async () => { throw new Error('Unexpected remote image'); } }))).target }, + { name: 'Anthropic Messages to Responses', source: anthropic, translate: async () => (await requireTrip(routeAnthropicMessagesViaOpenAIResponses(anthropic, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' }))).target }, + { name: 'Anthropic Messages to Chat Completions', source: anthropic, translate: async () => (await requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions(anthropic, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' }))).target }, + { name: 'Gemini to Responses', source: gemini, translate: async () => (await requireTrip(routeGeminiGenerateContentViaOpenAIResponses(gemini, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' }))).target }, + { name: 'Gemini to Chat Completions', source: gemini, translate: async () => (await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(gemini, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' }))).target }, + { name: 'Gemini to Anthropic Messages', source: gemini, translate: async () => (await requireTrip(routeGeminiGenerateContentViaAnthropicMessages(gemini, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm', fallbackMaxOutputTokens: 16 }))).target }, ]; test.each(translations)('$name owns its target payload without retaining source or prior target objects', async ({ source, translate }) => { @@ -114,15 +107,15 @@ type Protocol = 'openai-chat-completions' | 'openai-responses' | 'anthropic-mess const codecScope = 'test-codec-scope'; const returnedTrips: { a: Protocol; b: Protocol; trip: (codecScope: string) => Promise<{ events: (frames: AsyncIterable) => AsyncIterable }> }[] = [ - { a: 'openai-responses', b: 'openai-chat-completions', trip: codecScope => responsesRoute.viaOpenAIChatCompletions({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }) }, - { a: 'openai-responses', b: 'anthropic-messages', trip: codecScope => responsesRoute.viaAnthropicMessages({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', loadRemoteImage: async () => null }) }, - { a: 'openai-chat-completions', b: 'openai-responses', trip: codecScope => chatRoute.viaOpenAIResponses({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }) }, - { a: 'openai-chat-completions', b: 'anthropic-messages', trip: codecScope => chatRoute.viaAnthropicMessages({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', loadRemoteImage: async () => null }) }, - { a: 'anthropic-messages', b: 'openai-responses', trip: codecScope => anthropicRoute.viaOpenAIResponses({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }) }, - { a: 'anthropic-messages', b: 'openai-chat-completions', trip: codecScope => anthropicRoute.viaOpenAIChatCompletions({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }) }, - { a: 'gemini-generate-content', b: 'openai-responses', trip: codecScope => geminiRoute.viaOpenAIResponses({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }) }, - { a: 'gemini-generate-content', b: 'openai-chat-completions', trip: codecScope => geminiRoute.viaOpenAIChatCompletions({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }) }, - { a: 'gemini-generate-content', b: 'anthropic-messages', trip: codecScope => geminiRoute.viaAnthropicMessages({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', fallbackMaxOutputTokens: 16 }) }, + { a: 'openai-responses', b: 'openai-chat-completions', trip: codecScope => requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(responses, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })) }, + { a: 'openai-responses', b: 'anthropic-messages', trip: codecScope => requireTrip(routeOpenAIResponsesViaAnthropicMessages(responses, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', loadRemoteImage: async () => null })) }, + { a: 'openai-chat-completions', b: 'openai-responses', trip: codecScope => requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses(chat, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })) }, + { a: 'openai-chat-completions', b: 'anthropic-messages', trip: codecScope => requireTrip(routeOpenAIChatCompletionsViaAnthropicMessages(chat, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', loadRemoteImage: async () => null })) }, + { a: 'anthropic-messages', b: 'openai-responses', trip: codecScope => requireTrip(routeAnthropicMessagesViaOpenAIResponses(anthropic, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })) }, + { a: 'anthropic-messages', b: 'openai-chat-completions', trip: codecScope => requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions(anthropic, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })) }, + { a: 'gemini-generate-content', b: 'openai-responses', trip: codecScope => requireTrip(routeGeminiGenerateContentViaOpenAIResponses(gemini, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })) }, + { a: 'gemini-generate-content', b: 'openai-chat-completions', trip: codecScope => requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(gemini, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })) }, + { a: 'gemini-generate-content', b: 'anthropic-messages', trip: codecScope => requireTrip(routeGeminiGenerateContentViaAnthropicMessages(gemini, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', fallbackMaxOutputTokens: 16 })) }, ] as const; const protocolKeys = { @@ -223,25 +216,21 @@ type TargetPayload = const replayAssistantTurn = async (assistant: AssistantTurn, target: Protocol): Promise => { if (assistant.protocol === 'openai-responses') { - const route = routeOpenAIResponses({ ...responses, input: [...assistant.turn, { type: 'message', role: 'user', content: 'next question' }] }); - if (target === 'openai-chat-completions') return { protocol: target, payload: (await route.viaOpenAIChatCompletions({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })).target }; - if (target === 'anthropic-messages') return { protocol: target, payload: (await route.viaAnthropicMessages({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', loadRemoteImage: async () => null })).target }; + if (target === 'openai-chat-completions') return { protocol: target, payload: (await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions({ ...responses, input: [...assistant.turn, { type: 'message', role: 'user', content: 'next question' }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }))).target }; + if (target === 'anthropic-messages') return { protocol: target, payload: (await requireTrip(routeOpenAIResponsesViaAnthropicMessages({ ...responses, input: [...assistant.turn, { type: 'message', role: 'user', content: 'next question' }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', loadRemoteImage: async () => null }))).target }; } if (assistant.protocol === 'openai-chat-completions') { - const route = routeOpenAIChatCompletions({ ...chat, messages: [assistant.turn, { role: 'user', content: 'next question' }] }); - if (target === 'openai-responses') return { protocol: target, payload: (await route.viaOpenAIResponses({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })).target }; - if (target === 'anthropic-messages') return { protocol: target, payload: (await route.viaAnthropicMessages({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', loadRemoteImage: async () => null })).target }; + if (target === 'openai-responses') return { protocol: target, payload: (await requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses({ ...chat, messages: [assistant.turn, { role: 'user', content: 'next question' }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }))).target }; + if (target === 'anthropic-messages') return { protocol: target, payload: (await requireTrip(routeOpenAIChatCompletionsViaAnthropicMessages({ ...chat, messages: [assistant.turn, { role: 'user', content: 'next question' }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', loadRemoteImage: async () => null }))).target }; } if (assistant.protocol === 'anthropic-messages') { - const route = routeAnthropicMessages({ ...anthropic, messages: [assistant.turn, { role: 'user', content: 'next question' }] }); - if (target === 'openai-responses') return { protocol: target, payload: (await route.viaOpenAIResponses({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })).target }; - if (target === 'openai-chat-completions') return { protocol: target, payload: (await route.viaOpenAIChatCompletions({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })).target }; + if (target === 'openai-responses') return { protocol: target, payload: (await requireTrip(routeAnthropicMessagesViaOpenAIResponses({ ...anthropic, messages: [assistant.turn, { role: 'user', content: 'next question' }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }))).target }; + if (target === 'openai-chat-completions') return { protocol: target, payload: (await requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions({ ...anthropic, messages: [assistant.turn, { role: 'user', content: 'next question' }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }))).target }; } if (assistant.protocol === 'gemini-generate-content') { - const route = routeGeminiGenerateContent({ ...gemini, contents: [...assistant.turn, { role: 'user', parts: [{ text: 'next question' }] }] }); - if (target === 'openai-responses') return { protocol: target, payload: (await route.viaOpenAIResponses({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })).target }; - if (target === 'openai-chat-completions') return { protocol: target, payload: (await route.viaOpenAIChatCompletions({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })).target }; - if (target === 'anthropic-messages') return { protocol: target, payload: (await route.viaAnthropicMessages({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', fallbackMaxOutputTokens: 16 })).target }; + if (target === 'openai-responses') return { protocol: target, payload: (await requireTrip(routeGeminiGenerateContentViaOpenAIResponses({ ...gemini, contents: [...assistant.turn, { role: 'user', parts: [{ text: 'next question' }] }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }))).target }; + if (target === 'openai-chat-completions') return { protocol: target, payload: (await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions({ ...gemini, contents: [...assistant.turn, { role: 'user', parts: [{ text: 'next question' }] }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }))).target }; + if (target === 'anthropic-messages') return { protocol: target, payload: (await requireTrip(routeGeminiGenerateContentViaAnthropicMessages({ ...gemini, contents: [...assistant.turn, { role: 'user', parts: [{ text: 'next question' }] }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', fallbackMaxOutputTokens: 16 }))).target }; } throw new Error(`Unsupported route: ${assistant.protocol} via ${target}`); }; diff --git a/packages/translate/package.json b/packages/translate/package.json index 11e1c450d8..49bbe6174c 100644 --- a/packages/translate/package.json +++ b/packages/translate/package.json @@ -4,11 +4,10 @@ "version": "0.0.0", "type": "module", "exports": { - ".": { "import": "./src/index.ts", "types": "./src/index.ts" }, - "./anthropic-messages": { "import": "./src/protocols/anthropic-messages/index.ts", "types": "./src/protocols/anthropic-messages/index.ts" }, - "./openai-responses": { "import": "./src/protocols/openai-responses/index.ts", "types": "./src/protocols/openai-responses/index.ts" }, - "./openai-chat-completions": { "import": "./src/protocols/openai-chat-completions/index.ts", "types": "./src/protocols/openai-chat-completions/index.ts" }, - "./gemini-generate-content": { "import": "./src/protocols/gemini-generate-content/index.ts", "types": "./src/protocols/gemini-generate-content/index.ts" } + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + } }, "scripts": { "typecheck": "tsc --noEmit" diff --git a/packages/translate/src/index.ts b/packages/translate/src/index.ts index 347761c865..d7fedbcc9e 100644 --- a/packages/translate/src/index.ts +++ b/packages/translate/src/index.ts @@ -1,3 +1,14 @@ -export type { RemoteImageData, RemoteImageLoader, SidecarCodec, TranslatedApiError, TranslateTripResult, TranslationContext } from './types.ts'; -export { TranslatorInputError } from './translator-input-error.ts'; +export type { RemoteImageData, RemoteImageLoader, SidecarCodec, TranslateTrip, TranslateInputError, TranslateOptions, Translator } from './types.ts'; export { SidecarTaggedValue } from './round-trip/sidecar-tagged-value.ts'; +export { canonicalizeOpenAIResponsesPayload } from './protocols/openai-responses/canonicalize-payload.ts'; +export { + routeAnthropicMessagesViaOpenAIChatCompletions, + routeAnthropicMessagesViaOpenAIResponses, + routeGeminiGenerateContentViaAnthropicMessages, + routeGeminiGenerateContentViaOpenAIChatCompletions, + routeGeminiGenerateContentViaOpenAIResponses, + routeOpenAIChatCompletionsViaAnthropicMessages, + routeOpenAIChatCompletionsViaOpenAIResponses, + routeOpenAIResponsesViaAnthropicMessages, + routeOpenAIResponsesViaOpenAIChatCompletions, +} from './protocols/routes.ts'; 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 index 0b052c6c6b..d4d4a50239 100644 --- 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 @@ -1,6 +1,6 @@ import { isContextExceededError } from '../../../shared/context-window-error.ts'; -import type { TranslatedApiError } from '../../../types.ts'; -import { buildPromptTooLongBody } from '@floway-dev/protocols/anthropic-messages'; +import type { ApiErrorTranslator } from '../../../types.ts'; +import { PROMPT_TOO_LONG_MESSAGE } from '@floway-dev/protocols/anthropic-messages'; const isContextExceededErrorText = (text: string): boolean => { try { @@ -11,14 +11,14 @@ const isContextExceededErrorText = (text: string): boolean => { } }; -// Shared `TranslateTrip.apiError` implementation for the `anthropic-messages-via-*` +// 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 = (upstream: TranslatedApiError): TranslatedApiError | undefined => { - if (!isContextExceededErrorText(new TextDecoder().decode(upstream.body))) return undefined; +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: buildPromptTooLongBody(), + body: { type: 'error', error: { type: 'invalid_request_error', message: PROMPT_TOO_LONG_MESSAGE } }, }; }; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/tool-input-schema.ts b/packages/translate/src/protocols/anthropic-messages/as-target/tool-input-schema.ts index 8d9f4585d6..1612c98007 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/tool-input-schema.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/tool-input-schema.ts @@ -1,7 +1,7 @@ -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { AnthropicMessagesClientTool } from '@floway-dev/protocols/anthropic-messages'; export const anthropicMessagesToolInputSchema = (schema: Record): AnthropicMessagesClientTool['input_schema'] => { - if (schema.type !== 'object') throw new TranslatorInputError('Anthropic Messages function tools require an object input schema.'); + if (schema.type !== 'object') throw new TranslateInputException('Anthropic Messages function tools require an object input schema.'); return schema as AnthropicMessagesClientTool['input_schema']; }; diff --git a/packages/translate/src/protocols/anthropic-messages/index.ts b/packages/translate/src/protocols/anthropic-messages/index.ts deleted file mode 100644 index 37e4e83b84..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { routeAnthropicMessages } from './route.ts'; diff --git a/packages/translate/src/protocols/anthropic-messages/route.ts b/packages/translate/src/protocols/anthropic-messages/route.ts deleted file mode 100644 index f8ddf04f12..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/route.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { translate as viaOpenAIChatCompletions } from './via-openai-chat-completions/translate.ts'; -import { translate as viaOpenAIResponses } from './via-openai-responses/translate.ts'; -import type { AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; - -export const routeAnthropicMessages = (input: AnthropicMessagesPayload) => ({ - viaOpenAIChatCompletions: (ctx: Parameters[1]) => viaOpenAIChatCompletions(input, ctx), - viaOpenAIResponses: (ctx: Parameters[1]) => viaOpenAIResponses(input, ctx), -}); diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/request.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/request.ts index 97d044419b..fd53391c21 100644 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/request.ts +++ b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/request.ts @@ -1,7 +1,7 @@ import { klona } from 'klona/json'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { SidecarCodec } from '../../../types.ts'; import { filterAnthropicMessagesClientTools } from '../as-source/client-tools.ts'; import { partitionAnthropicMessagesTurns } from '../as-source/conversation-turns.ts'; @@ -70,7 +70,7 @@ const toOpenAIChatCompletionsContent = (content: string | AnthropicMessagesUserC } if (block.type === 'image') { - if (block.source.type === 'file') throw new TranslatorInputError('Cannot translate file_id-only Anthropic image input to Chat Completions.'); + if (block.source.type === 'file') throw new TranslateInputException('Cannot translate file_id-only Anthropic image input to Chat Completions.'); parts.push({ type: 'image_url', image_url: { url: block.source.type === 'url' ? block.source.url : `data:${block.source.media_type};base64,${block.source.data}` } }); } } @@ -148,7 +148,7 @@ const translateAnthropicMessagesUser = (message: AnthropicMessagesUserMessage, m } if (block.type !== 'text' && block.type !== 'image') { - throw new TranslatorInputError(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' content blocks are not supported on this model`); + throw new TranslateInputException(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' content blocks are not supported on this model`); } pendingUserBlocks.push(block); @@ -200,7 +200,7 @@ const translateAnthropicMessagesAssistant = (message: AnthropicMessagesAssistant }); break; default: - throw new TranslatorInputError(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' assistant content blocks are not supported on this model`); + throw new TranslateInputException(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' assistant content blocks are not supported on this model`); } } @@ -221,7 +221,7 @@ const translateAnthropicMessagesSystem = (message: AnthropicMessagesSystemMessag { role: 'system', content: typeof message.content === 'string' ? message.content : message.content.map(block => { - if (block.type !== 'text') throw new TranslatorInputError('Only text is supported in Chat system messages.'); + if (block.type !== 'text') throw new TranslateInputException('Only text is supported in Chat system messages.'); return { type: 'text', text: block.text }; }), }, @@ -243,7 +243,7 @@ const translateAnthropicMessagesTools = (tools?: AnthropicMessagesClientTool[]): const translateAnthropicMessagesToolChoice = (toolChoice?: AnthropicMessagesPayload['tool_choice'], tools?: AnthropicMessagesClientTool[]): OpenAIChatCompletionsPayload['tool_choice'] => { if (!toolChoice) return undefined; if (!tools || tools.length === 0) { - if (toolChoice.type === 'tool') throw new TranslatorInputError(`Anthropic Messages tool_choice names an undeclared tool: ${String(toolChoice.name)}`); + if (toolChoice.type === 'tool') throw new TranslateInputException(`Anthropic Messages tool_choice names an undeclared tool: ${String(toolChoice.name)}`); return undefined; } @@ -253,7 +253,7 @@ const translateAnthropicMessagesToolChoice = (toolChoice?: AnthropicMessagesPayl case 'any': return 'required'; case 'tool': { - if (!toolChoice.name || !tools.some(tool => tool.name === toolChoice.name)) throw new TranslatorInputError(`Anthropic Messages tool_choice names an undeclared tool: ${String(toolChoice.name)}`); + if (!toolChoice.name || !tools.some(tool => tool.name === toolChoice.name)) throw new TranslateInputException(`Anthropic Messages tool_choice names an undeclared tool: ${String(toolChoice.name)}`); return { type: 'function', function: { name: toolChoice.name } }; } case 'none': @@ -302,7 +302,7 @@ export const buildTargetRequest = async ( switch (message.role) { case 'user': messages.push(...translateAnthropicMessagesUser(message, messageIndex + index, legacyFunctionNames)); break; case 'system': messages.push(...translateAnthropicMessagesSystem(message)); break; - default: throw new TranslatorInputError(`messages.${messageIndex + index}.role: role '${(message as { role: string }).role}' is not supported on this model`); + default: throw new TranslateInputException(`messages.${messageIndex + index}.role: role '${(message as { role: string }).role}' is not supported on this model`); } } messageIndex += turn.items.length; 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 f09fcaf178..e8c0ad9d71 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 @@ -1,17 +1,24 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; -import type { TranslateTrip } from '../../../types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import type { Translator } from '../../../types.ts'; import { rewriteContextExceededToPromptTooLong } from '../as-source/context-window-error.ts'; import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; -export const translate: TranslateTrip< +export const translate: Translator< AnthropicMessagesPayload, AnthropicMessagesStreamEventEx, OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent > = async (src, ctx) => { - const target = await buildTargetRequest(src, ctx.sidecarCodec); - return { - target, - events: frames => translateToSourceEvents(frames, ctx.sidecarCodec, target.n ?? 1), - apiError: rewriteContextExceededToPromptTooLong, - }; + try { + const target = await buildTargetRequest(src, ctx.sidecarCodec); + return { + type: 'trip', + target, + events: frames => translateToSourceEvents(frames, ctx.sidecarCodec, target.n ?? 1), + error: rewriteContextExceededToPromptTooLong, + }; + } catch (error) { + if (error instanceof TranslateInputException) return error.failure; + throw error; + } }; diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/request.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/request.ts index c2bda591a2..cd9c0b0e7d 100644 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/request.ts +++ b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/request.ts @@ -2,7 +2,7 @@ import { klona } from 'klona/json'; import { anthropicMessagesReasoningBlockToOpenAIResponsesReasoning } from './reasoning.ts'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { SidecarCodec } from '../../../types.ts'; import { filterAnthropicMessagesClientTools } from '../as-source/client-tools.ts'; import { partitionAnthropicMessagesTurns } from '../as-source/conversation-turns.ts'; @@ -74,7 +74,7 @@ const translateUserContentBlock = ( }; } - throw new TranslatorInputError(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' user content blocks are not supported on this model`); + throw new TranslateInputException(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' user content blocks are not supported on this model`); }; const toOpenAIResponsesFunctionCall = (block: AnthropicMessagesToolUseBlockParam | AnthropicMessagesServerToolUseBlockParam): CanonicalOpenAIResponsesInputItem => ({ @@ -160,7 +160,7 @@ const translateAssistantMessage = (message: AnthropicMessagesAssistantMessage, m continue; } - throw new TranslatorInputError(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' assistant content blocks are not supported on this model`); + throw new TranslateInputException(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' assistant content blocks are not supported on this model`); } flushPendingContent(pendingContent, input, 'assistant'); @@ -176,7 +176,7 @@ const translateAnthropicMessagesSystem = (message: AnthropicMessagesSystemMessag content: typeof message.content === 'string' ? message.content : message.content.map(block => { - if (block.type !== 'text') throw new TranslatorInputError('Only text system content is supported by this Responses translation.'); + if (block.type !== 'text') throw new TranslateInputException('Only text system content is supported by this Responses translation.'); return { type: 'input_text', text: block.text }; }), }, @@ -234,7 +234,7 @@ const translateTools = (tools: AnthropicMessagesClientTool[] | undefined): OpenA const translateToolChoice = (toolChoice: AnthropicMessagesPayload['tool_choice'], tools?: AnthropicMessagesClientTool[]): OpenAIResponsesToolChoice | undefined => { if (!toolChoice) return undefined; if (!tools || tools.length === 0) { - if (toolChoice.type === 'tool') throw new TranslatorInputError(`Anthropic Messages tool_choice names an undeclared tool: ${String(toolChoice.name)}`); + if (toolChoice.type === 'tool') throw new TranslateInputException(`Anthropic Messages tool_choice names an undeclared tool: ${String(toolChoice.name)}`); return undefined; } @@ -246,7 +246,7 @@ const translateToolChoice = (toolChoice: AnthropicMessagesPayload['tool_choice'] case 'any': return 'required'; case 'tool': { - if (!toolChoice.name || !toolNames.has(toolChoice.name)) throw new TranslatorInputError(`Anthropic Messages tool_choice names an undeclared tool: ${String(toolChoice.name)}`); + if (!toolChoice.name || !toolNames.has(toolChoice.name)) throw new TranslateInputException(`Anthropic Messages tool_choice names an undeclared tool: ${String(toolChoice.name)}`); return { type: 'function', name: toolChoice.name }; } case 'none': @@ -256,7 +256,7 @@ const translateToolChoice = (toolChoice: AnthropicMessagesPayload['tool_choice'] const translateMetadata = (metadata: AnthropicMessagesMetadataEx): Record => Object.fromEntries(Object.entries(metadata).flatMap(([key, value]) => { if (key === 'user_id' && value == null) return []; - if (typeof value !== 'string') throw new TranslatorInputError(`Cannot translate Anthropic Messages metadata field '${key}' to a Responses string metadata value.`); + if (typeof value !== 'string') throw new TranslateInputException(`Cannot translate Anthropic Messages metadata field '${key}' to a Responses string metadata value.`); return [[key, value]]; })); @@ -296,7 +296,7 @@ export const buildTargetRequest = async ( switch (message.role) { case 'user': input.push(...translateUserMessage(message, messageIndex + index)); break; case 'system': input.push(...translateAnthropicMessagesSystem(message)); break; - default: throw new TranslatorInputError(`messages.${messageIndex + index}.role: role '${(message as { role: string }).role}' is not supported on this model`); + default: throw new TranslateInputException(`messages.${messageIndex + index}.role: role '${(message as { role: string }).role}' is not supported on this model`); } } messageIndex += turn.items.length; 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 55384bbadc..bdec21ae2e 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 @@ -1,16 +1,23 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; -import type { TranslateTrip } from '../../../types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import type { Translator } from '../../../types.ts'; import { rewriteContextExceededToPromptTooLong } from '../as-source/context-window-error.ts'; import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; -export const translate: TranslateTrip< +export const translate: Translator< AnthropicMessagesPayload, AnthropicMessagesStreamEventEx, CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx > = async (src, ctx) => { - return { - target: await buildTargetRequest(src, ctx.sidecarCodec), - events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), - apiError: rewriteContextExceededToPromptTooLong, - }; + try { + return { + type: 'trip', + target: await buildTargetRequest(src, ctx.sidecarCodec), + events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), + error: rewriteContextExceededToPromptTooLong, + }; + } catch (error) { + if (error instanceof TranslateInputException) return error.failure; + throw error; + } }; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/schema.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/schema.ts index c3da266060..a452afbfac 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/schema.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/schema.ts @@ -1,6 +1,6 @@ import { klona } from 'klona/json'; -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import { jsonInteger } from '@floway-dev/protocols/common'; import type { GeminiGenerateContentFunctionDeclaration, GeminiGenerateContentGenerationConfig, GeminiGenerateContentSchema } from '@floway-dev/protocols/gemini-generate-content'; @@ -22,7 +22,7 @@ export const geminiSchemaToJsonSchema = (schema: GeminiGenerateContentSchema): R }; for (const key of integerConstraints) { if (schema[key] === undefined) continue; - if (!/^\d+$/.test(schema[key])) throw new TranslatorInputError(`Gemini Schema ${key} must be a non-negative integer.`); + if (!/^\d+$/.test(schema[key])) throw new TranslateInputException(`Gemini Schema ${key} must be a non-negative integer.`); result[key] = jsonInteger(BigInt(schema[key])); } return nullable === true ? { anyOf: [result, { type: 'null' }] } : result; @@ -31,16 +31,16 @@ export const geminiSchemaToJsonSchema = (schema: GeminiGenerateContentSchema): R const jsonSchemaObject = (schema: unknown): Record => { if (schema === true) return {}; if (schema === false) return { not: {} }; - if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) throw new TranslatorInputError('The target protocol requires an object JSON Schema.'); + if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) throw new TranslateInputException('The target protocol requires an object JSON Schema.'); return klona(schema as Record); }; export const geminiFunctionParameters = (declaration: GeminiGenerateContentFunctionDeclaration): Record | undefined => { - if (declaration.parameters !== undefined && declaration.parametersJsonSchema !== undefined) throw new TranslatorInputError('Gemini parameters and parametersJsonSchema are mutually exclusive.'); + if (declaration.parameters !== undefined && declaration.parametersJsonSchema !== undefined) throw new TranslateInputException('Gemini parameters and parametersJsonSchema are mutually exclusive.'); return declaration.parametersJsonSchema !== undefined ? jsonSchemaObject(declaration.parametersJsonSchema) : declaration.parameters === undefined ? undefined : geminiSchemaToJsonSchema(declaration.parameters); }; export const geminiResponseSchema = (config: GeminiGenerateContentGenerationConfig): Record | undefined => { - if (config.responseSchema !== undefined && config.responseJsonSchema !== undefined) throw new TranslatorInputError('Gemini responseSchema and responseJsonSchema are mutually exclusive.'); + if (config.responseSchema !== undefined && config.responseJsonSchema !== undefined) throw new TranslateInputException('Gemini responseSchema and responseJsonSchema are mutually exclusive.'); return config.responseJsonSchema !== undefined ? jsonSchemaObject(config.responseJsonSchema) : config.responseSchema === undefined ? undefined : geminiSchemaToJsonSchema(config.responseSchema); }; diff --git a/packages/translate/src/protocols/gemini-generate-content/index.ts b/packages/translate/src/protocols/gemini-generate-content/index.ts deleted file mode 100644 index d414cdf75e..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { routeGeminiGenerateContent } from './route.ts'; diff --git a/packages/translate/src/protocols/gemini-generate-content/route.ts b/packages/translate/src/protocols/gemini-generate-content/route.ts deleted file mode 100644 index b9f4d835fe..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { translate as viaAnthropicMessages } from './via-anthropic-messages/translate.ts'; -import { translate as viaOpenAIChatCompletions } from './via-openai-chat-completions/translate.ts'; -import { translate as viaOpenAIResponses } from './via-openai-responses/translate.ts'; -import type { GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; - -export const routeGeminiGenerateContent = (input: GeminiGenerateContentPayload) => ({ - viaAnthropicMessages: (ctx: Parameters[1]) => viaAnthropicMessages(input, ctx), - viaOpenAIChatCompletions: (ctx: Parameters[1]) => viaOpenAIChatCompletions(input, ctx), - viaOpenAIResponses: (ctx: Parameters[1]) => viaOpenAIResponses(input, ctx), -}); diff --git a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/request.ts b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/request.ts index 8a79cac25f..4a0001f630 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/request.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/request.ts @@ -1,7 +1,7 @@ import { klona } from 'klona/json'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { SidecarCodec } from '../../../types.ts'; import { applyLastMessageCacheBreakpoint, applyLastSystemCacheBreakpoint, applyLastToolCacheBreakpoint } from '../../anthropic-messages/as-target/cache-breakpoints.ts'; import { anthropicMessagesToolInputSchema } from '../../anthropic-messages/as-target/tool-input-schema.ts'; @@ -46,7 +46,7 @@ const inlineDataToContentBlock = (part: GeminiGenerateContentPart): AnthropicMes }; case 'audio': case 'unsupported': - throw new TranslatorInputError(`Cannot translate Gemini generateContent inlineData MIME type '${inlineData.mimeType}' to Anthropic Messages.`); + throw new TranslateInputException(`Cannot translate Gemini generateContent inlineData MIME type '${inlineData.mimeType}' to Anthropic Messages.`); } }; @@ -78,7 +78,7 @@ const buildUserMessage = (content: GeminiGenerateContentContent, turnIndex: numb return; } default: - throw new TranslatorInputError(`"${kind}" parts are not supported in user content.`); + throw new TranslateInputException(`"${kind}" parts are not supported in user content.`); } }); @@ -120,7 +120,7 @@ const buildAssistantMessage = (content: GeminiGenerateContentContent, turnIndex: return; } default: - throw new TranslatorInputError(`"${kind}" parts are not supported in model content.`); + throw new TranslateInputException(`"${kind}" parts are not supported in model content.`); } }); @@ -223,7 +223,7 @@ export const buildTargetRequest = async ( if (message) request.messages.push(message); } } else for (const [index, content] of turn.items.entries()) { - if (content.role !== 'user' && content.role !== undefined) throw new TranslatorInputError(`"${(content as { role: string }).role}" is not a supported content role.`); + if (content.role !== 'user' && content.role !== undefined) throw new TranslateInputException(`"${(content as { role: string }).role}" is not a supported content role.`); const message = buildUserMessage(content, contentIndex + index, unmatchedToolCallIds); if (message) request.messages.push(message); } 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 34163cd542..bbd30e7ffc 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 @@ -1,15 +1,23 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; -import type { TranslateTrip } from '../../../types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import type { Translator } from '../../../types.ts'; import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; -export const translate: TranslateTrip< +export const translate: Translator< GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent, AnthropicMessagesPayload, AnthropicMessagesStreamEventEx, { fallbackMaxOutputTokens?: number } > = async (src, ctx) => { - return { - target: await buildTargetRequest(src, ctx.model, ctx.sidecarCodec, { fallbackMaxOutputTokens: ctx.fallbackMaxOutputTokens }), - events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), - }; + try { + return { + type: 'trip', + error: () => undefined, + target: await buildTargetRequest(src, ctx.model, ctx.sidecarCodec, { fallbackMaxOutputTokens: ctx.fallbackMaxOutputTokens }), + 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-chat-completions/request.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts index 43c9b78962..098478c8d0 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts @@ -1,7 +1,7 @@ import { klona } from 'klona/json'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { SidecarCodec } from '../../../types.ts'; import { geminiGenerateContentInlineData, geminiGenerateContentPartKind, geminiGenerateContentPartText, geminiGenerateContentText, geminiGenerateContentThoughtText, geminiGenerateContentVisibleText } from '../as-source/content.ts'; import { partitionGeminiGenerateContentTurns } from '../as-source/conversation-turns.ts'; @@ -33,7 +33,7 @@ const inlineDataToContentPart = (part: GeminiGenerateContentPart): OpenAIChatCom input_audio: { data: inlineData.data, format: inlineData.format }, }; case 'unsupported': - throw new TranslatorInputError(`Cannot translate Gemini generateContent inlineData MIME type '${inlineData.mimeType}' to OpenAI Chat Completions.`); + throw new TranslateInputException(`Cannot translate Gemini generateContent inlineData MIME type '${inlineData.mimeType}' to OpenAI Chat Completions.`); } }; @@ -97,11 +97,11 @@ const buildAssistantMessage = (content: GeminiGenerateContentContent, turnIndex: visibleParts.push(part); return; default: - throw new TranslatorInputError(`"${kind}" parts are not supported in model content.`); + throw new TranslateInputException(`"${kind}" parts are not supported in model content.`); } }); - if (visibleParts.some(part => part.inlineData !== undefined)) throw new TranslatorInputError('Cannot translate image content in a model turn to Chat assistant content.'); + if (visibleParts.some(part => part.inlineData !== undefined)) throw new TranslateInputException('Cannot translate image content in a model turn to Chat assistant content.'); const textParts = visibleParts.map(geminiGenerateContentPartText).filter((value): value is string => value !== null); const message: OpenAIChatCompletionsAssistantMessageEx = { role: 'assistant', @@ -150,7 +150,7 @@ const buildUserMessages = (content: GeminiGenerateContentContent, turnIndex: num pendingParts.push(part); return; default: - throw new TranslatorInputError(`"${kind}" parts are not supported in user content.`); + throw new TranslateInputException(`"${kind}" parts are not supported in user content.`); } }); @@ -248,7 +248,7 @@ export const buildTargetRequest = async ( if (message) request.messages.push(message); } } else for (const [index, content] of turn.items.entries()) { - if (content.role !== 'user' && content.role !== undefined) throw new TranslatorInputError(`"${(content as { role: string }).role}" is not a supported content role.`); + if (content.role !== 'user' && content.role !== undefined) throw new TranslateInputException(`"${(content as { role: string }).role}" is not a supported content role.`); if ((content.parts?.length ?? 0) === 0) { request.messages.push({ role: 'user', content: '' }); continue; } request.messages.push(...buildUserMessages(content, contentIndex + index, unmatchedToolCallIds, legacyFunctionNames)); } 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 db282d86b2..0290871d73 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 @@ -1,12 +1,18 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; -import type { TranslateTrip } from '../../../types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import type { Translator } from '../../../types.ts'; import type { GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; -export const translate: TranslateTrip< +export const translate: Translator< GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent, OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent > = async (src, ctx) => { - const target = await buildTargetRequest(src, ctx.model, ctx.sidecarCodec); - return { target, events: frames => translateToSourceEvents(frames, ctx.sidecarCodec, target.n ?? 1) }; + try { + const target = await buildTargetRequest(src, ctx.model, ctx.sidecarCodec); + return { type: 'trip', error: () => undefined, target, events: frames => translateToSourceEvents(frames, ctx.sidecarCodec, target.n ?? 1) }; + } catch (error) { + if (error instanceof TranslateInputException) return error.failure; + throw error; + } }; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/request.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/request.ts index cf03ffccf3..b5ff204ae1 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/request.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/request.ts @@ -1,5 +1,5 @@ import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { SidecarCodec } from '../../../types.ts'; import { geminiGenerateContentInlineData, geminiGenerateContentPartKind, geminiGenerateContentPartText, geminiGenerateContentText, geminiGenerateContentThoughtText, geminiGenerateContentVisibleText } from '../as-source/content.ts'; import { partitionGeminiGenerateContentTurns } from '../as-source/conversation-turns.ts'; @@ -35,7 +35,7 @@ const inlineDataToInputContent = (part: GeminiGenerateContentPart): OpenAIRespon }; case 'audio': case 'unsupported': - throw new TranslatorInputError(`Cannot translate Gemini generateContent inlineData MIME type '${inlineData.mimeType}' to OpenAI Responses.`); + throw new TranslateInputException(`Cannot translate Gemini generateContent inlineData MIME type '${inlineData.mimeType}' to OpenAI Responses.`); } }; @@ -70,7 +70,7 @@ const buildUserInputItems = (content: GeminiGenerateContentContent, turnIndex: n return; } default: - throw new TranslatorInputError(`"${kind}" parts are not supported in user content.`); + throw new TranslateInputException(`"${kind}" parts are not supported in user content.`); } }); @@ -119,7 +119,7 @@ const buildAssistantInputItems = (content: GeminiGenerateContentContent, turnInd return; } default: - throw new TranslatorInputError(`"${kind}" parts are not supported in model content.`); + throw new TranslateInputException(`"${kind}" parts are not supported in model content.`); } }); @@ -201,7 +201,7 @@ export const buildTargetRequest = async ( input.push(...buildAssistantInputItems(content, contentIndex + index, unmatchedToolCallIds)); } } else for (const [index, content] of turn.items.entries()) { - if (content.role !== 'user' && content.role !== undefined) throw new TranslatorInputError(`"${(content as { role: string }).role}" is not a supported content role.`); + if (content.role !== 'user' && content.role !== undefined) throw new TranslateInputException(`"${(content as { role: string }).role}" is not a supported content role.`); if ((content.parts?.length ?? 0) === 0) { input.push({ type: 'message', role: 'user', content: '' }); continue; } input.push(...buildUserInputItems(content, contentIndex + index, unmatchedToolCallIds)); } 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 fcd3a8250d..240d232135 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 @@ -1,14 +1,22 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; -import type { TranslateTrip } from '../../../types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import type { Translator } from '../../../types.ts'; import type { GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; -export const translate: TranslateTrip< +export const translate: Translator< GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent, CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx > = async (src, ctx) => { - return { - target: await buildTargetRequest(src, ctx.model, ctx.sidecarCodec), - events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), - }; + try { + return { + type: 'trip', + error: () => undefined, + target: await buildTargetRequest(src, ctx.model, ctx.sidecarCodec), + events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), + }; + } catch (error) { + if (error instanceof TranslateInputException) return error.failure; + throw error; + } }; diff --git a/packages/translate/src/protocols/openai-chat-completions/index.ts b/packages/translate/src/protocols/openai-chat-completions/index.ts deleted file mode 100644 index 4c24dfb3d7..0000000000 --- a/packages/translate/src/protocols/openai-chat-completions/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { routeOpenAIChatCompletions } from './route.ts'; diff --git a/packages/translate/src/protocols/openai-chat-completions/route.ts b/packages/translate/src/protocols/openai-chat-completions/route.ts deleted file mode 100644 index e5400d8a50..0000000000 --- a/packages/translate/src/protocols/openai-chat-completions/route.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { translate as viaAnthropicMessages } from './via-anthropic-messages/translate.ts'; -import { translate as viaOpenAIResponses } from './via-openai-responses/translate.ts'; -import type { OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; - -export const routeOpenAIChatCompletions = (input: OpenAIChatCompletionsPayloadEx) => ({ - viaAnthropicMessages: (ctx: Parameters[1]) => viaAnthropicMessages(input, ctx), - viaOpenAIResponses: (ctx: Parameters[1]) => viaOpenAIResponses(input, ctx), -}); diff --git a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/request.ts b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/request.ts index 7b6058ebb0..6c7cc5dc44 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/request.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/request.ts @@ -1,7 +1,7 @@ import { klona } from 'klona/json'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { SidecarCodec, RemoteImageLoader } from '../../../types.ts'; import { applyLastMessageCacheBreakpoint, applyLastSystemCacheBreakpoint, applyLastToolCacheBreakpoint } from '../../anthropic-messages/as-target/cache-breakpoints.ts'; import { anthropicMessagesReasoningFieldsFromEffort } from '../../anthropic-messages/as-target/reasoning-effort.ts'; @@ -54,7 +54,7 @@ const buildAssistantBlocks = (message: OpenAIChatCompletionsAssistantMessage): A if (message.refusal) blocks.push({ type: 'text', text: message.refusal }); for (const toolCall of message.tool_calls ?? []) { - if (toolCall.type !== 'function') throw new TranslatorInputError('Cannot translate a custom Chat tool call to Anthropic Messages.'); + if (toolCall.type !== 'function') throw new TranslateInputException('Cannot translate a custom Chat tool call to Anthropic Messages.'); blocks.push({ type: 'tool_use', id: toolCall.id, @@ -101,7 +101,7 @@ const convertUserContent = async (message: OpenAIChatCompletionsMessage, loadRem return resolveImageUrlToAnthropicMessagesImage(part.image_url.url, loadRemoteImage); } - throw new TranslatorInputError(`Invalid '${(part as { type: string }).type}' content part. Only 'text' and 'image_url' are supported in user content.`); + throw new TranslateInputException(`Invalid '${(part as { type: string }).type}' content part. Only 'text' and 'image_url' are supported in user content.`); }), ); @@ -125,7 +125,7 @@ const convertSystemContent = (content: OpenAIChatCompletionsMessage['content']): const blocks: AnthropicMessagesTextBlockParam[] = []; for (const part of content) { if (part.type === 'image_url') { - throw new TranslatorInputError("Invalid 'image_url' content part in system or developer message. Only 'text' content parts are supported in system messages on this model."); + throw new TranslateInputException("Invalid 'image_url' content part in system or developer message. Only 'text' content parts are supported in system messages on this model."); } if (part.type === 'text') { blocks.push({ type: 'text', text: part.text }); @@ -147,7 +147,7 @@ const translateNonAssistantMessages = async ( break; case 'tool': if (!message.tool_call_id) { - throw new TranslatorInputError("Missing required field 'tool_call_id' on a 'tool' role message."); + throw new TranslateInputException("Missing required field 'tool_call_id' on a 'tool' role message."); } appendUserBlocks(result, [ @@ -175,14 +175,14 @@ const translateNonAssistantMessages = async ( break; } default: - throw new TranslatorInputError(`Invalid role '${message.role}'.`); + throw new TranslateInputException(`Invalid role '${message.role}'.`); } } }; const translateOpenAIChatCompletionsTools = (tools: OpenAIChatCompletionsTool[]): AnthropicMessagesPayload['tools'] => tools.map(tool => { - if (tool.type !== 'function') throw new TranslatorInputError('Cannot translate custom Chat tools to Anthropic Messages.'); + if (tool.type !== 'function') throw new TranslateInputException('Cannot translate custom Chat tools to Anthropic Messages.'); return { name: tool.function.name, description: tool.function.description, @@ -195,7 +195,7 @@ const translateOpenAIChatCompletionsToolChoice = (toolChoice: NonNullable { - if (tool.type !== 'function') throw new TranslatorInputError('Cannot translate custom Chat tools to Anthropic Messages.'); + if (tool.type !== 'function') throw new TranslateInputException('Cannot translate custom Chat tools to Anthropic Messages.'); return tool.function.name; })) : undefined; 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 7fe487021d..588339a6bf 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 @@ -1,19 +1,27 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; -import type { RemoteImageLoader, TranslateTrip } from '../../../types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import type { RemoteImageLoader, Translator } from '../../../types.ts'; import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; -export const translate: TranslateTrip< +export const translate: Translator< OpenAIChatCompletionsPayloadEx, OpenAIChatCompletionsStreamEvent, AnthropicMessagesPayload, AnthropicMessagesStreamEventEx, { fallbackMaxOutputTokens?: number; loadRemoteImage: RemoteImageLoader } > = async (src, ctx) => { - const streamOptions = { ...src.stream_options }; - return { - target: await buildTargetRequest(src, ctx.sidecarCodec, { - fallbackMaxOutputTokens: ctx.fallbackMaxOutputTokens, - loadRemoteImage: ctx.loadRemoteImage, - }), - events: frames => translateToSourceEvents(frames, streamOptions, ctx.sidecarCodec), - }; + try { + const streamOptions = { ...src.stream_options }; + return { + type: 'trip', + error: () => undefined, + target: await buildTargetRequest(src, ctx.sidecarCodec, { + fallbackMaxOutputTokens: ctx.fallbackMaxOutputTokens, + loadRemoteImage: ctx.loadRemoteImage, + }), + events: frames => translateToSourceEvents(frames, streamOptions, ctx.sidecarCodec), + }; + } catch (error) { + if (error instanceof TranslateInputException) return error.failure; + throw error; + } }; diff --git a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/content.ts b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/content.ts index 5c901126c2..3ee7d1071e 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/content.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/content.ts @@ -1,4 +1,4 @@ -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { OpenAIChatCompletionsContentPart } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesInputContent } from '@floway-dev/protocols/openai-responses'; @@ -26,7 +26,7 @@ export const openaiChatCompletionsContentToOpenAIResponsesInputContent = (conten case 'refusal': return { type: 'refusal', refusal: part.refusal }; case 'input_audio': - throw new TranslatorInputError('Cannot translate input_audio content to OpenAI Responses.'); + throw new TranslateInputException('Cannot translate input_audio content to OpenAI Responses.'); case 'file': return { type: 'input_file', ...part.file }; case 'image_url': diff --git a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/request.ts b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/request.ts index 66a4928365..a5c0aec29e 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/request.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/request.ts @@ -2,7 +2,7 @@ import { klona } from 'klona/json'; import { openaiChatCompletionsContentToOpenAIResponsesInputContent, openaiChatCompletionsContentToText } from './content.ts'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { SidecarCodec } from '../../../types.ts'; import { partitionOpenAIChatCompletionsTurns } from '../as-source/conversation-turns.ts'; import { collectOpenAIChatCompletionsReplayContents, hasOpenAIChatCompletionsReasoning, findOpenAIChatCompletionsSidecarCarriers, verifyOpenAIChatCompletionsTurnDigest } from '../as-source/round-trip.ts'; @@ -140,11 +140,11 @@ export const buildTargetRequest = async ( } if (message.role !== 'tool') { - throw new TranslatorInputError(`Invalid role '${(message as { role: string }).role}'.`); + throw new TranslateInputException(`Invalid role '${(message as { role: string }).role}'.`); } if (!message.tool_call_id) { - throw new TranslatorInputError("Missing required field 'tool_call_id' on a 'tool' role message."); + throw new TranslateInputException("Missing required field 'tool_call_id' on a 'tool' role message."); } input.push({ @@ -168,7 +168,7 @@ export const buildTargetRequest = async ( } const format = klona(payload.response_format); - if (format?.type === 'json_schema' && format.json_schema.schema === undefined) throw new TranslatorInputError('Cannot translate json_schema response format without a schema.'); + if (format?.type === 'json_schema' && format.json_schema.schema === undefined) throw new TranslateInputException('Cannot translate json_schema response format without a schema.'); const responseTextConfig = format == null ? undefined : { format: format.type === 'json_schema' ? { type: 'json_schema' as const, ...format.json_schema, schema: format.json_schema.schema! } 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 b953c9a1d7..cbd5549db7 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 @@ -1,15 +1,23 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; -import type { TranslateTrip } from '../../../types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import type { Translator } from '../../../types.ts'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; -export const translate: TranslateTrip< +export const translate: Translator< OpenAIChatCompletionsPayloadEx, OpenAIChatCompletionsStreamEvent, CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx > = async (src, ctx) => { - const streamOptions = { ...src.stream_options }; - return { - target: await buildTargetRequest(src, ctx.sidecarCodec), - events: frames => translateToSourceEvents(frames, streamOptions, ctx.sidecarCodec), - }; + try { + const streamOptions = { ...src.stream_options }; + return { + type: 'trip', + error: () => undefined, + target: await buildTargetRequest(src, ctx.sidecarCodec), + events: frames => translateToSourceEvents(frames, streamOptions, ctx.sidecarCodec), + }; + } catch (error) { + if (error instanceof TranslateInputException) return error.failure; + throw error; + } }; diff --git a/packages/translate/src/protocols/openai-responses/as-source/allowed-tools.ts b/packages/translate/src/protocols/openai-responses/as-source/allowed-tools.ts index 21ce104ab8..62f7249ba1 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/allowed-tools.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/allowed-tools.ts @@ -1,4 +1,4 @@ -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; const validateCallableKinds = (tools: OpenAIResponsesTool[] | null | undefined): void => { @@ -8,7 +8,7 @@ const validateCallableKinds = (tools: OpenAIResponsesTool[] | null | undefined): if ('namespace' in tool && tool.namespace !== undefined) continue; const prior = kinds.get(tool.name); if (prior !== undefined && prior !== tool.type) { - throw new TranslatorInputError(`Cannot translate distinct callable kinds sharing '${tool.name}'.`); + throw new TranslateInputException(`Cannot translate distinct callable kinds sharing '${tool.name}'.`); } kinds.set(tool.name, tool.type); } @@ -29,7 +29,7 @@ export const restrictAllowedTools = ( return { tools, choice }; } if ((choice.mode !== 'auto' && choice.mode !== 'required') || !Array.isArray(choice.tools)) { - throw new TranslatorInputError('Cannot translate malformed allowed_tools mode or tools array.'); + throw new TranslateInputException('Cannot translate malformed allowed_tools mode or tools array.'); } const declared = new Map>(); @@ -52,21 +52,21 @@ export const restrictAllowedTools = ( || (selector.type !== 'function' && selector.type !== 'custom') || typeof selector.name !== 'string' || selector.namespace !== undefined || Object.keys(selector).some(key => key !== 'type' && key !== 'name' && key !== 'namespace')) { - throw new TranslatorInputError('Cannot translate an allowed_tools selector that is not a flat function or custom tool.'); + throw new TranslateInputException('Cannot translate an allowed_tools selector that is not a flat function or custom tool.'); } const matches = declared.get(selector.type)?.get(selector.name); if (matches === undefined) { - throw new TranslatorInputError(`Cannot translate allowed_tools selector '${selector.name}' without a matching callable declaration.`); + throw new TranslateInputException(`Cannot translate allowed_tools selector '${selector.name}' without a matching callable declaration.`); } const kind = selectedKinds.get(selector.name); if (kind !== undefined && kind !== selector.type) { - throw new TranslatorInputError(`Cannot translate distinct allowed_tools callable kinds sharing '${selector.name}'.`); + throw new TranslateInputException(`Cannot translate distinct allowed_tools callable kinds sharing '${selector.name}'.`); } selectedKinds.set(selector.name, selector.type); for (const tool of matches) selected.add(tool); } if (selected.size === 0 && choice.mode === 'required') { - throw new TranslatorInputError('Cannot translate required allowed_tools with an empty callable subset.'); + throw new TranslateInputException('Cannot translate required allowed_tools with an empty callable subset.'); } const restricted = (tools ?? []).filter(tool => selected.has(tool)); validateCallableKinds(restricted); diff --git a/packages/translate/src/protocols/openai-responses/as-source/namespace-tools.ts b/packages/translate/src/protocols/openai-responses/as-source/namespace-tools.ts index bdd195cdb8..299cedf0af 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/namespace-tools.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/namespace-tools.ts @@ -1,4 +1,4 @@ -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import { isOpenAIResponsesTerminalEvent, type CanonicalOpenAIResponsesPayload, type CanonicalOpenAIResponsesInputItem, type OpenAIResponsesOutputItemEx, type OpenAIResponsesResultEx, type OpenAIResponsesStreamEventEx, type OpenAIResponsesTool, type OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; @@ -74,7 +74,7 @@ export const flattenNamespaceTools = (payload: CanonicalOpenAIResponsesPayload): if (existing !== undefined) { const identity = names.targetToSource.get(existing)!; if (identity.namespace !== namespace || identity.name !== name || identity.type !== type) { - throw new TranslatorInputError(`Cannot translate ambiguous namespace tool '${key}'.`); + throw new TranslateInputException(`Cannot translate ambiguous namespace tool '${key}'.`); } return existing; } @@ -112,7 +112,7 @@ export const flattenNamespaceTools = (payload: CanonicalOpenAIResponsesPayload): byNamespace.set(tool.name, children); for (const child of tool.tools) { if (child.type !== 'function' && child.type !== 'custom') { - throw new TranslatorInputError(`Cannot translate non-callable child in namespace '${tool.name}'.`); + throw new TranslateInputException(`Cannot translate non-callable child in namespace '${tool.name}'.`); } const name = allocate(tool.name, child.name, child.type); children.push({ type: child.type, name }); @@ -136,12 +136,12 @@ export const flattenNamespaceTools = (payload: CanonicalOpenAIResponsesPayload): const key = namespace === undefined ? choice.name : `${namespace}.${choice.name}`; if (namespace === undefined && flatNames.has(choice.name)) return choice; if (namespace !== undefined && !byNamespace.get(namespace)?.some(child => child.type === choice.type && names.targetToSource.get(child.name)?.name === choice.name)) { - throw new TranslatorInputError(`Cannot translate tool_choice / allowed_tools selector for undeclared namespace tool '${key}'.`); + throw new TranslateInputException(`Cannot translate tool_choice / allowed_tools selector for undeclared namespace tool '${key}'.`); } const qualified = namespace === undefined ? [...names.targetToSource].filter(([, identity]) => `${identity.namespace}.${identity.name}` === key || `${identity.namespace}__${identity.name}` === key) : []; - if (qualified.length > 1) throw new TranslatorInputError(`Cannot select ambiguous qualified tool '${key}'.`); + if (qualified.length > 1) throw new TranslateInputException(`Cannot select ambiguous qualified tool '${key}'.`); const name = namespace === undefined ? qualified[0]?.[0] : names.sourceToTarget.get(key); if (name === undefined) return choice; const { namespace: _namespace, ...rest } = choice; @@ -154,15 +154,15 @@ export const flattenNamespaceTools = (payload: CanonicalOpenAIResponsesPayload): choice = { ...choice, tools: choice.tools.flatMap(tool => { - if (typeof tool !== 'object' || tool === null) throw new TranslatorInputError('Cannot translate malformed allowed_tools selector.'); + if (typeof tool !== 'object' || tool === null) throw new TranslateInputException('Cannot translate malformed allowed_tools selector.'); if (tool.type === 'namespace') { const children = typeof tool.name === 'string' ? byNamespace.get(tool.name) : undefined; - if (children === undefined) throw new TranslatorInputError(`Cannot select undeclared namespace '${String(tool.name)}'.`); - if (Object.keys(tool).some(key => key !== 'type' && key !== 'name')) throw new TranslatorInputError('Cannot translate namespace selector extensions.'); + if (children === undefined) throw new TranslateInputException(`Cannot select undeclared namespace '${String(tool.name)}'.`); + if (Object.keys(tool).some(key => key !== 'type' && key !== 'name')) throw new TranslateInputException('Cannot translate namespace selector extensions.'); return children; } if (tool.type !== 'function' && tool.type !== 'custom') return [tool]; - if (!isNamedToolChoice(tool)) throw new TranslatorInputError('Cannot translate malformed allowed_tools selector.'); + if (!isNamedToolChoice(tool)) throw new TranslateInputException('Cannot translate malformed allowed_tools selector.'); const selected = selector(tool); return selected === tool ? [tool] : [{ ...selected }]; }), diff --git a/packages/translate/src/protocols/openai-responses/as-source/programmatic-tooling.ts b/packages/translate/src/protocols/openai-responses/as-source/programmatic-tooling.ts index dd6ab4be57..5e17e8a741 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/programmatic-tooling.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/programmatic-tooling.ts @@ -1,13 +1,13 @@ -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { CanonicalOpenAIResponsesInputItem, OpenAIResponsesPayloadEx } from '@floway-dev/protocols/openai-responses'; export const rejectProgrammaticOpenAIResponsesPayload = (payload: OpenAIResponsesPayloadEx, target: string): void => { const toolChoice = payload.tool_choice; if (payload.tools?.some(hasProgrammaticCaller) === true || (toolChoice !== null && typeof toolChoice === 'object' && toolChoice.type === 'programmatic_tool_calling')) { - throw new TranslatorInputError(`Programmatic OpenAI Responses tooling cannot be translated to ${target}.`); + throw new TranslateInputException(`Programmatic OpenAI Responses tooling cannot be translated to ${target}.`); } if (payload.tools?.some(hasDeferredTool) === true) { - throw new TranslatorInputError(`Deferred OpenAI Responses tooling cannot be translated to ${target}.`); + throw new TranslateInputException(`Deferred OpenAI Responses tooling cannot be translated to ${target}.`); } }; @@ -34,6 +34,6 @@ const isProgramCaller = (item: CanonicalOpenAIResponsesInputItem): item is Canon export const rejectProgramCaller = (item: CanonicalOpenAIResponsesInputItem): void => { if (isProgramCaller(item)) { - throw new TranslatorInputError(`Cannot translate ${item.type} '${item.call_id}' with a program caller.`); + throw new TranslateInputException(`Cannot translate ${item.type} '${item.call_id}' with a program caller.`); } }; diff --git a/packages/translate/src/protocols/openai-responses/canonicalize-payload.ts b/packages/translate/src/protocols/openai-responses/canonicalize-payload.ts index 4831160ec3..f7896b7681 100644 --- a/packages/translate/src/protocols/openai-responses/canonicalize-payload.ts +++ b/packages/translate/src/protocols/openai-responses/canonicalize-payload.ts @@ -1,4 +1,5 @@ -import { TranslatorInputError } from '../../translator-input-error.ts'; +import { TranslateInputException } from '../../shared/input-error.ts'; +import { type TranslateInputError } from '../../types.ts'; import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesEasyInputMessageEx, CanonicalOpenAIResponsesInputItem, OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; // Wire `OpenAIResponsesRequestPayload.input` accepts a bare string and EasyInputMessage @@ -10,7 +11,16 @@ import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesEasyInputMessageEx // boundary that produces a payload destined for internal use and by direct // OpenAI-Responses-source translators; cross-protocol translators already construct // `CanonicalOpenAIResponsesPayload` with explicit message discriminators. -export function canonicalizeOpenAIResponsesPayload(value: unknown): CanonicalOpenAIResponsesPayload { +export const canonicalizeOpenAIResponsesPayload = (value: unknown): CanonicalOpenAIResponsesPayload | TranslateInputError => { + try { + return requireCanonicalOpenAIResponsesPayload(value); + } catch (error) { + if (error instanceof TranslateInputException) return error.failure; + throw error; + } +}; + +export const requireCanonicalOpenAIResponsesPayload = (value: unknown): CanonicalOpenAIResponsesPayload => { const hasValidPromptCacheBreakpoint = (content: Record): boolean => { const breakpoint = content.prompt_cache_breakpoint; if (breakpoint === undefined || breakpoint === null) return true; @@ -44,7 +54,7 @@ export function canonicalizeOpenAIResponsesPayload(value: unknown): CanonicalOpe }; if (typeof value !== 'object' || value === null) { - throw new TranslatorInputError('OpenAI Responses payload must be an object.'); + throw new TranslateInputException('OpenAI Responses payload must be an object.'); } const payload = value as OpenAIResponsesRequestPayloadEx; // Resolution binds `model` straight into a SQL lookup, so the wire boundary is @@ -52,11 +62,11 @@ export function canonicalizeOpenAIResponsesPayload(value: unknown): CanonicalOpe // and code reproduce OpenAI's own rejection verbatim. // https://github.com/mattermost/mattermost-plugin-agents/issues/476 if (typeof payload.model !== 'string' || payload.model.length === 0) { - throw new TranslatorInputError("Missing required parameter: 'model'.", { param: 'model', code: 'missing_required_parameter' }); + throw new TranslateInputException("Missing required parameter: 'model'.", { param: 'model', code: 'missing_required_parameter' }); } const input: unknown = payload.input; if (typeof input !== 'string' && !Array.isArray(input)) { - throw new TranslatorInputError('OpenAI Responses input must be a string or an array.', { param: 'input' }); + throw new TranslateInputException('OpenAI Responses input must be a string or an array.', { param: 'input' }); } return { ...payload, @@ -66,9 +76,9 @@ export function canonicalizeOpenAIResponsesPayload(value: unknown): CanonicalOpe : input.map((item, index) => { if (isImplicitEasyInputMessage(item)) return { ...item, type: 'message' }; if (typeof item !== 'object' || item === null || (item as { type?: unknown }).type === undefined) { - throw new TranslatorInputError('Untyped OpenAI Responses input items require a valid role and content.', { param: `input[${index}]` }); + throw new TranslateInputException('Untyped OpenAI Responses input items require a valid role and content.', { param: `input[${index}]` }); } return item as CanonicalOpenAIResponsesInputItem; }), }; -} +}; diff --git a/packages/translate/src/protocols/openai-responses/index.ts b/packages/translate/src/protocols/openai-responses/index.ts deleted file mode 100644 index 6067f0d024..0000000000 --- a/packages/translate/src/protocols/openai-responses/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { routeOpenAIResponses } from './route.ts'; -export { canonicalizeOpenAIResponsesPayload } from './canonicalize-payload.ts'; diff --git a/packages/translate/src/protocols/openai-responses/route.ts b/packages/translate/src/protocols/openai-responses/route.ts deleted file mode 100644 index f41836f2d5..0000000000 --- a/packages/translate/src/protocols/openai-responses/route.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { translate as viaAnthropicMessages } from './via-anthropic-messages/translate.ts'; -import { translate as viaOpenAIChatCompletions } from './via-openai-chat-completions/translate.ts'; -import type { OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; - -export const routeOpenAIResponses = (input: OpenAIResponsesRequestPayloadEx) => ({ - viaAnthropicMessages: (ctx: Parameters[1]) => viaAnthropicMessages(input, ctx), - viaOpenAIChatCompletions: (ctx: Parameters[1]) => viaOpenAIChatCompletions(input, ctx), -}); diff --git a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts index ae2be7981b..51164bfa0f 100644 --- a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts +++ b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts @@ -2,8 +2,8 @@ import { klona } from 'klona/json'; import { openaiResponsesReasoningToAnthropicMessagesUpstreamBlock } from './reasoning.ts'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import { parseJSONObject } from '../../../shared/json/object.ts'; -import { TranslatorInputError } from '../../../translator-input-error.ts'; import type { SidecarCodec, RemoteImageLoader } from '../../../types.ts'; import { applyLastMessageCacheBreakpoint, applyLastSystemCacheBreakpoint, applyLastToolCacheBreakpoint } from '../../anthropic-messages/as-target/cache-breakpoints.ts'; import { anthropicMessagesReasoningFieldsFromEffort } from '../../anthropic-messages/as-target/reasoning-effort.ts'; @@ -14,7 +14,7 @@ import { partitionOpenAIResponsesTurns } from '../as-source/conversation-turns.t import { flattenNamespaceTools, type NamespaceToolNames } from '../as-source/namespace-tools.ts'; import { rejectProgramCaller, rejectProgrammaticOpenAIResponsesPayload } from '../as-source/programmatic-tooling.ts'; import { collectOpenAIResponsesReplayContents, findOpenAIResponsesSidecarCarriers, isOpenAIResponsesEncryptedReasoning, verifyOpenAIResponsesTurnDigest } from '../as-source/round-trip.ts'; -import { canonicalizeOpenAIResponsesPayload } from '../canonicalize-payload.ts'; +import { requireCanonicalOpenAIResponsesPayload } from '../canonicalize-payload.ts'; import { buildCustomToolInputSchema } from '../common/custom-tool-wrap.ts'; import { ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS, @@ -94,18 +94,18 @@ const translateUserMessage = async (message: OpenAIResponsesInputMessageEx, load } if (block.type === 'input_file') { - throw new TranslatorInputError('Cannot translate input_file message content to Anthropic Messages.'); + throw new TranslateInputException('Cannot translate input_file message content to Anthropic Messages.'); } if (block.type === 'refusal') { - throw new TranslatorInputError('Cannot translate refusal content in a user message to Anthropic Messages.'); + throw new TranslateInputException('Cannot translate refusal content in a user message to Anthropic Messages.'); } if (block.type !== 'input_image') continue; const imageUrl = (block as OpenAIResponsesInputImage).image_url; if (typeof imageUrl !== 'string') { - throw new TranslatorInputError('Cannot translate file_id-only image content to Anthropic Messages.'); + throw new TranslateInputException('Cannot translate file_id-only image content to Anthropic Messages.'); } const image = await resolveImageUrlToAnthropicMessagesImage(imageUrl, loadRemoteImage); if (image) content.push(image); @@ -127,17 +127,17 @@ const translateToolOutput = async (output: string | OpenAIResponsesInputContent[ for (const part of output) { if (part.type === 'input_image') { if (typeof part.image_url !== 'string') { - throw new TranslatorInputError('Cannot translate file_id-only image tool output to Anthropic Messages.'); + throw new TranslateInputException('Cannot translate file_id-only image tool output to Anthropic Messages.'); } const image = await resolveImageUrlToAnthropicMessagesImage(part.image_url, loadRemoteImage); if (image === null) { - throw new TranslatorInputError('Cannot translate unavailable or unsupported image tool output to Anthropic Messages.'); + throw new TranslateInputException('Cannot translate unavailable or unsupported image tool output to Anthropic Messages.'); } blocks.push(image); } else if (part.type === 'input_file') { - throw new TranslatorInputError('Cannot translate input_file tool output to Anthropic Messages.'); + throw new TranslateInputException('Cannot translate input_file tool output to Anthropic Messages.'); } else if (part.type === 'refusal') { - throw new TranslatorInputError('Cannot translate refusal content in a tool output to Anthropic Messages.'); + throw new TranslateInputException('Cannot translate refusal content in a tool output to Anthropic Messages.'); } else { blocks.push({ type: 'text', text: part.text }); } @@ -155,10 +155,10 @@ const translateAssistantMessage = (message: OpenAIResponsesInputMessageEx): Anth for (const block of message.content) { if (block.type === 'input_image') { - throw new TranslatorInputError('Cannot translate input_image assistant content to Anthropic Messages.'); + throw new TranslateInputException('Cannot translate input_image assistant content to Anthropic Messages.'); } if (block.type === 'input_file') { - throw new TranslatorInputError('Cannot translate input_file assistant content to Anthropic Messages.'); + throw new TranslateInputException('Cannot translate input_file assistant content to Anthropic Messages.'); } if (block.type === 'refusal') { content.push({ type: 'text', text: block.refusal }); @@ -185,12 +185,12 @@ const openaiResponsesSystemBlocks = (message: OpenAIResponsesInputMessageEx): An const blocks: AnthropicMessagesTextBlockParam[] = []; for (const block of message.content) { if (block.type === 'input_image') { - throw new TranslatorInputError(`Invalid 'input_image' content part in ${message.role} message. Only 'input_text' content parts are supported in ${message.role} messages on this model.`); + throw new TranslateInputException(`Invalid 'input_image' content part in ${message.role} message. Only 'input_text' content parts are supported in ${message.role} messages on this model.`); } if (block.type !== 'input_text' && block.type !== 'output_text') { // Every non-text content variant must opt into translator behavior // rather than be silently dropped from system content. - throw new TranslatorInputError(`Invalid content block type '${(block as { type: string }).type}' in ${message.role} message.`); + throw new TranslateInputException(`Invalid content block type '${(block as { type: string }).type}' in ${message.role} message.`); } blocks.push({ type: 'text', text: block.text }); } @@ -252,7 +252,7 @@ const translateTools = ( }); continue; } - throw new TranslatorInputError(`Cannot translate OpenAI Responses tool type '${tool.type}' to Anthropic Messages.`); + throw new TranslateInputException(`Cannot translate OpenAI Responses tool type '${tool.type}' to Anthropic Messages.`); } return out.length > 0 ? out : undefined; }; @@ -271,7 +271,7 @@ const translateToolChoice = ( case 'required': return { type: 'any' }; default: - throw new TranslatorInputError(`Cannot translate OpenAI Responses tool choice '${toolChoice}' to Anthropic Messages.`); + throw new TranslateInputException(`Cannot translate OpenAI Responses tool choice '${toolChoice}' to Anthropic Messages.`); } } @@ -280,7 +280,7 @@ const translateToolChoice = ( if (toolChoice.type === 'function' || toolChoice.type === 'custom') { return toolChoice.name ? { type: 'tool', name: toolChoice.name } : undefined; } - throw new TranslatorInputError(`Cannot translate OpenAI Responses tool choice type '${toolChoice.type}' to Anthropic Messages.`); + throw new TranslateInputException(`Cannot translate OpenAI Responses tool choice type '${toolChoice.type}' to Anthropic Messages.`); }; export const buildTargetRequest = async ( @@ -288,7 +288,7 @@ export const buildTargetRequest = async ( codec: SidecarCodec, options: BuildTargetRequestOptions, ): Promise => { - const canonical = canonicalizeOpenAIResponsesPayload(source); + const canonical = requireCanonicalOpenAIResponsesPayload(source); const { payload, names: namespaceToolNames, flattenInput } = flattenNamespaceTools(canonical); rejectProgrammaticOpenAIResponsesPayload(payload, 'Anthropic Messages'); const customToolNames = new Set(); @@ -309,7 +309,7 @@ export const buildTargetRequest = async ( rejectProgramCaller(item); switch (item.type) { case 'message': - if (item.role !== 'assistant') throw new TranslatorInputError(`Invalid assistant message role '${item.role}'.`); + if (item.role !== 'assistant') throw new TranslateInputException(`Invalid assistant message role '${item.role}'.`); messages.push(translateAssistantMessage(item)); break; case 'function_call': @@ -330,9 +330,9 @@ export const buildTargetRequest = async ( case 'item_reference': case 'web_search_call': case 'image_generation_call': - throw new TranslatorInputError(`Invalid input item type '${item.type}'.`); + throw new TranslateInputException(`Invalid input item type '${item.type}'.`); default: - throw new TranslatorInputError(`Invalid input item: ${JSON.stringify(item)}`); + throw new TranslateInputException(`Invalid input item: ${JSON.stringify(item)}`); } } for (const message of messages) bareMessages.add(message); @@ -353,7 +353,7 @@ export const buildTargetRequest = async ( messages.push({ role: 'system', content: blocks.length > 0 ? blocks : '' }); break; } - default: throw new TranslatorInputError(`Invalid role '${item.role}' in input message.`); + default: throw new TranslateInputException(`Invalid role '${item.role}' in input message.`); } break; case 'function_call_output': @@ -367,9 +367,9 @@ export const buildTargetRequest = async ( case 'item_reference': case 'web_search_call': case 'image_generation_call': - throw new TranslatorInputError(`Invalid input item type '${item.type}'.`); + throw new TranslateInputException(`Invalid input item type '${item.type}'.`); default: - throw new TranslatorInputError(`Invalid input item: ${JSON.stringify(item)}`); + throw new TranslateInputException(`Invalid input item: ${JSON.stringify(item)}`); } } for (const message of messages) bareMessages.add(message); 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 018e070dce..7f71c1c4ed 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 @@ -1,26 +1,34 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; -import type { RemoteImageLoader, TranslateTrip } from '../../../types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import type { RemoteImageLoader, Translator } from '../../../types.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'; import type { OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; -export const translate: TranslateTrip< +export const translate: Translator< OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx, AnthropicMessagesPayload, AnthropicMessagesStreamEventEx, { fallbackMaxOutputTokens?: number; loadRemoteImage: RemoteImageLoader } > = async (src, ctx) => { - const responseId = createRandomOpenAIResponsesResponseId(); - // Tool-name maps are produced inside the request translator (it sees the - // tools first) and read by the events translator so wrapped custom calls and - // flattened namespace calls recover their source OpenAI Responses identities. - const { target, customToolNames, namespaceToolNames } = await buildTargetRequest(src, ctx.sidecarCodec, { - fallbackMaxOutputTokens: ctx.fallbackMaxOutputTokens, - loadRemoteImage: ctx.loadRemoteImage, - }); + try { + const responseId = createRandomOpenAIResponsesResponseId(); + // Tool-name maps are produced inside the request translator (it sees the + // tools first) and read by the events translator so wrapped custom calls and + // flattened namespace calls recover their source OpenAI Responses identities. + const { target, customToolNames, namespaceToolNames } = await buildTargetRequest(src, ctx.sidecarCodec, { + fallbackMaxOutputTokens: ctx.fallbackMaxOutputTokens, + loadRemoteImage: ctx.loadRemoteImage, + }); - return { - target, - events: frames => restoreNamespaceEvents(translateToSourceEvents(frames, responseId, customToolNames, ctx.sidecarCodec, namespaceToolNames), namespaceToolNames), - }; + return { + type: 'trip', + error: () => undefined, + target, + events: frames => restoreNamespaceEvents(translateToSourceEvents(frames, responseId, customToolNames, ctx.sidecarCodec, namespaceToolNames), namespaceToolNames), + }; + } catch (error) { + if (error instanceof TranslateInputException) return error.failure; + throw error; + } }; diff --git a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/content.ts b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/content.ts index 472fb53df5..041f45bb9d 100644 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/content.ts +++ b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/content.ts @@ -1,4 +1,4 @@ -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { OpenAIChatCompletionsContentPart } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesInputContent } from '@floway-dev/protocols/openai-responses'; @@ -17,7 +17,7 @@ export const openaiResponsesContentToText = (content: string | OpenAIResponsesIn export const openaiResponsesContentToOpenAIChatCompletionsContent = (content: string | OpenAIResponsesInputContent[]): string | OpenAIChatCompletionsContentPart[] => { if (typeof content === 'string') return content; if (!content.every((part): part is Exclude => part.type !== 'input_file')) { - throw new TranslatorInputError('Cannot translate input_file content to OpenAI Chat Completions.'); + throw new TranslateInputException('Cannot translate input_file content to OpenAI Chat Completions.'); } return content.some(part => part.type === 'input_image' || part.type === 'refusal') @@ -25,7 +25,7 @@ export const openaiResponsesContentToOpenAIChatCompletionsContent = (content: st (part): OpenAIChatCompletionsContentPart => { if (part.type === 'input_image') { if (typeof part.image_url !== 'string') { - throw new TranslatorInputError('Cannot translate file_id-only image content to OpenAI Chat Completions.'); + throw new TranslateInputException('Cannot translate file_id-only image content to OpenAI Chat Completions.'); } return { type: 'image_url', diff --git a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/request.ts b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/request.ts index 4ffa89b60d..8af6a7491f 100644 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/request.ts +++ b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/request.ts @@ -2,14 +2,14 @@ import { klona } from 'klona/json'; import { openaiResponsesContentToOpenAIChatCompletionsContent, openaiResponsesContentToText } from './content.ts'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { SidecarCodec } from '../../../types.ts'; import { restrictAllowedTools } from '../as-source/allowed-tools.ts'; import { partitionOpenAIResponsesTurns } from '../as-source/conversation-turns.ts'; import { flattenNamespaceTools, type NamespaceToolNames } from '../as-source/namespace-tools.ts'; import { rejectProgramCaller, rejectProgrammaticOpenAIResponsesPayload } from '../as-source/programmatic-tooling.ts'; import { collectOpenAIResponsesReplayContents, findOpenAIResponsesSidecarCarriers, isOpenAIResponsesEncryptedReasoning, verifyOpenAIResponsesTurnDigest } from '../as-source/round-trip.ts'; -import { canonicalizeOpenAIResponsesPayload } from '../canonicalize-payload.ts'; +import { requireCanonicalOpenAIResponsesPayload } from '../canonicalize-payload.ts'; import { buildCustomToolInputSchema } from '../common/custom-tool-wrap.ts'; import type { OpenAIChatCompletionsAssistantMessage, OpenAIChatCompletionsResponseFormat, OpenAIChatCompletionsUserContentPart, OpenAIChatCompletionsPayload, OpenAIChatCompletionsMessage, OpenAIChatCompletionsTool, OpenAIChatCompletionsToolCall } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesCustomToolCallOutputItem, OpenAIResponsesFunctionCallOutputItem, OpenAIResponsesInputImage, CanonicalOpenAIResponsesText, CanonicalOpenAIResponsesInputItem, OpenAIResponsesPayloadEx, OpenAIResponsesRequestPayloadEx, OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; @@ -65,7 +65,7 @@ interface ToolCallOutputProjection { const projectToolCallOutput = (item: OpenAIResponsesFunctionCallOutputItem | OpenAIResponsesCustomToolCallOutputItem): ToolCallOutputProjection => { if (typeof item.output === 'string') return { toolContent: item.output, liftedImageContent: [] }; if (item.output.some(part => part.type === 'input_file')) { - throw new TranslatorInputError('Cannot translate input_file tool output to OpenAI Chat Completions.'); + throw new TranslateInputException('Cannot translate input_file tool output to OpenAI Chat Completions.'); } const images = item.output.filter((part): part is OpenAIResponsesInputImage => part.type === 'input_image'); @@ -123,7 +123,7 @@ const translateOpenAIResponsesTools = (tools: OpenAIResponsesTool[] | null | und }); continue; } - throw new TranslatorInputError(`Cannot translate OpenAI Responses tool type '${tool.type}' to OpenAI Chat Completions.`); + throw new TranslateInputException(`Cannot translate OpenAI Responses tool type '${tool.type}' to OpenAI Chat Completions.`); } return out.length > 0 ? out : undefined; @@ -133,11 +133,11 @@ const translateOpenAIResponsesToolChoice = (choice?: OpenAIResponsesToolChoice | if (choice == null) return undefined; if (typeof choice === 'string') { if (choice === 'auto' || choice === 'none' || choice === 'required') return choice; - throw new TranslatorInputError(`Cannot translate OpenAI Responses tool choice '${choice}' to OpenAI Chat Completions.`); + throw new TranslateInputException(`Cannot translate OpenAI Responses tool choice '${choice}' to OpenAI Chat Completions.`); } // Both function and wrapped custom tools land on the target as named function // choices since they share the function-tool wire shape after translation. - if (choice.type !== 'function' && choice.type !== 'custom') throw new TranslatorInputError(`Cannot translate OpenAI Responses tool choice type '${choice.type}' to OpenAI Chat Completions.`); + if (choice.type !== 'function' && choice.type !== 'custom') throw new TranslateInputException(`Cannot translate OpenAI Responses tool choice type '${choice.type}' to OpenAI Chat Completions.`); return { type: 'function', function: { name: choice.name } }; }; @@ -179,7 +179,7 @@ export interface TargetRequestResult { } export const buildTargetRequest = async (source: OpenAIResponsesRequestPayloadEx, codec: SidecarCodec): Promise => { - const canonical = canonicalizeOpenAIResponsesPayload(source); + const canonical = requireCanonicalOpenAIResponsesPayload(source); const { payload, names: namespaceToolNames, flattenInput } = flattenNamespaceTools(canonical); rejectProgrammaticOpenAIResponsesPayload(payload, 'OpenAI Chat Completions'); const customToolNames = new Set(); @@ -200,10 +200,10 @@ export const buildTargetRequest = async (source: OpenAIResponsesRequestPayloadEx else if (item.type === 'message' && item.role === 'assistant') { if (Array.isArray(item.content)) { const unsupported = item.content.find(part => part.type === 'input_file' || part.type === 'input_image'); - if (unsupported !== undefined) throw new TranslatorInputError(`Cannot translate ${unsupported.type} assistant content to OpenAI Chat Completions.`); + if (unsupported !== undefined) throw new TranslateInputException(`Cannot translate ${unsupported.type} assistant content to OpenAI Chat Completions.`); } assistant = appendAssistantText(assistant, openaiResponsesContentToText(item.content)); - } else throw new TranslatorInputError(`Invalid input item type '${item.type}'.`); + } else throw new TranslateInputException(`Invalid input item type '${item.type}'.`); } return assistant === null ? [] : [{ ...assistant.message, ...(assistant.reasoningText === undefined ? {} : { reasoning_text: assistant.reasoningText }) }]; }; @@ -231,12 +231,12 @@ export const buildTargetRequest = async (source: OpenAIResponsesRequestPayloadEx if (item.role === 'user') messages.push({ role: 'user', content: openaiResponsesContentToOpenAIChatCompletionsContent(item.content) as OpenAIChatCompletionsUserContentPart[] | string }); else { const content = typeof item.content === 'string' ? item.content : item.content.map(part => { - if (part.type !== 'input_text' && part.type !== 'output_text') throw new TranslatorInputError('Only text content is supported in Chat system/developer messages.'); + if (part.type !== 'input_text' && part.type !== 'output_text') throw new TranslateInputException('Only text content is supported in Chat system/developer messages.'); return { type: 'text' as const, text: part.text }; }); messages.push({ role: item.role, content }); } - } else throw new TranslatorInputError(`Invalid input item type '${item.type}'.`); + } else throw new TranslateInputException(`Invalid input item type '${item.type}'.`); } flushToolOutputImages(); return messages; 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 728fdd3ac0..7ad9108450 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 @@ -1,19 +1,27 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; -import type { TranslateTrip } from '../../../types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import type { Translator } from '../../../types.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'; -export const translate: TranslateTrip< +export const translate: Translator< OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx, OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent > = async (src, ctx) => { - const { target, customToolNames, namespaceToolNames } = await buildTargetRequest(src, ctx.sidecarCodec); - return { - target, - events: frames => restoreNamespaceEvents( - translateToSourceEvents(frames, customToolNames, ctx.sidecarCodec, namespaceToolNames, target.n ?? 1), - namespaceToolNames, - ), - }; + try { + const { target, customToolNames, namespaceToolNames } = await buildTargetRequest(src, ctx.sidecarCodec); + return { + type: 'trip', + error: () => undefined, + target, + events: frames => restoreNamespaceEvents( + translateToSourceEvents(frames, customToolNames, ctx.sidecarCodec, namespaceToolNames, target.n ?? 1), + namespaceToolNames, + ), + }; + } catch (error) { + if (error instanceof TranslateInputException) return error.failure; + throw error; + } }; diff --git a/packages/translate/src/protocols/routes.ts b/packages/translate/src/protocols/routes.ts new file mode 100644 index 0000000000..d547865e59 --- /dev/null +++ b/packages/translate/src/protocols/routes.ts @@ -0,0 +1,9 @@ +export { translate as routeAnthropicMessagesViaOpenAIChatCompletions } from './anthropic-messages/via-openai-chat-completions/translate.ts'; +export { translate as routeAnthropicMessagesViaOpenAIResponses } from './anthropic-messages/via-openai-responses/translate.ts'; +export { translate as routeGeminiGenerateContentViaAnthropicMessages } from './gemini-generate-content/via-anthropic-messages/translate.ts'; +export { translate as routeGeminiGenerateContentViaOpenAIChatCompletions } from './gemini-generate-content/via-openai-chat-completions/translate.ts'; +export { translate as routeGeminiGenerateContentViaOpenAIResponses } from './gemini-generate-content/via-openai-responses/translate.ts'; +export { translate as routeOpenAIChatCompletionsViaAnthropicMessages } from './openai-chat-completions/via-anthropic-messages/translate.ts'; +export { translate as routeOpenAIChatCompletionsViaOpenAIResponses } from './openai-chat-completions/via-openai-responses/translate.ts'; +export { translate as routeOpenAIResponsesViaAnthropicMessages } from './openai-responses/via-anthropic-messages/translate.ts'; +export { translate as routeOpenAIResponsesViaOpenAIChatCompletions } from './openai-responses/via-openai-chat-completions/translate.ts'; diff --git a/packages/translate/src/shared/input-error.ts b/packages/translate/src/shared/input-error.ts new file mode 100644 index 0000000000..5fd9b72b53 --- /dev/null +++ b/packages/translate/src/shared/input-error.ts @@ -0,0 +1,14 @@ +import type { TranslateInputError } from '../types.ts'; + +// Expected input failures are caught at the package's public operations. Internal +// reader and writer helpers can abort without spreading a result union through +// every intermediate transformation. +export class TranslateInputException extends Error { + readonly failure: TranslateInputError; + + constructor(message: string, options?: Omit) { + super(message); + this.name = 'TranslateInputException'; + this.failure = { type: 'input-error', message, ...options }; + } +} diff --git a/packages/translate/src/translator-input-error.ts b/packages/translate/src/translator-input-error.ts deleted file mode 100644 index efa10a41b4..0000000000 --- a/packages/translate/src/translator-input-error.ts +++ /dev/null @@ -1,19 +0,0 @@ -// A caller-input validation failure surfaced by a translator: the caller -// sent something the target protocol cannot represent (an unsupported -// content-part type, a role the target does not accept, a missing field -// the target requires, etc.). Distinct from a plain `Error` so the -// data-plane http handlers can return a protocol-shaped 400 envelope -// instead of routing the failure through the generic internal-error 502 -// path. The optional `param` and `code` follow the OpenAI / Anthropic -// error-body convention. -export class TranslatorInputError extends Error { - readonly param: string | undefined; - readonly code: string | undefined; - - constructor(message: string, options?: { param?: string; code?: string }) { - super(message); - this.name = 'TranslatorInputError'; - this.param = options?.param; - this.code = options?.code; - } -} diff --git a/packages/translate/src/types.ts b/packages/translate/src/types.ts index 2c7735dbb1..d78efd00fa 100644 --- a/packages/translate/src/types.ts +++ b/packages/translate/src/types.ts @@ -13,72 +13,38 @@ export interface RemoteImageData { export type RemoteImageLoader = (url: string) => Promise; -/** - * Per-trip context. Carries the model name, the codec used by private sidecars, - * and a per-pair-declared `TExtras` - * shape that lists exactly the capability fields and runtime adapters the trip - * reads. Pairs that need no extra fields use the default `unknown`. Callers - * construct the context at the protocol boundary and inject runtime-owned - * dependencies without making this package import their implementation. - * - * The client's stream preference is intentionally not in this context. - * Translation always emits `stream: true` on the target payload; the LLM - * upstream layer enforces SSE streaming and source `respond.ts` boundaries - * collect a non-streamed downstream response when the client did not ask - * for SSE. - */ -export type TranslationContext = { +/** Runtime dependencies and target-specific settings for one translation. */ +export type TranslateOptions = { readonly model: string; readonly sidecarCodec: SidecarCodec; } & TExtras; -/** - * A wire-shaped upstream error body handed to `TranslateTrip.apiError`. The - * pair returns a same-shaped object to rewrite the outbound envelope, or - * `undefined` to pass it through unchanged. The provider layer's - * `ApiErrorResult` shape is intentionally not imported here — translate is a - * leaf below provider, so we express the contract in bare HTTP-response - * primitives and let the gateway compose it back into an `ApiErrorResult`. - */ -export interface TranslatedApiError { +export type ApiErrorTranslator = (status: number, headers: Headers, body: Uint8Array) => { readonly status: number; readonly headers: Headers; - readonly body: Uint8Array; -} + readonly body: unknown; +} | undefined; /** - * What one translation trip hands back: the target payload, an events - * translator closure mapping target-protocol events into source-protocol - * events, and an optional upstream-error rewriter. - * - * The target owns its mutable JSON objects and arrays. Downstream rules and - * providers can mutate it while the source request and other trips remain - * live, so pair builders clone values borrowed from their source. - * - * `apiError` is optional: when the target upstream returns a non-2xx HTTP - * body (rather than an SSE stream), the pair may rewrite it into the source - * protocol's envelope. Returning `undefined` — or omitting the field - * entirely — passes the upstream body through verbatim, which is what most - * pairs want. + * The target owns its mutable JSON objects and arrays. Providers can mutate it + * while the source request and other trips remain live, so builders clone values + * borrowed from their source. */ -export interface TranslateTripResult { - target: TgtPayload; - events: (frames: AsyncIterable>) => AsyncIterable>; - apiError?: (upstream: TranslatedApiError) => TranslatedApiError | undefined; +export interface TranslateTrip { + readonly type: 'trip'; + readonly target: TargetPayload; + readonly events: (frames: AsyncIterable>) => AsyncIterable>; + readonly error: ApiErrorTranslator; } -/** - * One internal target-specific translation trip. The function body owns the trip: it builds - * the target payload and returns an events translator closure that maps - * target-protocol events back into source-protocol events. Trip-scoped state - * (synthetic ids, custom-tool name sets, etc.) lives as locals captured by - * the returned closure — the source route never exposes them. - * - * `TExtras` is the pair-declared context surface: each pair lists exactly the - * capabilities and injected runtime adapters it reads. Pairs that need no - * extra context leave it as `unknown` (default). - */ -export type TranslateTrip = ( - src: SrcPayload, - ctx: TranslationContext, -) => Promise>; +export interface TranslateInputError { + readonly type: 'input-error'; + readonly message: string; + readonly param?: string; + readonly code?: string; +} + +export type Translator = ( + payload: SourcePayload, + options: TranslateOptions, +) => Promise | TranslateInputError>; From e9d30980cf39119afd0ba8bfb7a144e78fbf9bd2 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 04:55:49 +0800 Subject: [PATCH 02/15] fix(translate): preserve cross-protocol error details Restate recognized upstream errors through source-owned converters before and during streams. Preserve independent error classifications, nullable code and param values, envelope metadata, and one merged provider extension container. Use the same recognized context-overflow decision for Gemini HTTP and body codes. Keep unknown response bytes untouched and propagate unexpected parser failures with their original error and cause. Validate 322 tests in 19 named files, changed-file ESLint, and the translate and gateway TypeScript projects. --- .../as-source/context-window-error_test.ts | 79 -------- .../as-source/error-body_test.ts | 121 ++++++++++++ .../protocols/cross-protocol-errors_test.ts | 175 ++++++++++++++++++ .../as-source/error-body_test.ts | 71 +++++++ .../via-openai-responses/events_test.ts | 3 +- .../__tests__/shared/api-error_test.ts | 86 +++++++++ packages/translate/src/ir/types.ts | 1 + .../as-source/context-window-error.ts | 24 --- .../as-source/error-body.ts | 10 + .../anthropic-messages/as-source/sse-to.ts | 26 ++- .../via-openai-chat-completions/translate.ts | 4 +- .../via-openai-responses/translate.ts | 4 +- .../as-source/error-body.ts | 10 + .../as-source/sse-to.ts | 38 +++- .../via-anthropic-messages/translate.ts | 3 +- .../via-openai-chat-completions/translate.ts | 3 +- .../via-openai-responses/translate.ts | 3 +- .../as-source/error-body.ts | 9 + .../as-source/sse-to.ts | 20 +- .../via-anthropic-messages/translate.ts | 3 +- .../via-openai-responses/translate.ts | 3 +- .../openai-responses/as-source/error-body.ts | 9 + .../openai-responses/as-source/sse-to.ts | 21 ++- .../openai-responses/as-target/sse-from.ts | 10 +- .../via-anthropic-messages/translate.ts | 3 +- .../via-openai-chat-completions/translate.ts | 3 +- packages/translate/src/shared/api-error.ts | 112 +++++++++++ 27 files changed, 710 insertions(+), 144 deletions(-) delete mode 100644 packages/translate/__tests__/protocols/anthropic-messages/as-source/context-window-error_test.ts create mode 100644 packages/translate/__tests__/protocols/anthropic-messages/as-source/error-body_test.ts create mode 100644 packages/translate/__tests__/protocols/cross-protocol-errors_test.ts create mode 100644 packages/translate/__tests__/protocols/gemini-generate-content/as-source/error-body_test.ts create mode 100644 packages/translate/__tests__/shared/api-error_test.ts delete mode 100644 packages/translate/src/protocols/anthropic-messages/as-source/context-window-error.ts create mode 100644 packages/translate/src/protocols/anthropic-messages/as-source/error-body.ts create mode 100644 packages/translate/src/protocols/gemini-generate-content/as-source/error-body.ts create mode 100644 packages/translate/src/protocols/openai-chat-completions/as-source/error-body.ts create mode 100644 packages/translate/src/protocols/openai-responses/as-source/error-body.ts create mode 100644 packages/translate/src/shared/api-error.ts 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..051b7f2b20 --- /dev/null +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-source/error-body_test.ts @@ -0,0 +1,121 @@ +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/sse-to.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)); + 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('merges loose error fields with the upstream container and keeps its collision 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', 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/cross-protocol-errors_test.ts b/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts new file mode 100644 index 0000000000..de0b7adf5a --- /dev/null +++ b/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts @@ -0,0 +1,175 @@ +import { expect, test } from 'vitest'; + +import { requireTrip } from './require-trip.ts'; +import type { IRErrorPayload } from '../../src/ir/types.ts'; +import { irFromAnthropicMessages } from '../../src/protocols/anthropic-messages/as-target/sse-from.ts'; +import { irFromOpenAIChatCompletions } from '../../src/protocols/openai-chat-completions/as-target/sse-from.ts'; +import { irFromOpenAIResponses } from '../../src/protocols/openai-responses/as-target/sse-from.ts'; +import { collect, iterate } from '../ir/helpers.ts'; +import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { createRoundTripReaderCapture } from '../round-trip/test-helpers.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 }, + }, +}; +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 }, +}; +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 }, + }, +}; +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, + }, + }], + }, +}; + +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('native target errors retain canonical object identity', async () => { + const chatRecords = await collect(irFromOpenAIChatCompletions(iterate([eventFrame(chatEvent(failure))]), { + roundTrip: createRoundTripReaderCapture<'openaiChatCompletions'>().roundTrip, + })); + const messagesRecords = await collect(irFromAnthropicMessages(iterate([eventFrame(nestedEvent(failure))]), { + roundTrip: createRoundTripReaderCapture<'anthropicMessages'>().roundTrip, + })); + const responsesRecords = await collect(irFromOpenAIResponses(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/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..2d2cf41214 --- /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/sse-to.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('keeps the upstream status in both places GenerateContent states it', () => { + const result = restateGeminiGenerateContentError(...upstreamOf(503, { error: { message: 'upstream overloaded' } })); + assertEquals(result!.status, 503); + assertEquals(decoded(result).error.code, 503); + assertEquals(decoded(result).error.status, 'UNAVAILABLE'); + assertEquals(decoded(result).error.message, 'upstream overloaded'); +}); + +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 811f2cec7d..6f2dfbac5c 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', }); }); @@ -432,7 +431,7 @@ 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, }); 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..0d1d9c10db --- /dev/null +++ b/packages/translate/__tests__/shared/api-error_test.ts @@ -0,0 +1,86 @@ +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); +}); + +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' }, + }); +}); diff --git a/packages/translate/src/ir/types.ts b/packages/translate/src/ir/types.ts index 9e55e6e097..264c0b87b9 100644 --- a/packages/translate/src/ir/types.ts +++ b/packages/translate/src/ir/types.ts @@ -9,6 +9,7 @@ export type IRJSONObject = { readonly [key: string]: IRJSONValue }; // 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..270ead5694 --- /dev/null +++ b/packages/translate/src/protocols/anthropic-messages/as-source/error-body.ts @@ -0,0 +1,10 @@ +import { anthropicMessagesErrorEnvelope } from './sse-to.ts'; +import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; +import type { ApiErrorTranslator } from '../../../types.ts'; + +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/sse-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts index d31c3cf762..08ac20ae08 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts @@ -9,6 +9,7 @@ import { createIRUnitQueue, createIRUnitRegistry } from '../../../ir/unit-queue. import { appendContent, createContentState, type ContentState, type ContentStates } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; +import { errorDetailsOf } from '../../../shared/api-error.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; import { parseJSONObject, parseJSONObjectAtCompletion, requireJSONObject } from '../../../shared/json/object.ts'; import type { AnthropicMessagesAssistantState, AnthropicMessagesInputAssistantTurn } from '../as-target/assistant-turn.ts'; @@ -25,6 +26,7 @@ import { } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, type EventFrame } from '@floway-dev/protocols/common'; +// https://platform.claude.com/docs/en/api/errors#http-errors const MESSAGES_ERROR_TYPES = [ 'invalid_request_error', 'authentication_error', @@ -35,9 +37,25 @@ const MESSAGES_ERROR_TYPES = [ '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 }; +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)); + +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, + }; +}; interface MessagesUnit { readonly path: IRPath; @@ -220,7 +238,7 @@ export const anthropicMessagesFromIR = 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 e8c0ad9d71..70864f89d7 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, target.n ?? 1), - 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..951d2ee794 --- /dev/null +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/error-body.ts @@ -0,0 +1,10 @@ +import { geminiGenerateContentErrorFromIR } from './sse-to.ts'; +import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; +import type { ApiErrorTranslator } from '../../../types.ts'; + +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/sse-to.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts index 7e224bee09..0c187560f0 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts @@ -8,6 +8,7 @@ import { createIRUnitQueue, createIRUnitRegistry, type IRUnitQueue } from '../.. import { appendContent, createContentState, type ContentStates, type ContentState as ReferenceContentState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; +import { errorDetailsOf } from '../../../shared/api-error.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; import { isCompleteJSONObject, parseJSONObjectAtCompletion } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; @@ -23,12 +24,35 @@ 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 }; +// https://ai.google.dev/api/rest/v1beta/Status +const statusNameOf = (status: number): string => { + switch (status) { + case 400: return 'INVALID_ARGUMENT'; + case 401: return 'UNAUTHENTICATED'; + case 403: return 'PERMISSION_DENIED'; + case 404: return 'NOT_FOUND'; + case 408: return 'DEADLINE_EXCEEDED'; + case 409: return 'ABORTED'; + case 429: return 'RESOURCE_EXHAUSTED'; + case 500: return 'INTERNAL'; + case 501: return 'UNIMPLEMENTED'; + case 503: return 'UNAVAILABLE'; + case 504: return 'DEADLINE_EXCEEDED'; + default: return 'UNKNOWN'; + } +}; + +export const geminiGenerateContentErrorFromIR = (error: IRErrorPayload, httpStatus?: number): GeminiGenerateContentErrorResponse['error'] => { + const details = errorDetailsOf(error, ['message']); + // Arbitrary error fields use the source's Struct details container. + // 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: statusNameOf(code), message: error.message, ...extension }; }; interface GenerateContentUnit { @@ -352,7 +376,7 @@ export const geminiGenerateContentFromIR = 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 0290871d73..648c6d9fa6 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, target.n ?? 1) }; + return { type: 'trip', error: restateGeminiGenerateContentError, target, events: frames => translateToSourceEvents(frames, ctx.sidecarCodec, target.n ?? 1) }; } 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..7968ecc167 --- /dev/null +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/error-body.ts @@ -0,0 +1,9 @@ +import { openaiChatCompletionsErrorFromIR } from './sse-to.ts'; +import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; +import type { ApiErrorTranslator } from '../../../types.ts'; + +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/sse-to.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts index 9cba106198..175311b94b 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts @@ -7,6 +7,7 @@ import { IRAbsent, type IRErrorPayload, type IRItem, type IRMessageItem, type IR import { appendContent, createContentState, type ContentStates, type ContentState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; +import { errorDetailsOf } from '../../../shared/api-error.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIChatCompletionsInputAssistantTurn, type OpenAIChatCompletionsOutputAssistantState } from '../as-target/assistant-turn.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; @@ -19,13 +20,16 @@ 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 }), -}); +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 }), + }; +}; interface OpenAIChatCompletionsSSEOutputOptions extends RoundTripOutputOptions<'openaiChatCompletions'> { // Replay identity belongs to the newly emitted target audio, not the source IR content. @@ -175,7 +179,7 @@ export const openaiChatCompletionsFromIR = 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..7a0c0f957b --- /dev/null +++ b/packages/translate/src/protocols/openai-responses/as-source/error-body.ts @@ -0,0 +1,9 @@ +import { openaiResponsesErrorEventOf } from './sse-to.ts'; +import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; +import type { ApiErrorTranslator } from '../../../types.ts'; + +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/sse-to.ts b/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts index b08ce80dfe..a2224ba2cc 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts @@ -6,11 +6,12 @@ import type { IRCollector } from '../../../ir/collector.ts'; import { irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/operation-paths.ts'; import { createIROrdinalMapper } from '../../../ir/ordinal-mapper.ts'; import { consumeIRRecords, type IRPath, type IRRecord } from '../../../ir/stream.ts'; -import { IRAbsent, type IR, type IRItem, type IRMessageItem, type IRSourceCitation, type IRTokenLogprob } from '../../../ir/types.ts'; +import { IRAbsent, type IR, type IRErrorPayload, type IRItem, type IRMessageItem, type IRSourceCitation, type IRTokenLogprob } from '../../../ir/types.ts'; import { createIRUnitQueue, createIRUnitRegistry } from '../../../ir/unit-queue.ts'; import { appendContent, createContentState, type ContentState, type ContentStates } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; +import { errorDetailsOf } from '../../../shared/api-error.ts'; import { isCompleteJSONObject, parseJSONObjectAtCompletion } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIResponsesInputAssistantTurn, type OpenAIResponsesItemState, type OpenAIResponsesOutputAssistantState } from '../as-target/assistant-turn.ts'; @@ -176,6 +177,17 @@ const textOrRefusalPart = (state: IR, path: IRPath): Extract { + 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 }), + }; +}; + export const openaiResponsesFromIR = async function* ( records: AsyncIterable, options: OpenAIResponsesSSEOutputOptions, @@ -614,12 +626,7 @@ export const openaiResponsesFromIR = 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/sse-from.ts b/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts index 8521bdc743..565426dc20 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts @@ -11,6 +11,7 @@ import { import type { IRSourceCitation } from '../../../ir/types.ts'; import { appendField, createFieldState, invalidateField, type FieldStates } from '../../../round-trip/reference/reference-builder.ts'; import type { RoundTripReader } from '../../../round-trip/stream.ts'; +import { errorPayloadFromWire } from '../../../shared/api-error.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { @@ -548,10 +549,15 @@ export const irFromOpenAIResponses = 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') { + const { sequence_number: _sequenceNumber, type: _type, ...failure } = e; + ir.event({ type: 'error', error: errorPayloadFromWire('error' in e ? e.error ?? e : failure) }); + 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 7ad9108450..57da635c3d 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, target.n ?? 1), diff --git a/packages/translate/src/shared/api-error.ts b/packages/translate/src/shared/api-error.ts new file mode 100644 index 0000000000..39659d1e22 --- /dev/null +++ b/packages/translate/src/shared/api-error.ts @@ -0,0 +1,112 @@ +import type { IRErrorPayload } from '../ir/types.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; + +// Unknown bodies keep their original bytes; parser failures other than invalid syntax propagate. +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 nested = recordOf(envelopeRecord.error); + if (nested === undefined) { + const { type: _type, sequence_number: _sequenceNumber, ...failure } = envelopeRecord; + return errorPayloadOf(envelopeRecord.type === 'error' ? failure : envelopeRecord); + } + const payload = errorPayloadOf(nested); + if (payload === undefined) return undefined; + const envelope = extensionOf(envelopeRecord, new Set(['error', ...(envelopeRecord.type === 'error' ? ['type', 'sequence_number'] : [])])); + return envelope === undefined ? payload : { + ...payload, + provider_specific_fields: { ...envelope, ...payload.provider_specific_fields }, + }; +}; + +export const errorPayloadOf = (error: 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); + return { + message, + ...(type === undefined ? {} : { type }), + ...(code === undefined ? {} : { code }), + ...(param === undefined ? {} : { param }), + ...(extra === undefined ? {} : { provider_specific_fields: extra }), + }; +}; + +// Canonical wire payloads retain identity for native errors carried through the IR. +export const errorPayloadFromWire = (wire: unknown): IRErrorPayload => { + const stated = recordOf(wire) ?? {}; + const nested = recordOf(stated.error); + const source = nested ?? stated; + if (hasErrorMessage(source)) return source; + const aliasMessage = stringOf(source.msg); + if (aliasMessage !== undefined) { + const { msg: _msg, ...fields } = source; + return { ...fields, message: aliasMessage }; + } + const extra = extensionOf(source, NAMED_ERROR_FIELDS); + 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']); + +// The upstream container wins collisions; loose fields share that same container. +const extensionOf = (error: Readonly>, representedFields: ReadonlySet): Readonly> | undefined => { + const own = recordOf(error.provider_specific_fields); + const rest: Record = {}; + for (const [key, value] of Object.entries(error)) { + if (representedFields.has(key) || key === 'provider_specific_fields' || own !== undefined && Object.hasOwn(own, key)) continue; + rest[key] = value; + } + return Object.keys(rest).length === 0 ? own : { ...rest, ...own }; +}; + +export const errorDetailsOf = (error: IRErrorPayload, representedFields: readonly string[]): Readonly> | undefined => + extensionOf(error, new Set(representedFields)); + +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'); + kept.delete('content-length'); + kept.delete('content-encoding'); + return kept; +}; From 2c93c64fed98425aa6517fa08e9569c96b3fea29 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 05:30:09 +0800 Subject: [PATCH 03/15] fix(translate): retain streaming error envelope metadata Normalize HTTP and streaming error envelopes through the same metadata merge. Preserve outer request identifiers and trace fields in the existing extension container while retaining error-level collision priority. Distinguish event transport fields from native error payloads so canonical payloads retain their original reference and classification when no envelope data remains. Validate 324 tests in 19 named files, ESLint on all four changed files, and the translate and gateway TypeScript projects. --- .../protocols/cross-protocol-errors_test.ts | 32 ++++++++++++ .../__tests__/shared/api-error_test.ts | 3 ++ .../openai-responses/as-target/sse-from.ts | 5 +- packages/translate/src/shared/api-error.ts | 52 ++++++++++++------- 4 files changed, 70 insertions(+), 22 deletions(-) diff --git a/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts b/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts index de0b7adf5a..0a2cea6c25 100644 --- a/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts +++ b/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts @@ -141,6 +141,38 @@ test('Responses flat errors remove transport discriminants before restatement', 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 outer metadata 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', trace: { region: 'us' }, provider_specific_fields: { 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 }, + }, + }; + 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('native target errors retain canonical object identity', async () => { const chatRecords = await collect(irFromOpenAIChatCompletions(iterate([eventFrame(chatEvent(failure))]), { roundTrip: createRoundTripReaderCapture<'openaiChatCompletions'>().roundTrip, diff --git a/packages/translate/__tests__/shared/api-error_test.ts b/packages/translate/__tests__/shared/api-error_test.ts index 0d1d9c10db..e3e90446b5 100644 --- a/packages/translate/__tests__/shared/api-error_test.ts +++ b/packages/translate/__tests__/shared/api-error_test.ts @@ -74,6 +74,9 @@ 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', () => { diff --git a/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts b/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts index 565426dc20..1e48c0e3d2 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts @@ -11,7 +11,7 @@ import { import type { IRSourceCitation } from '../../../ir/types.ts'; import { appendField, createFieldState, invalidateField, type FieldStates } from '../../../round-trip/reference/reference-builder.ts'; import type { RoundTripReader } from '../../../round-trip/stream.ts'; -import { errorPayloadFromWire } from '../../../shared/api-error.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 { @@ -550,8 +550,7 @@ export const irFromOpenAIResponses = async function* ( if (frame.type === 'done') throw new Error('Responses done arrived without a terminal response'); const e = frame.event; if (e.type === 'error') { - const { sequence_number: _sequenceNumber, type: _type, ...failure } = e; - ir.event({ type: 'error', error: errorPayloadFromWire('error' in e ? e.error ?? e : failure) }); + ir.event({ type: 'error', error: errorPayloadFromEvent(e) }); yield* ir.drain(); return; } diff --git a/packages/translate/src/shared/api-error.ts b/packages/translate/src/shared/api-error.ts index 39659d1e22..2a95afa549 100644 --- a/packages/translate/src/shared/api-error.ts +++ b/packages/translate/src/shared/api-error.ts @@ -24,18 +24,9 @@ export const irErrorPayloadFromBody = (body: Uint8Array): IRErrorPayload | undef } const envelopeRecord = recordOf(parsed); if (envelopeRecord === undefined) return undefined; - const nested = recordOf(envelopeRecord.error); - if (nested === undefined) { - const { type: _type, sequence_number: _sequenceNumber, ...failure } = envelopeRecord; - return errorPayloadOf(envelopeRecord.type === 'error' ? failure : envelopeRecord); - } - const payload = errorPayloadOf(nested); - if (payload === undefined) return undefined; - const envelope = extensionOf(envelopeRecord, new Set(['error', ...(envelopeRecord.type === 'error' ? ['type', 'sequence_number'] : [])])); - return envelope === undefined ? payload : { - ...payload, - provider_specific_fields: { ...envelope, ...payload.provider_specific_fields }, - }; + const { source, details } = errorEnvelopeOf(envelopeRecord.type === 'error' ? errorEventPayloadOf(envelopeRecord) : envelopeRecord); + const payload = errorPayloadOf(source); + return payload === undefined ? undefined : withEnvelopeDetails(payload, details); }; export const errorPayloadOf = (error: Readonly>): IRErrorPayload | undefined => { @@ -60,19 +51,42 @@ export const errorPayloadOf = (error: Readonly>): IRErro }; }; -// Canonical wire payloads retain identity for native errors carried through the IR. +const errorEnvelopeOf = (envelope: Readonly>) => { + const nested = recordOf(envelope.error); + const transportFields = envelope.type === 'error' ? ['type', 'sequence_number'] : []; + if (nested !== undefined) return { + source: nested, + details: extensionOf(envelope, new Set(['error', ...transportFields])), + }; + return { source: envelope, details: undefined }; +}; + +// Error-level extension fields take precedence over envelope metadata in their shared container. +const withEnvelopeDetails = (payload: IRErrorPayload, details: Readonly> | undefined): IRErrorPayload => + details === undefined ? payload : { + ...payload, + provider_specific_fields: { ...details, ...errorDetailsOf(payload, ['message', 'type', 'code', 'param']) }, + }; + +const errorEventPayloadOf = (event: Readonly>) => { + const { type: _type, sequence_number: _sequenceNumber, ...payload } = event; + return payload; +}; + +export const errorPayloadFromEvent = (event: Readonly>): IRErrorPayload => + errorPayloadFromWire(errorEventPayloadOf(event)); + +// Canonical wire payloads retain identity when their envelope adds no metadata. export const errorPayloadFromWire = (wire: unknown): IRErrorPayload => { - const stated = recordOf(wire) ?? {}; - const nested = recordOf(stated.error); - const source = nested ?? stated; - if (hasErrorMessage(source)) return source; + 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 { ...fields, message: aliasMessage }; + return withEnvelopeDetails({ ...fields, message: aliasMessage }, details); } const extra = extensionOf(source, NAMED_ERROR_FIELDS); - return { message: '', ...(extra === undefined ? {} : { provider_specific_fields: extra }) }; + return withEnvelopeDetails({ message: '', ...(extra === undefined ? {} : { provider_specific_fields: extra }) }, details); }; const hasErrorMessage = (value: Readonly>): value is Readonly> & { readonly message: string } => From fa83fa3756151a7036b70af62b6957fdc1087c98 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:03:02 +0800 Subject: [PATCH 04/15] refactor(translate): give protocol errors dedicated owners Move each source protocol's error converters into as-source/error.ts so HTTP error restatement and SSE output share the focused error owner. Preserve the extracted implementation byte for byte. Align six existing error expectations in three test files with retained upstream details and independent classifications. Validate 376 tests in 22 named files, all 17 changed TypeScript lint targets, and the translate and gateway TypeScript projects. --- .../translate/__tests__/ir/edge-cases_test.ts | 4 +- .../as-source/error-body_test.ts | 2 +- .../events-protocol_test.ts | 2 + .../as-source/error-body_test.ts | 2 +- .../events-protocol_test.ts | 2 +- .../as-source/error-body.ts | 2 +- .../anthropic-messages/as-source/error.ts | 35 +++++++++++++++++ .../anthropic-messages/as-source/sse-to.ts | 38 +------------------ .../as-source/error-body.ts | 2 +- .../as-source/error.ts | 35 +++++++++++++++++ .../as-source/sse-to.ts | 37 +----------------- .../as-source/error-body.ts | 2 +- .../as-source/error.ts | 13 +++++++ .../as-source/sse-to.ts | 15 +------- .../openai-responses/as-source/error-body.ts | 2 +- .../openai-responses/as-source/error.ts | 14 +++++++ .../openai-responses/as-source/sse-to.ts | 15 +------- 17 files changed, 116 insertions(+), 106 deletions(-) create mode 100644 packages/translate/src/protocols/anthropic-messages/as-source/error.ts create mode 100644 packages/translate/src/protocols/gemini-generate-content/as-source/error.ts create mode 100644 packages/translate/src/protocols/openai-chat-completions/as-source/error.ts create mode 100644 packages/translate/src/protocols/openai-responses/as-source/error.ts diff --git a/packages/translate/__tests__/ir/edge-cases_test.ts b/packages/translate/__tests__/ir/edge-cases_test.ts index 652013558f..430d27d911 100644 --- a/packages/translate/__tests__/ir/edge-cases_test.ts +++ b/packages/translate/__tests__/ir/edge-cases_test.ts @@ -32,7 +32,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 () => { @@ -42,7 +42,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/anthropic-messages/as-source/error-body_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/error-body_test.ts index 051b7f2b20..ddf1fbf724 100644 --- 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 @@ -1,7 +1,7 @@ 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/sse-to.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'; 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 079e83e658..e3590ff9fa 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/gemini-generate-content/as-source/error-body_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/error-body_test.ts index 2d2cf41214..35e416fad2 100644 --- 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 @@ -1,7 +1,7 @@ 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/sse-to.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'; 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 d956a51402..eaa947fd77 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/src/protocols/anthropic-messages/as-source/error-body.ts b/packages/translate/src/protocols/anthropic-messages/as-source/error-body.ts index 270ead5694..68a1a70741 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/error-body.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/error-body.ts @@ -1,4 +1,4 @@ -import { anthropicMessagesErrorEnvelope } from './sse-to.ts'; +import { anthropicMessagesErrorEnvelope } from './error.ts'; import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; import type { ApiErrorTranslator } from '../../../types.ts'; 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..fc45587217 --- /dev/null +++ b/packages/translate/src/protocols/anthropic-messages/as-source/error.ts @@ -0,0 +1,35 @@ +import type { IRErrorPayload } from '../../../ir/types.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', +]; + +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)); + +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/sse-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts index 08ac20ae08..d4dc754190 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts @@ -1,62 +1,28 @@ +import { anthropicMessagesErrorFromIR } from './error.ts'; import { collectAnthropicMessagesTextViews, computeAnthropicMessagesTurnDigest, createAnthropicMessagesSidecarCarrier } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; import type { IRCollector } from '../../../ir/collector.ts'; import { isIRItemPath, isIRPartPath, irPathKey } from '../../../ir/operation-paths.ts'; import { createIROrdinalMapper } from '../../../ir/ordinal-mapper.ts'; import { consumeIRRecords, type IRPath, type IRRecord } from '../../../ir/stream.ts'; -import { IRAbsent, type IR, type IRErrorPayload, type IRJSONObject } from '../../../ir/types.ts'; +import { IRAbsent, type IR, type IRJSONObject } from '../../../ir/types.ts'; import { createIRUnitQueue, createIRUnitRegistry } from '../../../ir/unit-queue.ts'; import { appendContent, createContentState, type ContentState, type ContentStates } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; -import { errorDetailsOf } from '../../../shared/api-error.ts'; -import { isContextExceededError } from '../../../shared/context-window-error.ts'; import { parseJSONObject, parseJSONObjectAtCompletion, requireJSONObject } from '../../../shared/json/object.ts'; import type { AnthropicMessagesAssistantState, AnthropicMessagesInputAssistantTurn } from '../as-target/assistant-turn.ts'; import { generateAnthropicId, createAnthropicMessagesEventAccumulator, - PROMPT_TOO_LONG_MESSAGE, type AnthropicMessagesAssistantContentBlock, type AnthropicMessagesContentBlockDeltaEvent, - type AnthropicMessagesErrorEventEx, type AnthropicMessagesResult, type AnthropicMessagesStreamEventEx, type AnthropicMessagesUsage, } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, type EventFrame } from '@floway-dev/protocols/common'; -// 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', -]; - -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)); - -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, - }; -}; - interface MessagesUnit { readonly path: IRPath; readonly kind: 'text' | 'refusal' | 'audio' | 'image' | 'reasoning' | 'function_call'; 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 index 951d2ee794..fd77080bea 100644 --- 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 @@ -1,4 +1,4 @@ -import { geminiGenerateContentErrorFromIR } from './sse-to.ts'; +import { geminiGenerateContentErrorFromIR } from './error.ts'; import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; import type { ApiErrorTranslator } from '../../../types.ts'; 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..7c2b974075 --- /dev/null +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts @@ -0,0 +1,35 @@ +import type { IRErrorPayload } from '../../../ir/types.ts'; +import { errorDetailsOf } from '../../../shared/api-error.ts'; +import { isContextExceededError } from '../../../shared/context-window-error.ts'; +import type { GeminiGenerateContentErrorResponse } from '@floway-dev/protocols/gemini-generate-content'; + +// https://ai.google.dev/api/rest/v1beta/Status +const statusNameOf = (status: number): string => { + switch (status) { + case 400: return 'INVALID_ARGUMENT'; + case 401: return 'UNAUTHENTICATED'; + case 403: return 'PERMISSION_DENIED'; + case 404: return 'NOT_FOUND'; + case 408: return 'DEADLINE_EXCEEDED'; + case 409: return 'ABORTED'; + case 429: return 'RESOURCE_EXHAUSTED'; + case 500: return 'INTERNAL'; + case 501: return 'UNIMPLEMENTED'; + case 503: return 'UNAVAILABLE'; + case 504: return 'DEADLINE_EXCEEDED'; + default: return 'UNKNOWN'; + } +}; + +export const geminiGenerateContentErrorFromIR = (error: IRErrorPayload, httpStatus?: number): GeminiGenerateContentErrorResponse['error'] => { + const details = errorDetailsOf(error, ['message']); + // Arbitrary error fields use the source's Struct details container. + // 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: statusNameOf(code), message: error.message, ...extension }; +}; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts index 0c187560f0..e5fd8360b9 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts @@ -1,22 +1,20 @@ +import { geminiGenerateContentErrorFromIR } from './error.ts'; import { collectGeminiGenerateContentTextViews, computeGeminiGenerateContentTurnDigest, createGeminiGenerateContentSidecarCarrier } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; import type { IRCollector } from '../../../ir/collector.ts'; import { irItemPath, irPartPath, irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/operation-paths.ts'; import { consumeIRRecords, type IRPath, type IRRecord } from '../../../ir/stream.ts'; -import { IRAbsent, type IR, type IRErrorPayload, type IRJSONObject, type IRSourceCitation, type IRTokenLogprob } from '../../../ir/types.ts'; +import { IRAbsent, type IR, type IRJSONObject, type IRSourceCitation, type IRTokenLogprob } from '../../../ir/types.ts'; import { createIRUnitQueue, createIRUnitRegistry, type IRUnitQueue } from '../../../ir/unit-queue.ts'; import { appendContent, createContentState, type ContentStates, type ContentState as ReferenceContentState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; -import { errorDetailsOf } from '../../../shared/api-error.ts'; -import { isContextExceededError } from '../../../shared/context-window-error.ts'; import { isCompleteJSONObject, parseJSONObjectAtCompletion } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import type { GeminiGenerateContentAssistantState, GeminiGenerateContentInputAssistantTurn } from '../as-target/assistant-turn.ts'; import { eventFrame, FAST_SERVICE_TIER, type EventFrame } from '@floway-dev/protocols/common'; import { type GeminiGenerateContentCandidate, - type GeminiGenerateContentErrorResponse, type GeminiGenerateContentGroundingChunk, type GeminiGenerateContentPart, type GeminiGenerateContentResult, @@ -24,37 +22,6 @@ import { createGeminiGenerateContentEventAccumulator, } from '@floway-dev/protocols/gemini-generate-content'; -// https://ai.google.dev/api/rest/v1beta/Status -const statusNameOf = (status: number): string => { - switch (status) { - case 400: return 'INVALID_ARGUMENT'; - case 401: return 'UNAUTHENTICATED'; - case 403: return 'PERMISSION_DENIED'; - case 404: return 'NOT_FOUND'; - case 408: return 'DEADLINE_EXCEEDED'; - case 409: return 'ABORTED'; - case 429: return 'RESOURCE_EXHAUSTED'; - case 500: return 'INTERNAL'; - case 501: return 'UNIMPLEMENTED'; - case 503: return 'UNAVAILABLE'; - case 504: return 'DEADLINE_EXCEEDED'; - default: return 'UNKNOWN'; - } -}; - -export const geminiGenerateContentErrorFromIR = (error: IRErrorPayload, httpStatus?: number): GeminiGenerateContentErrorResponse['error'] => { - const details = errorDetailsOf(error, ['message']); - // Arbitrary error fields use the source's Struct details container. - // 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: statusNameOf(code), message: error.message, ...extension }; -}; - interface GenerateContentUnit { readonly path: IRPath; readonly kind: 'text' | 'refusal' | 'audio' | 'image' | 'reasoning' | 'function_call'; 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 index 7968ecc167..84db14ec20 100644 --- 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 @@ -1,4 +1,4 @@ -import { openaiChatCompletionsErrorFromIR } from './sse-to.ts'; +import { openaiChatCompletionsErrorFromIR } from './error.ts'; import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; import type { ApiErrorTranslator } from '../../../types.ts'; 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..169b8c35ea --- /dev/null +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/error.ts @@ -0,0 +1,13 @@ +import type { IRErrorPayload } from '../../../ir/types.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/sse-to.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts index 175311b94b..c1b08e4797 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts @@ -1,13 +1,13 @@ +import { openaiChatCompletionsErrorFromIR } from './error.ts'; import { collectOpenAIChatCompletionsTextViews, computeOpenAIChatCompletionsTurnDigest, createOpenAIChatCompletionsSidecarCarrier } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; import { irItemPath, irPartPath, irPathKey } from '../../../ir/operation-paths.ts'; import { createIROrdinalMapper } from '../../../ir/ordinal-mapper.ts'; import { consumeIRRecords, type IRRecord } from '../../../ir/stream.ts'; -import { IRAbsent, type IRErrorPayload, type IRItem, type IRMessageItem, type IRTokenLogprob } from '../../../ir/types.ts'; +import { IRAbsent, type IRItem, type IRMessageItem, type IRTokenLogprob } from '../../../ir/types.ts'; import { appendContent, createContentState, type ContentStates, type ContentState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; -import { errorDetailsOf } from '../../../shared/api-error.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIChatCompletionsInputAssistantTurn, type OpenAIChatCompletionsOutputAssistantState } from '../as-target/assistant-turn.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; @@ -20,17 +20,6 @@ import { createOpenAIChatCompletionsEventAccumulator, } from '@floway-dev/protocols/openai-chat-completions'; -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 }), - }; -}; - interface OpenAIChatCompletionsSSEOutputOptions extends RoundTripOutputOptions<'openaiChatCompletions'> { // Replay identity belongs to the newly emitted target audio, not the source IR content. audioMetadata?: (choice: number) => { id: string; expires_at: number }; 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 index 7a0c0f957b..0463d6c678 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/error-body.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/error-body.ts @@ -1,4 +1,4 @@ -import { openaiResponsesErrorEventOf } from './sse-to.ts'; +import { openaiResponsesErrorEventOf } from './error.ts'; import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; import type { ApiErrorTranslator } from '../../../types.ts'; 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..186d6a5900 --- /dev/null +++ b/packages/translate/src/protocols/openai-responses/as-source/error.ts @@ -0,0 +1,14 @@ +import type { IRErrorPayload } from '../../../ir/types.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/sse-to.ts b/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts index a2224ba2cc..0fddc5b6f5 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/sse-to.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 { collectOpenAIResponsesTextViews, computeOpenAIResponsesTurnDigest, createOpenAIResponsesSidecarCarrier } from './round-trip.ts'; @@ -6,12 +7,11 @@ import type { IRCollector } from '../../../ir/collector.ts'; import { irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/operation-paths.ts'; import { createIROrdinalMapper } from '../../../ir/ordinal-mapper.ts'; import { consumeIRRecords, type IRPath, type IRRecord } from '../../../ir/stream.ts'; -import { IRAbsent, type IR, type IRErrorPayload, type IRItem, type IRMessageItem, type IRSourceCitation, type IRTokenLogprob } from '../../../ir/types.ts'; +import { IRAbsent, type IR, type IRItem, type IRMessageItem, type IRSourceCitation, type IRTokenLogprob } from '../../../ir/types.ts'; import { createIRUnitQueue, createIRUnitRegistry } from '../../../ir/unit-queue.ts'; import { appendContent, createContentState, type ContentState, type ContentStates } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; -import { errorDetailsOf } from '../../../shared/api-error.ts'; import { isCompleteJSONObject, parseJSONObjectAtCompletion } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIResponsesInputAssistantTurn, type OpenAIResponsesItemState, type OpenAIResponsesOutputAssistantState } from '../as-target/assistant-turn.ts'; @@ -177,17 +177,6 @@ const textOrRefusalPart = (state: IR, path: IRPath): Extract { - 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 }), - }; -}; - export const openaiResponsesFromIR = async function* ( records: AsyncIterable, options: OpenAIResponsesSSEOutputOptions, From 2548bf0d149b03e87411ac0943df78ca98287bfe Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:03:34 +0800 Subject: [PATCH 05/15] fix(gateway): retain translation input error details Preserve the param and code supplied by raw translation input failures in Messages and GenerateContent error envelopes. Omit absent details and keep the rejecting candidate performance context intact. Keep protocol error results precise and validate both detail envelopes, the named translation boundary suites, and gateway TypeScript checks. --- .../chat/anthropic-messages/errors_test.ts | 38 +++++++++++----- .../gemini-generate-content/errors_test.ts | 44 +++++++++++++++++++ .../chat/anthropic-messages/errors.ts | 20 ++++----- .../chat/gemini-generate-content/errors.ts | 27 +++++++----- .../src/data-plane/chat/shared/errors.ts | 8 ++++ 5 files changed, 105 insertions(+), 32 deletions(-) create mode 100644 packages/gateway/__tests__/data-plane/chat/gemini-generate-content/errors_test.ts diff --git a/packages/gateway/__tests__/data-plane/chat/anthropic-messages/errors_test.ts b/packages/gateway/__tests__/data-plane/chat/anthropic-messages/errors_test.ts index 67385d36cf..6b7a32e28c 100644 --- a/packages/gateway/__tests__/data-plane/chat/anthropic-messages/errors_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/anthropic-messages/errors_test.ts @@ -1,23 +1,20 @@ import { test } from 'vitest'; import { inputErrorResult } from '../../../../src/data-plane/chat/anthropic-messages/errors.ts'; -import type { ApiErrorResult } from '@floway-dev/provider'; -import { assert, assertEquals } from '@floway-dev/test-utils'; +import { assert, assertEquals, mockPerfTelemetryContext } from '@floway-dev/test-utils'; -const apiErrorOf = (result: ReturnType): ApiErrorResult => result as ApiErrorResult; const bodyOf = (result: ReturnType): Record => - JSON.parse(new TextDecoder().decode(apiErrorOf(result).body)); + JSON.parse(new TextDecoder().decode(result.body)); test('inputErrorResult renders an Anthropic 400 invalid_request_error envelope with top-level request_id', () => { const result = inputErrorResult( { message: "Invalid 'image_url' content part in system or developer message. Only 'text' content parts are supported in system messages on this model." }, ); - const apiError = apiErrorOf(result); - assertEquals(apiError.type, 'api-error'); - assertEquals(apiError.source, 'gateway'); - assertEquals(apiError.status, 400); - assertEquals(apiError.headers.get('content-type'), 'application/json'); + assertEquals(result.type, 'api-error'); + assertEquals(result.source, 'gateway'); + assertEquals(result.status, 400); + assertEquals(result.headers.get('content-type'), 'application/json'); const body = bodyOf(result); assertEquals(body.type, 'error'); @@ -30,7 +27,28 @@ test('inputErrorResult renders an Anthropic 400 invalid_request_error envelope w test('inputErrorResult preserves Anthropic key order: type, error, request_id', () => { const result = inputErrorResult({ message: 'whatever' }); - const raw = new TextDecoder().decode(apiErrorOf(result).body); + const raw = new TextDecoder().decode(result.body); // Key-order is load-bearing for byte-faithfulness with Anthropic-direct. assert(/^\{"type":"error","error":\{"type":"invalid_request_error","message":"whatever"\},"request_id":"req_[A-Za-z0-9]{24}"\}$/.test(raw), `body ${raw} must match Anthropic-direct byte shape`); }); + +test.each([ + { name: 'field and code', fields: { param: 'model', code: 'missing_required_parameter' }, expected: { param: 'model', code: 'missing_required_parameter' } }, + { name: 'field only', fields: { param: 'messages[0].content' }, expected: { param: 'messages[0].content' } }, + { name: 'code only', fields: { code: 'unsupported_content' }, expected: { code: 'unsupported_content' } }, + { name: 'empty values', fields: { param: '', code: '' }, expected: { param: '', code: '' } }, + { name: 'neither field nor code', fields: {}, expected: undefined }, +])('inputErrorResult preserves $name exactly', ({ fields, expected }) => { + const result = inputErrorResult({ message: 'invalid input', ...fields }); + assertEquals(bodyOf(result).error, { + type: 'invalid_request_error', + message: 'invalid input', + ...(expected === undefined ? {} : { provider_specific_fields: expected }), + }); +}); + +test('inputErrorResult retains the rejecting candidate performance context', () => { + const performance = mockPerfTelemetryContext({ upstream: 'rejecting-candidate', model: 'resolved-model' }); + const result = inputErrorResult({ message: 'invalid input', param: 'model', code: 'missing_required_parameter' }, performance); + assert(result.performance === performance); +}); diff --git a/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/errors_test.ts b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/errors_test.ts new file mode 100644 index 0000000000..b999f6fccd --- /dev/null +++ b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/errors_test.ts @@ -0,0 +1,44 @@ +import { test } from 'vitest'; + +import { inputErrorResult } from '../../../../src/data-plane/chat/gemini-generate-content/errors.ts'; +import { assert, assertEquals, mockPerfTelemetryContext } from '@floway-dev/test-utils'; + +const bodyOf = (result: ReturnType): Record => + JSON.parse(new TextDecoder().decode(result.body)); + +test('inputErrorResult renders a GenerateContent 400 INVALID_ARGUMENT envelope', () => { + const result = inputErrorResult({ message: '"inlineData" parts are not supported in user content.' }); + assertEquals(result.type, 'api-error'); + assertEquals(result.source, 'gateway'); + assertEquals(result.status, 400); + assertEquals(result.headers.get('content-type'), 'application/json'); + assertEquals(bodyOf(result).error, { + code: 400, + message: '"inlineData" parts are not supported in user content.', + status: 'INVALID_ARGUMENT', + }); +}); + +test.each([ + { name: 'field and code', fields: { param: 'model', code: 'missing_required_parameter' }, expected: { param: 'model', code: 'missing_required_parameter' } }, + { name: 'field only', fields: { param: 'contents[0].parts' }, expected: { param: 'contents[0].parts' } }, + { name: 'code only', fields: { code: 'unsupported_content' }, expected: { code: 'unsupported_content' } }, + { name: 'empty values', fields: { param: '', code: '' }, expected: { param: '', code: '' } }, + { name: 'neither field nor code', fields: {}, expected: undefined }, +])('inputErrorResult preserves $name exactly', ({ fields, expected }) => { + const result = inputErrorResult({ message: 'invalid input', ...fields }); + assertEquals(bodyOf(result).error, { + code: 400, + message: 'invalid input', + status: 'INVALID_ARGUMENT', + ...(expected === undefined ? {} : { + details: [{ '@type': 'type.googleapis.com/google.protobuf.Struct', value: expected }], + }), + }); +}); + +test('inputErrorResult retains the rejecting candidate performance context', () => { + const performance = mockPerfTelemetryContext({ upstream: 'rejecting-candidate', model: 'resolved-model' }); + const result = inputErrorResult({ message: 'invalid input', param: 'model', code: 'missing_required_parameter' }, performance); + assert(result.performance === performance); +}); diff --git a/packages/gateway/src/data-plane/chat/anthropic-messages/errors.ts b/packages/gateway/src/data-plane/chat/anthropic-messages/errors.ts index 1bc87472f4..b79983c6a3 100644 --- a/packages/gateway/src/data-plane/chat/anthropic-messages/errors.ts +++ b/packages/gateway/src/data-plane/chat/anthropic-messages/errors.ts @@ -1,8 +1,8 @@ import { appendFailedUpstreams } from '../../shared/failed-upstreams.ts'; -import type { ChatServeFailure, InputErrorDetails } from '../shared/errors.ts'; +import { inputErrorFields, type ChatServeFailure, type InputErrorDetails } from '../shared/errors.ts'; import { generateAnthropicId, type AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { ExecuteResult, PerformanceTelemetryContext } from '@floway-dev/provider'; +import type { ApiErrorResult, ExecuteResult, PerformanceTelemetryContext } from '@floway-dev/provider'; // Anthropic Messages error envelope used to render pre-stream // `ChatServeFailure`s. These are gateway-synthesized rather than received @@ -15,30 +15,26 @@ const anthropicErrorResult = ( type: string, message: string, performance?: PerformanceTelemetryContext, -): ExecuteResult> => ({ + extension?: Record, +): ApiErrorResult => ({ type: 'api-error', source: 'gateway', status, headers: new Headers({ 'content-type': 'application/json' }), body: new TextEncoder().encode(JSON.stringify({ type: 'error', - error: { type, message }, + error: { type, message, ...(extension === undefined ? {} : { provider_specific_fields: extension }) }, request_id: generateAnthropicId('req'), })), ...(performance ? { performance } : {}), }); -// Translator surfaced a caller-input violation (unsupported content part, -// disallowed role, missing required field, etc.). Render as a 400 -// invalid_request_error so the caller sees a protocol-shaped failure -// instead of the internal-error 502 envelope. `performance` carries the -// candidate's telemetry attribution when validation failed -// during an attempt (see AttemptState.telemetry). +// Validation failures retain the telemetry of the candidate whose attempt rejected the request. export const inputErrorResult = ( error: InputErrorDetails, performance?: PerformanceTelemetryContext, -): ExecuteResult> => - anthropicErrorResult(400, 'invalid_request_error', error.message, performance); +): ApiErrorResult => + anthropicErrorResult(400, 'invalid_request_error', error.message, performance, inputErrorFields(error)); // `endpoint` selects between `/messages` and `/messages/count_tokens` only in // the `model-unsupported` message string. diff --git a/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts b/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts index edc87687c8..c3b0f50140 100644 --- a/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts +++ b/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts @@ -1,5 +1,5 @@ import { appendFailedUpstreams } from '../../shared/failed-upstreams.ts'; -import type { ChatServeFailure, InputErrorDetails } from '../shared/errors.ts'; +import { inputErrorFields, type ChatServeFailure, type InputErrorDetails } from '../shared/errors.ts'; import type { GeminiGenerateContentErrorResponse } from '@floway-dev/protocols/gemini-generate-content'; import { internalDebugErrorFields, type ApiErrorResult, type PerformanceTelemetryContext, type InternalDebugError } from '@floway-dev/provider'; @@ -45,28 +45,35 @@ export const geminiGenerateContentInternalErrorPayload = (status: number, error: }; }; -const geminiGenerateContentRpcErrorResult = (status: number, message: string, performance?: PerformanceTelemetryContext): ApiErrorResult => ({ +// ProtoJSON Any wraps a Struct's arbitrary JSON in value. +// https://protobuf.dev/programming-guides/json/#any +const geminiGenerateContentRpcErrorResult = ( + status: number, + message: string, + performance?: PerformanceTelemetryContext, + extension?: Record, +): ApiErrorResult => ({ type: 'api-error', source: 'gateway', status, headers: new Headers({ 'content-type': 'application/json' }), body: new TextEncoder().encode(JSON.stringify({ - error: { code: status, message, status: geminiGenerateContentStatusForHttpStatus(status) }, + error: { + code: status, + message, + status: geminiGenerateContentStatusForHttpStatus(status), + ...(extension === undefined ? {} : { details: [{ '@type': 'type.googleapis.com/google.protobuf.Struct', value: extension }] }), + }, })), ...(performance ? { performance } : {}), }); -// Translator surfaced a caller-input violation (unsupported content part, -// disallowed role, missing required field, etc.). Render as a 400 -// INVALID_ARGUMENT envelope so the caller sees a Gemini-generateContent-shaped failure -// instead of the internal-error 500 envelope. `performance` carries the -// candidate's telemetry attribution when validation failed -// during an attempt (see AttemptState.telemetry). +// Validation failures retain the telemetry of the candidate whose attempt rejected the request. export const inputErrorResult = ( error: InputErrorDetails, performance?: PerformanceTelemetryContext, ): ApiErrorResult => - geminiGenerateContentRpcErrorResult(400, error.message, performance); + geminiGenerateContentRpcErrorResult(400, error.message, performance, inputErrorFields(error)); // `endpoint` selects between `:generateContent` and `:countTokens` only in // the `model-unsupported` message string. diff --git a/packages/gateway/src/data-plane/chat/shared/errors.ts b/packages/gateway/src/data-plane/chat/shared/errors.ts index 98812f5f8f..b6923e8997 100644 --- a/packages/gateway/src/data-plane/chat/shared/errors.ts +++ b/packages/gateway/src/data-plane/chat/shared/errors.ts @@ -62,3 +62,11 @@ export const noViableCandidateFailure = ( sawModel ? { kind: 'model-unsupported', model, failedUpstreams } : { kind: 'model-missing', model, failedUpstreams }; + +export const inputErrorFields = (error: InputErrorDetails): Record | undefined => { + const fields = { + ...(error.param === undefined ? {} : { param: error.param }), + ...(error.code === undefined ? {} : { code: error.code }), + }; + return Object.keys(fields).length === 0 ? undefined : fields; +}; From 935b48d32819289b2e65a0b58f931d84e2cd179a Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:12:15 +0800 Subject: [PATCH 06/15] docs(translate): clarify prepared trip integration boundaries Port the reviewed capture, error telemetry, and Responses attempt explanations to their public-boundary owners. Remove redundant dispatcher and assertion narration and update the dashboard capture reference. --- .../src/components/requests/stream-render.ts | 6 +-- .../chat/shared/execute-translation_test.ts | 1 - .../chat/openai-chat-completions/errors.ts | 8 +--- .../chat/openai-responses/attempt.ts | 38 +++---------------- .../data-plane/chat/openai-responses/http.ts | 2 - .../chat/shared/execute-translation.ts | 32 +++------------- 6 files changed, 13 insertions(+), 74 deletions(-) diff --git a/apps/web/src/components/requests/stream-render.ts b/apps/web/src/components/requests/stream-render.ts index baecfb148c..8cc0eee53a 100644 --- a/apps/web/src/components/requests/stream-render.ts +++ b/apps/web/src/components/requests/stream-render.ts @@ -42,11 +42,7 @@ export const detectCollectKind = (path: string): CollectKind | null => { return null; }; -// Mirror of `detectCollectKind` for the target protocol of a translated turn. -// The downstream view derives its kind from `meta.path` (the client path = -// source protocol); the upstream view cannot, because the client path names -// the source, not the target. `meta.targetApi` is stamped at capture time by -// `traverseTranslation` and names the target protocol the inner attempt spoke. +// The upstream view uses meta.targetApi, stamped by captureFromDump, because meta.path identifies the client protocol. export const collectKindFromTargetApi = (api: string | null | undefined): CollectKind | null => { if (api === 'anthropicMessages') return 'anthropic-messages'; if (api === 'openaiResponses') return 'openai-responses'; diff --git a/packages/gateway/__tests__/data-plane/chat/shared/execute-translation_test.ts b/packages/gateway/__tests__/data-plane/chat/shared/execute-translation_test.ts index 0e20777312..48e81adb8a 100644 --- a/packages/gateway/__tests__/data-plane/chat/shared/execute-translation_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/shared/execute-translation_test.ts @@ -56,7 +56,6 @@ describe('executeTranslation', () => { }, innerAttempt); expect(seen).toEqual({ model: 'm', stream: true }); - // The caller's own payload is not mutated. expect(source).toEqual({ model: 'm', stream: false }); }); diff --git a/packages/gateway/src/data-plane/chat/openai-chat-completions/errors.ts b/packages/gateway/src/data-plane/chat/openai-chat-completions/errors.ts index c28736f571..96568f511a 100644 --- a/packages/gateway/src/data-plane/chat/openai-chat-completions/errors.ts +++ b/packages/gateway/src/data-plane/chat/openai-chat-completions/errors.ts @@ -4,13 +4,7 @@ import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; import type { ExecuteResult, PerformanceTelemetryContext } from '@floway-dev/provider'; -// Translator surfaced a caller-input violation. Render as a 400 -// invalid_request_error so the caller sees a protocol-shaped failure -// instead of the internal-error 502 envelope. `param` falls back to -// `messages` (the OpenAI Chat Completions canonical messages field) when the -// translator did not carry a more specific path. `performance` carries the -// candidate's telemetry attribution when validation failed -// during an attempt (see AttemptState.telemetry). +// Validation failures retain the telemetry of the candidate whose attempt rejected the request. export const inputErrorResult = ( error: InputErrorDetails, performance?: PerformanceTelemetryContext, diff --git a/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts b/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts index 4da1ac83f0..1b36bc596c 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts @@ -42,40 +42,14 @@ interface OpenAIResponsesAttemptBaseArgs { export type OpenAIResponsesAttemptInvokeArgs = OpenAIResponsesAttemptBaseArgs; type OpenAIResponsesAttemptGenerateArgs = Omit; -// Single entry point for both `action: 'generate'` and `action: 'compact'`. -// Envelope-drain branches on the caller's intent (`action` passed by value), -// not on `invocation.action`. Interceptors are free to mutate `ctx.action` -// to steer inner dispatch — and, by the project's interceptor convention, -// they do not restore on the way out — so post-chain `invocation.action` -// reflects whatever the last writer left it at. The shape of the result we -// hand back is the caller's contract; keying off the caller's value is the -// only place that contract lives. +// Capture the caller's action before interceptors: invocation action selects upstream execution, +// while caller action selects the returned resource shape. // -// The module-boundary invariant `compact-shaped ⇒ targetApi='openaiResponses'` -// at dispatch time is enforced in two places, each at the layer that owns -// the corresponding piece of state: +// Compact execution requires a native Responses target. On translated targets, the compact +// shim pivots execution to generate and consumes compact input items before source reading. // -// - `invocation.action === 'compact'` is caught inside `dispatchOpenAIResponses`'s -// `case 'messages'` / `case 'openai-chat-completions'` arms — action is a -// OpenAI-Responses-level metadata field that the translators never see. -// - A `compaction_trigger` (or any other compact-shaped) item in input is -// caught by the translator itself — the `openai-responses-via-anthropic-messages` and -// `openai-responses-via-openai-chat-completions` translators reject any input-item -// variant they do not handle, so a compaction_trigger that slipped past -// the shim surfaces as a translator-level error rather than a silent -// drop. -// -// Both safety nets fire pre-upstream-call, live inside the chain (not after -// the interceptor finally blocks), and stay independent of the shim's -// presence. -// -// OpenAI Responses state and client affinity both belong to the native source edge, -// outside this candidate attempt. Native serve passes the already-hydrated -// candidate payload; translated inner OpenAI Responses -// calls pass only their translated payload. Keeping this function free of -// affinity decoding, state hydration, and persistence -// prevents an inner OpenAI Responses target from owning another source protocol's -// client-visible state. +// Hydration, affinity and persistence belong to the native source edge, so translated inner +// calls cannot own another source protocol's client state. export const openaiResponsesAttempt = { invoke: async (args: OpenAIResponsesAttemptInvokeArgs): Promise => { const { action, ctx, candidate, headers: sourceHeaders } = args; diff --git a/packages/gateway/src/data-plane/chat/openai-responses/http.ts b/packages/gateway/src/data-plane/chat/openai-responses/http.ts index d6f198ef0a..3db06a3b7c 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/http.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/http.ts @@ -50,8 +50,6 @@ const respondWithInternalError = async (c: AuthedContext, error: unknown, reques return finalizeGatewayResponse(effectiveCtx, response); }; -// Pre-stream throw dispatcher. The previous-response case renders its protocol envelope; anything else falls -// through to the internal-error 502 path. const respondToThrow = async (c: AuthedContext, error: unknown, requestBody: RequestBody, ctx?: GatewayCtx): Promise => { if (error instanceof PreviousResponseNotFoundError) { const response = previousResponseNotFoundResponse(error.previousResponseId); diff --git a/packages/gateway/src/data-plane/chat/shared/execute-translation.ts b/packages/gateway/src/data-plane/chat/shared/execute-translation.ts index fe8f2b31ed..50ea24587a 100644 --- a/packages/gateway/src/data-plane/chat/shared/execute-translation.ts +++ b/packages/gateway/src/data-plane/chat/shared/execute-translation.ts @@ -3,22 +3,13 @@ import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { ChatTargetApi, ExecuteResult } from '@floway-dev/provider'; import type { TranslateTrip, TranslateInputError } from '@floway-dev/translate'; -// Protocol-agnostic capture hook for the pre-translation (target-protocol) -// upstream view. `executeTranslation` invokes it as it relays the inner -// attempt's frames/bytes through `trip.events`; the call site constructs one -// from its `(ctx.dump, targetApi)` via `captureFromDump`. The target protocol -// is statically known at every call site (the enclosing `if`/`case` branch), -// so the dashboard can render the captured frames with the right per-protocol -// serializer without re-deriving it from the client path. +// Capture target-protocol upstream data before translation so dumps use the target serializer. export interface TranslationUpstreamCapture { upstreamFrame(frame: ProtocolFrame): void; upstreamApiError(error: { status: number; headers: Headers; body: Uint8Array }): void; } -// `dump` is null when the api key opts out of dumps; returns `undefined` so -// `executeTranslation` skips all upstream capture work (no tee, no api-error -// copy) — zero per-request cost on that path. `targetApi` is stamped eagerly so -// `meta.targetApi` is set even when the upstream stream produces zero frames. +// Stamp the target API before reading frames so an empty upstream stream retains its protocol identity. export const captureFromDump = ( dump: DumpAccumulator | null, targetApi: ChatTargetApi, @@ -31,16 +22,8 @@ export const captureFromDump = ( }; }; -// On an upstream api-error, `trip.error` translates the source envelope so -// the pair can rewrite the body into the source protocol's envelope — the -// canonical case is `anthropic-messages-via-*` translating an upstream context-window -// error into the Anthropic `prompt is too long:` shape Claude Code recognizes -// for auto-compaction. Returning `undefined` passes the upstream body through verbatim. -// -// When `capture` is provided, the ORIGINAL target-protocol frames (events -// path) or the verbatim upstream api-error envelope (api-error path) are tee'd -// to the dump BEFORE translation/rewrite, so the dump records what the upstream -// actually sent independent of what Floway translated it into. +// Source error rendering may recognize an upstream failure; undefined preserves it verbatim. +// Capture the target envelope before source rendering. export const executeTranslation = async ( payload: SP, translator: (p: Omit & { readonly stream: true }) => Promise | TranslateInputError>, @@ -61,15 +44,10 @@ export const executeTranslation = async ( : teeUpstreamFrames(inner.events, capture.upstreamFrame); return { ...inner, events: trip.events(events) }; } - // Capture the ORIGINAL upstream api-error envelope before the source - // protocol restates it. Fires for EVERY upstream api-error — even when the - // trip states none — because the verbatim target-protocol body is still the - // pre-translation view operators need to diagnose upstream behavior. if (inner.type === 'api-error' && inner.source === 'upstream') { capture?.upstreamApiError({ status: inner.status, headers: inner.headers, body: inner.body }); const rewritten = trip.error(inner.status, inner.headers, inner.body); - // A restated failure is a JSON envelope, not bytes: the transport encodes - // it here, the only place a response body becomes wire form. + // Error renderers return JSON envelopes; this transport boundary encodes the response bytes. if (rewritten !== undefined) { return { ...inner, From 20a322019afa4977613dc9ef402504ea370a127d Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:16:52 +0800 Subject: [PATCH 07/15] docs(translate): explain native error ownership contracts Move reviewed error-format explanations to dedicated protocol error owners and HTTP wrappers. Clarify diagnostic field projection, unknown-body passthrough, and replacement headers while preserving the existing status policy and envelope metadata behavior. --- .../as-source/error-body_test.ts | 2 ++ .../anthropic-messages/as-source/error-body.ts | 1 + .../anthropic-messages/as-source/error.ts | 2 ++ .../as-source/error-body.ts | 4 ++++ .../gemini-generate-content/as-source/error.ts | 11 +++++++++-- .../as-source/error-body.ts | 1 + .../openai-responses/as-source/error-body.ts | 4 ++++ packages/translate/src/shared/api-error.ts | 14 +++++++++++++- 8 files changed, 36 insertions(+), 3 deletions(-) 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 index ddf1fbf724..b6216750a9 100644 --- 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 @@ -31,6 +31,8 @@ test.each([ ['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); 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 index 68a1a70741..6ca5ff6f0f 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/error-body.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/error-body.ts @@ -2,6 +2,7 @@ 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; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/error.ts b/packages/translate/src/protocols/anthropic-messages/as-source/error.ts index fc45587217..0b6f00e81c 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/error.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/error.ts @@ -14,6 +14,7 @@ const MESSAGES_ERROR_TYPES = [ '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' @@ -26,6 +27,7 @@ const messagesError = (error: IRErrorPayload, contextExceeded: boolean): Anthrop 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 { 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 index fd77080bea..bb8995b3c9 100644 --- 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 @@ -2,6 +2,10 @@ 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; 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 index 7c2b974075..33bf6e291b 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts @@ -3,7 +3,10 @@ import { errorDetailsOf } from '../../../shared/api-error.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; import type { GeminiGenerateContentErrorResponse } from '@floway-dev/protocols/gemini-generate-content'; -// https://ai.google.dev/api/rest/v1beta/Status +/** + * Use the Google RPC status vocabulary for the numeric error code. + * https://ai.google.dev/api/rest/v1beta/Status + */ const statusNameOf = (status: number): string => { switch (status) { case 400: return 'INVALID_ARGUMENT'; @@ -21,9 +24,13 @@ const statusNameOf = (status: number): string => { } }; +/** + * 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']); - // Arbitrary error fields use the source's Struct details container. + // 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; 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 index 84db14ec20..4018a309b8 100644 --- 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 @@ -2,6 +2,7 @@ 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; 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 index 0463d6c678..196dbc7dc5 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/error-body.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/error-body.ts @@ -2,6 +2,10 @@ 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; diff --git a/packages/translate/src/shared/api-error.ts b/packages/translate/src/shared/api-error.ts index 2a95afa549..561d55b4e6 100644 --- a/packages/translate/src/shared/api-error.ts +++ b/packages/translate/src/shared/api-error.ts @@ -6,7 +6,10 @@ const recordOf = (value: unknown): Readonly> | undefined const stringOf = (value: unknown): string | undefined => typeof value === 'string' ? value : undefined; -// Unknown bodies keep their original bytes; parser failures other than invalid syntax propagate. +/** + * 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 { @@ -29,6 +32,7 @@ export const irErrorPayloadFromBody = (body: Uint8Array): IRErrorPayload | undef return payload === undefined ? undefined : withEnvelopeDetails(payload, details); }; +/** Restatement requires a message; preserve every unrepresented field in diagnostic details. */ export const errorPayloadOf = (error: Readonly>): IRErrorPayload | undefined => { const message = stringOf(error.message) ?? stringOf(error.msg); if (message === undefined) return undefined; @@ -76,6 +80,7 @@ const errorEventPayloadOf = (event: Readonly>) => { 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) ?? {}); @@ -85,6 +90,7 @@ export const errorPayloadFromWire = (wire: unknown): IRErrorPayload => { 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); return withEnvelopeDetails({ message: '', ...(extra === undefined ? {} : { provider_specific_fields: extra }) }, details); }; @@ -104,9 +110,14 @@ const extensionOf = (error: Readonly>, representedFields return Object.keys(rest).length === 0 ? own : { ...rest, ...own }; }; +/** 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, @@ -120,6 +131,7 @@ export const restateError = ( 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; From 4792e62f6e71233e7aa90f76feb109b5b801af01 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:17:00 +0800 Subject: [PATCH 08/15] docs(gateway): explain Gemini input error field carrier Keep the reviewed Struct Any explanation beside the public-boundary field-preservation matrix. --- .../data-plane/chat/gemini-generate-content/errors_test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/errors_test.ts b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/errors_test.ts index b999f6fccd..f05691c0cf 100644 --- a/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/errors_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/errors_test.ts @@ -27,6 +27,7 @@ test.each([ { name: 'neither field nor code', fields: {}, expected: undefined }, ])('inputErrorResult preserves $name exactly', ({ fields, expected }) => { const result = inputErrorResult({ message: 'invalid input', ...fields }); + // We preserve the translator's field and code in a Struct Any inside details. assertEquals(bodyOf(result).error, { code: 400, message: 'invalid input', From f462664473e66d0e9b4600410e6eec92b1de85c3 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 13:41:12 +0800 Subject: [PATCH 09/15] refactor(protocols): own native request normalization Move Responses canonicalization and Gemini Part classification into their protocol modules. Protocol errors are ordinary native data, adapted at translation entrypoints. Share HTTP/RPC status facts while preserving gateway status policy. --- .../chat/gemini-generate-content/attempt.ts | 4 +- .../chat/gemini-generate-content/errors.ts | 16 ++-- .../chat/gemini-generate-content/respond.ts | 6 +- .../data-plane/chat/openai-responses/http.ts | 6 +- .../chat/openai-responses/websocket.ts | 5 +- .../gateway/src/data-plane/models/gemini.ts | 4 +- .../error-status_test.ts | 10 +++ .../gemini-generate-content/part_test.ts | 14 ++++ .../canonicalize-payload_test.ts | 18 +++- .../gemini-generate-content/error-status.ts | 18 ++++ .../src/gemini-generate-content/index.ts | 3 + .../src/gemini-generate-content/part.ts | 39 +++++++++ .../openai-responses/canonicalize-payload.ts | 67 +++++++++++++++ .../protocols/src/openai-responses/index.ts | 2 + packages/translate/AGENTS.md | 3 + .../as-source/namespace-tools_test.ts | 12 ++- .../protocols/public-boundary_test.ts | 2 - packages/translate/src/index.ts | 1 - .../as-source/content.ts | 40 +-------- .../via-anthropic-messages/request.ts | 3 +- .../via-openai-chat-completions/request.ts | 3 +- .../via-openai-responses/request.ts | 3 +- .../openai-responses/canonicalize-payload.ts | 84 ------------------- .../via-anthropic-messages/request.ts | 5 +- .../via-openai-chat-completions/request.ts | 5 +- 25 files changed, 209 insertions(+), 164 deletions(-) create mode 100644 packages/protocols/__tests__/gemini-generate-content/error-status_test.ts create mode 100644 packages/protocols/__tests__/gemini-generate-content/part_test.ts rename packages/{translate/__tests__/protocols => protocols/__tests__}/openai-responses/canonicalize-payload_test.ts (84%) create mode 100644 packages/protocols/src/gemini-generate-content/error-status.ts create mode 100644 packages/protocols/src/gemini-generate-content/part.ts create mode 100644 packages/protocols/src/openai-responses/canonicalize-payload.ts delete mode 100644 packages/translate/src/protocols/openai-responses/canonicalize-payload.ts diff --git a/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts b/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts index a38b0366ec..3bf5954679 100644 --- a/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts +++ b/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts @@ -1,4 +1,4 @@ -import { inputErrorResult, geminiGenerateContentInternalErrorPayload, geminiGenerateContentStatusForHttpStatus } from './errors.ts'; +import { inputErrorResult, geminiGenerateContentInternalErrorPayload, gatewayErrorStatus } from './errors.ts'; import { geminiGenerateContentCountTokensInterceptors, geminiGenerateContentInterceptors } from './interceptors/index.ts'; import { stripUnsupportedPartFieldsFromPayload } from './interceptors/strip-unsupported-part-fields.ts'; import { stripUnsupportedToolsFromPayload } from './interceptors/strip-unsupported-tools.ts'; @@ -122,7 +122,7 @@ const reshapeAnthropicMessagesCountAsGeminiGenerateContent = (anthropicMessagesR const geminiGenerateContentErrorPlainResult = (status: number, message: string, upstream?: string): PlainResult => plainResult( status, new Headers({ 'content-type': 'application/json' }), - new TextEncoder().encode(JSON.stringify({ error: { code: status, message, status: geminiGenerateContentStatusForHttpStatus(status) } })), + new TextEncoder().encode(JSON.stringify({ error: { code: status, message, status: gatewayErrorStatus(status) } })), upstream, ); diff --git a/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts b/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts index c3b0f50140..91a26a429d 100644 --- a/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts +++ b/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts @@ -1,27 +1,21 @@ import { appendFailedUpstreams } from '../../shared/failed-upstreams.ts'; import { inputErrorFields, type ChatServeFailure, type InputErrorDetails } from '../shared/errors.ts'; -import type { GeminiGenerateContentErrorResponse } from '@floway-dev/protocols/gemini-generate-content'; +import { geminiGenerateContentStatusForHttpStatus, type GeminiGenerateContentErrorResponse } from '@floway-dev/protocols/gemini-generate-content'; import { internalDebugErrorFields, type ApiErrorResult, type PerformanceTelemetryContext, type InternalDebugError } from '@floway-dev/provider'; // Google RPC Status envelope, used by Gemini's `error` channel everywhere // (HTTP body, SSE-tunnelled error event). -export const geminiGenerateContentStatusForHttpStatus = (status: number): string => { +export const gatewayErrorStatus = (status: number): string => { switch (status) { case 400: - return 'INVALID_ARGUMENT'; case 401: - return 'UNAUTHENTICATED'; case 403: - return 'PERMISSION_DENIED'; case 404: - return 'NOT_FOUND'; case 429: - return 'RESOURCE_EXHAUSTED'; case 500: - return 'INTERNAL'; case 502: case 503: - return 'UNAVAILABLE'; + return geminiGenerateContentStatusForHttpStatus(status)!; default: return 'INTERNAL'; } @@ -36,7 +30,7 @@ export const geminiGenerateContentInternalErrorPayload = (status: number, error: error: { code: status, message: error.message, - status: geminiGenerateContentStatusForHttpStatus(status), + status: gatewayErrorStatus(status), details: [ ...(stack === undefined ? [] : [{ '@type': 'type.googleapis.com/google.rpc.DebugInfo', stackEntries: stack.split('\n') }]), { '@type': 'type.googleapis.com/google.protobuf.Struct', value: { type: error.type, ...fields } }, @@ -61,7 +55,7 @@ const geminiGenerateContentRpcErrorResult = ( error: { code: status, message, - status: geminiGenerateContentStatusForHttpStatus(status), + status: gatewayErrorStatus(status), ...(extension === undefined ? {} : { details: [{ '@type': 'type.googleapis.com/google.protobuf.Struct', value: extension }] }), }, })), diff --git a/packages/gateway/src/data-plane/chat/gemini-generate-content/respond.ts b/packages/gateway/src/data-plane/chat/gemini-generate-content/respond.ts index 6b9e606376..d5276d94d4 100644 --- a/packages/gateway/src/data-plane/chat/gemini-generate-content/respond.ts +++ b/packages/gateway/src/data-plane/chat/gemini-generate-content/respond.ts @@ -2,7 +2,7 @@ import type { Context } from 'hono'; import { streamSSE } from 'hono/streaming'; import { wrapGeminiGenerateContentAffinityEgress } from './affinity/egress.ts'; -import { geminiGenerateContentInternalErrorPayload, geminiGenerateContentStatusForHttpStatus } from './errors.ts'; +import { geminiGenerateContentInternalErrorPayload, gatewayErrorStatus } from './errors.ts'; import type { GatewayCtx } from '../../shared/gateway-ctx.ts'; import { type StreamCompletion, writeSSEFrames } from '../../shared/sse.ts'; import { recordFailedRequest } from '../../shared/telemetry/performance.ts'; @@ -92,7 +92,7 @@ const googleRpcHttpStatusCode = (status: number): number => (Number.isInteger(st const geminiGenerateContentRpcErrorPayload = (status: number, message: string): GeminiGenerateContentErrorResponse => { const code = googleRpcHttpStatusCode(status); return { - error: { code, message, status: geminiGenerateContentStatusForHttpStatus(code) }, + error: { code, message, status: gatewayErrorStatus(code) }, }; }; @@ -113,7 +113,7 @@ export const geminiGenerateContentInternalRpcErrorResponse = (status: number, er const geminiGenerateContentErrorResponse = (status: number, message: string, error?: InternalDebugError): Response => { // For gateway-minted errors, a non-500 that maps to INTERNAL is coerced to 500. - const code = geminiGenerateContentStatusForHttpStatus(status) === 'INTERNAL' && status !== 500 ? 500 : status; + const code = gatewayErrorStatus(status) === 'INTERNAL' && status !== 500 ? 500 : status; const payload = error === undefined ? geminiGenerateContentRpcErrorPayload(code, message) : geminiGenerateContentInternalErrorPayload(code, error); return Response.json(payload, { status: code }); }; diff --git a/packages/gateway/src/data-plane/chat/openai-responses/http.ts b/packages/gateway/src/data-plane/chat/openai-responses/http.ts index 3db06a3b7c..67e4ae565a 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/http.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/http.ts @@ -12,8 +12,8 @@ import { settle } from '../../shared/telemetry/settle.ts'; import { createChatGatewayCtxFromHono, type ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import { providerModelsUnavailableResponse } from '../shared/upstream-models-error.ts'; import type { OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; +import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; import { internalErrorResult, toInternalDebugError } from '@floway-dev/provider'; -import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/translate'; // OpenAI's verbatim previous_response_not_found envelope. Codex compares this // body byte-for-byte against upstream — see the cross-references on @@ -70,7 +70,7 @@ export const openaiResponsesHttp = { const payload = parsePayload(requestBody); if (!('input' in payload)) { const errorCtx = createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); - return finalizeGatewayResponse(errorCtx, respondOpenAIResponsesFailure(inputErrorResult(payload), errorCtx)); + return finalizeGatewayResponse(errorCtx, respondOpenAIResponsesFailure(inputErrorResult(payload.error), errorCtx)); } const wantsStream = payload.stream === true; ctx = createChatGatewayCtxFromHono(c, { wantsStream, requestBody: takeRequestBody(requestBody), model: payload.model, backgroundScheduler: backgroundSchedulerFromContext(c) }, (apiKey, requestStartedAt) => createOpenAIResponsesHttpStore(apiKey, requestStartedAt, payload.store ?? undefined)); @@ -89,7 +89,7 @@ export const openaiResponsesHttp = { const payload = parsePayload(requestBody); if (!('input' in payload)) { const errorCtx = createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); - return finalizeGatewayResponse(errorCtx, respondOpenAIResponsesFailure(inputErrorResult(payload), errorCtx)); + return finalizeGatewayResponse(errorCtx, respondOpenAIResponsesFailure(inputErrorResult(payload.error), errorCtx)); } ctx = createChatGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), model: payload.model, backgroundScheduler: backgroundSchedulerFromContext(c) }, (apiKey, requestStartedAt) => createOpenAIResponsesHttpStore(apiKey, requestStartedAt, payload.store ?? undefined)); const result = await openaiResponsesServe.compact({ payload, ctx, headers: inboundHeaders(c) }); 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 fa073da736..094feb111c 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts @@ -19,10 +19,9 @@ import { createChatGatewayCtxFromHono, type ChatGatewayCtx } from '../shared/gat import { SourceStreamState, eventResultMetadata } from '../shared/respond.ts'; import type { BackgroundScheduler } from '@floway-dev/platform'; import { eventFrame, isJsonMediaType, type ProtocolFrame } from '@floway-dev/protocols/common'; -import { OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE } from '@floway-dev/protocols/openai-responses'; +import { OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE, canonicalizeOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; import { isOpenAIResponsesTerminalEvent, type CanonicalOpenAIResponsesPayload, type ClientOpenAIResponsesStreamEvent, type OpenAIResponsesOutputItemEx, type OpenAIResponsesRequestPayloadEx, type OpenAIResponsesResultEx, type OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; import { type ExecuteResult, internalDebugErrorFields, toInternalDebugError } from '@floway-dev/provider'; -import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/translate'; interface WorkerWebSocket extends WebSocket { accept(): void; @@ -365,7 +364,7 @@ const handleClientMessage = async ( : Object.fromEntries(Object.entries(message).filter(([key]) => key !== 'type' && key !== 'event_id')); const payload = canonicalizeOpenAIResponsesPayload({ ...source, stream: true }); if (!('input' in payload)) { - turnFailure.fail(400, { type: 'invalid_request_error', code: payload.code ?? 'invalid_request_error', message: payload.message, param: payload.param }); + turnFailure.fail(400, { type: 'invalid_request_error', code: payload.error.code ?? 'invalid_request_error', message: payload.error.message, param: payload.error.param }); return; } previousResponseId = payload.previous_response_id ?? undefined; diff --git a/packages/gateway/src/data-plane/models/gemini.ts b/packages/gateway/src/data-plane/models/gemini.ts index c6b1655d70..c0aea8a525 100644 --- a/packages/gateway/src/data-plane/models/gemini.ts +++ b/packages/gateway/src/data-plane/models/gemini.ts @@ -7,7 +7,7 @@ import { getRepo } from '../../repo/index.ts'; import type { ModelAliasesRepo, User } from '../../repo/types.ts'; import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; import { getRuntimeLocation } from '../../runtime/runtime-info.ts'; -import { geminiGenerateContentStatusForHttpStatus } from '../chat/gemini-generate-content/errors.ts'; +import { gatewayErrorStatus } from '../chat/gemini-generate-content/errors.ts'; import { mergeAliasesIntoModels } from '../shared/listing/alias.ts'; import type { ModelPricing } from '@floway-dev/protocols/common'; import type { InternalModel } from '@floway-dev/provider'; @@ -51,7 +51,7 @@ const toGeminiModel = (model: InternalModel): GeminiModel => { const geminiError = (status: number, message: string): Response => Response.json( - { error: { code: status, message, status: geminiGenerateContentStatusForHttpStatus(status) } }, + { error: { code: status, message, status: gatewayErrorStatus(status) } }, { status: status as 400 | 404 | 500 | 502 }, ); diff --git a/packages/protocols/__tests__/gemini-generate-content/error-status_test.ts b/packages/protocols/__tests__/gemini-generate-content/error-status_test.ts new file mode 100644 index 0000000000..0c312550a0 --- /dev/null +++ b/packages/protocols/__tests__/gemini-generate-content/error-status_test.ts @@ -0,0 +1,10 @@ +import { expect, test } from 'vitest'; + +import { geminiGenerateContentStatusForHttpStatus } from '../../src/gemini-generate-content/index.ts'; + +test('known HTTP codes have protocol status names while unmapped codes have no policy default', () => { + expect(geminiGenerateContentStatusForHttpStatus(400)).toBe('INVALID_ARGUMENT'); + expect(geminiGenerateContentStatusForHttpStatus(408)).toBe('DEADLINE_EXCEEDED'); + expect(geminiGenerateContentStatusForHttpStatus(502)).toBe('UNAVAILABLE'); + expect(geminiGenerateContentStatusForHttpStatus(599)).toBeUndefined(); +}); diff --git a/packages/protocols/__tests__/gemini-generate-content/part_test.ts b/packages/protocols/__tests__/gemini-generate-content/part_test.ts new file mode 100644 index 0000000000..60c7ab26c9 --- /dev/null +++ b/packages/protocols/__tests__/gemini-generate-content/part_test.ts @@ -0,0 +1,14 @@ +import { expect, test } from 'vitest'; + +import { geminiGenerateContentPartKind, type GeminiGenerateContentPart } from '../../src/gemini-generate-content/index.ts'; + +test('data classification ignores metadata and preserves a standalone signature', () => { + expect(geminiGenerateContentPartKind({ text: 'thought', thought: true, thoughtSignature: 'signed' })).toBe('text'); + expect(geminiGenerateContentPartKind({ functionCall: { name: 'lookup', args: {} } })).toBe('function_call'); + expect(geminiGenerateContentPartKind({ thoughtSignature: 'signed' })).toBeNull(); +}); + +test('conflicting or missing data fields expose the invalid Part', () => { + expect(() => geminiGenerateContentPartKind({ text: 'text', functionCall: { name: 'lookup' } } as unknown as GeminiGenerateContentPart)).toThrow('conflicting content fields: text, functionCall'); + expect(() => geminiGenerateContentPartKind({})).toThrow('no recognized content'); +}); diff --git a/packages/translate/__tests__/protocols/openai-responses/canonicalize-payload_test.ts b/packages/protocols/__tests__/openai-responses/canonicalize-payload_test.ts similarity index 84% rename from packages/translate/__tests__/protocols/openai-responses/canonicalize-payload_test.ts rename to packages/protocols/__tests__/openai-responses/canonicalize-payload_test.ts index c3e7fa5bfd..09f3af1134 100644 --- a/packages/translate/__tests__/protocols/openai-responses/canonicalize-payload_test.ts +++ b/packages/protocols/__tests__/openai-responses/canonicalize-payload_test.ts @@ -1,7 +1,7 @@ -import { test } from 'vitest'; +import { expect, test } from 'vitest'; +import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; import { assert, assertEquals } from '@floway-dev/test-utils'; -import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/translate'; test('canonicalizes string and implicit-message wire inputs', () => { assertEquals(canonicalizeOpenAIResponsesPayload({ model: 'gpt-test', input: 'hello' }), { @@ -61,7 +61,9 @@ test('rejects a payload without a usable model at the canonical boundary', () => { model: null, input: 'hello' }, ]) { assertEquals(canonicalizeOpenAIResponsesPayload(payload), { - type: 'input-error', message: "Missing required parameter: 'model'.", param: 'model', code: 'missing_required_parameter', + error: { + message: "Missing required parameter: 'model'.", param: 'model', code: 'missing_required_parameter', + }, }); } }); @@ -79,7 +81,9 @@ test('rejects malformed untyped input items at the canonical boundary', () => { { role: 'user', content: 'invalid phase', phase: 42 }, ]) { assertEquals(canonicalizeOpenAIResponsesPayload({ model: 'gpt-test', input: [malformed] }), { - type: 'input-error', message: 'Untyped OpenAI Responses input items require a valid role and content.', param: 'input[0]', + error: { + message: 'Untyped OpenAI Responses input items require a valid role and content.', param: 'input[0]', + }, }); } }); @@ -103,3 +107,9 @@ test('canonicalizeOpenAIResponsesPayload preserves reasoning.context verbatim, i assert('input' in canonicalFuture); assertEquals(canonicalFuture.reasoning, { context: 'future_mode' }); }); + +test('unexpected getters retain their original error and evaluation order', () => { + const failure = new Error('unexpected getter failure', { cause: new Error('cause') }); + expect(() => canonicalizeOpenAIResponsesPayload({ get model() { throw failure; } })).toThrow(failure); + expect(() => canonicalizeOpenAIResponsesPayload({ model: 'm', input: [null], get metadata() { throw failure; } })).toThrow(failure); +}); diff --git a/packages/protocols/src/gemini-generate-content/error-status.ts b/packages/protocols/src/gemini-generate-content/error-status.ts new file mode 100644 index 0000000000..d680a8c222 --- /dev/null +++ b/packages/protocols/src/gemini-generate-content/error-status.ts @@ -0,0 +1,18 @@ +// Google RPC status names for the HTTP error codes used by this protocol. +// https://ai.google.dev/api/rest/v1beta/Status +const HTTP_ERROR_STATUSES = new Map([ + [400, 'INVALID_ARGUMENT'], + [401, 'UNAUTHENTICATED'], + [403, 'PERMISSION_DENIED'], + [404, 'NOT_FOUND'], + [408, 'DEADLINE_EXCEEDED'], + [409, 'ABORTED'], + [429, 'RESOURCE_EXHAUSTED'], + [500, 'INTERNAL'], + [501, 'UNIMPLEMENTED'], + [502, 'UNAVAILABLE'], + [503, 'UNAVAILABLE'], + [504, 'DEADLINE_EXCEEDED'], +]); + +export const geminiGenerateContentStatusForHttpStatus = (status: number): string | undefined => HTTP_ERROR_STATUSES.get(status); diff --git a/packages/protocols/src/gemini-generate-content/index.ts b/packages/protocols/src/gemini-generate-content/index.ts index fa68d890da..5fbc4d7afa 100644 --- a/packages/protocols/src/gemini-generate-content/index.ts +++ b/packages/protocols/src/gemini-generate-content/index.ts @@ -139,3 +139,6 @@ export { GEMINI_GENERATE_CONTENT_CANDIDATE_KEYS, GEMINI_GENERATE_CONTENT_RESULT_ export { GEMINI_GENERATE_CONTENT_MISSING_TERMINAL_MESSAGE, createGeminiGenerateContentCandidateCompletion, isGeminiGenerateContentErrorEvent, isGeminiGenerateContentTerminalEvent, collectGeminiGenerateContentProtocolEventsToResult } from './to-result.ts'; export { createGeminiGenerateContentEventAccumulator, reassembleGeminiGenerateContentEvents, type GeminiGenerateContentEventAccumulator } from './reassemble.ts'; export { geminiGenerateContentProtocolFrameToSSEFrame } from './to-sse.ts'; + +export { geminiGenerateContentPartKind, type GeminiGenerateContentPartKind } from './part.ts'; +export { geminiGenerateContentStatusForHttpStatus } from './error-status.ts'; diff --git a/packages/protocols/src/gemini-generate-content/part.ts b/packages/protocols/src/gemini-generate-content/part.ts new file mode 100644 index 0000000000..ce2c395f8d --- /dev/null +++ b/packages/protocols/src/gemini-generate-content/part.ts @@ -0,0 +1,39 @@ +import type { GeminiGenerateContentPart, GeminiGenerateContentPartData } from './index.ts'; + +export type GeminiGenerateContentPartKind = 'text' | 'inline_data' | 'function_call' | 'function_response' | 'file_data' | 'executable_code' | 'code_execution_result' | 'tool_call' | 'tool_response'; + +type GeminiGenerateContentPartDataField = keyof GeminiGenerateContentPartData; + +// Source of truth for "what counts as a content-bearing field on a part". +// Record forces a matching entry whenever a new field +// is added to GeminiGenerateContentPart, so Part readers cannot silently start +// dropping it. +const GEMINI_GENERATE_CONTENT_PART_FIELD_TO_KIND: Record = { + text: 'text', + inlineData: 'inline_data', + functionCall: 'function_call', + functionResponse: 'function_response', + fileData: 'file_data', + executableCode: 'executable_code', + codeExecutionResult: 'code_execution_result', + toolCall: 'tool_call', + toolResponse: 'tool_response', +}; + +const GEMINI_GENERATE_CONTENT_PART_DATA_FIELDS = Object.keys(GEMINI_GENERATE_CONTENT_PART_FIELD_TO_KIND) as GeminiGenerateContentPartDataField[]; + +// Classify the part by its single content-bearing field. Returns null when +// the part only carries a thoughtSignature (a valid signature carrier with +// no body). Throws when the part sets zero or multiple content fields — +// both are silent-data-loss shapes the Part readers must refuse +// rather than coerce. +export const geminiGenerateContentPartKind = (part: GeminiGenerateContentPart): GeminiGenerateContentPartKind | null => { + const presentFields = GEMINI_GENERATE_CONTENT_PART_DATA_FIELDS.filter(field => part[field] !== undefined); + if (presentFields.length === 1) return GEMINI_GENERATE_CONTENT_PART_FIELD_TO_KIND[presentFields[0]]; + if (presentFields.length > 1) { + throw new Error(`Gemini generateContent part sets conflicting content fields: ${presentFields.join(', ')}.`); + } + if (part.thoughtSignature !== undefined) return null; + const keys = Object.keys(part); + throw new Error(`Gemini generateContent part has no recognized content. Keys present: ${keys.length ? keys.join(', ') : '(none)'}.`); +}; diff --git a/packages/protocols/src/openai-responses/canonicalize-payload.ts b/packages/protocols/src/openai-responses/canonicalize-payload.ts new file mode 100644 index 0000000000..aa2179b91f --- /dev/null +++ b/packages/protocols/src/openai-responses/canonicalize-payload.ts @@ -0,0 +1,67 @@ +import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesEasyInputMessageEx, CanonicalOpenAIResponsesInputItem, OpenAIResponsesRequestPayloadEx } from './index.ts'; + +// Expand shorthand input into explicitly discriminated message items. +export const canonicalizeOpenAIResponsesPayload = (value: unknown): CanonicalOpenAIResponsesPayload | { + readonly error: { readonly message: string; readonly param?: string; readonly code?: string }; +} => { + const hasValidPromptCacheBreakpoint = (content: Record): boolean => { + const breakpoint = content.prompt_cache_breakpoint; + if (breakpoint === undefined || breakpoint === null) return true; + return typeof breakpoint === 'object' + && typeof (breakpoint as Record).mode === 'string'; + }; + + const isImplicitEasyInputMessage = (item: unknown): item is OpenAIResponsesEasyInputMessageEx & { type?: undefined } => { + if (typeof item !== 'object' || item === null) return false; + const message = item as Record; + if (message.type !== undefined) return false; + if (message.role !== 'user' && message.role !== 'assistant' && message.role !== 'system' && message.role !== 'developer') return false; + if (message.phase !== undefined && message.phase !== null && typeof message.phase !== 'string') return false; + return typeof message.content === 'string' + || (Array.isArray(message.content) && message.content.every(part => { + if (typeof part !== 'object' || part === null) return false; + const content = part as Record; + switch (content.type) { + case 'input_text': + case 'output_text': + return typeof content.text === 'string' && hasValidPromptCacheBreakpoint(content); + case 'input_image': + return (typeof content.image_url === 'string' || typeof content.file_id === 'string') + && hasValidPromptCacheBreakpoint(content); + case 'input_file': + return hasValidPromptCacheBreakpoint(content); + default: + return false; + } + })); + }; + + if (typeof value !== 'object' || value === null) { + return { error: { message: 'OpenAI Responses payload must be an object.' } }; + } + const payload = value as OpenAIResponsesRequestPayloadEx; + // https://github.com/mattermost/mattermost-plugin-agents/issues/476 + if (typeof payload.model !== 'string' || payload.model.length === 0) { + return { error: { message: "Missing required parameter: 'model'.", param: 'model', code: 'missing_required_parameter' } }; + } + const input: unknown = payload.input; + if (typeof input !== 'string' && !Array.isArray(input)) { + return { error: { message: 'OpenAI Responses input must be a string or an array.', param: 'input' } }; + } + const canonical: CanonicalOpenAIResponsesPayload = { ...payload, model: payload.model, input: [] }; + const items = canonical.input; + if (typeof input === 'string') { + items.push({ type: 'message', role: 'user', content: input }); + } else { + for (const [index, item] of input.entries()) { + if (isImplicitEasyInputMessage(item)) { + items.push({ ...item, type: 'message' }); + } else if (typeof item !== 'object' || item === null || (item as { type?: unknown }).type === undefined) { + return { error: { message: 'Untyped OpenAI Responses input items require a valid role and content.', param: `input[${index}]` } }; + } else { + items.push(item as CanonicalOpenAIResponsesInputItem); + } + } + } + return canonical; +}; diff --git a/packages/protocols/src/openai-responses/index.ts b/packages/protocols/src/openai-responses/index.ts index 4b750d5384..16923ec637 100644 --- a/packages/protocols/src/openai-responses/index.ts +++ b/packages/protocols/src/openai-responses/index.ts @@ -1443,3 +1443,5 @@ export { OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE, collectOpenAIResponsesProtoc export { createRandomOpenAIResponsesItemId, type GeneratedOpenAIResponsesItemType } from './item-id.ts'; export { reassembleOpenAIResponsesEvents } from './reassemble.ts'; export { openaiResponsesProtocolFrameToSSEFrame } from './to-sse.ts'; + +export { canonicalizeOpenAIResponsesPayload } from './canonicalize-payload.ts'; diff --git a/packages/translate/AGENTS.md b/packages/translate/AGENTS.md index 0f0319c1e0..795b76c9ad 100644 --- a/packages/translate/AGENTS.md +++ b/packages/translate/AGENTS.md @@ -5,6 +5,9 @@ code. `__tests__/protocols/` mirrors production ownership. Package callers import the package root and call `routeVia(input, options)` to create an independent trip. +Protocol-native request normalization and Part data classification belong to +`@floway-dev/protocols`; translation maps their failures at its own boundary. + ## Categories 1. **Single-pair helper** — keep it in diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-tools_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-tools_test.ts index e33c749b2c..fdf426e86b 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-tools_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-tools_test.ts @@ -1,7 +1,6 @@ import { expect, test } from 'vitest'; import { flattenNamespaceTools, restoreNamespaceEvents, restoreNamespaceOutputItem } from '../../../../src/protocols/openai-responses/as-source/namespace-tools.ts'; -import { requireCanonicalOpenAIResponsesPayload } from '../../../../src/protocols/openai-responses/canonicalize-payload.ts'; import { buildTargetRequest as buildMessagesRequest } from '../../../../src/protocols/openai-responses/via-anthropic-messages/request.ts'; import { buildTargetRequest as buildChatRequest } from '../../../../src/protocols/openai-responses/via-openai-chat-completions/request.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; @@ -9,6 +8,7 @@ import { requireTrip } from '../../require-trip.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; +import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesOutputItemEx, OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; import { routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; @@ -49,6 +49,12 @@ test.each(['additional_tools', 'tool_search_output'] as const)('keeps %s as a us ]); }); +const canonicalPayload = () => { + const canonical = canonicalizeOpenAIResponsesPayload(payload()); + if (!('input' in canonical)) throw new Error(canonical.error.message); + return canonical; +}; + test('namespace setup preserves the original input and allocates historical names only when explicitly flattened', () => { const request: CanonicalOpenAIResponsesPayload = { model: 'm', @@ -107,7 +113,7 @@ test('forced namespace choices use the declaration mapping', async () => { }); test('restores function and custom calls across item events and terminal snapshots', async () => { - const prepared = flattenNamespaceTools(requireCanonicalOpenAIResponsesPayload(payload())); + const prepared = flattenNamespaceTools(canonicalPayload()); const output = [ { type: 'function_call' as const, name: 'agents_spawn_2', call_id: 'spawn', arguments: '{"name":"agents_spawn_2"}', status: 'completed' }, { type: 'custom_tool_call' as const, name: 'agents_audit', call_id: 'audit', input: 'agents_audit' }, @@ -129,7 +135,7 @@ test('restores function and custom calls across item events and terminal snapsho }); test('restores all function/custom output item type combinations without retaining the replaced payload field', () => { - const names = flattenNamespaceTools(requireCanonicalOpenAIResponsesPayload(payload())).names; + const names = flattenNamespaceTools(canonicalPayload()).names; const functionName = names.sourceToTarget.get('agents.spawn'); const customName = names.sourceToTarget.get('agents.audit'); if (functionName === undefined || customName === undefined) throw new Error('Expected flattened namespace tool names'); diff --git a/packages/translate/__tests__/protocols/public-boundary_test.ts b/packages/translate/__tests__/protocols/public-boundary_test.ts index 511e9c6bee..7c2cf32399 100644 --- a/packages/translate/__tests__/protocols/public-boundary_test.ts +++ b/packages/translate/__tests__/protocols/public-boundary_test.ts @@ -2,7 +2,6 @@ import { expect, test } from 'vitest'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { - canonicalizeOpenAIResponsesPayload, routeGeminiGenerateContentViaOpenAIChatCompletions, routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses, @@ -32,7 +31,6 @@ test('public operations preserve unexpected errors and their causes', async () = model: 'source-model', messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://example.com/image.png' } }] }], }, { ...options, fallbackMaxOutputTokens: 32, loadRemoteImage: async () => { throw failure; } })).rejects.toBe(failure); - expect(() => canonicalizeOpenAIResponsesPayload({ get model() { throw failure; } })).toThrow(failure); expect(failure.cause).toBe(cause); }); diff --git a/packages/translate/src/index.ts b/packages/translate/src/index.ts index d7fedbcc9e..805c45f2a9 100644 --- a/packages/translate/src/index.ts +++ b/packages/translate/src/index.ts @@ -1,6 +1,5 @@ export type { RemoteImageData, RemoteImageLoader, SidecarCodec, TranslateTrip, TranslateInputError, TranslateOptions, Translator } from './types.ts'; export { SidecarTaggedValue } from './round-trip/sidecar-tagged-value.ts'; -export { canonicalizeOpenAIResponsesPayload } from './protocols/openai-responses/canonicalize-payload.ts'; export { routeAnthropicMessagesViaOpenAIChatCompletions, routeAnthropicMessagesViaOpenAIResponses, diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/content.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/content.ts index 004ab1c235..f855b7b486 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/content.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/content.ts @@ -1,4 +1,4 @@ -import type { GeminiGenerateContentPartData, GeminiGenerateContentContent, GeminiGenerateContentPart } from '@floway-dev/protocols/gemini-generate-content'; +import type { GeminiGenerateContentContent, GeminiGenerateContentPart } from '@floway-dev/protocols/gemini-generate-content'; const GEMINI_GENERATE_CONTENT_SUPPORTED_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] as const; @@ -18,44 +18,6 @@ export type GeminiGenerateContentInlineData = | { kind: 'audio'; mimeType: keyof typeof GEMINI_GENERATE_CONTENT_AUDIO_FORMATS; format: (typeof GEMINI_GENERATE_CONTENT_AUDIO_FORMATS)[keyof typeof GEMINI_GENERATE_CONTENT_AUDIO_FORMATS]; data: string } | { kind: 'unsupported'; mimeType: string; data: string }; -export type GeminiGenerateContentPartKind = 'text' | 'inline_data' | 'function_call' | 'function_response' | 'file_data' | 'executable_code' | 'code_execution_result' | 'tool_call' | 'tool_response'; - -type GeminiGenerateContentPartDataField = keyof GeminiGenerateContentPartData; - -// Source of truth for "what counts as a content-bearing field on a part". -// Record forces a matching entry whenever a new field -// is added to GeminiGenerateContentPart, so request translators cannot silently start -// dropping it. -const GEMINI_GENERATE_CONTENT_PART_FIELD_TO_KIND: Record = { - text: 'text', - inlineData: 'inline_data', - functionCall: 'function_call', - functionResponse: 'function_response', - fileData: 'file_data', - executableCode: 'executable_code', - codeExecutionResult: 'code_execution_result', - toolCall: 'tool_call', - toolResponse: 'tool_response', -}; - -const GEMINI_GENERATE_CONTENT_PART_DATA_FIELDS = Object.keys(GEMINI_GENERATE_CONTENT_PART_FIELD_TO_KIND) as GeminiGenerateContentPartDataField[]; - -// Classify the part by its single content-bearing field. Returns null when -// the part only carries a thoughtSignature (a valid signature carrier with -// no body). Throws when the part sets zero or multiple content fields — -// both are silent-data-loss shapes the request translators must refuse -// rather than coerce. -export const geminiGenerateContentPartKind = (part: GeminiGenerateContentPart): GeminiGenerateContentPartKind | null => { - const presentFields = GEMINI_GENERATE_CONTENT_PART_DATA_FIELDS.filter(field => part[field] !== undefined); - if (presentFields.length === 1) return GEMINI_GENERATE_CONTENT_PART_FIELD_TO_KIND[presentFields[0]]; - if (presentFields.length > 1) { - throw new Error(`Gemini generateContent part sets conflicting content fields: ${presentFields.join(', ')}.`); - } - if (part.thoughtSignature !== undefined) return null; - const keys = Object.keys(part); - throw new Error(`Gemini generateContent part has no recognized content. Keys present: ${keys.length ? keys.join(', ') : '(none)'}.`); -}; - export const geminiGenerateContentPartText = (part: GeminiGenerateContentPart): string | null => (typeof part.text === 'string' ? part.text : null); export const geminiGenerateContentThoughtText = (part: GeminiGenerateContentPart): string | null => (part.thought === true && typeof part.text === 'string' ? part.text : null); diff --git a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/request.ts b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/request.ts index 4a0001f630..492c9b946b 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/request.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/request.ts @@ -5,7 +5,7 @@ import { TranslateInputException } from '../../../shared/input-error.ts'; import type { SidecarCodec } from '../../../types.ts'; import { applyLastMessageCacheBreakpoint, applyLastSystemCacheBreakpoint, applyLastToolCacheBreakpoint } from '../../anthropic-messages/as-target/cache-breakpoints.ts'; import { anthropicMessagesToolInputSchema } from '../../anthropic-messages/as-target/tool-input-schema.ts'; -import { geminiGenerateContentInlineData, geminiGenerateContentPartKind, geminiGenerateContentPartText, geminiGenerateContentText, geminiGenerateContentThoughtText, geminiGenerateContentVisibleText } from '../as-source/content.ts'; +import { geminiGenerateContentInlineData, geminiGenerateContentPartText, geminiGenerateContentText, geminiGenerateContentThoughtText, geminiGenerateContentVisibleText } from '../as-source/content.ts'; import { partitionGeminiGenerateContentTurns } from '../as-source/conversation-turns.ts'; import { geminiGenerateContentThinkingLevelEffort } from '../as-source/reasoning.ts'; import { collectGeminiGenerateContentReplayContents, hasGeminiGenerateContentThoughtSignature, findGeminiGenerateContentSidecarCarriers, verifyGeminiGenerateContentTurnDigest } from '../as-source/round-trip.ts'; @@ -19,6 +19,7 @@ import { type AnthropicMessagesTool, type AnthropicMessagesUserContentBlock, } from '@floway-dev/protocols/anthropic-messages'; +import { geminiGenerateContentPartKind } from '@floway-dev/protocols/gemini-generate-content'; import type { GeminiGenerateContentContent, GeminiGenerateContentPayload, GeminiGenerateContentGenerationConfig, GeminiGenerateContentPart, GeminiGenerateContentThinkingConfig } from '@floway-dev/protocols/gemini-generate-content'; const inlineDataToContentBlock = (part: GeminiGenerateContentPart): AnthropicMessagesUserContentBlock | null => { diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts index 098478c8d0..71c50594b2 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts @@ -3,12 +3,13 @@ import { klona } from 'klona/json'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { geminiGenerateContentInlineData, geminiGenerateContentPartKind, geminiGenerateContentPartText, geminiGenerateContentText, geminiGenerateContentThoughtText, geminiGenerateContentVisibleText } from '../as-source/content.ts'; +import { geminiGenerateContentInlineData, geminiGenerateContentPartText, geminiGenerateContentText, geminiGenerateContentThoughtText, geminiGenerateContentVisibleText } from '../as-source/content.ts'; import { partitionGeminiGenerateContentTurns } from '../as-source/conversation-turns.ts'; import { geminiGenerateContentReasoningEffort } from '../as-source/reasoning.ts'; import { collectGeminiGenerateContentReplayContents, hasGeminiGenerateContentThoughtSignature, findGeminiGenerateContentSidecarCarriers, verifyGeminiGenerateContentTurnDigest } from '../as-source/round-trip.ts'; import { geminiFunctionParameters, geminiResponseSchema } from '../as-source/schema.ts'; import { geminiGenerateContentFunctionCallingIntent, geminiGenerateContentFunctionCallPart, geminiGenerateContentFunctionDeclarations, geminiGenerateContentFunctionResponsePart, type GeminiGenerateContentToolCallIds } from '../as-source/tools.ts'; +import { geminiGenerateContentPartKind } from '@floway-dev/protocols/gemini-generate-content'; import type { GeminiGenerateContentContent, GeminiGenerateContentPayload, GeminiGenerateContentGenerationConfig, GeminiGenerateContentPart } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsUserContentPart, OpenAIChatCompletionsPayload, OpenAIChatCompletionsMessage, OpenAIChatCompletionsTool, OpenAIChatCompletionsToolCall } from '@floway-dev/protocols/openai-chat-completions'; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/request.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/request.ts index b5ff204ae1..411f77d985 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/request.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/request.ts @@ -1,12 +1,13 @@ import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { geminiGenerateContentInlineData, geminiGenerateContentPartKind, geminiGenerateContentPartText, geminiGenerateContentText, geminiGenerateContentThoughtText, geminiGenerateContentVisibleText } from '../as-source/content.ts'; +import { geminiGenerateContentInlineData, geminiGenerateContentPartText, geminiGenerateContentText, geminiGenerateContentThoughtText, geminiGenerateContentVisibleText } from '../as-source/content.ts'; import { partitionGeminiGenerateContentTurns } from '../as-source/conversation-turns.ts'; import { geminiGenerateContentReasoningEffort } from '../as-source/reasoning.ts'; import { collectGeminiGenerateContentReplayContents, hasGeminiGenerateContentThoughtSignature, findGeminiGenerateContentSidecarCarriers, verifyGeminiGenerateContentTurnDigest } from '../as-source/round-trip.ts'; import { geminiFunctionParameters, geminiResponseSchema } from '../as-source/schema.ts'; import { geminiGenerateContentFunctionCallingIntent, geminiGenerateContentFunctionCallPart, geminiGenerateContentFunctionDeclarations, geminiGenerateContentFunctionResponsePart, type GeminiGenerateContentToolCallIds } from '../as-source/tools.ts'; +import { geminiGenerateContentPartKind } from '@floway-dev/protocols/gemini-generate-content'; import type { GeminiGenerateContentContent, GeminiGenerateContentPayload, GeminiGenerateContentGenerationConfig, GeminiGenerateContentPart } from '@floway-dev/protocols/gemini-generate-content'; import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesInputContent, CanonicalOpenAIResponsesInputItem, OpenAIResponsesTool } from '@floway-dev/protocols/openai-responses'; diff --git a/packages/translate/src/protocols/openai-responses/canonicalize-payload.ts b/packages/translate/src/protocols/openai-responses/canonicalize-payload.ts deleted file mode 100644 index f7896b7681..0000000000 --- a/packages/translate/src/protocols/openai-responses/canonicalize-payload.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { TranslateInputException } from '../../shared/input-error.ts'; -import { type TranslateInputError } from '../../types.ts'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesEasyInputMessageEx, CanonicalOpenAIResponsesInputItem, OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; - -// Wire `OpenAIResponsesRequestPayload.input` accepts a bare string and EasyInputMessage -// objects whose `type: "message"` discriminator is omitted. The gateway's -// canonical internal shape is an explicitly discriminated item array: every -// consumer past HTTP / WS entry normalization or cross-protocol translation -// sees `type: "message"` on every message. -// Lifts a wire `OpenAIResponsesRequestPayload` to canonical form. Called at every wire -// boundary that produces a payload destined for internal use and by direct -// OpenAI-Responses-source translators; cross-protocol translators already construct -// `CanonicalOpenAIResponsesPayload` with explicit message discriminators. -export const canonicalizeOpenAIResponsesPayload = (value: unknown): CanonicalOpenAIResponsesPayload | TranslateInputError => { - try { - return requireCanonicalOpenAIResponsesPayload(value); - } catch (error) { - if (error instanceof TranslateInputException) return error.failure; - throw error; - } -}; - -export const requireCanonicalOpenAIResponsesPayload = (value: unknown): CanonicalOpenAIResponsesPayload => { - const hasValidPromptCacheBreakpoint = (content: Record): boolean => { - const breakpoint = content.prompt_cache_breakpoint; - if (breakpoint === undefined || breakpoint === null) return true; - return typeof breakpoint === 'object' - && typeof (breakpoint as Record).mode === 'string'; - }; - - const isImplicitEasyInputMessage = (item: unknown): item is OpenAIResponsesEasyInputMessageEx & { type?: undefined } => { - if (typeof item !== 'object' || item === null) return false; - const message = item as Record; - if (message.type !== undefined) return false; - if (message.role !== 'user' && message.role !== 'assistant' && message.role !== 'system' && message.role !== 'developer') return false; - if (message.phase !== undefined && message.phase !== null && typeof message.phase !== 'string') return false; - return typeof message.content === 'string' - || (Array.isArray(message.content) && message.content.every(part => { - if (typeof part !== 'object' || part === null) return false; - const content = part as Record; - switch (content.type) { - case 'input_text': - case 'output_text': - return typeof content.text === 'string' && hasValidPromptCacheBreakpoint(content); - case 'input_image': - return (typeof content.image_url === 'string' || typeof content.file_id === 'string') - && hasValidPromptCacheBreakpoint(content); - case 'input_file': - return hasValidPromptCacheBreakpoint(content); - default: - return false; - } - })); - }; - - if (typeof value !== 'object' || value === null) { - throw new TranslateInputException('OpenAI Responses payload must be an object.'); - } - const payload = value as OpenAIResponsesRequestPayloadEx; - // Resolution binds `model` straight into a SQL lookup, so the wire boundary is - // the only place a missing id can still become a caller-facing 400; message - // and code reproduce OpenAI's own rejection verbatim. - // https://github.com/mattermost/mattermost-plugin-agents/issues/476 - if (typeof payload.model !== 'string' || payload.model.length === 0) { - throw new TranslateInputException("Missing required parameter: 'model'.", { param: 'model', code: 'missing_required_parameter' }); - } - const input: unknown = payload.input; - if (typeof input !== 'string' && !Array.isArray(input)) { - throw new TranslateInputException('OpenAI Responses input must be a string or an array.', { param: 'input' }); - } - return { - ...payload, - model: payload.model, - input: typeof input === 'string' - ? [{ type: 'message', role: 'user', content: input }] - : input.map((item, index) => { - if (isImplicitEasyInputMessage(item)) return { ...item, type: 'message' }; - if (typeof item !== 'object' || item === null || (item as { type?: unknown }).type === undefined) { - throw new TranslateInputException('Untyped OpenAI Responses input items require a valid role and content.', { param: `input[${index}]` }); - } - return item as CanonicalOpenAIResponsesInputItem; - }), - }; -}; diff --git a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts index 51164bfa0f..4151951f91 100644 --- a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts +++ b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts @@ -14,7 +14,6 @@ import { partitionOpenAIResponsesTurns } from '../as-source/conversation-turns.t import { flattenNamespaceTools, type NamespaceToolNames } from '../as-source/namespace-tools.ts'; import { rejectProgramCaller, rejectProgrammaticOpenAIResponsesPayload } from '../as-source/programmatic-tooling.ts'; import { collectOpenAIResponsesReplayContents, findOpenAIResponsesSidecarCarriers, isOpenAIResponsesEncryptedReasoning, verifyOpenAIResponsesTurnDigest } from '../as-source/round-trip.ts'; -import { requireCanonicalOpenAIResponsesPayload } from '../canonicalize-payload.ts'; import { buildCustomToolInputSchema } from '../common/custom-tool-wrap.ts'; import { ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS, @@ -31,6 +30,7 @@ import { type AnthropicMessagesUserMessage, } from '@floway-dev/protocols/anthropic-messages'; import { isFastServiceTier } from '@floway-dev/protocols/common'; +import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; import type { OpenAIResponsesInputContent, OpenAIResponsesInputImage, @@ -288,7 +288,8 @@ export const buildTargetRequest = async ( codec: SidecarCodec, options: BuildTargetRequestOptions, ): Promise => { - const canonical = requireCanonicalOpenAIResponsesPayload(source); + const canonical = canonicalizeOpenAIResponsesPayload(source); + if (!('input' in canonical)) throw new TranslateInputException(canonical.error.message, canonical.error); const { payload, names: namespaceToolNames, flattenInput } = flattenNamespaceTools(canonical); rejectProgrammaticOpenAIResponsesPayload(payload, 'Anthropic Messages'); const customToolNames = new Set(); diff --git a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/request.ts b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/request.ts index 8af6a7491f..1fc0d54da9 100644 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/request.ts +++ b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/request.ts @@ -9,9 +9,9 @@ import { partitionOpenAIResponsesTurns } from '../as-source/conversation-turns.t import { flattenNamespaceTools, type NamespaceToolNames } from '../as-source/namespace-tools.ts'; import { rejectProgramCaller, rejectProgrammaticOpenAIResponsesPayload } from '../as-source/programmatic-tooling.ts'; import { collectOpenAIResponsesReplayContents, findOpenAIResponsesSidecarCarriers, isOpenAIResponsesEncryptedReasoning, verifyOpenAIResponsesTurnDigest } from '../as-source/round-trip.ts'; -import { requireCanonicalOpenAIResponsesPayload } from '../canonicalize-payload.ts'; import { buildCustomToolInputSchema } from '../common/custom-tool-wrap.ts'; import type { OpenAIChatCompletionsAssistantMessage, OpenAIChatCompletionsResponseFormat, OpenAIChatCompletionsUserContentPart, OpenAIChatCompletionsPayload, OpenAIChatCompletionsMessage, OpenAIChatCompletionsTool, OpenAIChatCompletionsToolCall } from '@floway-dev/protocols/openai-chat-completions'; +import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; import type { OpenAIResponsesCustomToolCallOutputItem, OpenAIResponsesFunctionCallOutputItem, OpenAIResponsesInputImage, CanonicalOpenAIResponsesText, CanonicalOpenAIResponsesInputItem, OpenAIResponsesPayloadEx, OpenAIResponsesRequestPayloadEx, OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; interface AssistantAccumulator { @@ -179,7 +179,8 @@ export interface TargetRequestResult { } export const buildTargetRequest = async (source: OpenAIResponsesRequestPayloadEx, codec: SidecarCodec): Promise => { - const canonical = requireCanonicalOpenAIResponsesPayload(source); + const canonical = canonicalizeOpenAIResponsesPayload(source); + if (!('input' in canonical)) throw new TranslateInputException(canonical.error.message, canonical.error); const { payload, names: namespaceToolNames, flattenInput } = flattenNamespaceTools(canonical); rejectProgrammaticOpenAIResponsesPayload(payload, 'OpenAI Chat Completions'); const customToolNames = new Set(); From 5056980fa94cdc52ebfd4a793cca936a48190ad5 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 13:43:41 +0800 Subject: [PATCH 10/15] refactor(translate): use protocol status names in error projection Share the native HTTP/RPC mapping while retaining this boundary's UNKNOWN classification for 502 and unrecognized codes. --- .../as-source/error.ts | 24 +++---------------- 1 file changed, 3 insertions(+), 21 deletions(-) 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 index 33bf6e291b..cf8ac50146 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts @@ -1,28 +1,10 @@ import type { IRErrorPayload } from '../../../ir/types.ts'; import { errorDetailsOf } from '../../../shared/api-error.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; -import type { GeminiGenerateContentErrorResponse } from '@floway-dev/protocols/gemini-generate-content'; +import { geminiGenerateContentStatusForHttpStatus, type GeminiGenerateContentErrorResponse } from '@floway-dev/protocols/gemini-generate-content'; -/** - * Use the Google RPC status vocabulary for the numeric error code. - * https://ai.google.dev/api/rest/v1beta/Status - */ -const statusNameOf = (status: number): string => { - switch (status) { - case 400: return 'INVALID_ARGUMENT'; - case 401: return 'UNAUTHENTICATED'; - case 403: return 'PERMISSION_DENIED'; - case 404: return 'NOT_FOUND'; - case 408: return 'DEADLINE_EXCEEDED'; - case 409: return 'ABORTED'; - case 429: return 'RESOURCE_EXHAUSTED'; - case 500: return 'INTERNAL'; - case 501: return 'UNIMPLEMENTED'; - case 503: return 'UNAVAILABLE'; - case 504: return 'DEADLINE_EXCEEDED'; - default: return 'UNKNOWN'; - } -}; +// This translated-error boundary retains UNKNOWN for an upstream 502. +const statusNameOf = (status: number): string => status === 502 ? 'UNKNOWN' : geminiGenerateContentStatusForHttpStatus(status) ?? 'UNKNOWN'; /** * HTTP failures supply an upstream status; streaming failures derive a code from the error kind. From 1a8c049fb02d476aa808fce2d7f8fa1f539e0ed7 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 17:20:41 +0800 Subject: [PATCH 11/15] fix(gateway): retain token-counting input failure telemetry Keep expected token-counting input failures as gateway API errors so final response rendering records the selected candidate's zero-output error. Preserve fallback across candidates and verify final failure attribution and successful fallback through real HTTP requests and performance records. --- .../chat/gemini-generate-content/http_test.ts | 69 +++++++++++++++++++ .../chat/gemini-generate-content/attempt.ts | 9 +-- .../interceptors/types.ts | 11 ++- 3 files changed, 77 insertions(+), 12 deletions(-) diff --git a/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/http_test.ts b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/http_test.ts index dd177a5582..91d95146d9 100644 --- a/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/http_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/http_test.ts @@ -225,6 +225,75 @@ test('POST /v1beta/models/:model:countTokens accepts the generateContentRequest assertEquals(await response.json(), { totalTokens: 7 }); }); +test.each([ + { upstreams: ['up_count_only'] }, + { upstreams: ['up_count_first', 'up_count_last'] }, +])('countTokens records the final translation input failure once for $upstreams', async ({ upstreams }) => { + const repo = installRepo(); + const recordFailure = vi.spyOn(repo.performance, 'recordZeroOutputError'); + const callAnthropicMessagesCountTokens = vi.fn(async (): Promise => { + throw new Error('Unsupported input must fail before the upstream call'); + }); + const candidates = upstreams.map(upstream => makeCandidate({ upstream, targetApi: 'anthropicMessages', callAnthropicMessagesCountTokens })); + queueCandidates(candidates); + + const response = await makeApp().request('/v1beta/models/test-model:countTokens', { + method: 'POST', + headers: new Headers({ 'content-type': 'application/json' }), + body: JSON.stringify({ contents: [{ role: 'user', parts: [{ inlineData: { mimeType: 'audio/wav', data: 'AQID' } }] }] }), + }); + + assertEquals(response.status, 400); + const body = await response.json() as { error: { status: string; message: string } }; + assertEquals(body.error.status, 'INVALID_ARGUMENT'); + assert(body.error.message.includes('audio/wav')); + assertEquals(callAnthropicMessagesCountTokens.mock.calls.length, 0); + assertEquals(recordFailure.mock.calls.length, 1); + assertEquals(recordFailure.mock.calls[0][0].keyId, API_KEY_ID); + assertEquals(recordFailure.mock.calls[0][0].upstream, upstreams.at(-1)); + assertEquals(recordFailure.mock.calls[0][0].model, candidates.at(-1)!.model.id); + assertEquals(recordFailure.mock.calls[0][0].operation, 'chat'); + const rows = await repo.performance.listAll(); + assertEquals(rows.map(row => ({ requests: row.requests, errors: row.errorsNoOutput, neutral: row.neutral })), [ + { requests: 1, errors: 1, neutral: 0 }, + ]); +}); + +test('generateContent retries a translation input failure and records the successful candidate', async () => { + const repo = installRepo(); + const recordFailure = vi.spyOn(repo.performance, 'recordZeroOutputError'); + const recordSuccess = vi.spyOn(repo.performance, 'recordNeutral'); + const callAnthropicMessages = vi.fn(async (): Promise> => { + throw new Error('Unsupported input must fail before the upstream call'); + }); + const callOpenAIChatCompletions = vi.fn(async (): Promise> => ({ + ok: true, events: makeProtocolFrames(makeOpenAIChatCompletionsEvents()), modelKey: 'k', headers: new Headers(), + })); + queueCandidates([ + makeCandidate({ upstream: 'up_messages', targetApi: 'anthropicMessages', callAnthropicMessages }), + makeCandidate({ upstream: 'up_chat', targetApi: 'openaiChatCompletions', callOpenAIChatCompletions }), + ]); + + const response = await makeApp().request('/v1beta/models/test-model:generateContent', { + method: 'POST', + headers: new Headers({ 'content-type': 'application/json' }), + body: JSON.stringify({ contents: [{ role: 'user', parts: [{ inlineData: { mimeType: 'audio/wav', data: 'AQID' } }] }] }), + }); + + assertEquals(response.status, 200); + const body = await response.json() as { candidates: unknown[] }; + assertEquals(body.candidates.length, 1); + assertEquals(callAnthropicMessages.mock.calls.length, 0); + assertEquals(callOpenAIChatCompletions.mock.calls.length, 1); + assertEquals(recordFailure.mock.calls.length, 0); + assertEquals(recordSuccess.mock.calls.length, 1); + assertEquals(recordSuccess.mock.calls[0][0].upstream, 'up_chat'); + const rows = await repo.performance.listAll(); + assertEquals(rows.map(row => ({ requests: row.requests, errors: row.errorsNoOutput, neutral: row.neutral })), [ + { requests: 1, errors: 0, neutral: 1 }, + ]); +}); + test('POST /v1beta/models/:model:generateContent translates through Anthropic Messages target end to end', async () => { installRepo(); const callAnthropicMessages = vi.fn(async (): Promise> => ({ diff --git a/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts b/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts index 3bf5954679..409e4b4d48 100644 --- a/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts +++ b/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts @@ -12,7 +12,7 @@ import { chatTargetPicker } from '../shared/target-picker.ts'; import { runInterceptors } from '@floway-dev/interceptor'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; -import { type ModelCandidate, plainResult, toInternalDebugError, type ExecuteResult, type GeminiGenerateContentInvocation, type PlainResult } from '@floway-dev/provider'; +import { type ModelCandidate, plainResult, toInternalDebugError, type ApiErrorResult, type ExecuteResult, type GeminiGenerateContentInvocation, type PlainResult } from '@floway-dev/provider'; import { routeGeminiGenerateContentViaAnthropicMessages, routeGeminiGenerateContentViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIResponses, type TranslateInputError } from '@floway-dev/translate'; // Gemini generateContent has no native upstream target in the provider API; prefer OpenAI Chat Completions @@ -48,7 +48,7 @@ export const geminiGenerateContentAttempt = { }); }, - countTokens: async (args: GeminiGenerateContentAttemptCountTokensArgs): Promise => { + countTokens: async (args: GeminiGenerateContentAttemptCountTokensArgs): Promise => { const { payload: sourcePayload, ctx, candidate, headers: sourceHeaders } = args; const payload = structuredClone(sourcePayload); const headers = new Headers(sourceHeaders); @@ -73,10 +73,7 @@ export const geminiGenerateContentAttempt = { stripUnsupportedToolsFromPayload(cleaned); delete cleaned.safetySettings; const trip = await routeGeminiGenerateContentViaAnthropicMessages(cleaned, transCtx); - if (trip.type === 'input-error') { - const failure = inputErrorResult(trip, ctx.attempt.telemetry); - return plainResult(failure.status, failure.headers, failure.body); - } + if (trip.type === 'input-error') return inputErrorResult(trip, ctx.attempt.telemetry); const { stream: _stream, ...target } = trip.target; const anthropicMessagesResult = await anthropicMessagesAttempt.countTokens({ payload: target, ctx, candidate, headers: invocation.headers, anthropicBeta: [], diff --git a/packages/gateway/src/data-plane/chat/gemini-generate-content/interceptors/types.ts b/packages/gateway/src/data-plane/chat/gemini-generate-content/interceptors/types.ts index e81d2e52f0..b6e0220eed 100644 --- a/packages/gateway/src/data-plane/chat/gemini-generate-content/interceptors/types.ts +++ b/packages/gateway/src/data-plane/chat/gemini-generate-content/interceptors/types.ts @@ -2,7 +2,7 @@ import type { GatewayCtx } from '../../../shared/gateway-ctx.ts'; import type { Interceptor } from '@floway-dev/interceptor'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; -import type { ExecuteResult, GeminiGenerateContentInvocation, PlainResult } from '@floway-dev/provider'; +import type { ApiErrorResult, ExecuteResult, GeminiGenerateContentInvocation, PlainResult } from '@floway-dev/provider'; export type GeminiGenerateContentInterceptor = Interceptor< GeminiGenerateContentInvocation, @@ -10,14 +10,13 @@ export type GeminiGenerateContentInterceptor = Interceptor< ExecuteResult> >; -// countTokens is a one-shot, non-streaming HTTP exchange — the terminal -// returns a `PlainResult` carrying the reshaped Gemini generateContent envelope, not an event -// stream. The interceptor chain still runs against a `GeminiGenerateContentInvocation` so -// payload-shaped reads stay symmetric with the generate path. Interceptors +// countTokens returns a reshaped HTTP body or an API error. The interceptor +// chain still uses GeminiGenerateContentInvocation so payload-shaped reads +// stay symmetric with the generate path. Interceptors // registered here MUST be pure header/payload mutators; post-`run()` result // inspection is not portable to this result type. export type GeminiGenerateContentCountTokensInterceptor = Interceptor< GeminiGenerateContentInvocation, GatewayCtx, - PlainResult + ApiErrorResult | PlainResult >; From 508c2e1e379f9a9d55099b5338cf3b20f2b73e36 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 17:21:59 +0800 Subject: [PATCH 12/15] fix(translate): preserve colliding error metadata Preserve every distinct same-named upstream error value beneath the first available numeric suffix while reserving original keys. Keep container and inner-error precedence, and coalesce equal JSON structures without rewriting raw number tokens. Merge HTTP and SSE namespaces before projection, keeping loose fields and canonical values from being duplicated during source rendering. Verified 91 scoped tests, the translate TypeScript project, and ESLint for all changed files. --- .../as-source/error-body_test.ts | 4 +- .../protocols/cross-protocol-errors_test.ts | 52 ++++++++++-- .../via-openai-responses/events_test.ts | 8 +- .../__tests__/shared/api-error_test.ts | 43 ++++++++++ packages/translate/src/shared/api-error.ts | 83 ++++++++++++++----- 5 files changed, 158 insertions(+), 32 deletions(-) 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 index b6216750a9..47379896a5 100644 --- 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 @@ -80,13 +80,13 @@ test('carries the fields the target protocol states that the IR does not name', }); }); -test('merges loose error fields with the upstream container and keeps its collision priority', () => { +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', retry_after: 10, code: 'invalid_value', param: 'temperature', + stack: 'theirs', stack_1: 'legacy', retry_after: 10, code: 'invalid_value', param: 'temperature', }); }); diff --git a/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts b/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts index 0a2cea6c25..bcb8138826 100644 --- a/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts +++ b/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts @@ -43,17 +43,17 @@ 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 }, + 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 }, + 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 }, + 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 = { @@ -62,7 +62,7 @@ const expectedGemini = { 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, + msg: failure.msg, request_id: 'req_1', retry_after: 9, retry_after_1: 7, }, }], }, @@ -152,7 +152,7 @@ test('Responses nested error envelopes preserve request_id in both phases', asyn expect(await collect(trip.events(iterate([eventFrame(envelope)])))).toEqual([eventFrame(expected)]); }); -test('Responses nested error envelopes preserve outer metadata in both phases', async () => { +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, @@ -160,12 +160,16 @@ test('Responses nested error envelopes preserve outer metadata in both phases', }; const envelope = { type: 'error' as const, sequence_number: 17, error, - request_id: 'outer', trace: { region: 'us' }, provider_specific_fields: { outer_detail: true }, + 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 }, + 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))); @@ -173,6 +177,40 @@ test('Responses nested error envelopes preserve outer metadata in both phases', 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(irFromOpenAIChatCompletions(iterate([eventFrame(chatEvent(failure))]), { roundTrip: createRoundTripReaderCapture<'openaiChatCompletions'>().roundTrip, 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 6f2dfbac5c..dc53f30682 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 @@ -408,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', @@ -433,9 +433,11 @@ test.each(['flat error', 'nested error', 'failed response'])('translateToSourceE message: error.message, ...(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__/shared/api-error_test.ts b/packages/translate/__tests__/shared/api-error_test.ts index e3e90446b5..53ad7b7095 100644 --- a/packages/translate/__tests__/shared/api-error_test.ts +++ b/packages/translate/__tests__/shared/api-error_test.ts @@ -87,3 +87,46 @@ test('envelope transport fields stay outside the error metadata', () => { 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/shared/api-error.ts b/packages/translate/src/shared/api-error.ts index 561d55b4e6..1f7308a028 100644 --- a/packages/translate/src/shared/api-error.ts +++ b/packages/translate/src/shared/api-error.ts @@ -1,4 +1,5 @@ import type { IRErrorPayload } from '../ir/types.ts'; +import { rawJSON } from './json/raw-json.ts'; import { parseJSONWithRawNumbers } from '@floway-dev/protocols/common'; const recordOf = (value: unknown): Readonly> | undefined => @@ -28,12 +29,14 @@ export const irErrorPayloadFromBody = (body: Uint8Array): IRErrorPayload | undef const envelopeRecord = recordOf(parsed); if (envelopeRecord === undefined) return undefined; const { source, details } = errorEnvelopeOf(envelopeRecord.type === 'error' ? errorEventPayloadOf(envelopeRecord) : envelopeRecord); - const payload = errorPayloadOf(source); - return payload === undefined ? undefined : withEnvelopeDetails(payload, details); + return errorPayloadOf(source, details); }; /** Restatement requires a message; preserve every unrepresented field in diagnostic details. */ -export const errorPayloadOf = (error: Readonly>): IRErrorPayload | undefined => { +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); @@ -45,7 +48,7 @@ export const errorPayloadOf = (error: Readonly>): IRErro ...(code === undefined ? [] : [error.code === null || typeof error.code === 'string' ? 'code' : 'status']), ...(param === undefined ? [] : ['param']), ]); - const extra = extensionOf(error, represented); + const extra = extensionOf(error, represented, envelopeDetails); return { message, ...(type === undefined ? {} : { type }), @@ -60,16 +63,19 @@ const errorEnvelopeOf = (envelope: Readonly>) => { const transportFields = envelope.type === 'error' ? ['type', 'sequence_number'] : []; if (nested !== undefined) return { source: nested, - details: extensionOf(envelope, new Set(['error', ...transportFields])), + details: extensionNamespacesOf(envelope, new Set(['error', ...transportFields])), }; - return { source: envelope, details: undefined }; + return { source: envelope, details: [] }; }; -// Error-level extension fields take precedence over envelope metadata in their shared container. -const withEnvelopeDetails = (payload: IRErrorPayload, details: Readonly> | undefined): IRErrorPayload => - details === undefined ? payload : { - ...payload, - provider_specific_fields: { ...details, ...errorDetailsOf(payload, ['message', 'type', 'code', 'param']) }, +// 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>) => { @@ -91,23 +97,60 @@ export const errorPayloadFromWire = (wire: unknown): IRErrorPayload => { 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); - return withEnvelopeDetails({ message: '', ...(extra === undefined ? {} : { provider_specific_fields: extra }) }, details); + 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']); -// The upstream container wins collisions; loose fields share that same container. -const extensionOf = (error: Readonly>, representedFields: ReadonlySet): Readonly> | undefined => { +const extensionNamespacesOf = (error: Readonly>, representedFields: ReadonlySet) => { const own = recordOf(error.provider_specific_fields); - const rest: Record = {}; - for (const [key, value] of Object.entries(error)) { - if (representedFields.has(key) || key === 'provider_specific_fields' || own !== undefined && Object.hasOwn(own, key)) continue; - rest[key] = value; + 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 Object.keys(rest).length === 0 ? own : { ...rest, ...own }; + return merged; }; /** Each source writer lists fields represented in its envelope; all remaining values stay in diagnostic extensions. */ From 8b4714aa60b2f6635f34f6fdfc137516105d517c Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 18:30:27 +0800 Subject: [PATCH 13/15] fix(translate): use the protocol status for upstream 502 errors Remove the translated-error exception that turned HTTP 502 into UNKNOWN. Reuse the protocol mapping to UNAVAILABLE while preserving HTTP 502, the unknown-status fallback and context-overflow classification. --- .../as-source/error-body_test.ts | 10 +++++----- .../gemini-generate-content/as-source/error.ts | 5 +---- 2 files changed, 6 insertions(+), 9 deletions(-) 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 index 35e416fad2..8b2ecb5dc8 100644 --- 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 @@ -15,12 +15,12 @@ const upstreamOf = (status: number, body: unknown, headers: Record { - const result = restateGeminiGenerateContentError(...upstreamOf(503, { error: { message: 'upstream overloaded' } })); - assertEquals(result!.status, 503); - assertEquals(decoded(result).error.code, 503); +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 overloaded'); + assertEquals(decoded(result).error.message, 'upstream unavailable'); }); test('keeps a status the inferred table would have flattened', () => { 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 index cf8ac50146..03ab047555 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts @@ -3,9 +3,6 @@ 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'; -// This translated-error boundary retains UNKNOWN for an upstream 502. -const statusNameOf = (status: number): string => status === 502 ? 'UNKNOWN' : geminiGenerateContentStatusForHttpStatus(status) ?? 'UNKNOWN'; - /** * HTTP failures supply an upstream status; streaming failures derive a code from the error kind. * Context overflow selects our local 400 code. @@ -20,5 +17,5 @@ export const geminiGenerateContentErrorFromIR = (error: IRErrorPayload, httpStat : 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: statusNameOf(code), message: error.message, ...extension }; + return { code, status: geminiGenerateContentStatusForHttpStatus(code) ?? 'UNKNOWN', message: error.message, ...extension }; }; From 368c4170845f934240c808171a70b675f39ccfda Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 19:15:26 +0800 Subject: [PATCH 14/15] fix(gateway): preserve Responses continuation errors Carry standard Responses error bodies through the hosted-tool event lifecycle, drain the following response.failed resource, and reconstruct non-streaming errors without retaining an HTTP-result side channel. Preserve accumulated usage telemetry while allowing stateful, affinity, HTTP, and WebSocket consumers to observe the complete failure lifecycle. --- .../openai-responses/affinity/egress_test.ts | 25 ++ .../interceptors/server-tool-shim_test.ts | 371 +++++------------- .../openai-responses/items/output_test.ts | 31 ++ .../chat/openai-responses/respond_test.ts | 125 +++++- .../chat/openai-responses/websocket_test.ts | 66 +++- .../chat/openai-responses/affinity/egress.ts | 2 - .../interceptors/server-tool-shim.ts | 108 +++-- .../chat/openai-responses/items/output.ts | 7 +- .../chat/openai-responses/respond.ts | 23 +- .../chat/openai-responses/websocket.ts | 7 +- .../openai-responses/reassemble_test.ts | 44 ++- .../__tests__/openai-responses/stream_test.ts | 22 +- .../openai-responses/to-result_test.ts | 39 +- .../protocols/src/openai-responses/index.ts | 2 +- .../src/openai-responses/reassemble.ts | 29 +- .../protocols/src/openai-responses/stream.ts | 14 +- .../src/openai-responses/terminal-event.ts | 10 +- .../src/openai-responses/to-result.ts | 6 +- 18 files changed, 537 insertions(+), 394 deletions(-) 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..091535cdc8 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,123 @@ 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([ + { 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..d09d4a133d 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/respond.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/respond.ts @@ -12,7 +12,7 @@ import { forwardUpstreamHeaders, mergeForwardedUpstreamHeaders } from '../../sha import { SourceStreamState, eventResultMetadata, plainResultToResponse } from '../shared/respond.ts'; import { doneFrame, eventFrame, 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: 502 }); + } + recordFailedRequest(ctx, result.performance); return internalOpenAIResponsesErrorResponse(502, toInternalDebugError(error)); } } @@ -111,17 +119,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 16923ec637..07209022eb 100644 --- a/packages/protocols/src/openai-responses/index.ts +++ b/packages/protocols/src/openai-responses/index.ts @@ -1420,7 +1420,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()); From cd914637ad68f540122e3762638177f255fc3e5b Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 19:32:21 +0800 Subject: [PATCH 15/15] fix(gateway): classify collected Responses errors Choose the non-streaming HTTP status from the standard Responses error code or type after the internal error-to-failed lifecycle completes. Preserve 502 only for unknown classifications and keep the reconstructed JSON body unchanged. --- .../chat/openai-responses/respond_test.ts | 20 +++++++++++ .../chat/openai-responses/respond.ts | 34 +++++++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) 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 091535cdc8..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 @@ -146,6 +146,26 @@ test.each([ } }); +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' }, 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 d09d4a133d..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,7 +10,7 @@ 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 { 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'; @@ -75,7 +75,7 @@ export const respondOpenAIResponses = async ( 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: 502 }); + return Response.json(body, { status: openAIResponsesErrorHttpStatus(error.cause) }); } recordFailedRequest(ctx, result.performance); return internalOpenAIResponsesErrorResponse(502, toInternalDebugError(error)); @@ -108,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: {