diff --git a/apps/web/src/components/requests/stream-render.ts b/apps/web/src/components/requests/stream-render.ts index e6269c48b7..72d7e5ac36 100644 --- a/apps/web/src/components/requests/stream-render.ts +++ b/apps/web/src/components/requests/stream-render.ts @@ -46,11 +46,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/anthropic-messages/errors_test.ts b/packages/gateway/__tests__/data-plane/chat/anthropic-messages/errors_test.ts index bd739593e1..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,24 +1,20 @@ import { test } from 'vitest'; -import { translatorInputErrorResult } 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 => - 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."), +import { inputErrorResult } from '../../../../src/data-plane/chat/anthropic-messages/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 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'); @@ -29,9 +25,30 @@ 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')); - const raw = new TextDecoder().decode(apiErrorOf(result).body); +test('inputErrorResult preserves Anthropic key order: type, error, request_id', () => { + const result = inputErrorResult({ message: 'whatever' }); + 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..f05691c0cf --- /dev/null +++ b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/errors_test.ts @@ -0,0 +1,45 @@ +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 }); + // We preserve the translator's field and code in a Struct Any inside details. + 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/__tests__/data-plane/chat/gemini-generate-content/http_test.ts b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/http_test.ts index 940646ea7f..80873c9e83 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 @@ -246,6 +246,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/__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..48e81adb8a 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,36 @@ 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 }); + 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 +77,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 +90,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 +99,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 +116,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 +139,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 +154,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 +170,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 +185,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 +203,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 +229,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 +243,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 +260,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 +268,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 +276,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 +295,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 +303,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 +315,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..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,9 +1,8 @@ import { appendFailedUpstreams } from '../../shared/failed-upstreams.ts'; -import type { ChatServeFailure } 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 { TranslatorInputError } from '@floway-dev/translate'; +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 @@ -16,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 -// throwing candidate's telemetry attribution when the throw fired -// mid-attempt (see AttemptState.telemetry). -export const translatorInputErrorResult = ( - error: TranslatorInputError, +// 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/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..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 @@ -1,19 +1,19 @@ -import { 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'; 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 { 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 // Completions, then Anthropic Messages, then OpenAI Responses for generate. countTokens has @@ -43,50 +43,12 @@ 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; }); }, - 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); @@ -110,7 +72,8 @@ 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') 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: [], @@ -156,7 +119,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, ); @@ -165,3 +128,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..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,29 +1,21 @@ 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 { inputErrorFields, type ChatServeFailure, type InputErrorDetails } from '../shared/errors.ts'; +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'; } @@ -38,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 } }, @@ -47,35 +39,42 @@ export const geminiGenerateContentInternalErrorPayload = (status: number, error: }; }; -const geminiGenerateContentRpcErrorResult = (status: number, message: string, performance?: PerformanceTelemetryContext): ExecuteResult> => ({ +// 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: gatewayErrorStatus(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 -// throwing candidate's telemetry attribution when the throw fired -// mid-attempt (see AttemptState.telemetry). -export const translatorInputErrorResult = ( - error: TranslatorInputError, +// Validation failures retain the telemetry of the candidate whose attempt rejected the request. +export const inputErrorResult = ( + error: InputErrorDetails, performance?: PerformanceTelemetryContext, -): ExecuteResult> => - geminiGenerateContentRpcErrorResult(400, error.message, performance); +): ApiErrorResult => + geminiGenerateContentRpcErrorResult(400, error.message, performance, inputErrorFields(error)); // `endpoint` selects between `:generateContent` and `:countTokens` only in // the `model-unsupported` message string. 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 b656a0946f..2890558ddf 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/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 >; 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-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..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 @@ -1,19 +1,12 @@ 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, +// Validation failures retain the telemetry of the candidate whose attempt rejected the request. +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..1b36bc596c 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 @@ -41,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; @@ -95,8 +70,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 +108,7 @@ export const openaiResponsesAttempt = { const dispatchOpenAIResponses = async ( invocation: OpenAIResponsesInvocation, ctx: ChatGatewayCtx, -): Promise>> => { +): Promise> | TranslateInputError> => { const { candidate, targetApi } = invocation; switch (targetApi) { case 'openaiResponses': { @@ -168,9 +145,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 +162,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..67e4ae565a 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 { canonicalizeOpenAIResponsesPayload } 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'; // OpenAI's verbatim previous_response_not_found envelope. Codex compares this // body byte-for-byte against upstream — see the cross-references on @@ -51,24 +50,16 @@ 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 -// 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); 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 +68,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.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)); const result = await openaiResponsesServe.generate({ payload, ctx, headers: inboundHeaders(c) }); @@ -92,6 +87,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.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) }); 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..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,12 +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 } 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'; interface WorkerWebSocket extends WebSocket { accept(): void; @@ -365,7 +362,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.error.code ?? 'invalid_request_error', message: payload.error.message, param: payload.error.param }); + return; + } previousResponseId = payload.previous_response_id ?? undefined; ctx = createChatGatewayCtxFromHono(c, { wantsStream: true, @@ -404,15 +405,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 +443,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..b6923e8997 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; @@ -56,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; +}; diff --git a/packages/gateway/src/data-plane/chat/shared/execute-translation.ts b/packages/gateway/src/data-plane/chat/shared/execute-translation.ts new file mode 100644 index 0000000000..50ea24587a --- /dev/null +++ b/packages/gateway/src/data-plane/chat/shared/execute-translation.ts @@ -0,0 +1,76 @@ +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 { TranslateTrip, TranslateInputError } from '@floway-dev/translate'; + +// 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; +} + +// Stamp the target API before reading frames so an empty upstream stream retains its protocol identity. +export const captureFromDump = ( + dump: DumpAccumulator | null, + targetApi: ChatTargetApi, +): TranslationUpstreamCapture | undefined => { + if (dump === null) return undefined; + dump.setUpstreamTargetApi(targetApi); + return { + upstreamFrame: frame => dump.upstreamFrame(frame), + upstreamApiError: error => dump.upstreamApiError(error), + }; +}; + +// 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>, + innerAttempt: (translated: TP) => Promise>>, + capture?: TranslationUpstreamCapture, +): 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 + ? inner.events + : teeUpstreamFrames(inner.events, capture.upstreamFrame); + return { ...inner, events: trip.events(events) }; + } + 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); + // Error renderers return JSON envelopes; this transport boundary encodes the response bytes. + if (rewritten !== undefined) { + return { + ...inner, + status: rewritten.status, + headers: rewritten.headers, + body: new TextEncoder().encode(JSON.stringify(rewritten.body)), + }; + } + } + return inner; +}; + +// Tee that pushes each original target-protocol frame to the dump BEFORE +// handing it to `trip.events`. Lockstep with the downstream respond layer's +// iteration: as the respond layer pulls `result.events`, the tee pulls +// `inner.events`, captures, and yields; by the time `finalize`→`write` runs, +// the upstream frame log is complete. No buffering — one array push per frame. +const teeUpstreamFrames = async function* ( + events: AsyncIterable>, + capture: (frame: ProtocolFrame) => void, +): AsyncGenerator> { + for await (const frame of events) { + capture(frame); + yield frame; + } +}; diff --git a/packages/gateway/src/data-plane/chat/shared/translate-traverse.ts b/packages/gateway/src/data-plane/chat/shared/translate-traverse.ts deleted file mode 100644 index b60fac01a2..0000000000 --- a/packages/gateway/src/data-plane/chat/shared/translate-traverse.ts +++ /dev/null @@ -1,92 +0,0 @@ -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'; - -// Protocol-agnostic capture hook for the pre-translation (target-protocol) -// upstream view. `traverseTranslation` 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. -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 -// `traverseTranslation` 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 = ( - dump: DumpAccumulator | null, - targetApi: ChatTargetApi, -): 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 }; -}; - -// 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 -// 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. -// -// 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 ( - payload: SP, - translate: (p: SP) => Promise>, - innerAttempt: (translated: TP) => Promise>>, - capture?: TranslationUpstreamCapture, -): Promise>> => { - const trip = await translate(payload); - const inner = await innerAttempt(trip.target); - if (inner.type === 'events') { - const events = capture === undefined - ? inner.events - : 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. - 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 }; - } - } - return inner; -}; - -// Tee that pushes each original target-protocol frame to the dump BEFORE -// handing it to `trip.events`. Lockstep with the downstream respond layer's -// iteration: as the respond layer pulls `result.events`, the tee pulls -// `inner.events`, captures, and yields; by the time `finalize`→`write` runs, -// the upstream frame log is complete. No buffering — one array push per frame. -const teeUpstreamFrames = async function* ( - events: AsyncIterable>, - capture: (frame: ProtocolFrame) => void, -): AsyncGenerator> { - for await (const frame of events) { - capture(frame); - yield frame; - } -}; diff --git a/packages/gateway/src/data-plane/models/gemini.ts b/packages/gateway/src/data-plane/models/gemini.ts index 44ddf4bc35..5efcf85e31 100644 --- a/packages/gateway/src/data-plane/models/gemini.ts +++ b/packages/gateway/src/data-plane/models/gemini.ts @@ -5,7 +5,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'; @@ -49,7 +49,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/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/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 77% rename from packages/translate/__tests__/protocols/openai-responses/canonicalize-payload_test.ts rename to packages/protocols/__tests__/openai-responses/canonicalize-payload_test.ts index 5e093764bc..09f3af1134 100644 --- a/packages/translate/__tests__/protocols/openai-responses/canonicalize-payload_test.ts +++ b/packages/protocols/__tests__/openai-responses/canonicalize-payload_test.ts @@ -1,9 +1,7 @@ -import { test } from 'vitest'; +import { expect, 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 { canonicalizeOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; +import { assert, assertEquals } from '@floway-dev/test-utils'; test('canonicalizes string and implicit-message wire inputs', () => { assertEquals(canonicalizeOpenAIResponsesPayload({ model: 'gpt-test', input: 'hello' }), { @@ -62,13 +60,11 @@ 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), { + error: { + message: "Missing required parameter: 'model'.", param: 'model', code: 'missing_required_parameter', + }, + }); } }); @@ -84,15 +80,11 @@ 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] }), { + error: { + message: 'Untyped OpenAI Responses input items require a valid role and content.', param: 'input[0]', + }, + }); } }); @@ -102,6 +94,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 +104,12 @@ test('canonicalizeOpenAIResponsesPayload preserves reasoning.context verbatim, i input: 'hi', reasoning: { context: 'future_mode' }, }); + 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/translate/src/protocols/openai-responses/canonicalize-payload.ts b/packages/protocols/src/openai-responses/canonicalize-payload.ts similarity index 50% rename from packages/translate/src/protocols/openai-responses/canonicalize-payload.ts rename to packages/protocols/src/openai-responses/canonicalize-payload.ts index 4831160ec3..aa2179b91f 100644 --- a/packages/translate/src/protocols/openai-responses/canonicalize-payload.ts +++ b/packages/protocols/src/openai-responses/canonicalize-payload.ts @@ -1,16 +1,9 @@ -import { TranslatorInputError } from '../../translator-input-error.ts'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesEasyInputMessageEx, CanonicalOpenAIResponsesInputItem, OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; +import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesEasyInputMessageEx, CanonicalOpenAIResponsesInputItem, OpenAIResponsesRequestPayloadEx } from './index.ts'; -// 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 function canonicalizeOpenAIResponsesPayload(value: unknown): CanonicalOpenAIResponsesPayload { +// 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; @@ -44,31 +37,31 @@ export function canonicalizeOpenAIResponsesPayload(value: unknown): CanonicalOpe }; if (typeof value !== 'object' || value === null) { - throw new TranslatorInputError('OpenAI Responses payload must be an object.'); + return { error: { message: '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 TranslatorInputError("Missing required parameter: 'model'.", { param: 'model', code: 'missing_required_parameter' }); + 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)) { - throw new TranslatorInputError('OpenAI Responses input must be a string or an array.', { param: 'input' }); + return { error: { message: '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 TranslatorInputError('Untyped OpenAI Responses input items require a valid role and content.', { param: `input[${index}]` }); - } - return item as CanonicalOpenAIResponsesInputItem; - }), - }; -} + 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 ed6828cf73..6736d19f7c 100644 --- a/packages/protocols/src/openai-responses/index.ts +++ b/packages/protocols/src/openai-responses/index.ts @@ -1448,3 +1448,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 78a20dd6ce..af0088cf7e 100644 --- a/packages/translate/AGENTS.md +++ b/packages/translate/AGENTS.md @@ -2,8 +2,11 @@ `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. + +Protocol-native request normalization and Part data classification belong to +`@floway-dev/protocols`; translation maps their failures at its own boundary. ## Categories @@ -27,7 +30,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__/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 7e786f5648..9a1f76834b 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 { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { iterate } from '../shared/iterables.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/as-source/continuous-usage_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/continuous-usage_test.ts index 20a2995bf6..bbdfc8de89 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/continuous-usage_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/continuous-usage_test.ts @@ -1,10 +1,11 @@ import { expect, test } from 'vitest'; -import { routeOpenAIChatCompletions } from '../../../../src/protocols/openai-chat-completions/index.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; import { collect, iterate } from '../../../shared/iterables.ts'; import { fixtureFrames } from '../../fixtures.ts'; +import { requireTrip } from '../../require-trip.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/openai-chat-completions/via-openai-responses/events_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts index a7d7f70afe..667d1a7eeb 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..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 @@ -4,11 +4,13 @@ import { flattenNamespaceTools, restoreNamespaceEvents, restoreNamespaceOutputIt 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 { canonicalizeOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; 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', @@ -47,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', @@ -105,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(canonicalizeOpenAIResponsesPayload(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' }, @@ -127,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(canonicalizeOpenAIResponsesPayload(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'); @@ -172,7 +180,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 +193,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/public-boundary_test.ts b/packages/translate/__tests__/protocols/public-boundary_test.ts new file mode 100644 index 0000000000..7c2cf32399 --- /dev/null +++ b/packages/translate/__tests__/protocols/public-boundary_test.ts @@ -0,0 +1,58 @@ +import { expect, test } from 'vitest'; + +import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { + 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(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 deacc16be5..f0e32770bd 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/common/json.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 9c462a803d..0fff1fbd05 100644 --- a/packages/translate/__tests__/protocols/routes_test.ts +++ b/packages/translate/__tests__/protocols/routes_test.ts @@ -1,6 +1,7 @@ import { test } from 'vitest'; import { fixtureFrames, type nativeResult } from './fixtures.ts'; +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..805c45f2a9 100644 --- a/packages/translate/src/index.ts +++ b/packages/translate/src/index.ts @@ -1,3 +1,13 @@ -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 { + 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/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/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..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 @@ -1,11 +1,11 @@ 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'; -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 => { @@ -46,7 +47,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 +79,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 +121,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 +224,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..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 @@ -1,14 +1,15 @@ 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 { 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'; @@ -33,7 +34,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 +98,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 +151,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 +249,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..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 { 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 { 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'; @@ -35,7 +36,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 +71,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 +120,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 +202,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 59890ae6b3..b4ce07eb24 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 { OpenAIChatCompletionsUserContentPart } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesInputContent } from '@floway-dev/protocols/openai-responses'; @@ -24,7 +24,7 @@ export const openaiChatCompletionsContentToOpenAIResponsesInputContent = (conten case 'text': return { type: 'input_text', text: part.text }; 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 e345fbf574..9bc4f1a914 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/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 dc14613a82..8c6519c06c 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,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 { canonicalizeOpenAIResponsesPayload } 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, @@ -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,15 +127,15 @@ 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 { blocks.push({ type: 'text', text: part.text }); } @@ -153,10 +153,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 }); @@ -183,12 +183,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 }); } @@ -250,7 +250,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; }; @@ -269,7 +269,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.`); } } @@ -278,7 +278,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 ( @@ -287,6 +287,7 @@ export const buildTargetRequest = async ( options: BuildTargetRequestOptions, ): Promise => { 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(); @@ -307,7 +308,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': @@ -328,9 +329,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); @@ -351,7 +352,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': @@ -365,9 +366,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 53433ed362..bb6b09a280 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 { OpenAIChatCompletionsUserContentPart } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesInputContent, OpenAIResponsesAssistantInputContent } from '@floway-dev/protocols/openai-responses'; @@ -17,7 +17,7 @@ export const openaiResponsesContentToText = (content: string | OpenAIResponsesAs export const openaiResponsesContentToOpenAIChatCompletionsContent = (content: string | OpenAIResponsesInputContent[]): string | OpenAIChatCompletionsUserContentPart[] => { 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') @@ -25,7 +25,7 @@ export const openaiResponsesContentToOpenAIChatCompletionsContent = (content: st (part): OpenAIChatCompletionsUserContentPart => { 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..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 @@ -2,16 +2,16 @@ 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 { 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 { @@ -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 } }; }; @@ -180,6 +180,7 @@ export interface TargetRequestResult { export const buildTargetRequest = async (source: OpenAIResponsesRequestPayloadEx, codec: SidecarCodec): Promise => { 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(); @@ -200,10 +201,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 +232,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>;