From dfb82f8cff6ccc4c82fe033e0ac9abc90fd4a0b7 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 04:40:27 +0800 Subject: [PATCH 1/6] 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 2548bf0d149b03e87411ac0943df78ca98287bfe Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:03:34 +0800 Subject: [PATCH 2/6] 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 3/6] 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 4792e62f6e71233e7aa90f76feb109b5b801af01 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:17:00 +0800 Subject: [PATCH 4/6] 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 5/6] 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 1a8c049fb02d476aa808fce2d7f8fa1f539e0ed7 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 17:20:41 +0800 Subject: [PATCH 6/6] 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 >;