From b2c0966992dc74d593237e7b69bc136d607ee9e5 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 04:38:30 +0800 Subject: [PATCH 01/66] fix(translate): accept consecutively reported upstream choices Remove the request-count ceiling from output IR construction and Chat event routing while requiring sequential valid indexes. Verify that every upstream choice is retained and skipped indexes still fail. --- .../translate/__tests__/ir/edge-cases_test.ts | 23 ++++++++++++++++--- .../translate/__tests__/ir/lifecycle_test.ts | 2 +- .../translate/__tests__/ir/streams_test.ts | 5 ++-- .../as-source/sse-to_test.ts | 2 +- .../as-target/sse-from_test.ts | 2 +- packages/translate/src/ir/stream.ts | 5 ++-- .../via-openai-chat-completions/events.ts | 4 ++-- .../via-openai-chat-completions/translate.ts | 2 +- .../via-openai-chat-completions/events.ts | 4 ++-- .../via-openai-chat-completions/translate.ts | 2 +- .../as-target/sse-from.ts | 4 ++-- .../via-openai-chat-completions/events.ts | 4 ++-- .../via-openai-chat-completions/translate.ts | 2 +- 13 files changed, 39 insertions(+), 22 deletions(-) diff --git a/packages/translate/__tests__/ir/edge-cases_test.ts b/packages/translate/__tests__/ir/edge-cases_test.ts index 652013558f..379df16b2a 100644 --- a/packages/translate/__tests__/ir/edge-cases_test.ts +++ b/packages/translate/__tests__/ir/edge-cases_test.ts @@ -116,10 +116,26 @@ test('ChatCompletions ignores late identity changes and accepts later model upda expect((await collectIR(irFromOpenAIChatCompletions(iterate(input)))).model).toBe('fallback'); }); -test('ChatCompletions choice indexes must fit the request choice count', async () => { +test('ChatCompletions choice indexes must arrive in sequence', async () => { await expect(collectIR(irFromOpenAIChatCompletions(iterate([ eventFrame({ id: 'chat_choices', model: 'served', choices: [{ index: 1, delta: {}, finish_reason: 'stop' }] } as any), - ]), { maxChoices: 1 }))).rejects.toThrow('configured choice count'); + ])))).rejects.toThrow('out of sequence'); +}); + +test('ChatCompletions accepts every consecutively reported upstream choice', async () => { + const ir = await collectIR(irFromOpenAIChatCompletions(iterate([ + eventFrame({ + id: 'chat_choices', model: 'served', choices: [ + { index: 0, delta: { content: 'first' }, finish_reason: 'stop' }, + { index: 1, delta: { content: 'second' }, finish_reason: 'stop' }, + ], + } as any), + ]))); + expect(ir.choices).toHaveLength(2); + expect(ir.choices.map(choice => choice.items)).toEqual([ + [{ type: 'message', content: [{ type: 'text', text: 'first' }] }], + [{ type: 'message', content: [{ type: 'text', text: 'second' }] }], + ]); }); test('Responses rejects output events before the initial envelope', async () => { @@ -153,9 +169,10 @@ test('writers omit output they cannot represent', async () => { }); test('single-choice writers reject a second choice when it is initialized', async () => { - const b = createIRBuilder(2); + const b = createIRBuilder(); b.assign(['model'], 'model'); b.event({ type: 'start', id: 'source' }); + b.choice(0); b.choice(1); const frames = [...b.drain()]; await expect(collect(anthropicMessagesFromIR(iterate(frames)))).rejects.toThrow('cannot represent multiple choices'); diff --git a/packages/translate/__tests__/ir/lifecycle_test.ts b/packages/translate/__tests__/ir/lifecycle_test.ts index 18e5df52ea..e0d4e6dfcd 100644 --- a/packages/translate/__tests__/ir/lifecycle_test.ts +++ b/packages/translate/__tests__/ir/lifecycle_test.ts @@ -39,7 +39,7 @@ test('ChatCompletions locks the first reasoning field independently for each cho chunk([{ index: 0, delta: { reasoning: 'A' } }, { index: 1, delta: { reasoning_content: 'X' } }]), chunk([{ index: 0, delta: { reasoning_text: 'B', reasoning: 'C' } }, { index: 1, delta: { reasoning: 'Y', reasoning_content: 'Z' } }]), chunk([{ index: 0, delta: {}, finish_reason: 'stop' }, { index: 1, delta: {}, finish_reason: 'stop' }]), doneFrame(), - ]), { maxChoices: 2 })); + ]))); expect(ir.choices.map(choice => choice.items)).toEqual([ [{ type: 'reasoning', summary: ['AC'] }], [{ type: 'reasoning', summary: ['XZ'] }], diff --git a/packages/translate/__tests__/ir/streams_test.ts b/packages/translate/__tests__/ir/streams_test.ts index 7040a5fdb2..6cdc48b4cc 100644 --- a/packages/translate/__tests__/ir/streams_test.ts +++ b/packages/translate/__tests__/ir/streams_test.ts @@ -236,7 +236,7 @@ describe('IR streaming', () => { }); it('emits each candidate end before pulling the next choice or late final usage', async () => { - const b = createIRBuilder(2); + const b = createIRBuilder(); b.choice(0); b.choice(1); b.assign(['model'], 'm'); b.event({ type: 'start', id: 's' }); let nextChoicePulled = false; let usagePulled = false; @@ -298,7 +298,8 @@ describe('IR streaming', () => { }); it('does not apply one choice termination to another choice tool', async () => { - const b = createIRBuilder(2); + const b = createIRBuilder(); + b.choice(0); b.choice(1); b.assign(['model'], 'm'); b.event({ type: 'start', id: 's' }); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts index 7fe2a02421..516e100cb3 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts @@ -6,7 +6,7 @@ import { createReferenceBuilder, collectContentViews, createFieldState, createRe import { type RoundTripWriter } from '../../../../src/round-trip/stream.ts'; test('prepares each Chat choice independently while another choice has partial audio', async () => { - const source = createIRBuilder(2); + const source = createIRBuilder(); source.choice(0); source.choice(1); source.assign(['model'], 'model'); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts index 134b73403f..506e958178 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts @@ -20,7 +20,7 @@ const drain = async (frames: ReturnType[]): Promise { turns.set(choice, turn); }, }; - for await (const _record of irFromOpenAIChatCompletions((async function* () { yield* frames; yield doneFrame(); })(), { maxChoices: 2, roundTrip })) {} + for await (const _record of irFromOpenAIChatCompletions((async function* () { yield* frames; yield doneFrame(); })(), { roundTrip })) {} return turns; }; diff --git a/packages/translate/src/ir/stream.ts b/packages/translate/src/ir/stream.ts index fec713ba24..13fc9677b5 100644 --- a/packages/translate/src/ir/stream.ts +++ b/packages/translate/src/ir/stream.ts @@ -49,8 +49,7 @@ export interface IRChoiceHandle extends IRWriteHandle { textParts: () => readonly { item: IRItemHandle; part: IRPartHandle; text: string }[]; } -export const createIRBuilder = (maxChoices = 1) => { - if (!Number.isSafeInteger(maxChoices) || maxChoices <= 0) throw new RangeError(`IR maxChoices must be a positive safe integer: ${maxChoices}`); +export const createIRBuilder = () => { const collector = createIRCollector(); const { state } = collector; let records: IRRecord[] = []; @@ -105,7 +104,7 @@ export const createIRBuilder = (maxChoices = 1) => { for (const [key, value] of Object.entries(fields)) assign(['usage', key], value); }; const choice = (index: number): IRChoiceHandle => { - if (!Number.isSafeInteger(index) || index < 0 || index >= maxChoices) throw new RangeError(`IR choice index exceeds the configured choice count: ${index}`); + if (!Number.isSafeInteger(index) || index < 0 || index > choices.size) throw new RangeError(`output IR choice index is out of sequence: ${index}`); while (choices.size <= index) { const choiceIndex = choices.size; const path = irUnitPath('choice', choiceIndex); diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts index efbdc00b2d..941eb267f7 100644 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts +++ b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts @@ -6,7 +6,7 @@ import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthr import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; -export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec, maxChoices = 1): AsyncIterable> => { +export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { const stream = createRoundTripStream('anthropicMessages', 'openaiChatCompletions', codec); - return anthropicMessagesFromIR(irFromOpenAIChatCompletions(frames, { maxChoices, roundTrip: stream.reader }), { roundTrip: stream.writer }); + return anthropicMessagesFromIR(irFromOpenAIChatCompletions(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); }; 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..782ba50e47 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 @@ -11,7 +11,7 @@ export const translate: TranslateTrip< const target = await buildTargetRequest(src, ctx.sidecarCodec); return { target, - events: frames => translateToSourceEvents(frames, ctx.sidecarCodec, target.n ?? 1), + events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), apiError: rewriteContextExceededToPromptTooLong, }; }; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts index 98467f304d..52f6c44eb6 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts @@ -6,7 +6,7 @@ import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; -export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec, maxChoices = 1): AsyncIterable> => { +export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { const stream = createRoundTripStream('geminiGenerateContent', 'openaiChatCompletions', codec); - return geminiGenerateContentFromIR(irFromOpenAIChatCompletions(frames, { maxChoices, roundTrip: stream.reader }), { roundTrip: stream.writer }); + return geminiGenerateContentFromIR(irFromOpenAIChatCompletions(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); }; 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..6b165ddfcb 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 @@ -8,5 +8,5 @@ export const translate: TranslateTrip< 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) }; + return { target, events: frames => translateToSourceEvents(frames, ctx.sidecarCodec) }; }; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts index 26e65ac120..68e373a22c 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts @@ -123,9 +123,9 @@ interface ChatChoiceState { export const irFromOpenAIChatCompletions = async function* ( frames: AsyncIterable>, - options: { customToolNames?: ReadonlySet; maxChoices?: number; roundTrip: RoundTripReader<'openaiChatCompletions'> }, + options: { customToolNames?: ReadonlySet; roundTrip: RoundTripReader<'openaiChatCompletions'> }, ): AsyncGenerator { - const ir = createIRBuilder(options.maxChoices ?? 1); + const ir = createIRBuilder(); const choices = new Map(); const nativeAccumulators = new Map>(); const nativeAccumulator = (choice: number): ReturnType => { diff --git a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts index 28f41fb471..f626c6bd30 100644 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts +++ b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts @@ -7,7 +7,7 @@ import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; -export const translateToSourceEvents = (frames: AsyncIterable>, customToolNames: ReadonlySet, codec: SidecarCodec, namespaceToolNames: NamespaceToolNames, maxChoices = 1): AsyncIterable> => { +export const translateToSourceEvents = (frames: AsyncIterable>, customToolNames: ReadonlySet, codec: SidecarCodec, namespaceToolNames: NamespaceToolNames): AsyncIterable> => { const stream = createRoundTripStream('openaiResponses', 'openaiChatCompletions', codec); - return openaiResponsesFromIR(irFromOpenAIChatCompletions(frames, { customToolNames, maxChoices, roundTrip: stream.reader }), { namespaceToolNames, roundTrip: stream.writer }); + return openaiResponsesFromIR(irFromOpenAIChatCompletions(frames, { customToolNames, roundTrip: stream.reader }), { namespaceToolNames, roundTrip: stream.writer }); }; 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..4885136ebb 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 @@ -12,7 +12,7 @@ export const translate: TranslateTrip< return { target, events: frames => restoreNamespaceEvents( - translateToSourceEvents(frames, customToolNames, ctx.sidecarCodec, namespaceToolNames, target.n ?? 1), + translateToSourceEvents(frames, customToolNames, ctx.sidecarCodec, namespaceToolNames), namespaceToolNames, ), }; From dfb82f8cff6ccc4c82fe033e0ac9abc90fd4a0b7 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 04:40:27 +0800 Subject: [PATCH 02/66] refactor(translate): expose prepared translation trips Replace fluent protocol subpaths with named root translators returning plain trips or input failures. Keep request builders and event adapters intact while containing expected input exceptions at public operations. Consume translation failures at gateway attempt and payload boundaries, and encode rewritten JSON errors at the transport boundary. Preserve the current model context and remote-image loader requirements for the pairwise request backend. Validate named translation and gateway suites, owning TypeScript projects, and changed lint targets. --- .../chat/anthropic-messages/errors_test.ts | 17 +- .../openai-chat-completions/errors_test.ts | 19 ++- .../chat/openai-responses/errors_test.ts | 21 ++- .../interceptors/agent-message-shim_test.ts | 35 ++-- .../interceptors/server-tool-shim_test.ts | 11 +- .../items/agent-message-content_test.ts | 15 +- ...se_test.ts => execute-translation_test.ts} | 151 +++++++++++------- .../data-plane/chat/shared/require-trip.ts | 7 + .../chat/anthropic-messages/attempt.ts | 78 +++++---- .../chat/anthropic-messages/errors.ts | 11 +- .../chat/anthropic-messages/http.ts | 16 +- .../chat/gemini-generate-content/attempt.ts | 101 +++++++----- .../chat/gemini-generate-content/errors.ts | 22 ++- .../chat/gemini-generate-content/http.ts | 9 +- .../chat/openai-chat-completions/attempt.ts | 88 +++++----- .../chat/openai-chat-completions/errors.ts | 11 +- .../chat/openai-chat-completions/http.ts | 14 +- .../chat/openai-responses/attempt.ts | 21 +-- .../chat/openai-responses/errors.ts | 6 +- .../data-plane/chat/openai-responses/http.ts | 25 +-- .../interceptors/agent-message-shim.ts | 28 ++-- .../items/agent-message-content.ts | 8 +- .../chat/openai-responses/websocket.ts | 25 +-- .../src/data-plane/chat/shared/errors.ts | 6 + ...ate-traverse.ts => execute-translation.ts} | 60 +++---- packages/gateway/src/dump/accumulator.ts | 4 +- packages/gateway/src/dump/types.ts | 2 +- packages/translate/AGENTS.md | 6 +- .../__tests__/ir/continuous-usage_test.ts | 9 +- .../as-source/context-window-error_test.ts | 14 +- .../as-source/native-reassembly_test.ts | 7 +- .../protocols/chat-legacy-round-trip_test.ts | 27 ++-- .../chat-reasoning-round-trip_test.ts | 7 +- .../as-source/native-reassembly_test.ts | 7 +- .../round-trip-request_test.ts | 9 +- .../via-openai-responses/events_test.ts | 2 +- .../via-openai-responses/request_test.ts | 4 +- .../as-source/allowed-tools_test.ts | 8 +- .../as-source/namespace-tools_test.ts | 12 +- .../canonicalize-payload_test.ts | 30 ++-- .../protocols/public-boundary_test.ts | 60 +++++++ .../__tests__/protocols/require-trip.ts | 7 + .../protocols/round-trip-receive_test.ts | 44 +++-- .../__tests__/protocols/routes_test.ts | 69 ++++---- packages/translate/package.json | 9 +- packages/translate/src/index.ts | 15 +- .../as-source/context-window-error.ts | 12 +- .../as-target/tool-input-schema.ts | 4 +- .../src/protocols/anthropic-messages/index.ts | 1 - .../src/protocols/anthropic-messages/route.ts | 8 - .../via-openai-chat-completions/request.ts | 16 +- .../via-openai-chat-completions/translate.ts | 23 ++- .../via-openai-responses/request.ts | 16 +- .../via-openai-responses/translate.ts | 21 ++- .../as-source/schema.ts | 10 +- .../gemini-generate-content/index.ts | 1 - .../gemini-generate-content/route.ts | 10 -- .../via-anthropic-messages/request.ts | 10 +- .../via-anthropic-messages/translate.ts | 20 ++- .../via-openai-chat-completions/request.ts | 12 +- .../via-openai-chat-completions/translate.ts | 14 +- .../via-openai-responses/request.ts | 10 +- .../via-openai-responses/translate.ts | 20 ++- .../openai-chat-completions/index.ts | 1 - .../openai-chat-completions/route.ts | 8 - .../via-anthropic-messages/request.ts | 18 +-- .../via-anthropic-messages/translate.ts | 28 ++-- .../via-openai-responses/content.ts | 4 +- .../via-openai-responses/request.ts | 8 +- .../via-openai-responses/translate.ts | 22 ++- .../as-source/allowed-tools.ts | 14 +- .../as-source/namespace-tools.ts | 18 +-- .../as-source/programmatic-tooling.ts | 8 +- .../openai-responses/canonicalize-payload.ts | 24 ++- .../src/protocols/openai-responses/index.ts | 2 - .../src/protocols/openai-responses/route.ts | 8 - .../via-anthropic-messages/request.ts | 46 +++--- .../via-anthropic-messages/translate.ts | 36 +++-- .../via-openai-chat-completions/content.ts | 6 +- .../via-openai-chat-completions/request.ts | 22 +-- .../via-openai-chat-completions/translate.ts | 28 ++-- packages/translate/src/protocols/routes.ts | 9 ++ packages/translate/src/shared/input-error.ts | 14 ++ .../translate/src/translator-input-error.ts | 19 --- packages/translate/src/types.ts | 82 +++------- 85 files changed, 957 insertions(+), 833 deletions(-) rename packages/gateway/__tests__/data-plane/chat/shared/{translate-traverse_test.ts => execute-translation_test.ts} (59%) create mode 100644 packages/gateway/__tests__/data-plane/chat/shared/require-trip.ts rename packages/gateway/src/data-plane/chat/shared/{translate-traverse.ts => execute-translation.ts} (61%) create mode 100644 packages/translate/__tests__/protocols/public-boundary_test.ts create mode 100644 packages/translate/__tests__/protocols/require-trip.ts delete mode 100644 packages/translate/src/protocols/anthropic-messages/index.ts delete mode 100644 packages/translate/src/protocols/anthropic-messages/route.ts delete mode 100644 packages/translate/src/protocols/gemini-generate-content/index.ts delete mode 100644 packages/translate/src/protocols/gemini-generate-content/route.ts delete mode 100644 packages/translate/src/protocols/openai-chat-completions/index.ts delete mode 100644 packages/translate/src/protocols/openai-chat-completions/route.ts delete mode 100644 packages/translate/src/protocols/openai-responses/index.ts delete mode 100644 packages/translate/src/protocols/openai-responses/route.ts create mode 100644 packages/translate/src/protocols/routes.ts create mode 100644 packages/translate/src/shared/input-error.ts delete mode 100644 packages/translate/src/translator-input-error.ts diff --git a/packages/gateway/__tests__/data-plane/chat/anthropic-messages/errors_test.ts b/packages/gateway/__tests__/data-plane/chat/anthropic-messages/errors_test.ts index bd739593e1..67385d36cf 100644 --- a/packages/gateway/__tests__/data-plane/chat/anthropic-messages/errors_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/anthropic-messages/errors_test.ts @@ -1,17 +1,16 @@ import { test } from 'vitest'; -import { translatorInputErrorResult } from '../../../../src/data-plane/chat/anthropic-messages/errors.ts'; +import { inputErrorResult } from '../../../../src/data-plane/chat/anthropic-messages/errors.ts'; import type { ApiErrorResult } from '@floway-dev/provider'; import { assert, assertEquals } from '@floway-dev/test-utils'; -import { TranslatorInputError } from '@floway-dev/translate'; -const apiErrorOf = (result: ReturnType): ApiErrorResult => result as ApiErrorResult; -const bodyOf = (result: ReturnType): Record => +const apiErrorOf = (result: ReturnType): ApiErrorResult => result as ApiErrorResult; +const bodyOf = (result: ReturnType): Record => JSON.parse(new TextDecoder().decode(apiErrorOf(result).body)); -test('translatorInputErrorResult renders an Anthropic 400 invalid_request_error envelope with top-level request_id', () => { - const result = translatorInputErrorResult( - new TranslatorInputError("Invalid 'image_url' content part in system or developer message. Only 'text' content parts are supported in system messages on this model."), +test('inputErrorResult renders an Anthropic 400 invalid_request_error envelope with top-level request_id', () => { + const result = inputErrorResult( + { message: "Invalid 'image_url' content part in system or developer message. Only 'text' content parts are supported in system messages on this model." }, ); const apiError = apiErrorOf(result); @@ -29,8 +28,8 @@ test('translatorInputErrorResult renders an Anthropic 400 invalid_request_error assert(typeof body.request_id === 'string' && /^req_[A-Za-z0-9]{24}$/.test(body.request_id), `request_id ${String(body.request_id)} must match Anthropic-shape req_<24 opaque chars>`); }); -test('translatorInputErrorResult preserves Anthropic key order: type, error, request_id', () => { - const result = translatorInputErrorResult(new TranslatorInputError('whatever')); +test('inputErrorResult preserves Anthropic key order: type, error, request_id', () => { + const result = inputErrorResult({ message: 'whatever' }); const raw = new TextDecoder().decode(apiErrorOf(result).body); // Key-order is load-bearing for byte-faithfulness with Anthropic-direct. assert(/^\{"type":"error","error":\{"type":"invalid_request_error","message":"whatever"\},"request_id":"req_[A-Za-z0-9]{24}"\}$/.test(raw), `body ${raw} must match Anthropic-direct byte shape`); diff --git a/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/errors_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/errors_test.ts index a2c24ebc05..ecc3a30973 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/errors_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/errors_test.ts @@ -1,17 +1,16 @@ import { test } from 'vitest'; -import { translatorInputErrorResult } from '../../../../src/data-plane/chat/openai-chat-completions/errors.ts'; +import { inputErrorResult } from '../../../../src/data-plane/chat/openai-chat-completions/errors.ts'; import type { ApiErrorResult } from '@floway-dev/provider'; import { assertEquals } from '@floway-dev/test-utils'; -import { TranslatorInputError } from '@floway-dev/translate'; -const apiErrorOf = (result: ReturnType): ApiErrorResult => result as ApiErrorResult; -const bodyOf = (result: ReturnType): unknown => +const apiErrorOf = (result: ReturnType): ApiErrorResult => result as ApiErrorResult; +const bodyOf = (result: ReturnType): unknown => JSON.parse(new TextDecoder().decode(apiErrorOf(result).body)); -test('translatorInputErrorResult renders an OpenAI 400 invalid_request_error envelope with default `messages` param', () => { - const result = translatorInputErrorResult( - new TranslatorInputError("Missing required field 'tool_call_id' on a 'tool' role message."), +test('inputErrorResult renders an OpenAI 400 invalid_request_error envelope with default `messages` param', () => { + const result = inputErrorResult( + { message: "Missing required field 'tool_call_id' on a 'tool' role message." }, ); const apiError = apiErrorOf(result); @@ -28,9 +27,9 @@ test('translatorInputErrorResult renders an OpenAI 400 invalid_request_error env }); }); -test('translatorInputErrorResult honors an explicit param from the translator', () => { - const result = translatorInputErrorResult( - new TranslatorInputError('content part not supported', { param: 'messages[2].content[1]' }), +test('inputErrorResult honors an explicit param from the translator', () => { + const result = inputErrorResult( + { message: 'content part not supported', param: 'messages[2].content[1]' }, ); assertEquals(bodyOf(result), { diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/errors_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/errors_test.ts index 94e23fba8a..59bd78b67c 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/errors_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/errors_test.ts @@ -1,10 +1,9 @@ import { test } from 'vitest'; -import { openaiResponsesInputErrorResult, type OpenAIResponsesServeFailure } from '../../../../src/data-plane/chat/openai-responses/errors.ts'; +import { inputErrorResult, type OpenAIResponsesServeFailure } from '../../../../src/data-plane/chat/openai-responses/errors.ts'; import { throwChatServeFailure, tryCatchChatServeFailure } from '../../../../src/data-plane/chat/shared/errors.ts'; import type { ApiErrorResult } from '@floway-dev/provider'; import { assertEquals, assertThrows } from '@floway-dev/test-utils'; -import { TranslatorInputError } from '@floway-dev/translate'; const bodyOf = (result: ApiErrorResult): unknown => JSON.parse(new TextDecoder().decode(result.body)); @@ -15,9 +14,9 @@ test('round-trips the OpenAI-Responses-only item-not-found failure through throw assertEquals(tryCatchChatServeFailure(error), failure); }); -test('openaiResponsesInputErrorResult renders an OpenAI 400 invalid_request_error envelope with default `input` param', () => { - const result = openaiResponsesInputErrorResult( - new TranslatorInputError("Invalid input item type 'image_generation_call'."), +test('inputErrorResult renders an OpenAI 400 invalid_request_error envelope with default `input` param', () => { + const result = inputErrorResult( + { message: "Invalid input item type 'image_generation_call'." }, ); assertEquals(result.type, 'api-error'); assertEquals(result.source, 'gateway'); @@ -32,9 +31,9 @@ test('openaiResponsesInputErrorResult renders an OpenAI 400 invalid_request_erro }); }); -test('openaiResponsesInputErrorResult carries an explicit error code into the envelope', () => { - const result = openaiResponsesInputErrorResult( - new TranslatorInputError("Missing required parameter: 'model'.", { param: 'model', code: 'missing_required_parameter' }), +test('inputErrorResult carries an explicit error code into the envelope', () => { + const result = inputErrorResult( + { message: "Missing required parameter: 'model'.", param: 'model', code: 'missing_required_parameter' }, ); assertEquals(bodyOf(result), { @@ -47,9 +46,9 @@ test('openaiResponsesInputErrorResult carries an explicit error code into the en }); }); -test('openaiResponsesInputErrorResult honors an explicit input param', () => { - const result = openaiResponsesInputErrorResult( - new TranslatorInputError('content block not supported', { param: 'input[1].content[0]' }), +test('inputErrorResult honors an explicit input param', () => { + const result = inputErrorResult( + { message: 'content block not supported', param: 'input[1].content[0]' }, ); assertEquals(bodyOf(result), { diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/agent-message-shim_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/agent-message-shim_test.ts index 4da0e89ac5..fcbbc959f0 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/agent-message-shim_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/agent-message-shim_test.ts @@ -6,8 +6,7 @@ import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; import { doneFrame } from '@floway-dev/protocols/common'; import type { OpenAIResponsesInputAgentMessageItem, CanonicalOpenAIResponsesInputItem } from '@floway-dev/protocols/openai-responses'; import { eventResult, type FlagId } from '@floway-dev/provider'; -import { assert, assertEquals, assertRejects, stubModelCandidate, testTelemetryModelIdentity } from '@floway-dev/test-utils'; -import { TranslatorInputError } from '@floway-dev/translate'; +import { assert, assertEquals, stubModelCandidate, testTelemetryModelIdentity } from '@floway-dev/test-utils'; const gatewayCtx = mockChatGatewayCtx(); const okEvents = () => Promise.resolve(eventResult((async function* () { yield doneFrame(); })(), testTelemetryModelIdentity)); @@ -20,7 +19,7 @@ const lower = async ( enabledFlags?: ReadonlySet; targetApi?: OpenAIResponsesInvocation['targetApi']; } = {}, -): Promise<{ before: OpenAIResponsesInvocation['payload']; seen: OpenAIResponsesInvocation['payload'] }> => { +) => { const invocation: OpenAIResponsesInvocation = { payload: { model: 'test-model', input }, candidate: stubModelCandidate({ enabledFlags }), @@ -30,11 +29,11 @@ const lower = async ( }; const before = invocation.payload; let seen: OpenAIResponsesInvocation['payload'] | undefined; - await withOpenAIResponsesAgentMessageShim(invocation, gatewayCtx, () => { + const result = await withOpenAIResponsesAgentMessageShim(invocation, gatewayCtx, () => { seen = invocation.payload; return okEvents(); }); - return { before, seen: seen! }; + return { before, seen, result }; }; const newTask: OpenAIResponsesInputAgentMessageItem = { @@ -78,6 +77,7 @@ test('lowers agent_message items in place to framed user messages when the flag { type: 'function_call', call_id: 'call_1', name: 'shell', arguments: '{}', status: 'completed' }, reply, ], { enabledFlags: FLAG }); + assert(seen !== undefined); assertEquals(seen.input, [ { type: 'message', role: 'developer', content: 'You are an agent in a team of agents.' }, @@ -103,6 +103,7 @@ test('lowers agent_message items in place to framed user messages when the flag test('forwards agent_message verbatim to a native OpenAI Responses target without the flag', async () => { const { before, seen } = await lower([newTask]); + assert(seen !== undefined); assert(seen === before); assertEquals(seen.input, [newTask]); }); @@ -111,6 +112,7 @@ test.each(['anthropicMessages', 'openaiChatCompletions'] as const)( 'lowers agent_message at a %s target even without the flag', async targetApi => { const { seen } = await lower([newTask], { targetApi }); + assert(seen !== undefined); assertEquals(seen.input, [framedNewTask]); }, ); @@ -119,19 +121,20 @@ test('leaves a payload without agent_message as the same object', async () => { const input: CanonicalOpenAIResponsesInputItem[] = [{ type: 'message', role: 'user', content: 'hi' }]; for (const options of [{ enabledFlags: FLAG }, {}, { targetApi: 'anthropicMessages' as const }]) { const { before, seen } = await lower(input, options); + assert(seen !== undefined); assert(seen === before); } }); -test('rejects an agent_message whose content cannot be lowered', async () => { - await assertRejects( - () => lower([{ - type: 'agent_message', - author: '/root', - recipient: '/root/worker', - content: [{ type: 'encrypted_content', encrypted_content: 'opaque' }], - }], { enabledFlags: FLAG }), - TranslatorInputError, - "Invalid value: 'encrypted_content' for 'agent_message.content[0].type'.", - ); +test('returns an input error without running downstream when agent content cannot be lowered', async () => { + const { result, seen } = await lower([{ + type: 'agent_message', author: '/root', recipient: '/root/worker', + content: [{ type: 'encrypted_content', encrypted_content: 'opaque' }], + }], { enabledFlags: FLAG }); + assertEquals(seen, undefined); + assert(result.type === 'api-error'); + assertEquals(result.status, 400); + const body = JSON.parse(new TextDecoder().decode(result.body)) as { error: { message: string; param: string } }; + assertEquals(body.error.message, "Invalid value: 'encrypted_content' for 'agent_message.content[0].type'."); + assertEquals(body.error.param, 'agent_message.content[0].type'); }); diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/server-tool-shim_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/server-tool-shim_test.ts index 51c4ab6ce9..fab9d21a0c 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/server-tool-shim_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/server-tool-shim_test.ts @@ -36,6 +36,7 @@ import type { import { getRepo, initRepo } from '../../../../../src/repo/index.ts'; import { InMemoryRepo } from '../../../../repo/memory.ts'; import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; +import { requireTrip } from '../../shared/require-trip.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { decodeCanonicalBase64, encodeBase64, eventFrame } from '@floway-dev/protocols/common'; import type { BillableUsage, ProtocolFrame } from '@floway-dev/protocols/common'; @@ -55,7 +56,7 @@ import type { } from '@floway-dev/protocols/openai-responses'; import { eventResult, type EventResult, type ExecuteResult, type FlagId } from '@floway-dev/provider'; import { assert, assertEquals, assertFalse, assertRejects, stubModelCandidate } from '@floway-dev/test-utils'; -import { routeOpenAIResponses } from '@floway-dev/translate/openai-responses'; +import { routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; const withOpenAIResponsesWebSearchShim = withOpenAIResponsesServerToolShim([webSearchServerTool]); @@ -7350,7 +7351,7 @@ for (const targetApi of ['openaiChatCompletions', 'anthropicMessages', 'openaiRe ]]).run(); } if (targetApi === 'openaiChatCompletions') { - const trip = await routeOpenAIResponses(inv.payload).viaOpenAIChatCompletions({ model: 'm', sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }); + const trip = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(inv.payload, { model: 'm', sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) })); const client = trip.target.tools?.[1]; assert(client?.type === 'function'); const events = (async function* (): AsyncGenerator> { @@ -7359,7 +7360,7 @@ for (const targetApi of ['openaiChatCompletions', 'anthropicMessages', 'openaiRe })(); return eventResult(trip.events(events), testTelemetryModelIdentity); } - const trip = await routeOpenAIResponses(inv.payload).viaAnthropicMessages({ model: 'm', sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), loadRemoteImage: async () => { throw new Error('Unexpected image'); } }); + const trip = await requireTrip(routeOpenAIResponsesViaAnthropicMessages(inv.payload, { model: 'm', sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), loadRemoteImage: async () => { throw new Error('Unexpected image'); } })); const tool = trip.target.tools?.[1]; const name = tool !== undefined && 'name' in tool ? tool.name : undefined; assert(typeof name === 'string'); @@ -7416,8 +7417,8 @@ for (const target of ['openaiChatCompletions', 'anthropicMessages'] as const) { choices.push(structuredClone(inv.payload.tool_choice)); const before = structuredClone(inv.payload); const trip = target === 'openaiChatCompletions' - ? await routeOpenAIResponses(inv.payload).viaOpenAIChatCompletions({ model: 'm', sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }) - : await routeOpenAIResponses(inv.payload).viaAnthropicMessages({ model: 'm', sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), loadRemoteImage: async () => { throw new Error('Unexpected image'); } }); + ? await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(inv.payload, { model: 'm', sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) })) + : await requireTrip(routeOpenAIResponsesViaAnthropicMessages(inv.payload, { model: 'm', sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), loadRemoteImage: async () => { throw new Error('Unexpected image'); } })); const wire = JSON.parse(JSON.stringify(trip.target)) as { tools: Array<{ name?: string; function?: { name: string } }> }; assert(wire.tools.some(tool => (tool.function?.name ?? tool.name) === alias), 'the translated subset must declare the helper alias'); assertEquals(inv.payload, before, 'repeated translation must preserve the outer loop payload'); diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/items/agent-message-content_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/items/agent-message-content_test.ts index 601b58e041..7d8da1b20d 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/items/agent-message-content_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/items/agent-message-content_test.ts @@ -1,6 +1,7 @@ import { test } from 'vitest'; import { agentMessageContent } from '../../../../../src/data-plane/chat/openai-responses/items/agent-message-content.ts'; +import { tryCatchChatServeFailure, type InputErrorDetails } from '../../../../../src/data-plane/chat/shared/errors.ts'; import type { OpenAIResponsesInputAgentMessageItem } from '@floway-dev/protocols/openai-responses'; import { assertEquals, assertThrows } from '@floway-dev/test-utils'; @@ -61,9 +62,12 @@ test('agentMessageContent reports the exact path for unknown beta content', () = const error = assertThrows( () => agentMessageContent(agentMessage([{ type: 'future_agent_part', value: 1 }])), Error, - "Invalid value: 'future_agent_part'", ); - assertEquals((error as Error & { param?: string }).param, 'agent_message.content[0].type'); + assertEquals(tryCatchChatServeFailure(error), { + kind: 'invalid-input', + message: "Invalid value: 'future_agent_part' for 'agent_message.content[0].type'.", + param: 'agent_message.content[0].type', + }); }); test('agentMessageContent reports the exact path for malformed text', () => { @@ -72,7 +76,10 @@ test('agentMessageContent reports the exact path for malformed text', () => { { type: 'input_text', text: 42 } as unknown as OpenAIResponsesInputAgentMessageItem['content'][number], ])), Error, - "Invalid type for 'agent_message.content[0].text'", ); - assertEquals((error as Error & { param?: string }).param, 'agent_message.content[0].text'); + assertEquals(tryCatchChatServeFailure(error), { + kind: 'invalid-input', + message: "Invalid type for 'agent_message.content[0].text': expected a string.", + param: 'agent_message.content[0].text', + }); }); diff --git a/packages/gateway/__tests__/data-plane/chat/shared/translate-traverse_test.ts b/packages/gateway/__tests__/data-plane/chat/shared/execute-translation_test.ts similarity index 59% rename from packages/gateway/__tests__/data-plane/chat/shared/translate-traverse_test.ts rename to packages/gateway/__tests__/data-plane/chat/shared/execute-translation_test.ts index e467fc77d1..0e20777312 100644 --- a/packages/gateway/__tests__/data-plane/chat/shared/translate-traverse_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/shared/execute-translation_test.ts @@ -1,18 +1,20 @@ import { describe, expect, test } from 'vitest'; -import { traverseTranslation } from '../../../../src/data-plane/chat/shared/translate-traverse.ts'; +import { executeTranslation } from '../../../../src/data-plane/chat/shared/execute-translation.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; -import type { ExecuteResult } from '@floway-dev/provider'; +import { toInternalDebugError, type ExecuteResult } from '@floway-dev/provider'; +import { testTelemetryModelIdentity } from '@floway-dev/test-utils'; +import type { TranslateTrip } from '@floway-dev/translate'; // Fake event types — no real protocol types needed. +type SrcPayload = { model: string; stream?: boolean }; type SrcEvent = { kind: 'src'; value: string }; type TgtEvent = { kind: 'tgt'; value: string }; -const translate = async (payload: string): Promise<{ - target: string; - events: (e: AsyncIterable>) => AsyncIterable>; -}> => ({ - target: `translated(${payload})`, +const translate = async (payload: SrcPayload): Promise> => ({ + type: 'trip', + error: () => undefined, + target: `translated(${payload.model})`, async *events(frames) { for await (const frame of frames) { if (frame.type === 'done') { @@ -35,17 +37,37 @@ const collectFrames = async (iterable: AsyncIterable>): return out; }; -const fakeModelIdentity = { modelId: 'fake' } as never; +describe('executeTranslation', () => { + test('asks the upstream for a stream whatever the caller requested', async () => { + const innerAttempt = async (_translated: string): Promise>> => ({ + type: 'events', + events: makeTgtFrames([]), + modelIdentity: testTelemetryModelIdentity, + }); + + // A client that asked for a single body still reaches a trip, whose only + // output is a stream. The caller's preference is a rendering decision made + // from the returned events, so it never reaches the source protocol. + const source = { model: 'm', stream: false }; + let seen: unknown; + await executeTranslation(source, async (payload: typeof source) => { + seen = payload; + return await translate(payload); + }, innerAttempt); + + expect(seen).toEqual({ model: 'm', stream: true }); + // The caller's own payload is not mutated. + expect(source).toEqual({ model: 'm', stream: false }); + }); -describe('traverseTranslation', () => { test('events inner — outer wraps with trip.events', async () => { const innerAttempt = async (_translated: string): Promise>> => ({ type: 'events', events: makeTgtFrames(['a', 'b']), - modelIdentity: fakeModelIdentity, + modelIdentity: testTelemetryModelIdentity, }); - const result = await traverseTranslation('payload', translate, innerAttempt); + const result = await executeTranslation({ model: 'payload' }, translate, innerAttempt); expect(result.type).toBe('events'); if (result.type !== 'events') throw new Error('unreachable'); @@ -56,7 +78,7 @@ describe('traverseTranslation', () => { { type: 'event', event: { kind: 'src', value: 'b' } }, { type: 'done' }, ]); - expect(result.modelIdentity).toBe(fakeModelIdentity); + expect(result.modelIdentity).toBe(testTelemetryModelIdentity); }); test('result inner — outer returns the result unchanged', async () => { @@ -69,7 +91,7 @@ describe('traverseTranslation', () => { }; const innerAttempt = async (_translated: string): Promise>> => inner; - const result = await traverseTranslation('payload', translate, innerAttempt); + const result = await executeTranslation({ model: 'payload' }, translate, innerAttempt); expect(result).toBe(inner); }); @@ -78,16 +100,16 @@ describe('traverseTranslation', () => { const inner: ExecuteResult> = { type: 'internal-error', status: 500, - error: new Error('boom') as never, + error: toInternalDebugError(new Error('boom')), }; const innerAttempt = async (_translated: string): Promise>> => inner; - const result = await traverseTranslation('payload', translate, innerAttempt); + const result = await executeTranslation({ model: 'payload' }, translate, innerAttempt); expect(result).toBe(inner); }); - test('upstream api-error with trip.apiError rewrite — outer returns the rewritten envelope', async () => { + test('upstream api-error with trip.error — outer returns the restated envelope', async () => { const inner: ExecuteResult> = { type: 'api-error', source: 'upstream', @@ -95,26 +117,22 @@ describe('traverseTranslation', () => { headers: new Headers({ 'x-upstream-request-id': 'abc' }), body: new TextEncoder().encode('raw upstream'), }; - const translateWithRewrite = async (payload: string): Promise<{ - target: string; - events: (e: AsyncIterable>) => AsyncIterable>; - apiError: (upstream: { status: number; headers: Headers; body: Uint8Array }) => { status: number; headers: Headers; body: Uint8Array }; - }> => ({ + const translateWithRewrite = async (payload: SrcPayload): Promise> => ({ ...(await translate(payload)), - apiError: () => ({ status: 400, headers: new Headers({ 'content-type': 'application/json' }), body: new TextEncoder().encode('rewritten') }), + error: () => ({ status: 400, headers: new Headers({ 'content-type': 'application/json' }), body: { error: { message: 'rewritten' } } }), }); - const result = await traverseTranslation('payload', translateWithRewrite, async () => inner); + const result = await executeTranslation({ model: 'payload' }, translateWithRewrite, async () => inner); expect(result.type).toBe('api-error'); if (result.type !== 'api-error') throw new Error('unreachable'); - expect(new TextDecoder().decode(result.body)).toBe('rewritten'); + expect(JSON.parse(new TextDecoder().decode(result.body))).toEqual({ error: { message: 'rewritten' } }); expect(result.headers.get('content-type')).toBe('application/json'); expect(result.headers.get('x-upstream-request-id')).toBeNull(); // Fields not touched by the rewriter (source, performance, upstream) survive. expect(result.source).toBe('upstream'); }); - test('upstream api-error with trip.apiError returning undefined — outer passes the envelope through', async () => { + test('upstream api-error with trip.error returning undefined — outer passes the envelope through', async () => { const inner: ExecuteResult> = { type: 'api-error', source: 'upstream', @@ -122,17 +140,13 @@ describe('traverseTranslation', () => { headers: new Headers(), body: new TextEncoder().encode('bad gateway'), }; - const translateWithRewrite = async (payload: string): Promise<{ - target: string; - events: (e: AsyncIterable>) => AsyncIterable>; - apiError: () => undefined; - }> => ({ ...(await translate(payload)), apiError: () => undefined }); + const translateWithRewrite = async (payload: SrcPayload): Promise> => ({ ...(await translate(payload)), error: () => undefined }); - const result = await traverseTranslation('payload', translateWithRewrite, async () => inner); + const result = await executeTranslation({ model: 'payload' }, translateWithRewrite, async () => inner); expect(result).toBe(inner); }); - test('gateway-sourced api-error — trip.apiError is NOT invoked', async () => { + test('gateway-sourced api-error — trip.error is NOT invoked', async () => { const inner: ExecuteResult> = { type: 'api-error', source: 'gateway', @@ -141,19 +155,15 @@ describe('traverseTranslation', () => { body: new TextEncoder().encode('synthesized'), }; let called = false; - const translateWithRewrite = async (payload: string): Promise<{ - target: string; - events: (e: AsyncIterable>) => AsyncIterable>; - apiError: () => { status: number; headers: Headers; body: Uint8Array }; - }> => ({ + const translateWithRewrite = async (payload: SrcPayload): Promise> => ({ ...(await translate(payload)), - apiError: () => { + error: () => { called = true; - return { status: 999, headers: new Headers(), body: new Uint8Array() }; + return { status: 999, headers: new Headers(), body: { error: { message: 'unused' } } }; }, }); - const result = await traverseTranslation('payload', translateWithRewrite, async () => inner); + const result = await executeTranslation({ model: 'payload' }, translateWithRewrite, async () => inner); expect(called).toBe(false); expect(result).toBe(inner); }); @@ -161,14 +171,14 @@ describe('traverseTranslation', () => { // The pre-translation capture hook sees the ORIGINAL target-protocol frames // (events path) and the verbatim upstream api-error envelope (api-error path), -// before `trip.events` / `trip.apiError` rewrite them. `capture === undefined` +// before `trip.events` / `trip.error` restate them. `capture === undefined` // (api key opts out of dumps) skips all capture work. -describe('traverseTranslation upstream capture', () => { +describe('executeTranslation upstream capture', () => { test('events path — capture receives each original target frame, in order, before translation', async () => { const innerAttempt = async (_translated: string): Promise>> => ({ type: 'events', events: makeTgtFrames(['a', 'b']), - modelIdentity: fakeModelIdentity, + modelIdentity: testTelemetryModelIdentity, }); const captured: ProtocolFrame[] = []; const capture = { @@ -176,7 +186,7 @@ describe('traverseTranslation upstream capture', () => { upstreamApiError: () => { throw new Error('should not be called on events path'); }, }; - const result = await traverseTranslation('payload', translate, innerAttempt, capture); + const result = await executeTranslation({ model: 'payload' }, translate, innerAttempt, capture); expect(result.type).toBe('events'); if (result.type !== 'events') throw new Error('unreachable'); // Drain the translated stream so the tee pulls the inner frames. @@ -194,11 +204,11 @@ describe('traverseTranslation upstream capture', () => { const innerAttempt = async (_translated: string): Promise>> => ({ type: 'events', events: makeTgtFrames(['a']), - modelIdentity: fakeModelIdentity, + modelIdentity: testTelemetryModelIdentity, }); const capture = { upstreamFrame: () => {}, upstreamApiError: () => {} }; - const result = await traverseTranslation('payload', translate, innerAttempt, capture); + const result = await executeTranslation({ model: 'payload' }, translate, innerAttempt, capture); if (result.type !== 'events') throw new Error('unreachable'); const frames = await collectFrames(result.events); expect(frames).toEqual([ @@ -220,16 +230,12 @@ describe('traverseTranslation upstream capture', () => { upstreamFrame: () => { throw new Error('should not be called on api-error path'); }, upstreamApiError: (error: { status: number; headers: Headers; body: Uint8Array }) => captured.push(error), }; - const translateWithRewrite = async (payload: string): Promise<{ - target: string; - events: (e: AsyncIterable>) => AsyncIterable>; - apiError: () => { status: number; headers: Headers; body: Uint8Array }; - }> => ({ + const translateWithRewrite = async (payload: SrcPayload): Promise> => ({ ...(await translate(payload)), - apiError: () => ({ status: 400, headers: new Headers(), body: new TextEncoder().encode('rewritten') }), + error: () => ({ status: 400, headers: new Headers(), body: { error: { message: 'rewritten' } } }), }); - const result = await traverseTranslation('payload', translateWithRewrite, async () => inner, capture); + const result = await executeTranslation({ model: 'payload' }, translateWithRewrite, async () => inner, capture); expect(result.type).toBe('api-error'); if (result.type !== 'api-error') throw new Error('unreachable'); // Capture saw the ORIGINAL envelope, not the rewrite. @@ -238,10 +244,10 @@ describe('traverseTranslation upstream capture', () => { expect(captured[0].headers.get('x-upstream')).toBe('yes'); expect(new TextDecoder().decode(captured[0].body)).toBe('raw upstream body'); // Downstream got the rewrite. - expect(new TextDecoder().decode(result.body)).toBe('rewritten'); + expect(JSON.parse(new TextDecoder().decode(result.body))).toEqual({ error: { message: 'rewritten' } }); }); - test('api-error path — capture fires even when trip.apiError is undefined (no rewrite)', async () => { + test('api-error path — capture fires even when trip.error is undefined (no restatement)', async () => { const inner: ExecuteResult> = { type: 'api-error', source: 'upstream', @@ -255,7 +261,7 @@ describe('traverseTranslation upstream capture', () => { upstreamApiError: (error: { status: number; headers: Headers; body: Uint8Array }) => captured.push({ status: error.status }), }; - await traverseTranslation('payload', translate, async () => inner, capture); + await executeTranslation({ model: 'payload' }, translate, async () => inner, capture); expect(captured).toEqual([{ status: 502 }]); }); @@ -263,7 +269,7 @@ describe('traverseTranslation upstream capture', () => { const inner: ExecuteResult> = { type: 'internal-error', status: 500, - error: new Error('boom') as never, + error: toInternalDebugError(new Error('boom')), }; let called = false; const capture = { @@ -271,7 +277,7 @@ describe('traverseTranslation upstream capture', () => { upstreamApiError: () => { called = true; }, }; - const result = await traverseTranslation('payload', translate, async () => inner, capture); + const result = await executeTranslation({ model: 'payload' }, translate, async () => inner, capture); expect(called).toBe(false); expect(result).toBe(inner); }); @@ -290,7 +296,7 @@ describe('traverseTranslation upstream capture', () => { upstreamApiError: () => { called = true; }, }; - await traverseTranslation('payload', translate, async () => inner, capture); + await executeTranslation({ model: 'payload' }, translate, async () => inner, capture); expect(called).toBe(false); }); @@ -298,10 +304,10 @@ describe('traverseTranslation upstream capture', () => { const innerAttempt = async (_translated: string): Promise>> => ({ type: 'events', events: makeTgtFrames(['a']), - modelIdentity: fakeModelIdentity, + modelIdentity: testTelemetryModelIdentity, }); - const result = await traverseTranslation('payload', translate, innerAttempt, undefined); + const result = await executeTranslation({ model: 'payload' }, translate, innerAttempt, undefined); if (result.type !== 'events') throw new Error('unreachable'); const frames = await collectFrames(result.events); expect(frames).toEqual([ @@ -310,3 +316,26 @@ describe('traverseTranslation upstream capture', () => { ]); }); }); + +test('returns a raw translation input error before calling the inner attempt', async () => { + const failure = { type: 'input-error' as const, message: 'unsupported content', param: 'messages[0]', code: 'unsupported_content' }; + let called = false; + const result = await executeTranslation({ model: 'payload' }, async () => failure, async () => { + called = true; + return { type: 'events', events: makeTgtFrames([]), modelIdentity: testTelemetryModelIdentity }; + }); + expect(result).toBe(failure); + expect(called).toBe(false); +}); + +test('propagates unexpected translator errors with their identity and cause intact', async () => { + const cause = new Error('codec'); + const failure = new Error('translation failed', { cause }); + let called = false; + await expect(executeTranslation({ model: 'payload' }, async () => { throw failure; }, async () => { + called = true; + return { type: 'events', events: makeTgtFrames([]), modelIdentity: testTelemetryModelIdentity }; + })).rejects.toBe(failure); + expect(failure.cause).toBe(cause); + expect(called).toBe(false); +}); diff --git a/packages/gateway/__tests__/data-plane/chat/shared/require-trip.ts b/packages/gateway/__tests__/data-plane/chat/shared/require-trip.ts new file mode 100644 index 0000000000..e9200a26bb --- /dev/null +++ b/packages/gateway/__tests__/data-plane/chat/shared/require-trip.ts @@ -0,0 +1,7 @@ +import type { TranslateInputError } from '@floway-dev/translate'; + +export const requireTrip = async (result: Promise): Promise => { + const trip = await result; + if (trip.type === 'input-error') throw new Error(trip.message); + return trip; +}; diff --git a/packages/gateway/src/data-plane/chat/anthropic-messages/attempt.ts b/packages/gateway/src/data-plane/chat/anthropic-messages/attempt.ts index 43d6c7dfcb..838fbd4f72 100644 --- a/packages/gateway/src/data-plane/chat/anthropic-messages/attempt.ts +++ b/packages/gateway/src/data-plane/chat/anthropic-messages/attempt.ts @@ -1,3 +1,4 @@ +import { inputErrorResult } from './errors.ts'; import { anthropicMessagesInterceptors, anthropicMessagesCountTokensInterceptors } from './interceptors/index.ts'; import type { AnthropicMessagesInvocation } from './interceptors/types.ts'; import { createAnthropicMessagesBillableUsageReader } from './usage.ts'; @@ -5,18 +6,18 @@ import { buildUpstreamCallOptions } from '../../shared/upstream-call-options.ts' import { openaiChatCompletionsAttempt } from '../openai-chat-completions/attempt.ts'; import { openaiResponsesAttempt } from '../openai-responses/attempt.ts'; import { applyRulesToUpstreamAnthropicMessages } from '../shared/alias-rules.ts'; +import { captureFromDump, executeTranslation } from '../shared/execute-translation.ts'; import type { ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import { providerStreamResultToExecuteResult } from '../shared/provider-stream-result.ts'; import { plainResultFromResponse } from '../shared/respond.ts'; import { SidecarDomain } from '../shared/sidecar-codec.ts'; import { chatTargetPicker } from '../shared/target-picker.ts'; -import { captureFromDump, traverseTranslation } from '../shared/translate-traverse.ts'; import { runInterceptors } from '@floway-dev/interceptor'; import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { ModelCandidate, ExecuteResult, AnthropicMessagesUpstreamCallOptions, PlainResult } from '@floway-dev/provider'; import { providerModelOf } from '@floway-dev/provider'; -import { routeAnthropicMessages } from '@floway-dev/translate/anthropic-messages'; +import { routeAnthropicMessagesViaOpenAIChatCompletions, routeAnthropicMessagesViaOpenAIResponses, type TranslateInputError } from '@floway-dev/translate'; // `/v1/messages` generate prefers a native Anthropic Messages target, then the // translated OpenAI Responses path, then the translated OpenAI Chat Completions path. @@ -59,38 +60,8 @@ export const anthropicMessagesAttempt = { anthropicBeta, }; return await runInterceptors(invocation, ctx, anthropicMessagesInterceptors, async () => { - if (targetApi === 'anthropicMessages') { - if (candidate.rules !== undefined) applyRulesToUpstreamAnthropicMessages(invocation.payload, candidate.rules); - const { model: _model, ...body } = invocation.payload; - const providerResult = await candidate.provider.instance.callAnthropicMessages( - providerModelOf(candidate), - body, - ctx.abortSignal, - buildAnthropicMessagesUpstreamCallOptions(candidate, ctx, invocation.headers, invocation.anthropicBeta), - ); - return await providerStreamResultToExecuteResult(providerResult, candidate, targetApi, ctx, createAnthropicMessagesBillableUsageReader()); - } - if (targetApi === 'openaiResponses') { - return await traverseTranslation( - invocation.payload, - p => routeAnthropicMessages(p).viaOpenAIResponses({ model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), - translated => openaiResponsesAttempt.generate({ - payload: translated, ctx, candidate, headers: invocation.headers, - }), - captureFromDump(ctx.dump, targetApi), - ); - } - if (targetApi === 'openaiChatCompletions') { - return await traverseTranslation( - invocation.payload, - p => routeAnthropicMessages(p).viaOpenAIChatCompletions({ model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), - translated => openaiChatCompletionsAttempt.generate({ - payload: translated, ctx, candidate, headers: invocation.headers, - }), - captureFromDump(ctx.dump, targetApi), - ); - } - throw new Error(`anthropicMessagesAttempt.generate: unexpected targetApi '${targetApi as string}'`); + const result = await dispatchAnthropicMessages(invocation, ctx); + return result.type === 'input-error' ? inputErrorResult(result, ctx.attempt.telemetry) : result; }); }, @@ -124,3 +95,42 @@ export const anthropicMessagesAttempt = { return await plainResultFromResponse(response, candidate.provider.upstreamId); }, }; + +const dispatchAnthropicMessages = async ( + invocation: AnthropicMessagesInvocation, + ctx: ChatGatewayCtx, +): Promise> | TranslateInputError> => { + const { candidate, targetApi } = invocation; + if (targetApi === 'anthropicMessages') { + if (candidate.rules !== undefined) applyRulesToUpstreamAnthropicMessages(invocation.payload, candidate.rules); + const { model: _model, ...body } = invocation.payload; + const providerResult = await candidate.provider.instance.callAnthropicMessages( + providerModelOf(candidate), + body, + ctx.abortSignal, + buildAnthropicMessagesUpstreamCallOptions(candidate, ctx, invocation.headers, invocation.anthropicBeta), + ); + return await providerStreamResultToExecuteResult(providerResult, candidate, targetApi, ctx, createAnthropicMessagesBillableUsageReader()); + } + if (targetApi === 'openaiResponses') { + return await executeTranslation( + invocation.payload, + p => routeAnthropicMessagesViaOpenAIResponses(p, { model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), + translated => openaiResponsesAttempt.generate({ + payload: translated, ctx, candidate, headers: invocation.headers, + }), + captureFromDump(ctx.dump, targetApi), + ); + } + if (targetApi === 'openaiChatCompletions') { + return await executeTranslation( + invocation.payload, + p => routeAnthropicMessagesViaOpenAIChatCompletions(p, { model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), + translated => openaiChatCompletionsAttempt.generate({ + payload: translated, ctx, candidate, headers: invocation.headers, + }), + captureFromDump(ctx.dump, targetApi), + ); + } + throw new Error(`anthropicMessagesAttempt.generate: unexpected targetApi '${targetApi as string}'`); +}; diff --git a/packages/gateway/src/data-plane/chat/anthropic-messages/errors.ts b/packages/gateway/src/data-plane/chat/anthropic-messages/errors.ts index d76830d291..1bc87472f4 100644 --- a/packages/gateway/src/data-plane/chat/anthropic-messages/errors.ts +++ b/packages/gateway/src/data-plane/chat/anthropic-messages/errors.ts @@ -1,9 +1,8 @@ import { appendFailedUpstreams } from '../../shared/failed-upstreams.ts'; -import type { ChatServeFailure } from '../shared/errors.ts'; +import type { ChatServeFailure, InputErrorDetails } from '../shared/errors.ts'; import { generateAnthropicId, type AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { ExecuteResult, PerformanceTelemetryContext } from '@floway-dev/provider'; -import type { TranslatorInputError } from '@floway-dev/translate'; // Anthropic Messages error envelope used to render pre-stream // `ChatServeFailure`s. These are gateway-synthesized rather than received @@ -33,10 +32,10 @@ const anthropicErrorResult = ( // disallowed role, missing required field, etc.). Render as a 400 // invalid_request_error so the caller sees a protocol-shaped failure // instead of the internal-error 502 envelope. `performance` carries the -// throwing candidate's telemetry attribution when the throw fired -// mid-attempt (see AttemptState.telemetry). -export const translatorInputErrorResult = ( - error: TranslatorInputError, +// candidate's telemetry attribution when validation failed +// during an attempt (see AttemptState.telemetry). +export const inputErrorResult = ( + error: InputErrorDetails, performance?: PerformanceTelemetryContext, ): ExecuteResult> => anthropicErrorResult(400, 'invalid_request_error', error.message, performance); diff --git a/packages/gateway/src/data-plane/chat/anthropic-messages/http.ts b/packages/gateway/src/data-plane/chat/anthropic-messages/http.ts index 2489a30d3f..60ba4bf852 100644 --- a/packages/gateway/src/data-plane/chat/anthropic-messages/http.ts +++ b/packages/gateway/src/data-plane/chat/anthropic-messages/http.ts @@ -1,4 +1,3 @@ -import { translatorInputErrorResult } from './errors.ts'; import { respondAnthropicMessages } from './respond.ts'; import { anthropicMessagesServe } from './serve.ts'; import type { AuthedContext } from '../../../middleware/auth.ts'; @@ -11,7 +10,6 @@ import { createChatGatewayCtxFromHono, type ChatGatewayCtx } from '../shared/gat import { providerModelsUnavailableResponse } from '../shared/upstream-models-error.ts'; import type { AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; import { internalErrorResult, toInternalDebugError } from '@floway-dev/provider'; -import { TranslatorInputError } from '@floway-dev/translate'; // Reject `anthropic_beta` / `betas` in the body; the Anthropic Messages protocol carries // them via the `anthropic-beta` HTTP header. @@ -52,16 +50,6 @@ const respondWithInternalError = async (c: AuthedContext, error: unknown, reques return finalizeGatewayResponse(effectiveCtx, response); }; -// Pre-stream caller-input failure raised by a translator → Anthropic-Messages-shaped -// 400 invalid_request_error envelope. Anything else falls through to the -// internal-error 502 path. -const respondToThrow = async (c: AuthedContext, error: unknown, requestBody: RequestBody, ctx?: GatewayCtx): Promise => { - if (!(error instanceof TranslatorInputError)) return await respondWithInternalError(c, error, requestBody, ctx); - const effectiveCtx = ctx ?? createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); - const response = await respondAnthropicMessages(c, translatorInputErrorResult(error, effectiveCtx.attempt.telemetry), false, effectiveCtx); - return finalizeGatewayResponse(effectiveCtx, response); -}; - const parsePayload = (requestBody: RequestBody): AnthropicMessagesPayload => JSON.parse(new TextDecoder().decode(requestBody.bytes)) as AnthropicMessagesPayload; @@ -80,7 +68,7 @@ export const anthropicMessagesHttp = { const response = await respondAnthropicMessages(c, result, wantsStream, ctx); return finalizeGatewayResponse(ctx, response); } catch (error) { - return await respondToThrow(c, error, requestBody, ctx); + return await respondWithInternalError(c, error, requestBody, ctx); } }, @@ -97,7 +85,7 @@ export const anthropicMessagesHttp = { const response = await respondAnthropicMessages(c, result, false, ctx); return finalizeGatewayResponse(ctx, response); } catch (error) { - return await respondToThrow(c, error, requestBody, ctx); + return await respondWithInternalError(c, error, requestBody, ctx); } }, }; diff --git a/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts b/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts index 7a945525d5..a38b0366ec 100644 --- a/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts +++ b/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts @@ -1,19 +1,19 @@ -import { geminiGenerateContentInternalErrorPayload, geminiGenerateContentStatusForHttpStatus } from './errors.ts'; +import { inputErrorResult, geminiGenerateContentInternalErrorPayload, geminiGenerateContentStatusForHttpStatus } from './errors.ts'; import { geminiGenerateContentCountTokensInterceptors, geminiGenerateContentInterceptors } from './interceptors/index.ts'; import { stripUnsupportedPartFieldsFromPayload } from './interceptors/strip-unsupported-part-fields.ts'; import { stripUnsupportedToolsFromPayload } from './interceptors/strip-unsupported-tools.ts'; import { anthropicMessagesAttempt } from '../anthropic-messages/attempt.ts'; import { openaiChatCompletionsAttempt } from '../openai-chat-completions/attempt.ts'; import { openaiResponsesAttempt } from '../openai-responses/attempt.ts'; +import { captureFromDump, executeTranslation } from '../shared/execute-translation.ts'; import type { ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import { SidecarDomain } from '../shared/sidecar-codec.ts'; import { chatTargetPicker } from '../shared/target-picker.ts'; -import { captureFromDump, traverseTranslation } from '../shared/translate-traverse.ts'; import { runInterceptors } from '@floway-dev/interceptor'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; import { type ModelCandidate, plainResult, toInternalDebugError, type ExecuteResult, type GeminiGenerateContentInvocation, type PlainResult } from '@floway-dev/provider'; -import { routeGeminiGenerateContent } from '@floway-dev/translate/gemini-generate-content'; +import { routeGeminiGenerateContentViaAnthropicMessages, routeGeminiGenerateContentViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIResponses, type TranslateInputError } from '@floway-dev/translate'; // Gemini generateContent has no native upstream target in the provider API; prefer OpenAI Chat Completions // Completions, then Anthropic Messages, then OpenAI Responses for generate. countTokens has @@ -43,46 +43,8 @@ export const geminiGenerateContentAttempt = { const targetApi = geminiGenerateContentGenerateTarget.pick(candidate.model.endpoints); const invocation: GeminiGenerateContentInvocation = { payload, candidate, targetApi, headers }; return await runInterceptors(invocation, ctx, geminiGenerateContentInterceptors, async () => { - // Gemini generateContent has no native upstream target today — every targetApi we - // pick is reached via translation. The dispatch threads each branch - // through `traverseTranslation` so each inner attempt owns its own - // interceptor chain and rewrite. - const transCtx = { - model: candidate.model.id, - sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), - fallbackMaxOutputTokens: candidate.model.limits.max_output_tokens, - }; - if (targetApi === 'anthropicMessages') { - return await traverseTranslation( - invocation.payload, - p => routeGeminiGenerateContent(p).viaAnthropicMessages(transCtx), - translated => anthropicMessagesAttempt.generate({ - payload: translated, ctx, candidate, headers: invocation.headers, anthropicBeta: [], - }), - captureFromDump(ctx.dump, targetApi), - ); - } - if (targetApi === 'openaiResponses') { - return await traverseTranslation( - invocation.payload, - p => routeGeminiGenerateContent(p).viaOpenAIResponses(transCtx), - translated => openaiResponsesAttempt.generate({ - payload: translated, ctx, candidate, headers: invocation.headers, - }), - captureFromDump(ctx.dump, targetApi), - ); - } - if (targetApi === 'openaiChatCompletions') { - return await traverseTranslation( - invocation.payload, - p => routeGeminiGenerateContent(p).viaOpenAIChatCompletions(transCtx), - translated => openaiChatCompletionsAttempt.generate({ - payload: translated, ctx, candidate, headers: invocation.headers, - }), - captureFromDump(ctx.dump, targetApi), - ); - } - throw new Error(`geminiGenerateContentAttempt.generate: unexpected targetApi '${targetApi as string}'`); + const result = await dispatchGeminiGenerateContent(invocation, ctx); + return result.type === 'input-error' ? inputErrorResult(result, ctx.attempt.telemetry) : result; }); }, @@ -110,7 +72,11 @@ export const geminiGenerateContentAttempt = { stripUnsupportedPartFieldsFromPayload(cleaned); stripUnsupportedToolsFromPayload(cleaned); delete cleaned.safetySettings; - const trip = await routeGeminiGenerateContent(cleaned).viaAnthropicMessages(transCtx); + const trip = await routeGeminiGenerateContentViaAnthropicMessages(cleaned, transCtx); + if (trip.type === 'input-error') { + const failure = inputErrorResult(trip, ctx.attempt.telemetry); + return plainResult(failure.status, failure.headers, failure.body); + } const { stream: _stream, ...target } = trip.target; const anthropicMessagesResult = await anthropicMessagesAttempt.countTokens({ payload: target, ctx, candidate, headers: invocation.headers, anthropicBeta: [], @@ -165,3 +131,50 @@ const geminiGenerateContentInternalPlainResult = (status: number, error: Error): new Headers({ 'content-type': 'application/json' }), new TextEncoder().encode(JSON.stringify(geminiGenerateContentInternalErrorPayload(status, toInternalDebugError(error)))), ); + +const dispatchGeminiGenerateContent = async ( + invocation: GeminiGenerateContentInvocation, + ctx: ChatGatewayCtx, +): Promise> | TranslateInputError> => { + const { candidate, targetApi } = invocation; + // Gemini generateContent has no native upstream target today — every targetApi we + // pick is reached via translation. The dispatch threads each branch + // through `executeTranslation` so each inner attempt owns its own + // interceptor chain and rewrite. + const transCtx = { + model: candidate.model.id, + sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), + fallbackMaxOutputTokens: candidate.model.limits.max_output_tokens, + }; + if (targetApi === 'anthropicMessages') { + return await executeTranslation( + invocation.payload, + p => routeGeminiGenerateContentViaAnthropicMessages(p, transCtx), + translated => anthropicMessagesAttempt.generate({ + payload: translated, ctx, candidate, headers: invocation.headers, anthropicBeta: [], + }), + captureFromDump(ctx.dump, targetApi), + ); + } + if (targetApi === 'openaiResponses') { + return await executeTranslation( + invocation.payload, + p => routeGeminiGenerateContentViaOpenAIResponses(p, transCtx), + translated => openaiResponsesAttempt.generate({ + payload: translated, ctx, candidate, headers: invocation.headers, + }), + captureFromDump(ctx.dump, targetApi), + ); + } + if (targetApi === 'openaiChatCompletions') { + return await executeTranslation( + invocation.payload, + p => routeGeminiGenerateContentViaOpenAIChatCompletions(p, transCtx), + translated => openaiChatCompletionsAttempt.generate({ + payload: translated, ctx, candidate, headers: invocation.headers, + }), + captureFromDump(ctx.dump, targetApi), + ); + } + throw new Error(`geminiGenerateContentAttempt.generate: unexpected targetApi '${targetApi as string}'`); +}; diff --git a/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts b/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts index ec96327089..edc87687c8 100644 --- a/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts +++ b/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts @@ -1,9 +1,7 @@ import { appendFailedUpstreams } from '../../shared/failed-upstreams.ts'; -import type { ChatServeFailure } from '../shared/errors.ts'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { GeminiGenerateContentStreamEvent, GeminiGenerateContentErrorResponse } from '@floway-dev/protocols/gemini-generate-content'; -import { internalDebugErrorFields, type ExecuteResult, type PerformanceTelemetryContext, type InternalDebugError } from '@floway-dev/provider'; -import type { TranslatorInputError } from '@floway-dev/translate'; +import type { ChatServeFailure, InputErrorDetails } from '../shared/errors.ts'; +import type { GeminiGenerateContentErrorResponse } from '@floway-dev/protocols/gemini-generate-content'; +import { internalDebugErrorFields, type ApiErrorResult, type PerformanceTelemetryContext, type InternalDebugError } from '@floway-dev/provider'; // Google RPC Status envelope, used by Gemini's `error` channel everywhere // (HTTP body, SSE-tunnelled error event). @@ -47,7 +45,7 @@ export const geminiGenerateContentInternalErrorPayload = (status: number, error: }; }; -const geminiGenerateContentRpcErrorResult = (status: number, message: string, performance?: PerformanceTelemetryContext): ExecuteResult> => ({ +const geminiGenerateContentRpcErrorResult = (status: number, message: string, performance?: PerformanceTelemetryContext): ApiErrorResult => ({ type: 'api-error', source: 'gateway', status, @@ -62,12 +60,12 @@ const geminiGenerateContentRpcErrorResult = (status: number, message: string, pe // disallowed role, missing required field, etc.). Render as a 400 // INVALID_ARGUMENT envelope so the caller sees a Gemini-generateContent-shaped failure // instead of the internal-error 500 envelope. `performance` carries the -// throwing candidate's telemetry attribution when the throw fired -// mid-attempt (see AttemptState.telemetry). -export const translatorInputErrorResult = ( - error: TranslatorInputError, +// candidate's telemetry attribution when validation failed +// during an attempt (see AttemptState.telemetry). +export const inputErrorResult = ( + error: InputErrorDetails, performance?: PerformanceTelemetryContext, -): ExecuteResult> => +): ApiErrorResult => geminiGenerateContentRpcErrorResult(400, error.message, performance); // `endpoint` selects between `:generateContent` and `:countTokens` only in @@ -75,7 +73,7 @@ export const translatorInputErrorResult = ( export const renderGeminiGenerateContentFailure = ( failure: ChatServeFailure, endpoint: 'generate' | 'countTokens', -): ExecuteResult> => { +): ApiErrorResult => { switch (failure.kind) { case 'routing-unavailable': return geminiGenerateContentRpcErrorResult(400, failure.message); diff --git a/packages/gateway/src/data-plane/chat/gemini-generate-content/http.ts b/packages/gateway/src/data-plane/chat/gemini-generate-content/http.ts index 6836b3ee0f..5eff42994d 100644 --- a/packages/gateway/src/data-plane/chat/gemini-generate-content/http.ts +++ b/packages/gateway/src/data-plane/chat/gemini-generate-content/http.ts @@ -1,4 +1,3 @@ -import { translatorInputErrorResult } from './errors.ts'; import { geminiGenerateContentInternalRpcErrorResponse, geminiGenerateContentRpcErrorResponse, respondGeminiGenerateContent } from './respond.ts'; import { geminiGenerateContentServe } from './serve.ts'; import type { AuthedContext } from '../../../middleware/auth.ts'; @@ -10,7 +9,6 @@ import { createNonOpenAIResponsesSourceStore } from '../openai-responses/items/s import { createChatGatewayCtxFromHono, type ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import type { GeminiCountTokensPayload, GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; import { internalErrorResult, ProviderModelsUnavailableError, toInternalDebugError } from '@floway-dev/provider'; -import { TranslatorInputError } from '@floway-dev/translate'; interface GeminiGenerateContentModelAction { readonly model: string; @@ -50,8 +48,7 @@ const parseGeminiGenerateContentBodyBytes = (requestBody: RequestBody, projec // Surfaces a pre-stream throw as a Gemini-generateContent-RPC envelope, routing through // `respondGeminiGenerateContent` so the dump records the failure exactly as the sibling -// HTTP handlers do. `TranslatorInputError` renders a 400 INVALID_ARGUMENT -// envelope (caller-input violation). A `ProviderModelsUnavailableError` +// HTTP handlers do. A `ProviderModelsUnavailableError` // carrying an upstream HTTP body relays that body through the `api-error` // path with `source: 'upstream'`; everything else collapses to an // `internal-error` result rendered as the Gemini generateContent internal-error envelope @@ -65,10 +62,6 @@ const respondWithGeminiGenerateContentError = async ( ctx: ChatGatewayCtx, wantsStream: boolean, ): Promise => { - if (error instanceof TranslatorInputError) { - const response = await respondGeminiGenerateContent(c, translatorInputErrorResult(error, ctx.attempt.telemetry), wantsStream, ctx); - return finalizeGatewayResponse(ctx, response); - } if (error instanceof ProviderModelsUnavailableError && error.httpResponse) { const { status, headers, body } = error.httpResponse; const apiErrorResult = { diff --git a/packages/gateway/src/data-plane/chat/openai-chat-completions/attempt.ts b/packages/gateway/src/data-plane/chat/openai-chat-completions/attempt.ts index 5e8995655d..3c642d6313 100644 --- a/packages/gateway/src/data-plane/chat/openai-chat-completions/attempt.ts +++ b/packages/gateway/src/data-plane/chat/openai-chat-completions/attempt.ts @@ -1,3 +1,4 @@ +import { inputErrorResult } from './errors.ts'; import { openaiChatCompletionsInterceptors } from './interceptors/index.ts'; import type { OpenAIChatCompletionsInvocation } from './interceptors/types.ts'; import { billableUsageFromOpenAIChatCompletionsEvent } from './usage.ts'; @@ -5,17 +6,17 @@ import { buildUpstreamCallOptions } from '../../shared/upstream-call-options.ts' import { anthropicMessagesAttempt } from '../anthropic-messages/attempt.ts'; import { openaiResponsesAttempt } from '../openai-responses/attempt.ts'; import { applyRulesToUpstreamOpenAIChatCompletions } from '../shared/alias-rules.ts'; +import { captureFromDump, executeTranslation } from '../shared/execute-translation.ts'; import { createExternalImageLoader } from '../shared/external-image-loader.ts'; import type { ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import { providerStreamResultToExecuteResult } from '../shared/provider-stream-result.ts'; import { SidecarDomain } from '../shared/sidecar-codec.ts'; import { chatTargetPicker } from '../shared/target-picker.ts'; -import { captureFromDump, traverseTranslation } from '../shared/translate-traverse.ts'; import { runInterceptors } from '@floway-dev/interceptor'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; import { type ModelCandidate, type ExecuteResult, providerModelOf } from '@floway-dev/provider'; -import { routeOpenAIChatCompletions } from '@floway-dev/translate/openai-chat-completions'; +import { routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses, type TranslateInputError } from '@floway-dev/translate'; // `/v1/chat/completions` generate prefers a native OpenAI Chat Completions target, // then the translated Anthropic Messages path, then the translated OpenAI Responses path. @@ -41,43 +42,52 @@ export const openaiChatCompletionsAttempt = { headers, }; return await runInterceptors(invocation, ctx, openaiChatCompletionsInterceptors, async () => { - if (targetApi === 'openaiChatCompletions') { - if (candidate.rules !== undefined) applyRulesToUpstreamOpenAIChatCompletions(invocation.payload, candidate.rules); - const { model: _model, ...body } = invocation.payload; - const providerResult = await candidate.provider.instance.callOpenAIChatCompletions( - providerModelOf(candidate), - body, - ctx.abortSignal, - buildUpstreamCallOptions(candidate, ctx, invocation.headers), - ); - return await providerStreamResultToExecuteResult(providerResult, candidate, 'openaiChatCompletions', ctx, billableUsageFromOpenAIChatCompletionsEvent); - } - if (targetApi === 'anthropicMessages') { - return await traverseTranslation( - invocation.payload, - p => routeOpenAIChatCompletions(p).viaAnthropicMessages({ - model: candidate.model.id, - sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), - fallbackMaxOutputTokens: candidate.model.limits.max_output_tokens, - loadRemoteImage: createExternalImageLoader(ctx.abortSignal), - }), - translated => anthropicMessagesAttempt.generate({ - payload: translated, ctx, candidate, headers: invocation.headers, anthropicBeta: [], - }), - captureFromDump(ctx.dump, targetApi), - ); - } - if (targetApi === 'openaiResponses') { - return await traverseTranslation( - invocation.payload, - p => routeOpenAIChatCompletions(p).viaOpenAIResponses({ model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), - translated => openaiResponsesAttempt.generate({ - payload: translated, ctx, candidate, headers: invocation.headers, - }), - captureFromDump(ctx.dump, targetApi), - ); - } - throw new Error(`openaiChatCompletionsAttempt.generate: unexpected targetApi '${targetApi as string}'`); + const result = await dispatchOpenAIChatCompletions(invocation, ctx); + return result.type === 'input-error' ? inputErrorResult(result, ctx.attempt.telemetry) : result; }); }, }; + +const dispatchOpenAIChatCompletions = async ( + invocation: OpenAIChatCompletionsInvocation, + ctx: ChatGatewayCtx, +): Promise> | TranslateInputError> => { + const { candidate, targetApi } = invocation; + if (targetApi === 'openaiChatCompletions') { + if (candidate.rules !== undefined) applyRulesToUpstreamOpenAIChatCompletions(invocation.payload, candidate.rules); + const { model: _model, ...body } = invocation.payload; + const providerResult = await candidate.provider.instance.callOpenAIChatCompletions( + providerModelOf(candidate), + body, + ctx.abortSignal, + buildUpstreamCallOptions(candidate, ctx, invocation.headers), + ); + return await providerStreamResultToExecuteResult(providerResult, candidate, 'openaiChatCompletions', ctx, billableUsageFromOpenAIChatCompletionsEvent); + } + if (targetApi === 'anthropicMessages') { + return await executeTranslation( + invocation.payload, + p => routeOpenAIChatCompletionsViaAnthropicMessages(p, { + model: candidate.model.id, + sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), + fallbackMaxOutputTokens: candidate.model.limits.max_output_tokens, + loadRemoteImage: createExternalImageLoader(ctx.abortSignal), + }), + translated => anthropicMessagesAttempt.generate({ + payload: translated, ctx, candidate, headers: invocation.headers, anthropicBeta: [], + }), + captureFromDump(ctx.dump, targetApi), + ); + } + if (targetApi === 'openaiResponses') { + return await executeTranslation( + invocation.payload, + p => routeOpenAIChatCompletionsViaOpenAIResponses(p, { model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), + translated => openaiResponsesAttempt.generate({ + payload: translated, ctx, candidate, headers: invocation.headers, + }), + captureFromDump(ctx.dump, targetApi), + ); + } + throw new Error(`openaiChatCompletionsAttempt.generate: unexpected targetApi '${targetApi as string}'`); +}; diff --git a/packages/gateway/src/data-plane/chat/openai-chat-completions/errors.ts b/packages/gateway/src/data-plane/chat/openai-chat-completions/errors.ts index 8f4d29575c..c28736f571 100644 --- a/packages/gateway/src/data-plane/chat/openai-chat-completions/errors.ts +++ b/packages/gateway/src/data-plane/chat/openai-chat-completions/errors.ts @@ -1,19 +1,18 @@ import { appendFailedUpstreams } from '../../shared/failed-upstreams.ts'; -import { openAiErrorResult, type ChatServeFailure } from '../shared/errors.ts'; +import { openAiErrorResult, type ChatServeFailure, type InputErrorDetails } from '../shared/errors.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; import type { ExecuteResult, PerformanceTelemetryContext } from '@floway-dev/provider'; -import type { TranslatorInputError } from '@floway-dev/translate'; // Translator surfaced a caller-input violation. Render as a 400 // invalid_request_error so the caller sees a protocol-shaped failure // instead of the internal-error 502 envelope. `param` falls back to // `messages` (the OpenAI Chat Completions canonical messages field) when the // translator did not carry a more specific path. `performance` carries the -// throwing candidate's telemetry attribution when the throw fired -// mid-attempt (see AttemptState.telemetry). -export const translatorInputErrorResult = ( - error: TranslatorInputError, +// candidate's telemetry attribution when validation failed +// during an attempt (see AttemptState.telemetry). +export const inputErrorResult = ( + error: InputErrorDetails, performance?: PerformanceTelemetryContext, ): ExecuteResult> => openAiErrorResult(400, error.message, { param: error.param ?? 'messages', code: null }, performance); diff --git a/packages/gateway/src/data-plane/chat/openai-chat-completions/http.ts b/packages/gateway/src/data-plane/chat/openai-chat-completions/http.ts index a1d936b749..32b39d3a20 100644 --- a/packages/gateway/src/data-plane/chat/openai-chat-completions/http.ts +++ b/packages/gateway/src/data-plane/chat/openai-chat-completions/http.ts @@ -1,4 +1,3 @@ -import { translatorInputErrorResult } from './errors.ts'; import { respondOpenAIChatCompletions } from './respond.ts'; import { openaiChatCompletionsServe } from './serve.ts'; import type { AuthedContext } from '../../../middleware/auth.ts'; @@ -11,7 +10,6 @@ import { createChatGatewayCtxFromHono, type ChatGatewayCtx } from '../shared/gat import { providerModelsUnavailableResponse } from '../shared/upstream-models-error.ts'; import type { OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; import { internalErrorResult, toInternalDebugError } from '@floway-dev/provider'; -import { TranslatorInputError } from '@floway-dev/translate'; // Surfaces a pre-stream throw (malformed JSON body, an interceptor crash, // etc.) as an OpenAI Chat Completions-shaped 502 with the same internal-error @@ -32,16 +30,6 @@ const respondWithInternalError = async (c: AuthedContext, error: unknown, reques return finalizeGatewayResponse(effectiveCtx, response); }; -// Pre-stream caller-input failure raised by a translator → an OpenAI Chat -// Completions-shaped 400 invalid_request_error envelope. Anything else -// falls through to the internal-error 502 path. -const respondToThrow = async (c: AuthedContext, error: unknown, requestBody: RequestBody, ctx?: GatewayCtx): Promise => { - if (!(error instanceof TranslatorInputError)) return await respondWithInternalError(c, error, requestBody, ctx); - const effectiveCtx = ctx ?? createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); - const response = await respondOpenAIChatCompletions(c, translatorInputErrorResult(error, effectiveCtx.attempt.telemetry), false, false, effectiveCtx); - return finalizeGatewayResponse(effectiveCtx, response); -}; - export const openaiChatCompletionsHttp = { generate: async (c: AuthedContext): Promise => { const requestBody = await readRequestBody(c); @@ -60,7 +48,7 @@ export const openaiChatCompletionsHttp = { const response = await respondOpenAIChatCompletions(c, result, wantsStream, includeUsageChunk, ctx); return finalizeGatewayResponse(ctx, response); } catch (error) { - return await respondToThrow(c, error, requestBody, ctx); + return await respondWithInternalError(c, error, requestBody, ctx); } }, }; diff --git a/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts b/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts index 18a0d75359..4da1ac83f0 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts @@ -1,5 +1,6 @@ import { klona } from 'klona/json'; +import { inputErrorResult } from './errors.ts'; import { openaiResponsesInterceptors } from './interceptors/index.ts'; import type { OpenAIResponsesAttemptResult, OpenAIResponsesInvocation } from './interceptors/types.ts'; import { normalizeAssistantInputText } from './items/normalize-assistant-content.ts'; @@ -11,17 +12,17 @@ import { buildUpstreamCallOptions } from '../../shared/upstream-call-options.ts' import { anthropicMessagesAttempt } from '../anthropic-messages/attempt.ts'; import { openaiChatCompletionsAttempt } from '../openai-chat-completions/attempt.ts'; import { applyRulesToUpstreamOpenAIResponses } from '../shared/alias-rules.ts'; +import { captureFromDump, executeTranslation } from '../shared/execute-translation.ts'; import { createExternalImageLoader } from '../shared/external-image-loader.ts'; import type { ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import { providerStreamResultToExecuteResult } from '../shared/provider-stream-result.ts'; import { SidecarDomain } from '../shared/sidecar-codec.ts'; import { chatTargetPicker } from '../shared/target-picker.ts'; -import { captureFromDump, traverseTranslation } from '../shared/translate-traverse.ts'; import { runInterceptors } from '@floway-dev/interceptor'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import { collectOpenAIResponsesProtocolEventsToResult, type CanonicalOpenAIResponsesPayload, type OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; import { type ModelCandidate, eventResult, readUpstreamApiError, providerModelOf, type ChatTargetApi, type ExecuteResult, type ProviderOpenAIResponsesResult, type OpenAIResponsesAction } from '@floway-dev/provider'; -import { routeOpenAIResponses } from '@floway-dev/translate/openai-responses'; +import { routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions, type TranslateInputError } from '@floway-dev/translate'; // `/v1/responses` generate prefers the native OpenAI Responses target, then the // translated Anthropic Messages path, then the translated OpenAI Chat Completions path. The @@ -95,8 +96,10 @@ export const openaiResponsesAttempt = { targetApi, headers, }; - const chainResult = await runInterceptors(invocation, ctx, openaiResponsesInterceptors, async () => - await dispatchOpenAIResponses(invocation, ctx)); + const chainResult = await runInterceptors(invocation, ctx, openaiResponsesInterceptors, async () => { + const result = await dispatchOpenAIResponses(invocation, ctx); + return result.type === 'input-error' ? inputErrorResult(result, ctx.attempt.telemetry) : result; + }); if (chainResult.type !== 'events') return chainResult; @@ -131,7 +134,7 @@ export const openaiResponsesAttempt = { const dispatchOpenAIResponses = async ( invocation: OpenAIResponsesInvocation, ctx: ChatGatewayCtx, -): Promise>> => { +): Promise> | TranslateInputError> => { const { candidate, targetApi } = invocation; switch (targetApi) { case 'openaiResponses': { @@ -168,9 +171,9 @@ const dispatchOpenAIResponses = async ( // unexpected-input-item guard. throw new Error(`openaiResponsesAttempt: action='compact' reached dispatch on targetApi='anthropicMessages' — the openai-responses-compact-shim must engage and pivot the action`); } - return await traverseTranslation( + return await executeTranslation( invocation.payload, - p => routeOpenAIResponses(p).viaAnthropicMessages({ + p => routeOpenAIResponsesViaAnthropicMessages(p, { model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), fallbackMaxOutputTokens: candidate.model.limits.max_output_tokens, @@ -185,9 +188,9 @@ const dispatchOpenAIResponses = async ( if (invocation.action === 'compact') { throw new Error(`openaiResponsesAttempt: action='compact' reached dispatch on targetApi='openaiChatCompletions' — the openai-responses-compact-shim must engage and pivot the action`); } - return await traverseTranslation( + return await executeTranslation( invocation.payload, - p => routeOpenAIResponses(p).viaOpenAIChatCompletions({ model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), + p => routeOpenAIResponsesViaOpenAIChatCompletions(p, { model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), translated => openaiChatCompletionsAttempt.generate({ payload: translated, ctx, candidate, headers: invocation.headers, }), diff --git a/packages/gateway/src/data-plane/chat/openai-responses/errors.ts b/packages/gateway/src/data-plane/chat/openai-responses/errors.ts index 55af640934..c77960f733 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/errors.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/errors.ts @@ -1,5 +1,5 @@ import { appendFailedUpstreams } from '../../shared/failed-upstreams.ts'; -import { openAiErrorResult, type ChatServeFailure } from '../shared/errors.ts'; +import { openAiErrorResult, type ChatServeFailure, type InputErrorDetails } from '../shared/errors.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; import type { ApiErrorResult, ExecuteResult, PerformanceTelemetryContext } from '@floway-dev/provider'; @@ -9,8 +9,8 @@ export type OpenAIResponsesServeFailure = ChatServeFailure | { readonly kind: 'i // Caller-input violations discovered by translation or the source affinity // membrane share the OpenAI Responses 400 envelope. `performance` retains candidate // attribution when validation fires after attempt dispatch. -export const openaiResponsesInputErrorResult = ( - error: { readonly message: string; readonly param?: string; readonly code?: string }, +export const inputErrorResult = ( + error: InputErrorDetails, performance?: PerformanceTelemetryContext, ): ApiErrorResult => openAiErrorResult(400, error.message, { param: error.param ?? 'input', code: error.code ?? null }, performance); diff --git a/packages/gateway/src/data-plane/chat/openai-responses/http.ts b/packages/gateway/src/data-plane/chat/openai-responses/http.ts index 46423989a3..d6f198ef0a 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/http.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/http.ts @@ -1,4 +1,4 @@ -import { openaiResponsesInputErrorResult } from './errors.ts'; +import { inputErrorResult } from './errors.ts'; import { createOpenAIResponsesHttpStore } from './items/store.ts'; import { respondOpenAIResponses, respondOpenAIResponsesFailure } from './respond.ts'; import { PreviousResponseNotFoundError } from './serve-prep.ts'; @@ -11,10 +11,9 @@ import { readRequestBody, takeRequestBody, type RequestBody } from '../../shared import { settle } from '../../shared/telemetry/settle.ts'; import { createChatGatewayCtxFromHono, type ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import { providerModelsUnavailableResponse } from '../shared/upstream-models-error.ts'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; +import type { OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; import { internalErrorResult, toInternalDebugError } from '@floway-dev/provider'; -import { TranslatorInputError } from '@floway-dev/translate'; -import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/translate/openai-responses'; +import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/translate'; // OpenAI's verbatim previous_response_not_found envelope. Codex compares this // body byte-for-byte against upstream — see the cross-references on @@ -51,8 +50,7 @@ const respondWithInternalError = async (c: AuthedContext, error: unknown, reques return finalizeGatewayResponse(effectiveCtx, response); }; -// Pre-stream throw dispatcher. `PreviousResponseNotFoundError` and the -// translator-input case render protocol-shaped 400s; anything else falls +// Pre-stream throw dispatcher. The previous-response case renders its protocol envelope; anything else falls // through to the internal-error 502 path. const respondToThrow = async (c: AuthedContext, error: unknown, requestBody: RequestBody, ctx?: GatewayCtx): Promise => { if (error instanceof PreviousResponseNotFoundError) { @@ -60,15 +58,10 @@ const respondToThrow = async (c: AuthedContext, error: unknown, requestBody: Req ctx?.dump?.error('gateway'); return ctx ? finalizeGatewayResponse(ctx, response) : response; } - if (error instanceof TranslatorInputError) { - const effectiveCtx = ctx ?? createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); - const response = respondOpenAIResponsesFailure(openaiResponsesInputErrorResult(error, effectiveCtx.attempt.telemetry), effectiveCtx); - return finalizeGatewayResponse(effectiveCtx, response); - } return await respondWithInternalError(c, error, requestBody, ctx); }; -const parsePayload = (requestBody: RequestBody): CanonicalOpenAIResponsesPayload => +const parsePayload = (requestBody: RequestBody) => canonicalizeOpenAIResponsesPayload(JSON.parse(new TextDecoder().decode(requestBody.bytes)) as OpenAIResponsesRequestPayloadEx); export const openaiResponsesHttp = { @@ -77,6 +70,10 @@ export const openaiResponsesHttp = { let ctx: ChatGatewayCtx | undefined; try { const payload = parsePayload(requestBody); + if (!('input' in payload)) { + const errorCtx = createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); + return finalizeGatewayResponse(errorCtx, respondOpenAIResponsesFailure(inputErrorResult(payload), errorCtx)); + } const wantsStream = payload.stream === true; ctx = createChatGatewayCtxFromHono(c, { wantsStream, requestBody: takeRequestBody(requestBody), model: payload.model, backgroundScheduler: backgroundSchedulerFromContext(c) }, (apiKey, requestStartedAt) => createOpenAIResponsesHttpStore(apiKey, requestStartedAt, payload.store ?? undefined)); const result = await openaiResponsesServe.generate({ payload, ctx, headers: inboundHeaders(c) }); @@ -92,6 +89,10 @@ export const openaiResponsesHttp = { let ctx: ChatGatewayCtx | undefined; try { const payload = parsePayload(requestBody); + if (!('input' in payload)) { + const errorCtx = createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); + return finalizeGatewayResponse(errorCtx, respondOpenAIResponsesFailure(inputErrorResult(payload), errorCtx)); + } ctx = createChatGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), model: payload.model, backgroundScheduler: backgroundSchedulerFromContext(c) }, (apiKey, requestStartedAt) => createOpenAIResponsesHttpStore(apiKey, requestStartedAt, payload.store ?? undefined)); const result = await openaiResponsesServe.compact({ payload, ctx, headers: inboundHeaders(c) }); if (result.type === 'result') { diff --git a/packages/gateway/src/data-plane/chat/openai-responses/interceptors/agent-message-shim.ts b/packages/gateway/src/data-plane/chat/openai-responses/interceptors/agent-message-shim.ts index 70c26a62cd..ac0a605812 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/interceptors/agent-message-shim.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/interceptors/agent-message-shim.ts @@ -1,4 +1,6 @@ import type { OpenAIResponsesInterceptor } from './types.ts'; +import { tryCatchChatServeFailure, type InputErrorDetails } from '../../shared/errors.ts'; +import { inputErrorResult } from '../errors.ts'; import { agentMessageContent } from '../items/agent-message-content.ts'; import { providerModelOf } from '@floway-dev/provider'; @@ -14,16 +16,22 @@ import { providerModelOf } from '@floway-dev/provider'; // `openai-responses-agent-message-shim` on a native target, which otherwise // receives the item untouched. // https://github.com/openai/codex/blob/0a2eb4696c26ac33204bcd255721ab30220a4774/codex-rs/protocol/src/models.rs#L1036-L1046 -export const withOpenAIResponsesAgentMessageShim: OpenAIResponsesInterceptor = (ctx, _gatewayCtx, run) => { +export const withOpenAIResponsesAgentMessageShim: OpenAIResponsesInterceptor = async (ctx, gatewayCtx, run) => { if (ctx.targetApi === 'openaiResponses' - && !providerModelOf(ctx.candidate).enabledFlags.has('openai-responses-agent-message-shim')) return run(); - if (!ctx.payload.input.some(item => item.type === 'agent_message')) return run(); + && !providerModelOf(ctx.candidate).enabledFlags.has('openai-responses-agent-message-shim')) return await run(); + if (!ctx.payload.input.some(item => item.type === 'agent_message')) return await run(); - ctx.payload = { - ...ctx.payload, - input: ctx.payload.input.map(item => item.type === 'agent_message' - ? { type: 'message', role: 'user', content: agentMessageContent(item) } - : item), - }; - return run(); + try { + ctx.payload = { + ...ctx.payload, + input: ctx.payload.input.map(item => item.type === 'agent_message' + ? { type: 'message', role: 'user', content: agentMessageContent(item) } + : item), + }; + } catch (error) { + const failure = tryCatchChatServeFailure(error); + if (failure === null) throw error; + return inputErrorResult(failure, gatewayCtx.attempt.telemetry); + } + return await run(); }; diff --git a/packages/gateway/src/data-plane/chat/openai-responses/items/agent-message-content.ts b/packages/gateway/src/data-plane/chat/openai-responses/items/agent-message-content.ts index 9603bfd535..ace3f38c2a 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/items/agent-message-content.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/items/agent-message-content.ts @@ -1,5 +1,5 @@ +import { throwChatServeFailure } from '../../shared/errors.ts'; import type { OpenAIResponsesInputAgentMessageItem, OpenAIResponsesInputContent, OpenAIResponsesInputImage } from '@floway-dev/protocols/openai-responses'; -import { TranslatorInputError } from '@floway-dev/translate'; interface AgentContentFields { type: string; @@ -12,14 +12,14 @@ interface AgentContentFields { const requiredString = (value: unknown, path: string): string => { if (typeof value !== 'string') { - throw new TranslatorInputError(`Invalid type for '${path}': expected a string.`, { param: path }); + return throwChatServeFailure({ kind: 'invalid-input', message: `Invalid type for '${path}': expected a string.`, param: path }); } return value; }; const nullableString = (value: unknown, path: string): string | null | undefined => { if (value !== undefined && value !== null && typeof value !== 'string') { - throw new TranslatorInputError(`Invalid type for '${path}': expected a string or null.`, { param: path }); + return throwChatServeFailure({ kind: 'invalid-input', message: `Invalid type for '${path}': expected a string or null.`, param: path }); } return value; }; @@ -107,7 +107,7 @@ export const agentMessageContent = ( }); break; default: - throw new TranslatorInputError(`Invalid value: '${part.type}' for '${path}.type'.`, { param: `${path}.type` }); + return throwChatServeFailure({ kind: 'invalid-input', message: `Invalid value: '${part.type}' for '${path}.type'.`, param: `${path}.type` }); } } diff --git a/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts b/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts index da8055d5df..fa073da736 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts @@ -21,10 +21,8 @@ import type { BackgroundScheduler } from '@floway-dev/platform'; import { eventFrame, isJsonMediaType, type ProtocolFrame } from '@floway-dev/protocols/common'; import { OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE } from '@floway-dev/protocols/openai-responses'; import { isOpenAIResponsesTerminalEvent, type CanonicalOpenAIResponsesPayload, type ClientOpenAIResponsesStreamEvent, type OpenAIResponsesOutputItemEx, type OpenAIResponsesRequestPayloadEx, type OpenAIResponsesResultEx, type OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; -import type { ExecuteResult } from '@floway-dev/provider'; -import { internalDebugErrorFields, toInternalDebugError } from '@floway-dev/provider'; -import { TranslatorInputError } from '@floway-dev/translate'; -import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/translate/openai-responses'; +import { type ExecuteResult, internalDebugErrorFields, toInternalDebugError } from '@floway-dev/provider'; +import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/translate'; interface WorkerWebSocket extends WebSocket { accept(): void; @@ -365,7 +363,11 @@ const handleClientMessage = async ( const source = message.response && typeof message.response === 'object' ? message.response : Object.fromEntries(Object.entries(message).filter(([key]) => key !== 'type' && key !== 'event_id')); - const payload = openaiResponsesPayloadFromClientSource(source); + const payload = canonicalizeOpenAIResponsesPayload({ ...source, stream: true }); + if (!('input' in payload)) { + turnFailure.fail(400, { type: 'invalid_request_error', code: payload.code ?? 'invalid_request_error', message: payload.message, param: payload.param }); + return; + } previousResponseId = payload.previous_response_id ?? undefined; ctx = createChatGatewayCtxFromHono(c, { wantsStream: true, @@ -404,15 +406,6 @@ const handleClientMessage = async ( await respondOpenAIResponsesWebSocket({ socket, eventId, signal, isClosed, result, ctx, payload, turnFailure, turn }); } catch (error) { if (signal.aborted || isClosed()) return; - if (error instanceof TranslatorInputError) { - turnFailure.fail(400, { - type: 'invalid_request_error', - code: error.code ?? 'invalid_request_error', - message: error.message, - param: error.param, - }); - return; - } if (error instanceof WebSocketClientMessageError) { turnFailure.fail(400, { type: 'invalid_request_error', @@ -451,10 +444,6 @@ const validateClientMessage = (parsed: unknown): OpenAIResponsesWebSocketClientE return parsed as OpenAIResponsesWebSocketClientEvent; }; -// The transport always streams, whatever the client sent. -const openaiResponsesPayloadFromClientSource = (source: object): CanonicalOpenAIResponsesPayload => - ({ ...canonicalizeOpenAIResponsesPayload(source as OpenAIResponsesRequestPayloadEx), stream: true }); - const respondOpenAIResponsesWebSocket = async (input: { readonly socket: OpenAIResponsesWebSocketSocket; readonly eventId: string | undefined; diff --git a/packages/gateway/src/data-plane/chat/shared/errors.ts b/packages/gateway/src/data-plane/chat/shared/errors.ts index 51629a9f89..98812f5f8f 100644 --- a/packages/gateway/src/data-plane/chat/shared/errors.ts +++ b/packages/gateway/src/data-plane/chat/shared/errors.ts @@ -10,6 +10,12 @@ export type ChatServeFailure = | { readonly kind: 'model-unsupported'; readonly model: string; readonly failedUpstreams: readonly string[] } | { readonly kind: 'routing-unavailable'; readonly message: string }; +export interface InputErrorDetails { + readonly message: string; + readonly param?: string; + readonly code?: string; +} + class ChatServeFailureError extends Error { readonly failure: TFailure; diff --git a/packages/gateway/src/data-plane/chat/shared/translate-traverse.ts b/packages/gateway/src/data-plane/chat/shared/execute-translation.ts similarity index 61% rename from packages/gateway/src/data-plane/chat/shared/translate-traverse.ts rename to packages/gateway/src/data-plane/chat/shared/execute-translation.ts index b60fac01a2..fe8f2b31ed 100644 --- a/packages/gateway/src/data-plane/chat/shared/translate-traverse.ts +++ b/packages/gateway/src/data-plane/chat/shared/execute-translation.ts @@ -1,10 +1,10 @@ import type { DumpAccumulator } from '../../../dump/accumulator.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { ChatTargetApi, ExecuteResult } from '@floway-dev/provider'; -import type { TranslateTripResult } from '@floway-dev/translate'; +import type { TranslateTrip, TranslateInputError } from '@floway-dev/translate'; // Protocol-agnostic capture hook for the pre-translation (target-protocol) -// upstream view. `traverseTranslation` invokes it as it relays the inner +// upstream view. `executeTranslation` invokes it as it relays the inner // attempt's frames/bytes through `trip.events`; the call site constructs one // from its `(ctx.dump, targetApi)` via `captureFromDump`. The target protocol // is statically known at every call site (the enclosing `if`/`case` branch), @@ -16,7 +16,7 @@ export interface TranslationUpstreamCapture { } // `dump` is null when the api key opts out of dumps; returns `undefined` so -// `traverseTranslation` skips all upstream capture work (no tee, no api-error +// `executeTranslation` skips all upstream capture work (no tee, no api-error // copy) — zero per-request cost on that path. `targetApi` is stamped eagerly so // `meta.targetApi` is set even when the upstream stream produces zero frames. export const captureFromDump = ( @@ -25,35 +25,35 @@ export const captureFromDump = ( ): TranslationUpstreamCapture | undefined => { if (dump === null) return undefined; dump.setUpstreamTargetApi(targetApi); - const upstreamFrame = (frame: ProtocolFrame) => dump.upstreamFrame(frame); - const upstreamApiError = (error: { status: number; headers: Headers; body: Uint8Array }) => dump.upstreamApiError(error); - return { upstreamFrame, upstreamApiError }; + return { + upstreamFrame: frame => dump.upstreamFrame(frame), + upstreamApiError: error => dump.upstreamApiError(error), + }; }; -// Threads a translate trip around an inner attempt. The trip itself is async -// (the real `@floway-dev/translate` route methods resolve a `Promise`), so -// `translate` returns the trip object behind a promise. This helper's -// `translate` parameter stays unary so each caller closes over its own -// `ctx` (`p => routeSource(p).viaTarget(ctx)`). -// -// On an upstream api-error the trip's optional `apiError` hook is invoked so +// On an upstream api-error, `trip.error` translates the source envelope so // the pair can rewrite the body into the source protocol's envelope — the // canonical case is `anthropic-messages-via-*` translating an upstream context-window // error into the Anthropic `prompt is too long:` shape Claude Code recognizes -// for auto-compaction. Pairs that don't set `apiError` (or return `undefined`) -// pass the upstream body through verbatim. +// for auto-compaction. Returning `undefined` passes the upstream body through verbatim. // // When `capture` is provided, the ORIGINAL target-protocol frames (events // path) or the verbatim upstream api-error envelope (api-error path) are tee'd // to the dump BEFORE translation/rewrite, so the dump records what the upstream // actually sent independent of what Floway translated it into. -export const traverseTranslation = async ( +export const executeTranslation = async ( payload: SP, - translate: (p: SP) => Promise>, + translator: (p: Omit & { readonly stream: true }) => Promise | TranslateInputError>, innerAttempt: (translated: TP) => Promise>>, capture?: TranslationUpstreamCapture, -): Promise>> => { - const trip = await translate(payload); +): Promise> | TranslateInputError> => { + // A translation trip only produces a stream, so the upstream is always asked + // for one. Whether the caller wanted a stream is a rendering decision made + // after the trip returns; asking the upstream for a single body here would + // leave the trip with an answer it has no path for. A source protocol whose + // request states no stream field ignores the added key. + const trip = await translator({ ...payload, stream: true as const }); + if (trip.type === 'input-error') return trip; const inner = await innerAttempt(trip.target); if (inner.type === 'events') { const events = capture === undefined @@ -61,16 +61,22 @@ export const traverseTranslation = async ( : teeUpstreamFrames(inner.events, capture.upstreamFrame); return { ...inner, events: trip.events(events) }; } - // Capture the ORIGINAL upstream api-error envelope before the optional - // `trip.apiError` rewrite. Fires for EVERY upstream api-error — even when no - // rewrite happens (trip.apiError undefined or returns undefined), because the - // verbatim target-protocol body is still the pre-translation view operators - // need to diagnose upstream behavior. + // Capture the ORIGINAL upstream api-error envelope before the source + // protocol restates it. Fires for EVERY upstream api-error — even when the + // trip states none — because the verbatim target-protocol body is still the + // pre-translation view operators need to diagnose upstream behavior. if (inner.type === 'api-error' && inner.source === 'upstream') { capture?.upstreamApiError({ status: inner.status, headers: inner.headers, body: inner.body }); - if (trip.apiError !== undefined) { - const rewritten = trip.apiError({ status: inner.status, headers: inner.headers, body: inner.body }); - if (rewritten !== undefined) return { ...inner, status: rewritten.status, headers: rewritten.headers, body: rewritten.body }; + const rewritten = trip.error(inner.status, inner.headers, inner.body); + // A restated failure is a JSON envelope, not bytes: the transport encodes + // it here, the only place a response body becomes wire form. + if (rewritten !== undefined) { + return { + ...inner, + status: rewritten.status, + headers: rewritten.headers, + body: new TextEncoder().encode(JSON.stringify(rewritten.body)), + }; } } return inner; diff --git a/packages/gateway/src/dump/accumulator.ts b/packages/gateway/src/dump/accumulator.ts index 9344efa314..87c15943c1 100644 --- a/packages/gateway/src/dump/accumulator.ts +++ b/packages/gateway/src/dump/accumulator.ts @@ -111,7 +111,7 @@ export class DumpAccumulator { private errorMeta: DumpErrorMeta | null = null; private readonly preparedRequestBody: Promise; // Pre-translation (target-protocol) view. Populated only on translated turns - // via `traverseTranslation`'s capture hook; stays empty on native turns. + // via `executeTranslation`'s capture hook; stays empty on native turns. private readonly upstreamEvents: DumpStreamEvent[] = []; private upstreamTargetApi: ChatTargetApi | null = null; private upstreamApiErrorEnvelope: { status: number; headers: Array<[string, string]>; body: Uint8Array } | null = null; @@ -155,7 +155,7 @@ export class DumpAccumulator { this.events.push({ frame, ts: Date.now() - this.startedAt }); } - // --- pre-translation upstream hooks (called from `traverseTranslation`) --- + // --- pre-translation upstream hooks (called from `executeTranslation`) --- // Stamped eagerly at capture construction so `meta.targetApi` is set even // when the upstream stream produces zero frames (e.g. immediate done). diff --git a/packages/gateway/src/dump/types.ts b/packages/gateway/src/dump/types.ts index 6c68476141..39621828c6 100644 --- a/packages/gateway/src/dump/types.ts +++ b/packages/gateway/src/dump/types.ts @@ -76,7 +76,7 @@ export type StoredDumpResponseBody = | { type: 'none' }; // The pre-translation (target-protocol) view of what the upstream returned. -// Present only for turns that traversed a translation (`traverseTranslation`) +// Present only for turns that traversed a translation (`executeTranslation`) // and only when the inner attempt produced events or an upstream api-error. // Native turns and gateway-synthesized errors have no upstream view. export interface StoredDumpUpstreamResponse { diff --git a/packages/translate/AGENTS.md b/packages/translate/AGENTS.md index 8a7031c696..0f0319c1e0 100644 --- a/packages/translate/AGENTS.md +++ b/packages/translate/AGENTS.md @@ -2,8 +2,8 @@ `src/protocols//` owns translation entrypoints and protocol-specific code. `__tests__/protocols/` mirrors production ownership. Package callers -import the source protocol's subpath and call -`route(input).via(context)` to create an independent trip. +import the package root and call +`routeVia(input, options)` to create an independent trip. ## Categories @@ -27,7 +27,7 @@ import the source protocol's subpath and call validation, and native stream coordination under `round-trip/`. Source replay inspection and carriers belong in `as-source/round-trip.ts`; native assistant-turn shapes and output-to-input converters belong in `as-target/assistant-turn.ts`. - Transport encoding is injected through `TranslationContext.sidecarCodec`. + Transport encoding is injected through `TranslateOptions.sidecarCodec`. 7. **Protocol-independent utility** — keep it in `shared/`, with tests under `__tests__/shared/`. Protocol behavior belongs in its owning protocol directory. diff --git a/packages/translate/__tests__/ir/continuous-usage_test.ts b/packages/translate/__tests__/ir/continuous-usage_test.ts index a64739675a..055a0efa2e 100644 --- a/packages/translate/__tests__/ir/continuous-usage_test.ts +++ b/packages/translate/__tests__/ir/continuous-usage_test.ts @@ -2,9 +2,10 @@ import { expect, test } from 'vitest'; import { collect, iterate } from './helpers.ts'; import { fixtureFrames } from './translation-cases.ts'; -import { routeOpenAIChatCompletions } from '../../src/protocols/openai-chat-completions/index.ts'; +import { requireTrip } from '../protocols/require-trip.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import type { OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; +import { routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses } from '@floway-dev/translate'; const protocols = ['anthropic-messages', 'openai-responses'] as const; const flags = [undefined, false, true]; @@ -13,8 +14,8 @@ test.each(protocols)('ChatCompletions via %s captures continuous usage preferenc for (const include_usage of flags) for (const continuous_usage_stats of flags) { const src: OpenAIChatCompletionsPayloadEx = { model: 'model', messages: [], stream: true, stream_options: { include_usage, continuous_usage_stats } }; const trip = protocol === 'anthropic-messages' - ? await routeOpenAIChatCompletions(src).viaAnthropicMessages({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'model', fallbackMaxOutputTokens: 100, loadRemoteImage: async () => { throw new Error('Unexpected image'); } }) - : await routeOpenAIChatCompletions(src).viaOpenAIResponses({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'model' }); + ? await requireTrip(routeOpenAIChatCompletionsViaAnthropicMessages(src, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'model', fallbackMaxOutputTokens: 100, loadRemoteImage: async () => { throw new Error('Unexpected image'); } })) + : await requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses(src, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'model' })); src.stream_options!.include_usage = include_usage !== true; src.stream_options!.continuous_usage_stats = continuous_usage_stats !== true; const usage = protocol === 'anthropic-messages' ? { input_tokens: 10, output_tokens: 4 } : { input_tokens: 10, output_tokens: 4, total_tokens: 14 }; @@ -31,7 +32,7 @@ test.each(protocols)('ChatCompletions via %s captures continuous usage preferenc }); test('ChatCompletions continuous usage reflects reported Responses counters without extra progress chunks', async () => { - const trip = await routeOpenAIChatCompletions({ model: 'model', messages: [], stream_options: { include_usage: true, continuous_usage_stats: true } }).viaOpenAIResponses({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'model' }); + const trip = await requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses({ model: 'model', messages: [], stream_options: { include_usage: true, continuous_usage_stats: true } }, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'model' })); const response = { id: 'resp', model: 'model', created_at: 1, status: 'in_progress', output: [], usage: null }; const frames = [ { type: 'response.created', response }, diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-source/context-window-error_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/context-window-error_test.ts index b796be5d69..e5e266c303 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-source/context-window-error_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-source/context-window-error_test.ts @@ -22,11 +22,11 @@ test('rewriteContextExceededToPromptTooLong — rewrites Copilot OpenAI Response })), }; - const result = rewriteContextExceededToPromptTooLong(upstream); + const result = rewriteContextExceededToPromptTooLong(upstream.status, upstream.headers, upstream.body); assert(result !== undefined); assertEquals(result!.status, 400); assertEquals(result!.headers.get('content-type'), 'application/json'); - const parsed = JSON.parse(new TextDecoder().decode(result!.body)) as { error: { message: string } }; + const parsed = result.body as { error: { message: string } }; assert(parsed.error.message.startsWith('prompt is too long:')); }); @@ -38,7 +38,7 @@ test('rewriteContextExceededToPromptTooLong — rewrites Codex OpenAI Responses error: { code: 'context_length_exceeded', message: 'Your input exceeds the context window of this model. Please adjust your input and try again.' }, })), }; - const result = rewriteContextExceededToPromptTooLong(upstream); + const result = rewriteContextExceededToPromptTooLong(upstream.status, upstream.headers, upstream.body); assert(result !== undefined); }); @@ -50,7 +50,7 @@ test('rewriteContextExceededToPromptTooLong — recognizes bodies whose signal i error: { message: "This model's maximum context length is 4097 tokens. However, your messages resulted in 4498 tokens." }, })), }; - assert(rewriteContextExceededToPromptTooLong(upstream) !== undefined); + assert(rewriteContextExceededToPromptTooLong(upstream.status, upstream.headers, upstream.body) !== undefined); }); test('rewriteContextExceededToPromptTooLong — passes unrelated bodies through unchanged', () => { @@ -59,7 +59,7 @@ test('rewriteContextExceededToPromptTooLong — passes unrelated bodies through headers: new Headers(), body: new TextEncoder().encode(JSON.stringify({ error: { code: 'invalid_api_key', message: 'bad token' } })), }; - assertEquals(rewriteContextExceededToPromptTooLong(upstream), undefined); + assertEquals(rewriteContextExceededToPromptTooLong(upstream.status, upstream.headers, upstream.body), undefined); }); test('rewriteContextExceededToPromptTooLong — falls back to plain-text substring when the body is not JSON', () => { @@ -68,12 +68,12 @@ test('rewriteContextExceededToPromptTooLong — falls back to plain-text substri headers: new Headers(), body: new TextEncoder().encode('some upstream prose: exceeds the context window of this model'), }; - assert(rewriteContextExceededToPromptTooLong(matching) !== undefined); + assert(rewriteContextExceededToPromptTooLong(matching.status, matching.headers, matching.body) !== undefined); const unrelated = { status: 502, headers: new Headers(), body: new TextEncoder().encode('Bad Gateway'), }; - assertEquals(rewriteContextExceededToPromptTooLong(unrelated), undefined); + assertEquals(rewriteContextExceededToPromptTooLong(unrelated.status, unrelated.headers, unrelated.body), undefined); }); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-source/native-reassembly_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/native-reassembly_test.ts index d5ce4aa386..9eda683e9a 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-source/native-reassembly_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-source/native-reassembly_test.ts @@ -2,9 +2,10 @@ import { expect, test, vi } from 'vitest'; import { computeAnthropicMessagesTurnDigest } from '../../../../src/protocols/anthropic-messages/as-source/round-trip.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { requireTrip } from '../../require-trip.ts'; import { createAnthropicMessagesEventAccumulator, reassembleAnthropicMessagesEvents } from '@floway-dev/protocols/anthropic-messages'; import { openaiResponsesResultToEvents } from '@floway-dev/protocols/openai-responses'; -import { routeAnthropicMessages } from '@floway-dev/translate/anthropic-messages'; +import { routeAnthropicMessagesViaOpenAIResponses } from '@floway-dev/translate'; vi.mock('@floway-dev/protocols/anthropic-messages', async importOriginal => { const actual = await importOriginal(); @@ -21,9 +22,9 @@ test.each(['complete', 'truncated'] as const)('reads only the current native blo vi.mocked(createAnthropicMessagesEventAccumulator).mockClear(); const count = 32; const codec = createTestSidecarCodec(); - const trip = await routeAnthropicMessages({ + const trip = await requireTrip(routeAnthropicMessagesViaOpenAIResponses({ model: 'source', max_tokens: 4096, messages: [{ role: 'user', content: 'question' }], - }).viaOpenAIResponses({ model: 'target', sidecarCodec: codec }); + }, { model: 'target', sidecarCodec: codec })); const frames = async function* () { yield* openaiResponsesResultToEvents({ id: 'response', object: 'response', model: 'target', diff --git a/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts b/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts index 0e54d49260..63edd51909 100644 --- a/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts +++ b/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts @@ -1,5 +1,6 @@ import { expect, test } from 'vitest'; +import { requireTrip } from './require-trip.ts'; import { toOpenAIResponsesInputAssistantTurn } from '../../src/protocols/openai-responses/as-target/assistant-turn.ts'; import { iterate } from '../ir/helpers.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; @@ -8,9 +9,7 @@ import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols import { collectGeminiGenerateContentProtocolEventsToResult, type GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIChatCompletionsAssistantDeltaEx, OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; import { collectOpenAIResponsesProtocolEventsToResult, type OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; -import { routeAnthropicMessages } from '@floway-dev/translate/anthropic-messages'; -import { routeGeminiGenerateContent } from '@floway-dev/translate/gemini-generate-content'; -import { routeOpenAIResponses } from '@floway-dev/translate/openai-responses'; +import { routeAnthropicMessagesViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIChatCompletions, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; const nativeFrames = (mode: 'legacy' | 'modern' | 'mixed', name: string, arguments_ = '{"x":1}'): ProtocolFrame[] => { const chunk = (delta: OpenAIChatCompletionsAssistantDeltaEx, finish_reason: 'function_call' | 'tool_calls' | null = null) => eventFrame({ @@ -52,13 +51,13 @@ test('Messages returns matching legacy function results while modern calls remai tools: [{ name: 'lookup', input_schema: { type: 'object', properties: { x: { type: 'integer' } } } }], }; for (const [index, mode] of (['legacy', 'modern', 'legacy'] as const).entries()) { - const trip = await routeAnthropicMessages(payload).viaOpenAIChatCompletions(context); + const trip = await requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)); const result = await collectAnthropicMessagesProtocolEventsToResult(trip.events(iterate(nativeFrames(mode, 'lookup')))); const call = result.content.find(block => block.type === 'tool_use'); if (call === undefined) throw new Error('Expected a public Messages tool invocation'); payload.messages.push({ role: 'assistant', content: result.content }, { role: 'user', content: [{ type: 'tool_result', tool_use_id: call.id, content: `result-${index}` }] }); } - const replay = await routeAnthropicMessages(payload).viaOpenAIChatCompletions(context); + const replay = await requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)); assertNativeHistory(replay.target, 'lookup', ['result-0', 'result-1', 'result-2']); }); @@ -70,7 +69,7 @@ test('Responses binds public namespaced call IDs to the native legacy function n }; let nativeName = ''; for (const [index, mode] of (['legacy', 'modern', 'legacy'] as const).entries()) { - const trip = await routeOpenAIResponses(payload).viaOpenAIChatCompletions(context); + const trip = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(payload, context)); const declaration = trip.target.tools?.find(tool => tool.type === 'function'); if (declaration?.type !== 'function') throw new Error('Expected a native function declaration'); nativeName = declaration.function.name; @@ -83,7 +82,7 @@ test('Responses binds public namespaced call IDs to the native legacy function n if (!Array.isArray(payload.input)) throw new Error('Expected item-array history'); payload.input.push(...toOpenAIResponsesInputAssistantTurn(result.output), { type: 'function_call_output', call_id: call.call_id, output: `result-${index}` }); } - const replay = await routeOpenAIResponses(payload).viaOpenAIChatCompletions(context); + const replay = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(payload, context)); assertNativeHistory(replay.target, nativeName, ['result-0', 'result-1', 'result-2']); }); @@ -94,7 +93,7 @@ test('Gemini matches ID-less legacy responses by public name and explicit modern tools: [{ functionDeclarations: [{ name: 'lookup', parameters: { type: 'OBJECT', properties: { x: { type: 'INTEGER' } } } }] }], }; for (const [index, mode] of (['legacy', 'modern', 'legacy'] as const).entries()) { - const trip = await routeGeminiGenerateContent(payload).viaOpenAIChatCompletions(context); + const trip = await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(payload, context)); const result = await collectGeminiGenerateContentProtocolEventsToResult(trip.events(iterate(nativeFrames(mode, 'lookup')))); const content = result.candidates?.[0]?.content; const call = content?.parts?.find(part => part.functionCall !== undefined)?.functionCall; @@ -102,20 +101,20 @@ test('Gemini matches ID-less legacy responses by public name and explicit modern expect(call.id).toBe(mode === 'modern' ? 'native-modern' : undefined); payload.contents.push(content, { role: 'user', parts: [{ functionResponse: { name: call.name, ...(call.id === undefined ? {} : { id: call.id }), response: { result: index } } }] }); } - const replay = await routeGeminiGenerateContent(payload).viaOpenAIChatCompletions(context); + const replay = await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(payload, context)); assertNativeHistory(replay.target, 'lookup', ['{"result":0}', '{"result":1}', '{"result":2}']); }); test('Gemini keeps modern IDs separate when a vendor stream includes both call formats', async () => { const context = { model: 'native-model', sidecarCodec: createTestSidecarCodec() }; const source: GeminiGenerateContentPayload = { contents: [{ role: 'user', parts: [{ text: 'Call lookup' }] }] }; - const trip = await routeGeminiGenerateContent(source).viaOpenAIChatCompletions(context); + const trip = await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(source, context)); const result = await collectGeminiGenerateContentProtocolEventsToResult(trip.events(iterate(nativeFrames('mixed', 'lookup')))); const content = result.candidates?.[0]?.content; if (content === undefined) throw new Error('Expected a public Gemini assistant content'); const calls = content.parts?.flatMap(part => part.functionCall === undefined ? [] : [part.functionCall]); expect(calls).toHaveLength(2); - const replay = await routeGeminiGenerateContent({ + const replay = await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions({ contents: [ ...source.contents ?? [], content, { @@ -125,7 +124,7 @@ test('Gemini keeps modern IDs separate when a vendor stream includes both call f ], }, ], - }).viaOpenAIChatCompletions(context); + }, context)); const native = replay.target.messages.find(message => message.role === 'assistant'); expect(native?.function_call).toEqual({ name: 'lookup', arguments: '{"x":1}' }); expect(native?.tool_calls).toEqual([{ id: 'native-modern', type: 'function', function: { name: 'lookup', arguments: '{"x":1}' } }]); @@ -137,13 +136,13 @@ test('Gemini keeps modern IDs separate when a vendor stream includes both call f test('restored legacy results retain the ordinary Responses input_file rejection', async () => { const context = { model: 'native-model', sidecarCodec: createTestSidecarCodec() }; - const trip = await routeOpenAIResponses({ model: 'public-model', input: 'Call lookup' }).viaOpenAIChatCompletions(context); + const trip = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'public-model', input: 'Call lookup' }, context)); const result = await collectOpenAIResponsesProtocolEventsToResult(trip.events(iterate(nativeFrames('legacy', 'lookup')))); const call = result.output.find(item => item.type === 'function_call'); if (call === undefined) throw new Error('Expected a public Responses invocation'); const output = { type: 'function_call_output' as const, call_id: call.call_id, output: [{ type: 'input_file' as const, file_id: 'unsupported-file' }] }; const input = toOpenAIResponsesInputAssistantTurn(result.output); for (const history of [input, input.filter(item => item.type !== 'reasoning')]) { - await expect(routeOpenAIResponses({ model: 'public-model', input: [...history, output] }).viaOpenAIChatCompletions(context)).rejects.toThrow('Cannot translate input_file tool output to OpenAI Chat Completions.'); + await expect(requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'public-model', input: [...history, output] }, context))).rejects.toThrow('Cannot translate input_file tool output to OpenAI Chat Completions.'); } }); diff --git a/packages/translate/__tests__/protocols/chat-reasoning-round-trip_test.ts b/packages/translate/__tests__/protocols/chat-reasoning-round-trip_test.ts index faae6a5b09..16f1a9b44a 100644 --- a/packages/translate/__tests__/protocols/chat-reasoning-round-trip_test.ts +++ b/packages/translate/__tests__/protocols/chat-reasoning-round-trip_test.ts @@ -1,11 +1,12 @@ import { expect, test } from 'vitest'; +import { requireTrip } from './require-trip.ts'; import { toOpenAIResponsesInputAssistantTurn } from '../../src/protocols/openai-responses/as-target/assistant-turn.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsAssistantDeltaEx, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; import { reassembleOpenAIResponsesEvents } from '@floway-dev/protocols/openai-responses'; -import { routeOpenAIResponses } from '@floway-dev/translate/openai-responses'; +import { routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; const chunk = (delta: OpenAIChatCompletionsAssistantDeltaEx, finish_reason: 'stop' | null = null) => eventFrame({ id: 'native', model: 'target', object: 'chat.completion.chunk', created: 1, choices: [{ index: 0, delta, finish_reason }], @@ -13,7 +14,7 @@ const chunk = (delta: OpenAIChatCompletionsAssistantDeltaEx, finish_reason: 'sto test('round-trips native Chat reasoning fragments through Responses without corrupting detail identity', async () => { const context = { model: 'target', sidecarCodec: createTestSidecarCodec() }; - const trip = await routeOpenAIResponses({ model: 'source', input: [{ role: 'user', content: 'question' }] }).viaOpenAIChatCompletions(context); + const trip = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'source', input: [{ role: 'user', content: 'question' }] }, context)); const upstream = async function* () { yield chunk({ role: 'assistant', reasoning_details: [{ type: 'reasoning.summary', summary: ' reading', id: 'summary', index: 0, format: 'openai-responses-v1' }] }); yield chunk({ reasoning_details: [{ type: 'reasoning.summary', summary: ' it.', id: 'summary', index: 0, format: 'openai-responses-v1' }] }); @@ -25,7 +26,7 @@ test('round-trips native Chat reasoning fragments through Responses without corr for await (const frame of trip.events(upstream())) if (frame.type === 'event') yield frame.event; }; const result = await reassembleOpenAIResponsesEvents(responseEvents()); - const restored = await routeOpenAIResponses({ model: 'source', input: toOpenAIResponsesInputAssistantTurn(result.output) }).viaOpenAIChatCompletions(context); + const restored = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'source', input: toOpenAIResponsesInputAssistantTurn(result.output) }, context)); expect(restored.target.messages).toEqual([{ role: 'assistant', content: 'answer', refusal: null, provider_specific_fields: { trace: 'native' }, reasoning_details: [ diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/native-reassembly_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/native-reassembly_test.ts index f8c64a1543..8dbd1ed9df 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/native-reassembly_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/native-reassembly_test.ts @@ -2,10 +2,11 @@ import { expect, test, vi } from 'vitest'; import { computeGeminiGenerateContentTurnDigest } from '../../../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { requireTrip } from '../../require-trip.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { createGeminiGenerateContentEventAccumulator, reassembleGeminiGenerateContentEvents } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; -import { routeGeminiGenerateContent } from '@floway-dev/translate/gemini-generate-content'; +import { routeGeminiGenerateContentViaOpenAIChatCompletions } from '@floway-dev/translate'; vi.mock('@floway-dev/protocols/gemini-generate-content', async importOriginal => { const actual = await importOriginal(); @@ -33,9 +34,9 @@ const chatFrames = async function* (count: number): AsyncGenerator { vi.mocked(createGeminiGenerateContentEventAccumulator).mockClear(); const codec = createTestSidecarCodec(); - const trip = await routeGeminiGenerateContent({ + const trip = await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions({ contents: [{ role: 'user', parts: [{ text: 'question' }] }], generationConfig: { candidateCount: count }, - }).viaOpenAIChatCompletions({ model: 'target', sidecarCodec: codec }); + }, { model: 'target', sidecarCodec: codec })); const events = async function* () { for await (const frame of trip.events(chatFrames(count))) if (frame.type === 'event') yield frame.event; }; diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/round-trip-request_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/round-trip-request_test.ts index 994004902a..25708c0be3 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/round-trip-request_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/round-trip-request_test.ts @@ -3,8 +3,9 @@ import { expect, test } from 'vitest'; import { computeGeminiGenerateContentTurnDigest } from '../../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; import { encodeRoundTripSidecar } from '../../../src/round-trip/sidecar.ts'; import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; +import { requireTrip } from '../require-trip.ts'; import type { GeminiGenerateContentContent } from '@floway-dev/protocols/gemini-generate-content'; -import { routeGeminiGenerateContent } from '@floway-dev/translate/gemini-generate-content'; +import { routeGeminiGenerateContentViaAnthropicMessages, routeGeminiGenerateContentViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIResponses } from '@floway-dev/translate'; const targetTurn = (name: string): GeminiGenerateContentContent[] => [{ role: 'model', parts: [{ functionCall: { name, args: { query: 'docs' } } }] }]; const replayedContents = (name: string, carrier: string): GeminiGenerateContentContent[] => [ @@ -18,7 +19,7 @@ test.each(['lookup', '__proto__'])('Gemini via Chat uses restored call IDs for i const codec = createTestSidecarCodec(); const native = { role: 'assistant' as const, tool_calls: [{ id: 'native-chat-call', type: 'function' as const, function: { name, arguments: '{"query":"docs"}' } }] }; const carrier = await encodeRoundTripSidecar(codec, { v: 1, source: 'geminiGenerateContent', target: 'openaiChatCompletions', targetTurn: native, sourceTurnDigest: await computeGeminiGenerateContentTurnDigest(targetTurn(name)) }); - const { target: request } = await routeGeminiGenerateContent({ contents: replayedContents(name, carrier) }).viaOpenAIChatCompletions({ model: 'test', sidecarCodec: createTestSidecarCodec('test-codec-scope') }); + const { target: request } = await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions({ contents: replayedContents(name, carrier) }, { model: 'test', sidecarCodec: createTestSidecarCodec('test-codec-scope') })); expect(request.messages).toEqual([ { role: 'user', content: 'question' }, native, @@ -31,7 +32,7 @@ test.each(['lookup', '__proto__'])('Gemini via Messages uses restored call IDs w const codec = createTestSidecarCodec(); const native = { role: 'assistant' as const, content: [{ type: 'tool_use' as const, id: 'native-messages-call', name, input: { query: 'docs' } }] }; const carrier = await encodeRoundTripSidecar(codec, { v: 1, source: 'geminiGenerateContent', target: 'anthropicMessages', targetTurn: native, sourceTurnDigest: await computeGeminiGenerateContentTurnDigest(targetTurn(name)) }); - const { target: request } = await routeGeminiGenerateContent({ contents: replayedContents(name, carrier) }).viaAnthropicMessages({ model: 'test', sidecarCodec: createTestSidecarCodec('test-codec-scope'), fallbackMaxOutputTokens: undefined }); + const { target: request } = await requireTrip(routeGeminiGenerateContentViaAnthropicMessages({ contents: replayedContents(name, carrier) }, { model: 'test', sidecarCodec: createTestSidecarCodec('test-codec-scope'), fallbackMaxOutputTokens: undefined })); expect(request.messages[1]).toEqual(native); expect(request.messages[2]).toEqual({ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'native-messages-call', content: '{"answer":42}' }] }); expect(request.messages[3]).toEqual({ role: 'assistant', content: [{ type: 'tool_use', id: 'gemini_call_3_0', name: 'other', input: {}, cache_control: { type: 'ephemeral' } }] }); @@ -41,7 +42,7 @@ test.each(['lookup', '__proto__'])('Gemini via Responses uses restored call IDs const codec = createTestSidecarCodec(); const native = [{ type: 'function_call' as const, call_id: 'native-responses-call', name, arguments: '{"query":"docs"}', status: 'completed' as const }]; const carrier = await encodeRoundTripSidecar(codec, { v: 1, source: 'geminiGenerateContent', target: 'openaiResponses', targetTurn: native, sourceTurnDigest: await computeGeminiGenerateContentTurnDigest(targetTurn(name)) }); - const { target: request } = await routeGeminiGenerateContent({ contents: replayedContents(name, carrier) }).viaOpenAIResponses({ model: 'test', sidecarCodec: createTestSidecarCodec('test-codec-scope') }); + const { target: request } = await requireTrip(routeGeminiGenerateContentViaOpenAIResponses({ contents: replayedContents(name, carrier) }, { model: 'test', sidecarCodec: createTestSidecarCodec('test-codec-scope') })); expect(request.input).toEqual([ { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'question' }] }, ...native, diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts index beafe30ffa..811f2cec7d 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts @@ -411,7 +411,7 @@ test('translateToSourceEvents translates OpenAI Responses failed terminal events test.each(['flat error', 'nested error', 'failed response'])('translateToSourceEvents preserves the error provider namespace without copying (%s)', async path => { const provider_specific_fields = { - name: 'TranslatorInputError', + name: 'TranslateInputException', stack: 'translator stack', cause: { message: 'source failed', cause: { code: 17, details: [false, null, { value: 'nested' }] } }, target_api: 'openai-responses', diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/request_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/request_test.ts index 8aac03d24e..826b190ded 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/request_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/request_test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; import { buildTargetRequest as buildTargetRequestWithSidecar } from '../../../../src/protocols/openai-chat-completions/via-openai-responses/request.ts'; -import { TranslatorInputError } from '../../../../src/translator-input-error.ts'; +import { TranslateInputException } from '../../../../src/shared/input-error.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; import type { OpenAIChatCompletionsPayload, OpenAIChatCompletionsMessage } from '@floway-dev/protocols/openai-chat-completions'; import { assertEquals, assertFalse, assertRejects } from '@floway-dev/test-utils'; @@ -259,7 +259,7 @@ test.each([null, undefined])('buildTargetRequest omits unset reasoning_effort=%j }); test('rejects a json_schema request with no schema instead of removing constraints', async () => { - await assertRejects(() => buildWithTestSidecar({ model: 'gpt-test', messages: [], response_format: { type: 'json_schema', json_schema: { name: 'shape' } } }), TranslatorInputError, 'without a schema'); + await assertRejects(() => buildWithTestSidecar({ model: 'gpt-test', messages: [], response_format: { type: 'json_schema', json_schema: { name: 'shape' } } }), TranslateInputException, 'without a schema'); }); test('allowed_tools retains both native callable kinds', async () => { diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/allowed-tools_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/allowed-tools_test.ts index 1927534f24..58a9a2d950 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/allowed-tools_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/allowed-tools_test.ts @@ -2,7 +2,7 @@ import { test } from 'vitest'; import { buildTargetRequest as buildMessages } from '../../../../src/protocols/openai-responses/via-anthropic-messages/request.ts'; import { buildTargetRequest as buildChat } from '../../../../src/protocols/openai-responses/via-openai-chat-completions/request.ts'; -import { TranslatorInputError } from '../../../../src/translator-input-error.ts'; +import { TranslateInputException } from '../../../../src/shared/input-error.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; import type { CanonicalOpenAIResponsesInputItem, OpenAIResponsesRequestPayloadEx, OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; @@ -56,7 +56,7 @@ for (const target of ['chat', 'messages'] as const) { tools: [{ type: 'function', name: 'web_search', parameters: { type: 'object' } }], input: [item], }; - await assertRejects(() => build(payload), TranslatorInputError, "distinct callable kinds sharing 'web_search'"); + await assertRejects(() => build(payload), TranslateInputException, "distinct callable kinds sharing 'web_search'"); }); } @@ -69,7 +69,7 @@ for (const target of ['chat', 'messages'] as const) { test(`${target} request rejects a flat selector that would erase a declaration namespace`, async () => { const payload = source({ type: 'allowed_tools', mode: 'auto', tools: [{ type: 'function', name: 'read' }] }); payload.tools![0] = { ...payload.tools![0], namespace: 'files' } as unknown as OpenAIResponsesTool; - await assertRejects(() => build(payload), TranslatorInputError, 'allowed_tools'); + await assertRejects(() => build(payload), TranslateInputException, 'allowed_tools'); }); for (const [label, choice] of [ @@ -87,7 +87,7 @@ for (const target of ['chat', 'messages'] as const) { test(`${target} request typed-rejects ${label} instead of widening allowed_tools`, async () => { const payload = source(choice as unknown as OpenAIResponsesToolChoice); payload.tools!.push({ type: 'custom', name: 'read' }); - await assertRejects(() => build(payload), TranslatorInputError, 'allowed_tools'); + await assertRejects(() => build(payload), TranslateInputException, 'allowed_tools'); }); } } diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-tools_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-tools_test.ts index b909c5bf69..e33c749b2c 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-tools_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-tools_test.ts @@ -1,14 +1,16 @@ import { expect, test } from 'vitest'; import { flattenNamespaceTools, restoreNamespaceEvents, restoreNamespaceOutputItem } from '../../../../src/protocols/openai-responses/as-source/namespace-tools.ts'; +import { requireCanonicalOpenAIResponsesPayload } from '../../../../src/protocols/openai-responses/canonicalize-payload.ts'; import { buildTargetRequest as buildMessagesRequest } from '../../../../src/protocols/openai-responses/via-anthropic-messages/request.ts'; import { buildTargetRequest as buildChatRequest } from '../../../../src/protocols/openai-responses/via-openai-chat-completions/request.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { requireTrip } from '../../require-trip.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesOutputItemEx, OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; -import { routeOpenAIResponses, canonicalizeOpenAIResponsesPayload } from '@floway-dev/translate/openai-responses'; +import { routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; const payload = (): OpenAIResponsesRequestPayloadEx => ({ model: 'm', @@ -105,7 +107,7 @@ test('forced namespace choices use the declaration mapping', async () => { }); test('restores function and custom calls across item events and terminal snapshots', async () => { - const prepared = flattenNamespaceTools(canonicalizeOpenAIResponsesPayload(payload())); + const prepared = flattenNamespaceTools(requireCanonicalOpenAIResponsesPayload(payload())); const output = [ { type: 'function_call' as const, name: 'agents_spawn_2', call_id: 'spawn', arguments: '{"name":"agents_spawn_2"}', status: 'completed' }, { type: 'custom_tool_call' as const, name: 'agents_audit', call_id: 'audit', input: 'agents_audit' }, @@ -127,7 +129,7 @@ test('restores function and custom calls across item events and terminal snapsho }); test('restores all function/custom output item type combinations without retaining the replaced payload field', () => { - const names = flattenNamespaceTools(canonicalizeOpenAIResponsesPayload(payload())).names; + const names = flattenNamespaceTools(requireCanonicalOpenAIResponsesPayload(payload())).names; const functionName = names.sourceToTarget.get('agents.spawn'); const customName = names.sourceToTarget.get('agents.audit'); if (functionName === undefined || customName === undefined) throw new Error('Expected flattened namespace tool names'); @@ -172,7 +174,7 @@ test('rejects function/custom ambiguity and distinct tuples with the same qualif }); test('the complete Chat Completions trip restores the namespace after target tool calls', async () => { - const trip = await routeOpenAIResponses(payload()).viaOpenAIChatCompletions({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' }); + const trip = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(payload(), { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' })); const frames = (async function* (): AsyncGenerator> { yield eventFrame({ id: 'chat1', object: 'chat.completion.chunk', model: 'm', created: 0, choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call1', type: 'function', function: { name: 'agents_spawn_2', arguments: '{}' } }] }, finish_reason: null }] }); yield eventFrame({ id: 'chat1', object: 'chat.completion.chunk', model: 'm', created: 0, choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] }); @@ -185,7 +187,7 @@ test('the complete Chat Completions trip restores the namespace after target too }); test('the complete Anthropic Messages trip restores the namespace after target tool calls', async () => { - const trip = await routeOpenAIResponses(payload()).viaAnthropicMessages({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm', loadRemoteImage: async () => { throw new Error('Unexpected remote image'); } }); + const trip = await requireTrip(routeOpenAIResponsesViaAnthropicMessages(payload(), { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm', loadRemoteImage: async () => { throw new Error('Unexpected remote image'); } })); const frames = (async function* (): AsyncGenerator> { yield eventFrame({ type: 'message_start', message: { container: null, diagnostics: null, stop_details: null, id: 'msg1', type: 'message', model: 'm', role: 'assistant', content: [], stop_reason: null, stop_sequence: null, usage: { cache_creation: null, cache_creation_input_tokens: null, cache_read_input_tokens: null, inference_geo: null, output_tokens_details: null, server_tool_use: null, service_tier: null, input_tokens: 1, output_tokens: 0 } } }); yield eventFrame({ type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'call1', name: 'agents_spawn_2', input: {} } }); diff --git a/packages/translate/__tests__/protocols/openai-responses/canonicalize-payload_test.ts b/packages/translate/__tests__/protocols/openai-responses/canonicalize-payload_test.ts index 5e093764bc..c3e7fa5bfd 100644 --- a/packages/translate/__tests__/protocols/openai-responses/canonicalize-payload_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/canonicalize-payload_test.ts @@ -1,9 +1,7 @@ import { test } from 'vitest'; -import { TranslatorInputError } from '../../../src/translator-input-error.ts'; -import type { OpenAIResponsesPayloadEx } from '@floway-dev/protocols/openai-responses'; -import { assertEquals, assertThrows } from '@floway-dev/test-utils'; -import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/translate/openai-responses'; +import { assert, assertEquals } from '@floway-dev/test-utils'; +import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/translate'; test('canonicalizes string and implicit-message wire inputs', () => { assertEquals(canonicalizeOpenAIResponsesPayload({ model: 'gpt-test', input: 'hello' }), { @@ -62,13 +60,9 @@ test('rejects a payload without a usable model at the canonical boundary', () => { model: 42, input: 'hello' }, { model: null, input: 'hello' }, ]) { - const error = assertThrows( - () => canonicalizeOpenAIResponsesPayload(payload), - TranslatorInputError, - "Missing required parameter: 'model'.", - ) as TranslatorInputError; - assertEquals(error.param, 'model'); - assertEquals(error.code, 'missing_required_parameter'); + assertEquals(canonicalizeOpenAIResponsesPayload(payload), { + type: 'input-error', message: "Missing required parameter: 'model'.", param: 'model', code: 'missing_required_parameter', + }); } }); @@ -84,15 +78,9 @@ test('rejects malformed untyped input items at the canonical boundary', () => { { role: 'user', content: [{ type: 'input_text', text: 'invalid breakpoint', prompt_cache_breakpoint: {} }] }, { role: 'user', content: 'invalid phase', phase: 42 }, ]) { - const error = assertThrows( - () => canonicalizeOpenAIResponsesPayload({ - model: 'gpt-test', - input: [malformed] as unknown as OpenAIResponsesPayloadEx['input'], - }), - TranslatorInputError, - 'valid role and content', - ) as TranslatorInputError; - assertEquals(error.param, 'input[0]'); + assertEquals(canonicalizeOpenAIResponsesPayload({ model: 'gpt-test', input: [malformed] }), { + type: 'input-error', message: 'Untyped OpenAI Responses input items require a valid role and content.', param: 'input[0]', + }); } }); @@ -102,6 +90,7 @@ test('canonicalizeOpenAIResponsesPayload preserves reasoning.context verbatim, i input: [{ type: 'message', role: 'user', content: 'hi' }], reasoning: { effort: 'high', context: 'current_turn' }, }); + assert('input' in canonicalCurrent); assertEquals(canonicalCurrent.reasoning, { effort: 'high', context: 'current_turn' }); // An unknown/future context string rides through the wire→canonical boundary @@ -111,5 +100,6 @@ test('canonicalizeOpenAIResponsesPayload preserves reasoning.context verbatim, i input: 'hi', reasoning: { context: 'future_mode' }, }); + assert('input' in canonicalFuture); assertEquals(canonicalFuture.reasoning, { context: 'future_mode' }); }); diff --git a/packages/translate/__tests__/protocols/public-boundary_test.ts b/packages/translate/__tests__/protocols/public-boundary_test.ts new file mode 100644 index 0000000000..511e9c6bee --- /dev/null +++ b/packages/translate/__tests__/protocols/public-boundary_test.ts @@ -0,0 +1,60 @@ +import { expect, test } from 'vitest'; + +import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { + canonicalizeOpenAIResponsesPayload, + routeGeminiGenerateContentViaOpenAIChatCompletions, + routeOpenAIChatCompletionsViaAnthropicMessages, + routeOpenAIChatCompletionsViaOpenAIResponses, + routeOpenAIResponsesViaOpenAIChatCompletions, +} from '@floway-dev/translate'; + +const options = { model: 'resolved-model', sidecarCodec: createTestSidecarCodec('public-boundary') }; + +test('public translators return ordinary input-failure data before preparing a trip', async () => { + const failure = await routeOpenAIChatCompletionsViaOpenAIResponses({ + model: 'source-model', messages: [], + response_format: { type: 'json_schema', json_schema: { name: 'shape' } }, + }, options); + expect(failure).toEqual({ type: 'input-error', message: expect.stringContaining('without a schema') }); + expect(Object.getPrototypeOf(failure)).toBe(Object.prototype); + + const canonicalFailure = await routeOpenAIResponsesViaOpenAIChatCompletions({ model: '', input: [] }, options); + expect(canonicalFailure).toEqual({ + type: 'input-error', message: "Missing required parameter: 'model'.", param: 'model', code: 'missing_required_parameter', + }); +}); + +test('public operations preserve unexpected errors and their causes', async () => { + const cause = new Error('transport'); + const failure = new Error('image loader', { cause }); + await expect(routeOpenAIChatCompletionsViaAnthropicMessages({ + model: 'source-model', messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://example.com/image.png' } }] }], + }, { ...options, fallbackMaxOutputTokens: 32, loadRemoteImage: async () => { throw failure; } })).rejects.toBe(failure); + + expect(() => canonicalizeOpenAIResponsesPayload({ get model() { throw failure; } })).toThrow(failure); + expect(failure.cause).toBe(cause); +}); + +test('Messages translation retains the injected remote-image loader', async () => { + const urls: string[] = []; + const trip = await routeOpenAIChatCompletionsViaAnthropicMessages({ + model: 'source-model', messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://example.com/image.png' } }] }], + }, { + ...options, fallbackMaxOutputTokens: 32, + loadRemoteImage: async url => { urls.push(url); return { mediaType: 'image/png', data: new Uint8Array([1, 2, 3]) }; }, + }); + expect(trip.type).toBe('trip'); + if (trip.type !== 'trip') throw new Error(trip.message); + expect(urls).toEqual(['https://example.com/image.png']); + expect(trip.target.messages[0].content).toEqual([{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AQID' }, cache_control: { type: 'ephemeral' } }]); + expect(trip.target.max_tokens).toBe(32); + expect(trip.error(418, new Headers(), new Uint8Array())).toBeUndefined(); +}); + +test('Gemini translation retains the resolved model supplied by the gateway', async () => { + const trip = await routeGeminiGenerateContentViaOpenAIChatCompletions({ contents: [{ role: 'user', parts: [{ text: 'hello' }] }] }, options); + expect(trip.type).toBe('trip'); + if (trip.type !== 'trip') throw new Error(trip.message); + expect(trip.target.model).toBe('resolved-model'); +}); diff --git a/packages/translate/__tests__/protocols/require-trip.ts b/packages/translate/__tests__/protocols/require-trip.ts new file mode 100644 index 0000000000..e9200a26bb --- /dev/null +++ b/packages/translate/__tests__/protocols/require-trip.ts @@ -0,0 +1,7 @@ +import type { TranslateInputError } from '@floway-dev/translate'; + +export const requireTrip = async (result: Promise): Promise => { + const trip = await result; + if (trip.type === 'input-error') throw new Error(trip.message); + return trip; +}; diff --git a/packages/translate/__tests__/protocols/round-trip-receive_test.ts b/packages/translate/__tests__/protocols/round-trip-receive_test.ts index ef74461ed8..6a19f827b5 100644 --- a/packages/translate/__tests__/protocols/round-trip-receive_test.ts +++ b/packages/translate/__tests__/protocols/round-trip-receive_test.ts @@ -1,5 +1,6 @@ import { expect, test } from 'vitest'; +import { requireTrip } from './require-trip.ts'; import type { IRJSONObject } from '../../src/ir/types.ts'; import { computeAnthropicMessagesTurnDigest } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; import { computeGeminiGenerateContentTurnDigest } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; @@ -16,10 +17,7 @@ import type { AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-m import type { GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; -import { routeAnthropicMessages } from '@floway-dev/translate/anthropic-messages'; -import { routeGeminiGenerateContent } from '@floway-dev/translate/gemini-generate-content'; -import { routeOpenAIChatCompletions } from '@floway-dev/translate/openai-chat-completions'; -import { routeOpenAIResponses } from '@floway-dev/translate/openai-responses'; +import { routeAnthropicMessagesViaOpenAIChatCompletions, routeAnthropicMessagesViaOpenAIResponses, routeGeminiGenerateContentViaAnthropicMessages, routeGeminiGenerateContentViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIResponses, routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses, routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; const context = { model: 'target', sidecarCodec: createTestSidecarCodec('test-codec-scope'), loadRemoteImage: async () => null, fallbackMaxOutputTokens: undefined }; const chat = (carrier: string, text = 'answer'): OpenAIChatCompletionsPayloadEx => { @@ -46,15 +44,15 @@ const nativeTurn = (target: NativeProtocol, text = 'restored'): AssistantTurnFor : [{ type: 'message', role: 'assistant', content: text }]; const cases = [ - { source: 'openaiChatCompletions', target: 'anthropicMessages', run: (carrier: string) => routeOpenAIChatCompletions(chat(carrier)).viaAnthropicMessages(context) }, - { source: 'openaiChatCompletions', target: 'openaiResponses', run: (carrier: string) => routeOpenAIChatCompletions(chat(carrier)).viaOpenAIResponses(context) }, - { source: 'anthropicMessages', target: 'openaiChatCompletions', run: (carrier: string) => routeAnthropicMessages(messages(carrier)).viaOpenAIChatCompletions(context) }, - { source: 'anthropicMessages', target: 'openaiResponses', run: (carrier: string) => routeAnthropicMessages(messages(carrier)).viaOpenAIResponses(context) }, - { source: 'openaiResponses', target: 'anthropicMessages', run: (carrier: string) => routeOpenAIResponses(responses(carrier)).viaAnthropicMessages(context) }, - { source: 'openaiResponses', target: 'openaiChatCompletions', run: (carrier: string) => routeOpenAIResponses(responses(carrier)).viaOpenAIChatCompletions(context) }, - { source: 'geminiGenerateContent', target: 'anthropicMessages', run: (carrier: string) => routeGeminiGenerateContent(gemini(carrier)).viaAnthropicMessages(context) }, - { source: 'geminiGenerateContent', target: 'openaiChatCompletions', run: (carrier: string) => routeGeminiGenerateContent(gemini(carrier)).viaOpenAIChatCompletions(context) }, - { source: 'geminiGenerateContent', target: 'openaiResponses', run: (carrier: string) => routeGeminiGenerateContent(gemini(carrier)).viaOpenAIResponses(context) }, + { source: 'openaiChatCompletions', target: 'anthropicMessages', run: (carrier: string) => requireTrip(routeOpenAIChatCompletionsViaAnthropicMessages(chat(carrier), context)) }, + { source: 'openaiChatCompletions', target: 'openaiResponses', run: (carrier: string) => requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses(chat(carrier), context)) }, + { source: 'anthropicMessages', target: 'openaiChatCompletions', run: (carrier: string) => requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions(messages(carrier), context)) }, + { source: 'anthropicMessages', target: 'openaiResponses', run: (carrier: string) => requireTrip(routeAnthropicMessagesViaOpenAIResponses(messages(carrier), context)) }, + { source: 'openaiResponses', target: 'anthropicMessages', run: (carrier: string) => requireTrip(routeOpenAIResponsesViaAnthropicMessages(responses(carrier), context)) }, + { source: 'openaiResponses', target: 'openaiChatCompletions', run: (carrier: string) => requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(responses(carrier), context)) }, + { source: 'geminiGenerateContent', target: 'anthropicMessages', run: (carrier: string) => requireTrip(routeGeminiGenerateContentViaAnthropicMessages(gemini(carrier), context)) }, + { source: 'geminiGenerateContent', target: 'openaiChatCompletions', run: (carrier: string) => requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(gemini(carrier), context)) }, + { source: 'geminiGenerateContent', target: 'openaiResponses', run: (carrier: string) => requireTrip(routeGeminiGenerateContentViaOpenAIResponses(gemini(carrier), context)) }, ] as const; test.each(cases)('receives recognized $source history via $target', async ({ source, target, run }) => { @@ -91,7 +89,7 @@ test('hydrates a native JSON field from a replayed GenerateContent argument cand referencedContentHashes: [await hashRoundTripContent(JSON.stringify(args))], sourceTurnDigest: await hashRoundTripContent(JSON.stringify(checkMaterial)), }); - const trip = await routeGeminiGenerateContent({ contents: [{ role: 'model', parts: [{ functionCall: { name: 'lookup', args }, thoughtSignature: carrier }] }] }).viaAnthropicMessages(context); + const trip = await requireTrip(routeGeminiGenerateContentViaAnthropicMessages({ contents: [{ role: 'model', parts: [{ functionCall: { name: 'lookup', args }, thoughtSignature: carrier }] }] }, context)); expect(trip.target.messages).toEqual([{ role: 'assistant', content: [{ type: 'tool_use', id: 'native-call', name: 'native-tool', input: args }] }]); }); @@ -119,7 +117,7 @@ test.each(['different codec scope', 'changed sidecar handle', 'changed replay'] const encoded = await encodeRoundTripSidecar(codec, { v: 1, source: 'openaiChatCompletions', target: 'anthropicMessages', targetTurn: native, sourceTurnDigest: await turnDigest('openaiChatCompletions') }); const carrier = failure === 'changed sidecar handle' ? `${encoded.startsWith('A') ? 'B' : 'A'}${encoded.slice(1)}` : encoded; const text = failure === 'changed replay' ? 'edited' : 'answer'; - const trip = await routeOpenAIChatCompletions(chat(carrier, text)).viaAnthropicMessages(context); + const trip = await requireTrip(routeOpenAIChatCompletionsViaAnthropicMessages(chat(carrier, text), context)); expect(trip.target.messages).toEqual([{ role: 'assistant', content: [{ type: 'text', text, cache_control: { type: 'ephemeral' } }] }]); }); @@ -128,11 +126,11 @@ test.each([{ input: 'text' }, { input: null }, { input: [1] }])('preserves ordin model: 'source', max_tokens: 16, messages: [{ role: 'assistant', content: [{ type: 'tool_use', id: 'call', name: 'echo', input }] }], }; - const chatTrip = await routeAnthropicMessages(payload).viaOpenAIChatCompletions(context); + const chatTrip = await requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)); expect(chatTrip.target.messages).toMatchObject([ { role: 'assistant', tool_calls: [{ id: 'call', function: { name: 'echo', arguments: JSON.stringify(input) } }] }, ]); - const responsesTrip = await routeAnthropicMessages(payload).viaOpenAIResponses(context); + const responsesTrip = await requireTrip(routeAnthropicMessagesViaOpenAIResponses(payload, context)); expect(responsesTrip.target.input).toMatchObject([ { type: 'function_call', call_id: 'call', name: 'echo', arguments: JSON.stringify(input) }, ]); @@ -140,15 +138,15 @@ test.each([{ input: 'text' }, { input: null }, { input: [1] }])('preserves ordin test('preserves ordinary GenerateContent null-argument handling', async () => { const payload: GeminiGenerateContentPayload = JSON.parse('{"contents":[{"role":"model","parts":[{"functionCall":{"name":"lookup","args":null}}]}]}'); - const chatTrip = await routeGeminiGenerateContent(payload).viaOpenAIChatCompletions(context); + const chatTrip = await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(payload, context)); expect(chatTrip.target.messages).toMatchObject([ { role: 'assistant', tool_calls: [{ function: { name: 'lookup', arguments: 'null' } }] }, ]); - const responsesTrip = await routeGeminiGenerateContent(payload).viaOpenAIResponses(context); + const responsesTrip = await requireTrip(routeGeminiGenerateContentViaOpenAIResponses(payload, context)); expect(responsesTrip.target.input).toMatchObject([ { type: 'function_call', name: 'lookup', arguments: 'null' }, ]); - const messagesTrip = await routeGeminiGenerateContent(payload).viaAnthropicMessages(context); + const messagesTrip = await requireTrip(routeGeminiGenerateContentViaAnthropicMessages(payload, context)); expect(messagesTrip.target.messages).toMatchObject([ { role: 'assistant', content: [{ type: 'tool_use', name: 'lookup', input: null }] }, ]); @@ -163,7 +161,7 @@ test('exposes a recognized malformed assistant envelope as an internal error', a targetTurn: { role: 'user', content: [] }, sourceTurnDigest: await turnDigest('openaiChatCompletions'), }); - await expect(routeOpenAIChatCompletions(chat(carrier)).viaAnthropicMessages(context)).rejects.toThrow('Messages assistant turn is invalid'); + await expect(requireTrip(routeOpenAIChatCompletionsViaAnthropicMessages(chat(carrier), context))).rejects.toThrow('Messages assistant turn is invalid'); }); test('accepts incomplete Responses call history and requires its replayed status to match', async () => { @@ -179,9 +177,9 @@ test('accepts incomplete Responses call history and requires its replayed status sourceTurnDigest: await hashRoundTripContent(JSON.stringify(expected)), }); const reasoning = { type: 'reasoning' as const, id: 'carrier', summary: [], encrypted_content: carrier }; - const replayed = await routeOpenAIResponses({ model: 'source', input: [call, reasoning] }).viaAnthropicMessages(context); + const replayed = await requireTrip(routeOpenAIResponsesViaAnthropicMessages({ model: 'source', input: [call, reasoning] }, context)); expect(replayed.target.messages).toEqual([native]); - const changed = await routeOpenAIResponses({ model: 'source', input: [{ ...call, status: 'completed' }, reasoning] }).viaAnthropicMessages(context); + const changed = await requireTrip(routeOpenAIResponsesViaAnthropicMessages({ model: 'source', input: [{ ...call, status: 'completed' }, reasoning] }, context)); expect(changed.target.messages).not.toEqual([native]); expect(changed.target.messages).toEqual(expect.arrayContaining([ expect.objectContaining({ content: expect.arrayContaining([expect.objectContaining({ type: 'tool_use', id: 'call', name: 'lookup', input: { x: 1 } })]) }), diff --git a/packages/translate/__tests__/protocols/routes_test.ts b/packages/translate/__tests__/protocols/routes_test.ts index 129b7a61c5..319c6c3ba7 100644 --- a/packages/translate/__tests__/protocols/routes_test.ts +++ b/packages/translate/__tests__/protocols/routes_test.ts @@ -1,5 +1,6 @@ import { test } from 'vitest'; +import { requireTrip } from './require-trip.ts'; import { partitionAnthropicMessagesTurns } from '../../src/protocols/anthropic-messages/as-source/conversation-turns.ts'; import { findAnthropicMessagesSidecarCarriers } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; import { findGeminiGenerateContentSidecarCarriers } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; @@ -19,10 +20,7 @@ import { collectGeminiGenerateContentProtocolEventsToResult, type GeminiGenerate import { collectOpenAIChatCompletionsProtocolEventsToResult, type OpenAIChatCompletionsAssistantMessageEx, type OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; import { collectOpenAIResponsesProtocolEventsToResult, type CanonicalOpenAIResponsesInputItem, type CanonicalOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; import { assert, assertEquals } from '@floway-dev/test-utils'; -import { routeAnthropicMessages } from '@floway-dev/translate/anthropic-messages'; -import { routeGeminiGenerateContent } from '@floway-dev/translate/gemini-generate-content'; -import { routeOpenAIChatCompletions } from '@floway-dev/translate/openai-chat-completions'; -import { routeOpenAIResponses } from '@floway-dev/translate/openai-responses'; +import { routeAnthropicMessagesViaOpenAIChatCompletions, routeAnthropicMessagesViaOpenAIResponses, routeGeminiGenerateContentViaAnthropicMessages, routeGeminiGenerateContentViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIResponses, routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses, routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; const schema = { type: 'object' as const, properties: { x: { type: 'string' } } }; const formatSchema = { type: 'object', properties: { y: { type: 'string' } } }; @@ -77,21 +75,16 @@ const objectsIn = (value: unknown): Set => { return objects; }; -const responsesRoute = routeOpenAIResponses(responses); -const chatRoute = routeOpenAIChatCompletions(chat); -const anthropicRoute = routeAnthropicMessages(anthropic); -const geminiRoute = routeGeminiGenerateContent(gemini); - const translations: Array<{ name: string; source: unknown; translate: () => Promise }> = [ - { name: 'Responses to Chat Completions', source: responses, translate: async () => (await responsesRoute.viaOpenAIChatCompletions({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' })).target }, - { name: 'Responses to Anthropic Messages', source: responses, translate: async () => (await responsesRoute.viaAnthropicMessages({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm', loadRemoteImage: async () => { throw new Error('Unexpected remote image'); } })).target }, - { name: 'Chat Completions to Responses', source: chat, translate: async () => (await chatRoute.viaOpenAIResponses({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' })).target }, - { name: 'Chat Completions to Anthropic Messages', source: chat, translate: async () => (await chatRoute.viaAnthropicMessages({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm', loadRemoteImage: async () => { throw new Error('Unexpected remote image'); } })).target }, - { name: 'Anthropic Messages to Responses', source: anthropic, translate: async () => (await anthropicRoute.viaOpenAIResponses({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' })).target }, - { name: 'Anthropic Messages to Chat Completions', source: anthropic, translate: async () => (await anthropicRoute.viaOpenAIChatCompletions({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' })).target }, - { name: 'Gemini to Responses', source: gemini, translate: async () => (await geminiRoute.viaOpenAIResponses({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' })).target }, - { name: 'Gemini to Chat Completions', source: gemini, translate: async () => (await geminiRoute.viaOpenAIChatCompletions({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' })).target }, - { name: 'Gemini to Anthropic Messages', source: gemini, translate: async () => (await geminiRoute.viaAnthropicMessages({ sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm', fallbackMaxOutputTokens: 16 })).target }, + { name: 'Responses to Chat Completions', source: responses, translate: async () => (await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(responses, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' }))).target }, + { name: 'Responses to Anthropic Messages', source: responses, translate: async () => (await requireTrip(routeOpenAIResponsesViaAnthropicMessages(responses, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm', loadRemoteImage: async () => { throw new Error('Unexpected remote image'); } }))).target }, + { name: 'Chat Completions to Responses', source: chat, translate: async () => (await requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses(chat, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' }))).target }, + { name: 'Chat Completions to Anthropic Messages', source: chat, translate: async () => (await requireTrip(routeOpenAIChatCompletionsViaAnthropicMessages(chat, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm', loadRemoteImage: async () => { throw new Error('Unexpected remote image'); } }))).target }, + { name: 'Anthropic Messages to Responses', source: anthropic, translate: async () => (await requireTrip(routeAnthropicMessagesViaOpenAIResponses(anthropic, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' }))).target }, + { name: 'Anthropic Messages to Chat Completions', source: anthropic, translate: async () => (await requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions(anthropic, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' }))).target }, + { name: 'Gemini to Responses', source: gemini, translate: async () => (await requireTrip(routeGeminiGenerateContentViaOpenAIResponses(gemini, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' }))).target }, + { name: 'Gemini to Chat Completions', source: gemini, translate: async () => (await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(gemini, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' }))).target }, + { name: 'Gemini to Anthropic Messages', source: gemini, translate: async () => (await requireTrip(routeGeminiGenerateContentViaAnthropicMessages(gemini, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm', fallbackMaxOutputTokens: 16 }))).target }, ]; test.each(translations)('$name owns its target payload without retaining source or prior target objects', async ({ source, translate }) => { @@ -114,15 +107,15 @@ type Protocol = 'openai-chat-completions' | 'openai-responses' | 'anthropic-mess const codecScope = 'test-codec-scope'; const returnedTrips: { a: Protocol; b: Protocol; trip: (codecScope: string) => Promise<{ events: (frames: AsyncIterable) => AsyncIterable }> }[] = [ - { a: 'openai-responses', b: 'openai-chat-completions', trip: codecScope => responsesRoute.viaOpenAIChatCompletions({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }) }, - { a: 'openai-responses', b: 'anthropic-messages', trip: codecScope => responsesRoute.viaAnthropicMessages({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', loadRemoteImage: async () => null }) }, - { a: 'openai-chat-completions', b: 'openai-responses', trip: codecScope => chatRoute.viaOpenAIResponses({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }) }, - { a: 'openai-chat-completions', b: 'anthropic-messages', trip: codecScope => chatRoute.viaAnthropicMessages({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', loadRemoteImage: async () => null }) }, - { a: 'anthropic-messages', b: 'openai-responses', trip: codecScope => anthropicRoute.viaOpenAIResponses({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }) }, - { a: 'anthropic-messages', b: 'openai-chat-completions', trip: codecScope => anthropicRoute.viaOpenAIChatCompletions({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }) }, - { a: 'gemini-generate-content', b: 'openai-responses', trip: codecScope => geminiRoute.viaOpenAIResponses({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }) }, - { a: 'gemini-generate-content', b: 'openai-chat-completions', trip: codecScope => geminiRoute.viaOpenAIChatCompletions({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }) }, - { a: 'gemini-generate-content', b: 'anthropic-messages', trip: codecScope => geminiRoute.viaAnthropicMessages({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', fallbackMaxOutputTokens: 16 }) }, + { a: 'openai-responses', b: 'openai-chat-completions', trip: codecScope => requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(responses, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })) }, + { a: 'openai-responses', b: 'anthropic-messages', trip: codecScope => requireTrip(routeOpenAIResponsesViaAnthropicMessages(responses, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', loadRemoteImage: async () => null })) }, + { a: 'openai-chat-completions', b: 'openai-responses', trip: codecScope => requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses(chat, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })) }, + { a: 'openai-chat-completions', b: 'anthropic-messages', trip: codecScope => requireTrip(routeOpenAIChatCompletionsViaAnthropicMessages(chat, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', loadRemoteImage: async () => null })) }, + { a: 'anthropic-messages', b: 'openai-responses', trip: codecScope => requireTrip(routeAnthropicMessagesViaOpenAIResponses(anthropic, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })) }, + { a: 'anthropic-messages', b: 'openai-chat-completions', trip: codecScope => requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions(anthropic, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })) }, + { a: 'gemini-generate-content', b: 'openai-responses', trip: codecScope => requireTrip(routeGeminiGenerateContentViaOpenAIResponses(gemini, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })) }, + { a: 'gemini-generate-content', b: 'openai-chat-completions', trip: codecScope => requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(gemini, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })) }, + { a: 'gemini-generate-content', b: 'anthropic-messages', trip: codecScope => requireTrip(routeGeminiGenerateContentViaAnthropicMessages(gemini, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', fallbackMaxOutputTokens: 16 })) }, ] as const; const protocolKeys = { @@ -223,25 +216,21 @@ type TargetPayload = const replayAssistantTurn = async (assistant: AssistantTurn, target: Protocol): Promise => { if (assistant.protocol === 'openai-responses') { - const route = routeOpenAIResponses({ ...responses, input: [...assistant.turn, { type: 'message', role: 'user', content: 'next question' }] }); - if (target === 'openai-chat-completions') return { protocol: target, payload: (await route.viaOpenAIChatCompletions({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })).target }; - if (target === 'anthropic-messages') return { protocol: target, payload: (await route.viaAnthropicMessages({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', loadRemoteImage: async () => null })).target }; + if (target === 'openai-chat-completions') return { protocol: target, payload: (await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions({ ...responses, input: [...assistant.turn, { type: 'message', role: 'user', content: 'next question' }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }))).target }; + if (target === 'anthropic-messages') return { protocol: target, payload: (await requireTrip(routeOpenAIResponsesViaAnthropicMessages({ ...responses, input: [...assistant.turn, { type: 'message', role: 'user', content: 'next question' }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', loadRemoteImage: async () => null }))).target }; } if (assistant.protocol === 'openai-chat-completions') { - const route = routeOpenAIChatCompletions({ ...chat, messages: [assistant.turn, { role: 'user', content: 'next question' }] }); - if (target === 'openai-responses') return { protocol: target, payload: (await route.viaOpenAIResponses({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })).target }; - if (target === 'anthropic-messages') return { protocol: target, payload: (await route.viaAnthropicMessages({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', loadRemoteImage: async () => null })).target }; + if (target === 'openai-responses') return { protocol: target, payload: (await requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses({ ...chat, messages: [assistant.turn, { role: 'user', content: 'next question' }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }))).target }; + if (target === 'anthropic-messages') return { protocol: target, payload: (await requireTrip(routeOpenAIChatCompletionsViaAnthropicMessages({ ...chat, messages: [assistant.turn, { role: 'user', content: 'next question' }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', loadRemoteImage: async () => null }))).target }; } if (assistant.protocol === 'anthropic-messages') { - const route = routeAnthropicMessages({ ...anthropic, messages: [assistant.turn, { role: 'user', content: 'next question' }] }); - if (target === 'openai-responses') return { protocol: target, payload: (await route.viaOpenAIResponses({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })).target }; - if (target === 'openai-chat-completions') return { protocol: target, payload: (await route.viaOpenAIChatCompletions({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })).target }; + if (target === 'openai-responses') return { protocol: target, payload: (await requireTrip(routeAnthropicMessagesViaOpenAIResponses({ ...anthropic, messages: [assistant.turn, { role: 'user', content: 'next question' }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }))).target }; + if (target === 'openai-chat-completions') return { protocol: target, payload: (await requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions({ ...anthropic, messages: [assistant.turn, { role: 'user', content: 'next question' }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }))).target }; } if (assistant.protocol === 'gemini-generate-content') { - const route = routeGeminiGenerateContent({ ...gemini, contents: [...assistant.turn, { role: 'user', parts: [{ text: 'next question' }] }] }); - if (target === 'openai-responses') return { protocol: target, payload: (await route.viaOpenAIResponses({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })).target }; - if (target === 'openai-chat-completions') return { protocol: target, payload: (await route.viaOpenAIChatCompletions({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })).target }; - if (target === 'anthropic-messages') return { protocol: target, payload: (await route.viaAnthropicMessages({ sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', fallbackMaxOutputTokens: 16 })).target }; + if (target === 'openai-responses') return { protocol: target, payload: (await requireTrip(routeGeminiGenerateContentViaOpenAIResponses({ ...gemini, contents: [...assistant.turn, { role: 'user', parts: [{ text: 'next question' }] }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }))).target }; + if (target === 'openai-chat-completions') return { protocol: target, payload: (await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions({ ...gemini, contents: [...assistant.turn, { role: 'user', parts: [{ text: 'next question' }] }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }))).target }; + if (target === 'anthropic-messages') return { protocol: target, payload: (await requireTrip(routeGeminiGenerateContentViaAnthropicMessages({ ...gemini, contents: [...assistant.turn, { role: 'user', parts: [{ text: 'next question' }] }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', fallbackMaxOutputTokens: 16 }))).target }; } throw new Error(`Unsupported route: ${assistant.protocol} via ${target}`); }; diff --git a/packages/translate/package.json b/packages/translate/package.json index 11e1c450d8..49bbe6174c 100644 --- a/packages/translate/package.json +++ b/packages/translate/package.json @@ -4,11 +4,10 @@ "version": "0.0.0", "type": "module", "exports": { - ".": { "import": "./src/index.ts", "types": "./src/index.ts" }, - "./anthropic-messages": { "import": "./src/protocols/anthropic-messages/index.ts", "types": "./src/protocols/anthropic-messages/index.ts" }, - "./openai-responses": { "import": "./src/protocols/openai-responses/index.ts", "types": "./src/protocols/openai-responses/index.ts" }, - "./openai-chat-completions": { "import": "./src/protocols/openai-chat-completions/index.ts", "types": "./src/protocols/openai-chat-completions/index.ts" }, - "./gemini-generate-content": { "import": "./src/protocols/gemini-generate-content/index.ts", "types": "./src/protocols/gemini-generate-content/index.ts" } + ".": { + "import": "./src/index.ts", + "types": "./src/index.ts" + } }, "scripts": { "typecheck": "tsc --noEmit" diff --git a/packages/translate/src/index.ts b/packages/translate/src/index.ts index 347761c865..d7fedbcc9e 100644 --- a/packages/translate/src/index.ts +++ b/packages/translate/src/index.ts @@ -1,3 +1,14 @@ -export type { RemoteImageData, RemoteImageLoader, SidecarCodec, TranslatedApiError, TranslateTripResult, TranslationContext } from './types.ts'; -export { TranslatorInputError } from './translator-input-error.ts'; +export type { RemoteImageData, RemoteImageLoader, SidecarCodec, TranslateTrip, TranslateInputError, TranslateOptions, Translator } from './types.ts'; export { SidecarTaggedValue } from './round-trip/sidecar-tagged-value.ts'; +export { canonicalizeOpenAIResponsesPayload } from './protocols/openai-responses/canonicalize-payload.ts'; +export { + routeAnthropicMessagesViaOpenAIChatCompletions, + routeAnthropicMessagesViaOpenAIResponses, + routeGeminiGenerateContentViaAnthropicMessages, + routeGeminiGenerateContentViaOpenAIChatCompletions, + routeGeminiGenerateContentViaOpenAIResponses, + routeOpenAIChatCompletionsViaAnthropicMessages, + routeOpenAIChatCompletionsViaOpenAIResponses, + routeOpenAIResponsesViaAnthropicMessages, + routeOpenAIResponsesViaOpenAIChatCompletions, +} from './protocols/routes.ts'; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/context-window-error.ts b/packages/translate/src/protocols/anthropic-messages/as-source/context-window-error.ts index 0b052c6c6b..d4d4a50239 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/context-window-error.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/context-window-error.ts @@ -1,6 +1,6 @@ import { isContextExceededError } from '../../../shared/context-window-error.ts'; -import type { TranslatedApiError } from '../../../types.ts'; -import { buildPromptTooLongBody } from '@floway-dev/protocols/anthropic-messages'; +import type { ApiErrorTranslator } from '../../../types.ts'; +import { PROMPT_TOO_LONG_MESSAGE } from '@floway-dev/protocols/anthropic-messages'; const isContextExceededErrorText = (text: string): boolean => { try { @@ -11,14 +11,14 @@ const isContextExceededErrorText = (text: string): boolean => { } }; -// Shared `TranslateTrip.apiError` implementation for the `anthropic-messages-via-*` +// Shared `TranslateTrip.error` implementation for the `anthropic-messages-via-*` // pairs. Rewrites an upstream context-exceeded body into an Anthropic // prompt-too-long envelope; passes anything else through unchanged. -export const rewriteContextExceededToPromptTooLong = (upstream: TranslatedApiError): TranslatedApiError | undefined => { - if (!isContextExceededErrorText(new TextDecoder().decode(upstream.body))) return undefined; +export const rewriteContextExceededToPromptTooLong: ApiErrorTranslator = (_status, _headers, body) => { + if (!isContextExceededErrorText(new TextDecoder().decode(body))) return undefined; return { status: 400, headers: new Headers({ 'content-type': 'application/json' }), - body: buildPromptTooLongBody(), + body: { type: 'error', error: { type: 'invalid_request_error', message: PROMPT_TOO_LONG_MESSAGE } }, }; }; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/tool-input-schema.ts b/packages/translate/src/protocols/anthropic-messages/as-target/tool-input-schema.ts index 8d9f4585d6..1612c98007 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/tool-input-schema.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/tool-input-schema.ts @@ -1,7 +1,7 @@ -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { AnthropicMessagesClientTool } from '@floway-dev/protocols/anthropic-messages'; export const anthropicMessagesToolInputSchema = (schema: Record): AnthropicMessagesClientTool['input_schema'] => { - if (schema.type !== 'object') throw new TranslatorInputError('Anthropic Messages function tools require an object input schema.'); + if (schema.type !== 'object') throw new TranslateInputException('Anthropic Messages function tools require an object input schema.'); return schema as AnthropicMessagesClientTool['input_schema']; }; diff --git a/packages/translate/src/protocols/anthropic-messages/index.ts b/packages/translate/src/protocols/anthropic-messages/index.ts deleted file mode 100644 index 37e4e83b84..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { routeAnthropicMessages } from './route.ts'; diff --git a/packages/translate/src/protocols/anthropic-messages/route.ts b/packages/translate/src/protocols/anthropic-messages/route.ts deleted file mode 100644 index f8ddf04f12..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/route.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { translate as viaOpenAIChatCompletions } from './via-openai-chat-completions/translate.ts'; -import { translate as viaOpenAIResponses } from './via-openai-responses/translate.ts'; -import type { AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; - -export const routeAnthropicMessages = (input: AnthropicMessagesPayload) => ({ - viaOpenAIChatCompletions: (ctx: Parameters[1]) => viaOpenAIChatCompletions(input, ctx), - viaOpenAIResponses: (ctx: Parameters[1]) => viaOpenAIResponses(input, ctx), -}); diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/request.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/request.ts index 97d044419b..fd53391c21 100644 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/request.ts +++ b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/request.ts @@ -1,7 +1,7 @@ import { klona } from 'klona/json'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { SidecarCodec } from '../../../types.ts'; import { filterAnthropicMessagesClientTools } from '../as-source/client-tools.ts'; import { partitionAnthropicMessagesTurns } from '../as-source/conversation-turns.ts'; @@ -70,7 +70,7 @@ const toOpenAIChatCompletionsContent = (content: string | AnthropicMessagesUserC } if (block.type === 'image') { - if (block.source.type === 'file') throw new TranslatorInputError('Cannot translate file_id-only Anthropic image input to Chat Completions.'); + if (block.source.type === 'file') throw new TranslateInputException('Cannot translate file_id-only Anthropic image input to Chat Completions.'); parts.push({ type: 'image_url', image_url: { url: block.source.type === 'url' ? block.source.url : `data:${block.source.media_type};base64,${block.source.data}` } }); } } @@ -148,7 +148,7 @@ const translateAnthropicMessagesUser = (message: AnthropicMessagesUserMessage, m } if (block.type !== 'text' && block.type !== 'image') { - throw new TranslatorInputError(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' content blocks are not supported on this model`); + throw new TranslateInputException(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' content blocks are not supported on this model`); } pendingUserBlocks.push(block); @@ -200,7 +200,7 @@ const translateAnthropicMessagesAssistant = (message: AnthropicMessagesAssistant }); break; default: - throw new TranslatorInputError(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' assistant content blocks are not supported on this model`); + throw new TranslateInputException(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' assistant content blocks are not supported on this model`); } } @@ -221,7 +221,7 @@ const translateAnthropicMessagesSystem = (message: AnthropicMessagesSystemMessag { role: 'system', content: typeof message.content === 'string' ? message.content : message.content.map(block => { - if (block.type !== 'text') throw new TranslatorInputError('Only text is supported in Chat system messages.'); + if (block.type !== 'text') throw new TranslateInputException('Only text is supported in Chat system messages.'); return { type: 'text', text: block.text }; }), }, @@ -243,7 +243,7 @@ const translateAnthropicMessagesTools = (tools?: AnthropicMessagesClientTool[]): const translateAnthropicMessagesToolChoice = (toolChoice?: AnthropicMessagesPayload['tool_choice'], tools?: AnthropicMessagesClientTool[]): OpenAIChatCompletionsPayload['tool_choice'] => { if (!toolChoice) return undefined; if (!tools || tools.length === 0) { - if (toolChoice.type === 'tool') throw new TranslatorInputError(`Anthropic Messages tool_choice names an undeclared tool: ${String(toolChoice.name)}`); + if (toolChoice.type === 'tool') throw new TranslateInputException(`Anthropic Messages tool_choice names an undeclared tool: ${String(toolChoice.name)}`); return undefined; } @@ -253,7 +253,7 @@ const translateAnthropicMessagesToolChoice = (toolChoice?: AnthropicMessagesPayl case 'any': return 'required'; case 'tool': { - if (!toolChoice.name || !tools.some(tool => tool.name === toolChoice.name)) throw new TranslatorInputError(`Anthropic Messages tool_choice names an undeclared tool: ${String(toolChoice.name)}`); + if (!toolChoice.name || !tools.some(tool => tool.name === toolChoice.name)) throw new TranslateInputException(`Anthropic Messages tool_choice names an undeclared tool: ${String(toolChoice.name)}`); return { type: 'function', function: { name: toolChoice.name } }; } case 'none': @@ -302,7 +302,7 @@ export const buildTargetRequest = async ( switch (message.role) { case 'user': messages.push(...translateAnthropicMessagesUser(message, messageIndex + index, legacyFunctionNames)); break; case 'system': messages.push(...translateAnthropicMessagesSystem(message)); break; - default: throw new TranslatorInputError(`messages.${messageIndex + index}.role: role '${(message as { role: string }).role}' is not supported on this model`); + default: throw new TranslateInputException(`messages.${messageIndex + index}.role: role '${(message as { role: string }).role}' is not supported on this model`); } } messageIndex += turn.items.length; diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/translate.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/translate.ts index f09fcaf178..e8c0ad9d71 100644 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/translate.ts +++ b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/translate.ts @@ -1,17 +1,24 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; -import type { TranslateTrip } from '../../../types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import type { Translator } from '../../../types.ts'; import { rewriteContextExceededToPromptTooLong } from '../as-source/context-window-error.ts'; import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; -export const translate: TranslateTrip< +export const translate: Translator< AnthropicMessagesPayload, AnthropicMessagesStreamEventEx, OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent > = async (src, ctx) => { - const target = await buildTargetRequest(src, ctx.sidecarCodec); - return { - target, - events: frames => translateToSourceEvents(frames, ctx.sidecarCodec, target.n ?? 1), - apiError: rewriteContextExceededToPromptTooLong, - }; + try { + const target = await buildTargetRequest(src, ctx.sidecarCodec); + return { + type: 'trip', + target, + events: frames => translateToSourceEvents(frames, ctx.sidecarCodec, target.n ?? 1), + error: rewriteContextExceededToPromptTooLong, + }; + } catch (error) { + if (error instanceof TranslateInputException) return error.failure; + throw error; + } }; diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/request.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/request.ts index c2bda591a2..cd9c0b0e7d 100644 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/request.ts +++ b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/request.ts @@ -2,7 +2,7 @@ import { klona } from 'klona/json'; import { anthropicMessagesReasoningBlockToOpenAIResponsesReasoning } from './reasoning.ts'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { SidecarCodec } from '../../../types.ts'; import { filterAnthropicMessagesClientTools } from '../as-source/client-tools.ts'; import { partitionAnthropicMessagesTurns } from '../as-source/conversation-turns.ts'; @@ -74,7 +74,7 @@ const translateUserContentBlock = ( }; } - throw new TranslatorInputError(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' user content blocks are not supported on this model`); + throw new TranslateInputException(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' user content blocks are not supported on this model`); }; const toOpenAIResponsesFunctionCall = (block: AnthropicMessagesToolUseBlockParam | AnthropicMessagesServerToolUseBlockParam): CanonicalOpenAIResponsesInputItem => ({ @@ -160,7 +160,7 @@ const translateAssistantMessage = (message: AnthropicMessagesAssistantMessage, m continue; } - throw new TranslatorInputError(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' assistant content blocks are not supported on this model`); + throw new TranslateInputException(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' assistant content blocks are not supported on this model`); } flushPendingContent(pendingContent, input, 'assistant'); @@ -176,7 +176,7 @@ const translateAnthropicMessagesSystem = (message: AnthropicMessagesSystemMessag content: typeof message.content === 'string' ? message.content : message.content.map(block => { - if (block.type !== 'text') throw new TranslatorInputError('Only text system content is supported by this Responses translation.'); + if (block.type !== 'text') throw new TranslateInputException('Only text system content is supported by this Responses translation.'); return { type: 'input_text', text: block.text }; }), }, @@ -234,7 +234,7 @@ const translateTools = (tools: AnthropicMessagesClientTool[] | undefined): OpenA const translateToolChoice = (toolChoice: AnthropicMessagesPayload['tool_choice'], tools?: AnthropicMessagesClientTool[]): OpenAIResponsesToolChoice | undefined => { if (!toolChoice) return undefined; if (!tools || tools.length === 0) { - if (toolChoice.type === 'tool') throw new TranslatorInputError(`Anthropic Messages tool_choice names an undeclared tool: ${String(toolChoice.name)}`); + if (toolChoice.type === 'tool') throw new TranslateInputException(`Anthropic Messages tool_choice names an undeclared tool: ${String(toolChoice.name)}`); return undefined; } @@ -246,7 +246,7 @@ const translateToolChoice = (toolChoice: AnthropicMessagesPayload['tool_choice'] case 'any': return 'required'; case 'tool': { - if (!toolChoice.name || !toolNames.has(toolChoice.name)) throw new TranslatorInputError(`Anthropic Messages tool_choice names an undeclared tool: ${String(toolChoice.name)}`); + if (!toolChoice.name || !toolNames.has(toolChoice.name)) throw new TranslateInputException(`Anthropic Messages tool_choice names an undeclared tool: ${String(toolChoice.name)}`); return { type: 'function', name: toolChoice.name }; } case 'none': @@ -256,7 +256,7 @@ const translateToolChoice = (toolChoice: AnthropicMessagesPayload['tool_choice'] const translateMetadata = (metadata: AnthropicMessagesMetadataEx): Record => Object.fromEntries(Object.entries(metadata).flatMap(([key, value]) => { if (key === 'user_id' && value == null) return []; - if (typeof value !== 'string') throw new TranslatorInputError(`Cannot translate Anthropic Messages metadata field '${key}' to a Responses string metadata value.`); + if (typeof value !== 'string') throw new TranslateInputException(`Cannot translate Anthropic Messages metadata field '${key}' to a Responses string metadata value.`); return [[key, value]]; })); @@ -296,7 +296,7 @@ export const buildTargetRequest = async ( switch (message.role) { case 'user': input.push(...translateUserMessage(message, messageIndex + index)); break; case 'system': input.push(...translateAnthropicMessagesSystem(message)); break; - default: throw new TranslatorInputError(`messages.${messageIndex + index}.role: role '${(message as { role: string }).role}' is not supported on this model`); + default: throw new TranslateInputException(`messages.${messageIndex + index}.role: role '${(message as { role: string }).role}' is not supported on this model`); } } messageIndex += turn.items.length; diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/translate.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/translate.ts index 55384bbadc..bdec21ae2e 100644 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/translate.ts +++ b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/translate.ts @@ -1,16 +1,23 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; -import type { TranslateTrip } from '../../../types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import type { Translator } from '../../../types.ts'; import { rewriteContextExceededToPromptTooLong } from '../as-source/context-window-error.ts'; import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; -export const translate: TranslateTrip< +export const translate: Translator< AnthropicMessagesPayload, AnthropicMessagesStreamEventEx, CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx > = async (src, ctx) => { - return { - target: await buildTargetRequest(src, ctx.sidecarCodec), - events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), - apiError: rewriteContextExceededToPromptTooLong, - }; + try { + return { + type: 'trip', + target: await buildTargetRequest(src, ctx.sidecarCodec), + events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), + error: rewriteContextExceededToPromptTooLong, + }; + } catch (error) { + if (error instanceof TranslateInputException) return error.failure; + throw error; + } }; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/schema.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/schema.ts index c3da266060..a452afbfac 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/schema.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/schema.ts @@ -1,6 +1,6 @@ import { klona } from 'klona/json'; -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import { jsonInteger } from '@floway-dev/protocols/common'; import type { GeminiGenerateContentFunctionDeclaration, GeminiGenerateContentGenerationConfig, GeminiGenerateContentSchema } from '@floway-dev/protocols/gemini-generate-content'; @@ -22,7 +22,7 @@ export const geminiSchemaToJsonSchema = (schema: GeminiGenerateContentSchema): R }; for (const key of integerConstraints) { if (schema[key] === undefined) continue; - if (!/^\d+$/.test(schema[key])) throw new TranslatorInputError(`Gemini Schema ${key} must be a non-negative integer.`); + if (!/^\d+$/.test(schema[key])) throw new TranslateInputException(`Gemini Schema ${key} must be a non-negative integer.`); result[key] = jsonInteger(BigInt(schema[key])); } return nullable === true ? { anyOf: [result, { type: 'null' }] } : result; @@ -31,16 +31,16 @@ export const geminiSchemaToJsonSchema = (schema: GeminiGenerateContentSchema): R const jsonSchemaObject = (schema: unknown): Record => { if (schema === true) return {}; if (schema === false) return { not: {} }; - if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) throw new TranslatorInputError('The target protocol requires an object JSON Schema.'); + if (schema === null || typeof schema !== 'object' || Array.isArray(schema)) throw new TranslateInputException('The target protocol requires an object JSON Schema.'); return klona(schema as Record); }; export const geminiFunctionParameters = (declaration: GeminiGenerateContentFunctionDeclaration): Record | undefined => { - if (declaration.parameters !== undefined && declaration.parametersJsonSchema !== undefined) throw new TranslatorInputError('Gemini parameters and parametersJsonSchema are mutually exclusive.'); + if (declaration.parameters !== undefined && declaration.parametersJsonSchema !== undefined) throw new TranslateInputException('Gemini parameters and parametersJsonSchema are mutually exclusive.'); return declaration.parametersJsonSchema !== undefined ? jsonSchemaObject(declaration.parametersJsonSchema) : declaration.parameters === undefined ? undefined : geminiSchemaToJsonSchema(declaration.parameters); }; export const geminiResponseSchema = (config: GeminiGenerateContentGenerationConfig): Record | undefined => { - if (config.responseSchema !== undefined && config.responseJsonSchema !== undefined) throw new TranslatorInputError('Gemini responseSchema and responseJsonSchema are mutually exclusive.'); + if (config.responseSchema !== undefined && config.responseJsonSchema !== undefined) throw new TranslateInputException('Gemini responseSchema and responseJsonSchema are mutually exclusive.'); return config.responseJsonSchema !== undefined ? jsonSchemaObject(config.responseJsonSchema) : config.responseSchema === undefined ? undefined : geminiSchemaToJsonSchema(config.responseSchema); }; diff --git a/packages/translate/src/protocols/gemini-generate-content/index.ts b/packages/translate/src/protocols/gemini-generate-content/index.ts deleted file mode 100644 index d414cdf75e..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { routeGeminiGenerateContent } from './route.ts'; diff --git a/packages/translate/src/protocols/gemini-generate-content/route.ts b/packages/translate/src/protocols/gemini-generate-content/route.ts deleted file mode 100644 index b9f4d835fe..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/route.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { translate as viaAnthropicMessages } from './via-anthropic-messages/translate.ts'; -import { translate as viaOpenAIChatCompletions } from './via-openai-chat-completions/translate.ts'; -import { translate as viaOpenAIResponses } from './via-openai-responses/translate.ts'; -import type { GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; - -export const routeGeminiGenerateContent = (input: GeminiGenerateContentPayload) => ({ - viaAnthropicMessages: (ctx: Parameters[1]) => viaAnthropicMessages(input, ctx), - viaOpenAIChatCompletions: (ctx: Parameters[1]) => viaOpenAIChatCompletions(input, ctx), - viaOpenAIResponses: (ctx: Parameters[1]) => viaOpenAIResponses(input, ctx), -}); diff --git a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/request.ts b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/request.ts index 8a79cac25f..4a0001f630 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/request.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/request.ts @@ -1,7 +1,7 @@ import { klona } from 'klona/json'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { SidecarCodec } from '../../../types.ts'; import { applyLastMessageCacheBreakpoint, applyLastSystemCacheBreakpoint, applyLastToolCacheBreakpoint } from '../../anthropic-messages/as-target/cache-breakpoints.ts'; import { anthropicMessagesToolInputSchema } from '../../anthropic-messages/as-target/tool-input-schema.ts'; @@ -46,7 +46,7 @@ const inlineDataToContentBlock = (part: GeminiGenerateContentPart): AnthropicMes }; case 'audio': case 'unsupported': - throw new TranslatorInputError(`Cannot translate Gemini generateContent inlineData MIME type '${inlineData.mimeType}' to Anthropic Messages.`); + throw new TranslateInputException(`Cannot translate Gemini generateContent inlineData MIME type '${inlineData.mimeType}' to Anthropic Messages.`); } }; @@ -78,7 +78,7 @@ const buildUserMessage = (content: GeminiGenerateContentContent, turnIndex: numb return; } default: - throw new TranslatorInputError(`"${kind}" parts are not supported in user content.`); + throw new TranslateInputException(`"${kind}" parts are not supported in user content.`); } }); @@ -120,7 +120,7 @@ const buildAssistantMessage = (content: GeminiGenerateContentContent, turnIndex: return; } default: - throw new TranslatorInputError(`"${kind}" parts are not supported in model content.`); + throw new TranslateInputException(`"${kind}" parts are not supported in model content.`); } }); @@ -223,7 +223,7 @@ export const buildTargetRequest = async ( if (message) request.messages.push(message); } } else for (const [index, content] of turn.items.entries()) { - if (content.role !== 'user' && content.role !== undefined) throw new TranslatorInputError(`"${(content as { role: string }).role}" is not a supported content role.`); + if (content.role !== 'user' && content.role !== undefined) throw new TranslateInputException(`"${(content as { role: string }).role}" is not a supported content role.`); const message = buildUserMessage(content, contentIndex + index, unmatchedToolCallIds); if (message) request.messages.push(message); } diff --git a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/translate.ts b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/translate.ts index 34163cd542..bbd30e7ffc 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/translate.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/translate.ts @@ -1,15 +1,23 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; -import type { TranslateTrip } from '../../../types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import type { Translator } from '../../../types.ts'; import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; -export const translate: TranslateTrip< +export const translate: Translator< GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent, AnthropicMessagesPayload, AnthropicMessagesStreamEventEx, { fallbackMaxOutputTokens?: number } > = async (src, ctx) => { - return { - target: await buildTargetRequest(src, ctx.model, ctx.sidecarCodec, { fallbackMaxOutputTokens: ctx.fallbackMaxOutputTokens }), - events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), - }; + try { + return { + type: 'trip', + error: () => undefined, + target: await buildTargetRequest(src, ctx.model, ctx.sidecarCodec, { fallbackMaxOutputTokens: ctx.fallbackMaxOutputTokens }), + events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), + }; + } catch (error) { + if (error instanceof TranslateInputException) return error.failure; + throw error; + } }; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts index 43c9b78962..098478c8d0 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts @@ -1,7 +1,7 @@ import { klona } from 'klona/json'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { SidecarCodec } from '../../../types.ts'; import { geminiGenerateContentInlineData, geminiGenerateContentPartKind, geminiGenerateContentPartText, geminiGenerateContentText, geminiGenerateContentThoughtText, geminiGenerateContentVisibleText } from '../as-source/content.ts'; import { partitionGeminiGenerateContentTurns } from '../as-source/conversation-turns.ts'; @@ -33,7 +33,7 @@ const inlineDataToContentPart = (part: GeminiGenerateContentPart): OpenAIChatCom input_audio: { data: inlineData.data, format: inlineData.format }, }; case 'unsupported': - throw new TranslatorInputError(`Cannot translate Gemini generateContent inlineData MIME type '${inlineData.mimeType}' to OpenAI Chat Completions.`); + throw new TranslateInputException(`Cannot translate Gemini generateContent inlineData MIME type '${inlineData.mimeType}' to OpenAI Chat Completions.`); } }; @@ -97,11 +97,11 @@ const buildAssistantMessage = (content: GeminiGenerateContentContent, turnIndex: visibleParts.push(part); return; default: - throw new TranslatorInputError(`"${kind}" parts are not supported in model content.`); + throw new TranslateInputException(`"${kind}" parts are not supported in model content.`); } }); - if (visibleParts.some(part => part.inlineData !== undefined)) throw new TranslatorInputError('Cannot translate image content in a model turn to Chat assistant content.'); + if (visibleParts.some(part => part.inlineData !== undefined)) throw new TranslateInputException('Cannot translate image content in a model turn to Chat assistant content.'); const textParts = visibleParts.map(geminiGenerateContentPartText).filter((value): value is string => value !== null); const message: OpenAIChatCompletionsAssistantMessageEx = { role: 'assistant', @@ -150,7 +150,7 @@ const buildUserMessages = (content: GeminiGenerateContentContent, turnIndex: num pendingParts.push(part); return; default: - throw new TranslatorInputError(`"${kind}" parts are not supported in user content.`); + throw new TranslateInputException(`"${kind}" parts are not supported in user content.`); } }); @@ -248,7 +248,7 @@ export const buildTargetRequest = async ( if (message) request.messages.push(message); } } else for (const [index, content] of turn.items.entries()) { - if (content.role !== 'user' && content.role !== undefined) throw new TranslatorInputError(`"${(content as { role: string }).role}" is not a supported content role.`); + if (content.role !== 'user' && content.role !== undefined) throw new TranslateInputException(`"${(content as { role: string }).role}" is not a supported content role.`); if ((content.parts?.length ?? 0) === 0) { request.messages.push({ role: 'user', content: '' }); continue; } request.messages.push(...buildUserMessages(content, contentIndex + index, unmatchedToolCallIds, legacyFunctionNames)); } diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/translate.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/translate.ts index db282d86b2..0290871d73 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/translate.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/translate.ts @@ -1,12 +1,18 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; -import type { TranslateTrip } from '../../../types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import type { Translator } from '../../../types.ts'; import type { GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; -export const translate: TranslateTrip< +export const translate: Translator< GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent, OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent > = async (src, ctx) => { - const target = await buildTargetRequest(src, ctx.model, ctx.sidecarCodec); - return { target, events: frames => translateToSourceEvents(frames, ctx.sidecarCodec, target.n ?? 1) }; + try { + const target = await buildTargetRequest(src, ctx.model, ctx.sidecarCodec); + return { type: 'trip', error: () => undefined, target, events: frames => translateToSourceEvents(frames, ctx.sidecarCodec, target.n ?? 1) }; + } catch (error) { + if (error instanceof TranslateInputException) return error.failure; + throw error; + } }; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/request.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/request.ts index cf03ffccf3..b5ff204ae1 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/request.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/request.ts @@ -1,5 +1,5 @@ import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { SidecarCodec } from '../../../types.ts'; import { geminiGenerateContentInlineData, geminiGenerateContentPartKind, geminiGenerateContentPartText, geminiGenerateContentText, geminiGenerateContentThoughtText, geminiGenerateContentVisibleText } from '../as-source/content.ts'; import { partitionGeminiGenerateContentTurns } from '../as-source/conversation-turns.ts'; @@ -35,7 +35,7 @@ const inlineDataToInputContent = (part: GeminiGenerateContentPart): OpenAIRespon }; case 'audio': case 'unsupported': - throw new TranslatorInputError(`Cannot translate Gemini generateContent inlineData MIME type '${inlineData.mimeType}' to OpenAI Responses.`); + throw new TranslateInputException(`Cannot translate Gemini generateContent inlineData MIME type '${inlineData.mimeType}' to OpenAI Responses.`); } }; @@ -70,7 +70,7 @@ const buildUserInputItems = (content: GeminiGenerateContentContent, turnIndex: n return; } default: - throw new TranslatorInputError(`"${kind}" parts are not supported in user content.`); + throw new TranslateInputException(`"${kind}" parts are not supported in user content.`); } }); @@ -119,7 +119,7 @@ const buildAssistantInputItems = (content: GeminiGenerateContentContent, turnInd return; } default: - throw new TranslatorInputError(`"${kind}" parts are not supported in model content.`); + throw new TranslateInputException(`"${kind}" parts are not supported in model content.`); } }); @@ -201,7 +201,7 @@ export const buildTargetRequest = async ( input.push(...buildAssistantInputItems(content, contentIndex + index, unmatchedToolCallIds)); } } else for (const [index, content] of turn.items.entries()) { - if (content.role !== 'user' && content.role !== undefined) throw new TranslatorInputError(`"${(content as { role: string }).role}" is not a supported content role.`); + if (content.role !== 'user' && content.role !== undefined) throw new TranslateInputException(`"${(content as { role: string }).role}" is not a supported content role.`); if ((content.parts?.length ?? 0) === 0) { input.push({ type: 'message', role: 'user', content: '' }); continue; } input.push(...buildUserInputItems(content, contentIndex + index, unmatchedToolCallIds)); } diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/translate.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/translate.ts index fcd3a8250d..240d232135 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/translate.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/translate.ts @@ -1,14 +1,22 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; -import type { TranslateTrip } from '../../../types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import type { Translator } from '../../../types.ts'; import type { GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; -export const translate: TranslateTrip< +export const translate: Translator< GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent, CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx > = async (src, ctx) => { - return { - target: await buildTargetRequest(src, ctx.model, ctx.sidecarCodec), - events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), - }; + try { + return { + type: 'trip', + error: () => undefined, + target: await buildTargetRequest(src, ctx.model, ctx.sidecarCodec), + events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), + }; + } catch (error) { + if (error instanceof TranslateInputException) return error.failure; + throw error; + } }; diff --git a/packages/translate/src/protocols/openai-chat-completions/index.ts b/packages/translate/src/protocols/openai-chat-completions/index.ts deleted file mode 100644 index 4c24dfb3d7..0000000000 --- a/packages/translate/src/protocols/openai-chat-completions/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { routeOpenAIChatCompletions } from './route.ts'; diff --git a/packages/translate/src/protocols/openai-chat-completions/route.ts b/packages/translate/src/protocols/openai-chat-completions/route.ts deleted file mode 100644 index e5400d8a50..0000000000 --- a/packages/translate/src/protocols/openai-chat-completions/route.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { translate as viaAnthropicMessages } from './via-anthropic-messages/translate.ts'; -import { translate as viaOpenAIResponses } from './via-openai-responses/translate.ts'; -import type { OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; - -export const routeOpenAIChatCompletions = (input: OpenAIChatCompletionsPayloadEx) => ({ - viaAnthropicMessages: (ctx: Parameters[1]) => viaAnthropicMessages(input, ctx), - viaOpenAIResponses: (ctx: Parameters[1]) => viaOpenAIResponses(input, ctx), -}); diff --git a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/request.ts b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/request.ts index 7b6058ebb0..6c7cc5dc44 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/request.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/request.ts @@ -1,7 +1,7 @@ import { klona } from 'klona/json'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { SidecarCodec, RemoteImageLoader } from '../../../types.ts'; import { applyLastMessageCacheBreakpoint, applyLastSystemCacheBreakpoint, applyLastToolCacheBreakpoint } from '../../anthropic-messages/as-target/cache-breakpoints.ts'; import { anthropicMessagesReasoningFieldsFromEffort } from '../../anthropic-messages/as-target/reasoning-effort.ts'; @@ -54,7 +54,7 @@ const buildAssistantBlocks = (message: OpenAIChatCompletionsAssistantMessage): A if (message.refusal) blocks.push({ type: 'text', text: message.refusal }); for (const toolCall of message.tool_calls ?? []) { - if (toolCall.type !== 'function') throw new TranslatorInputError('Cannot translate a custom Chat tool call to Anthropic Messages.'); + if (toolCall.type !== 'function') throw new TranslateInputException('Cannot translate a custom Chat tool call to Anthropic Messages.'); blocks.push({ type: 'tool_use', id: toolCall.id, @@ -101,7 +101,7 @@ const convertUserContent = async (message: OpenAIChatCompletionsMessage, loadRem return resolveImageUrlToAnthropicMessagesImage(part.image_url.url, loadRemoteImage); } - throw new TranslatorInputError(`Invalid '${(part as { type: string }).type}' content part. Only 'text' and 'image_url' are supported in user content.`); + throw new TranslateInputException(`Invalid '${(part as { type: string }).type}' content part. Only 'text' and 'image_url' are supported in user content.`); }), ); @@ -125,7 +125,7 @@ const convertSystemContent = (content: OpenAIChatCompletionsMessage['content']): const blocks: AnthropicMessagesTextBlockParam[] = []; for (const part of content) { if (part.type === 'image_url') { - throw new TranslatorInputError("Invalid 'image_url' content part in system or developer message. Only 'text' content parts are supported in system messages on this model."); + throw new TranslateInputException("Invalid 'image_url' content part in system or developer message. Only 'text' content parts are supported in system messages on this model."); } if (part.type === 'text') { blocks.push({ type: 'text', text: part.text }); @@ -147,7 +147,7 @@ const translateNonAssistantMessages = async ( break; case 'tool': if (!message.tool_call_id) { - throw new TranslatorInputError("Missing required field 'tool_call_id' on a 'tool' role message."); + throw new TranslateInputException("Missing required field 'tool_call_id' on a 'tool' role message."); } appendUserBlocks(result, [ @@ -175,14 +175,14 @@ const translateNonAssistantMessages = async ( break; } default: - throw new TranslatorInputError(`Invalid role '${message.role}'.`); + throw new TranslateInputException(`Invalid role '${message.role}'.`); } } }; const translateOpenAIChatCompletionsTools = (tools: OpenAIChatCompletionsTool[]): AnthropicMessagesPayload['tools'] => tools.map(tool => { - if (tool.type !== 'function') throw new TranslatorInputError('Cannot translate custom Chat tools to Anthropic Messages.'); + if (tool.type !== 'function') throw new TranslateInputException('Cannot translate custom Chat tools to Anthropic Messages.'); return { name: tool.function.name, description: tool.function.description, @@ -195,7 +195,7 @@ const translateOpenAIChatCompletionsToolChoice = (toolChoice: NonNullable { - if (tool.type !== 'function') throw new TranslatorInputError('Cannot translate custom Chat tools to Anthropic Messages.'); + if (tool.type !== 'function') throw new TranslateInputException('Cannot translate custom Chat tools to Anthropic Messages.'); return tool.function.name; })) : undefined; diff --git a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/translate.ts b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/translate.ts index 7fe487021d..588339a6bf 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/translate.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/translate.ts @@ -1,19 +1,27 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; -import type { RemoteImageLoader, TranslateTrip } from '../../../types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import type { RemoteImageLoader, Translator } from '../../../types.ts'; import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; -export const translate: TranslateTrip< +export const translate: Translator< OpenAIChatCompletionsPayloadEx, OpenAIChatCompletionsStreamEvent, AnthropicMessagesPayload, AnthropicMessagesStreamEventEx, { fallbackMaxOutputTokens?: number; loadRemoteImage: RemoteImageLoader } > = async (src, ctx) => { - const streamOptions = { ...src.stream_options }; - return { - target: await buildTargetRequest(src, ctx.sidecarCodec, { - fallbackMaxOutputTokens: ctx.fallbackMaxOutputTokens, - loadRemoteImage: ctx.loadRemoteImage, - }), - events: frames => translateToSourceEvents(frames, streamOptions, ctx.sidecarCodec), - }; + try { + const streamOptions = { ...src.stream_options }; + return { + type: 'trip', + error: () => undefined, + target: await buildTargetRequest(src, ctx.sidecarCodec, { + fallbackMaxOutputTokens: ctx.fallbackMaxOutputTokens, + loadRemoteImage: ctx.loadRemoteImage, + }), + events: frames => translateToSourceEvents(frames, streamOptions, ctx.sidecarCodec), + }; + } catch (error) { + if (error instanceof TranslateInputException) return error.failure; + throw error; + } }; diff --git a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/content.ts b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/content.ts index 5c901126c2..3ee7d1071e 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/content.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/content.ts @@ -1,4 +1,4 @@ -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { OpenAIChatCompletionsContentPart } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesInputContent } from '@floway-dev/protocols/openai-responses'; @@ -26,7 +26,7 @@ export const openaiChatCompletionsContentToOpenAIResponsesInputContent = (conten case 'refusal': return { type: 'refusal', refusal: part.refusal }; case 'input_audio': - throw new TranslatorInputError('Cannot translate input_audio content to OpenAI Responses.'); + throw new TranslateInputException('Cannot translate input_audio content to OpenAI Responses.'); case 'file': return { type: 'input_file', ...part.file }; case 'image_url': diff --git a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/request.ts b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/request.ts index 66a4928365..a5c0aec29e 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/request.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/request.ts @@ -2,7 +2,7 @@ import { klona } from 'klona/json'; import { openaiChatCompletionsContentToOpenAIResponsesInputContent, openaiChatCompletionsContentToText } from './content.ts'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { SidecarCodec } from '../../../types.ts'; import { partitionOpenAIChatCompletionsTurns } from '../as-source/conversation-turns.ts'; import { collectOpenAIChatCompletionsReplayContents, hasOpenAIChatCompletionsReasoning, findOpenAIChatCompletionsSidecarCarriers, verifyOpenAIChatCompletionsTurnDigest } from '../as-source/round-trip.ts'; @@ -140,11 +140,11 @@ export const buildTargetRequest = async ( } if (message.role !== 'tool') { - throw new TranslatorInputError(`Invalid role '${(message as { role: string }).role}'.`); + throw new TranslateInputException(`Invalid role '${(message as { role: string }).role}'.`); } if (!message.tool_call_id) { - throw new TranslatorInputError("Missing required field 'tool_call_id' on a 'tool' role message."); + throw new TranslateInputException("Missing required field 'tool_call_id' on a 'tool' role message."); } input.push({ @@ -168,7 +168,7 @@ export const buildTargetRequest = async ( } const format = klona(payload.response_format); - if (format?.type === 'json_schema' && format.json_schema.schema === undefined) throw new TranslatorInputError('Cannot translate json_schema response format without a schema.'); + if (format?.type === 'json_schema' && format.json_schema.schema === undefined) throw new TranslateInputException('Cannot translate json_schema response format without a schema.'); const responseTextConfig = format == null ? undefined : { format: format.type === 'json_schema' ? { type: 'json_schema' as const, ...format.json_schema, schema: format.json_schema.schema! } diff --git a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/translate.ts b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/translate.ts index b953c9a1d7..cbd5549db7 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/translate.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/translate.ts @@ -1,15 +1,23 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; -import type { TranslateTrip } from '../../../types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import type { Translator } from '../../../types.ts'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; -export const translate: TranslateTrip< +export const translate: Translator< OpenAIChatCompletionsPayloadEx, OpenAIChatCompletionsStreamEvent, CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx > = async (src, ctx) => { - const streamOptions = { ...src.stream_options }; - return { - target: await buildTargetRequest(src, ctx.sidecarCodec), - events: frames => translateToSourceEvents(frames, streamOptions, ctx.sidecarCodec), - }; + try { + const streamOptions = { ...src.stream_options }; + return { + type: 'trip', + error: () => undefined, + target: await buildTargetRequest(src, ctx.sidecarCodec), + events: frames => translateToSourceEvents(frames, streamOptions, ctx.sidecarCodec), + }; + } catch (error) { + if (error instanceof TranslateInputException) return error.failure; + throw error; + } }; diff --git a/packages/translate/src/protocols/openai-responses/as-source/allowed-tools.ts b/packages/translate/src/protocols/openai-responses/as-source/allowed-tools.ts index 21ce104ab8..62f7249ba1 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/allowed-tools.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/allowed-tools.ts @@ -1,4 +1,4 @@ -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; const validateCallableKinds = (tools: OpenAIResponsesTool[] | null | undefined): void => { @@ -8,7 +8,7 @@ const validateCallableKinds = (tools: OpenAIResponsesTool[] | null | undefined): if ('namespace' in tool && tool.namespace !== undefined) continue; const prior = kinds.get(tool.name); if (prior !== undefined && prior !== tool.type) { - throw new TranslatorInputError(`Cannot translate distinct callable kinds sharing '${tool.name}'.`); + throw new TranslateInputException(`Cannot translate distinct callable kinds sharing '${tool.name}'.`); } kinds.set(tool.name, tool.type); } @@ -29,7 +29,7 @@ export const restrictAllowedTools = ( return { tools, choice }; } if ((choice.mode !== 'auto' && choice.mode !== 'required') || !Array.isArray(choice.tools)) { - throw new TranslatorInputError('Cannot translate malformed allowed_tools mode or tools array.'); + throw new TranslateInputException('Cannot translate malformed allowed_tools mode or tools array.'); } const declared = new Map>(); @@ -52,21 +52,21 @@ export const restrictAllowedTools = ( || (selector.type !== 'function' && selector.type !== 'custom') || typeof selector.name !== 'string' || selector.namespace !== undefined || Object.keys(selector).some(key => key !== 'type' && key !== 'name' && key !== 'namespace')) { - throw new TranslatorInputError('Cannot translate an allowed_tools selector that is not a flat function or custom tool.'); + throw new TranslateInputException('Cannot translate an allowed_tools selector that is not a flat function or custom tool.'); } const matches = declared.get(selector.type)?.get(selector.name); if (matches === undefined) { - throw new TranslatorInputError(`Cannot translate allowed_tools selector '${selector.name}' without a matching callable declaration.`); + throw new TranslateInputException(`Cannot translate allowed_tools selector '${selector.name}' without a matching callable declaration.`); } const kind = selectedKinds.get(selector.name); if (kind !== undefined && kind !== selector.type) { - throw new TranslatorInputError(`Cannot translate distinct allowed_tools callable kinds sharing '${selector.name}'.`); + throw new TranslateInputException(`Cannot translate distinct allowed_tools callable kinds sharing '${selector.name}'.`); } selectedKinds.set(selector.name, selector.type); for (const tool of matches) selected.add(tool); } if (selected.size === 0 && choice.mode === 'required') { - throw new TranslatorInputError('Cannot translate required allowed_tools with an empty callable subset.'); + throw new TranslateInputException('Cannot translate required allowed_tools with an empty callable subset.'); } const restricted = (tools ?? []).filter(tool => selected.has(tool)); validateCallableKinds(restricted); diff --git a/packages/translate/src/protocols/openai-responses/as-source/namespace-tools.ts b/packages/translate/src/protocols/openai-responses/as-source/namespace-tools.ts index bdd195cdb8..299cedf0af 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/namespace-tools.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/namespace-tools.ts @@ -1,4 +1,4 @@ -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import { isOpenAIResponsesTerminalEvent, type CanonicalOpenAIResponsesPayload, type CanonicalOpenAIResponsesInputItem, type OpenAIResponsesOutputItemEx, type OpenAIResponsesResultEx, type OpenAIResponsesStreamEventEx, type OpenAIResponsesTool, type OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; @@ -74,7 +74,7 @@ export const flattenNamespaceTools = (payload: CanonicalOpenAIResponsesPayload): if (existing !== undefined) { const identity = names.targetToSource.get(existing)!; if (identity.namespace !== namespace || identity.name !== name || identity.type !== type) { - throw new TranslatorInputError(`Cannot translate ambiguous namespace tool '${key}'.`); + throw new TranslateInputException(`Cannot translate ambiguous namespace tool '${key}'.`); } return existing; } @@ -112,7 +112,7 @@ export const flattenNamespaceTools = (payload: CanonicalOpenAIResponsesPayload): byNamespace.set(tool.name, children); for (const child of tool.tools) { if (child.type !== 'function' && child.type !== 'custom') { - throw new TranslatorInputError(`Cannot translate non-callable child in namespace '${tool.name}'.`); + throw new TranslateInputException(`Cannot translate non-callable child in namespace '${tool.name}'.`); } const name = allocate(tool.name, child.name, child.type); children.push({ type: child.type, name }); @@ -136,12 +136,12 @@ export const flattenNamespaceTools = (payload: CanonicalOpenAIResponsesPayload): const key = namespace === undefined ? choice.name : `${namespace}.${choice.name}`; if (namespace === undefined && flatNames.has(choice.name)) return choice; if (namespace !== undefined && !byNamespace.get(namespace)?.some(child => child.type === choice.type && names.targetToSource.get(child.name)?.name === choice.name)) { - throw new TranslatorInputError(`Cannot translate tool_choice / allowed_tools selector for undeclared namespace tool '${key}'.`); + throw new TranslateInputException(`Cannot translate tool_choice / allowed_tools selector for undeclared namespace tool '${key}'.`); } const qualified = namespace === undefined ? [...names.targetToSource].filter(([, identity]) => `${identity.namespace}.${identity.name}` === key || `${identity.namespace}__${identity.name}` === key) : []; - if (qualified.length > 1) throw new TranslatorInputError(`Cannot select ambiguous qualified tool '${key}'.`); + if (qualified.length > 1) throw new TranslateInputException(`Cannot select ambiguous qualified tool '${key}'.`); const name = namespace === undefined ? qualified[0]?.[0] : names.sourceToTarget.get(key); if (name === undefined) return choice; const { namespace: _namespace, ...rest } = choice; @@ -154,15 +154,15 @@ export const flattenNamespaceTools = (payload: CanonicalOpenAIResponsesPayload): choice = { ...choice, tools: choice.tools.flatMap(tool => { - if (typeof tool !== 'object' || tool === null) throw new TranslatorInputError('Cannot translate malformed allowed_tools selector.'); + if (typeof tool !== 'object' || tool === null) throw new TranslateInputException('Cannot translate malformed allowed_tools selector.'); if (tool.type === 'namespace') { const children = typeof tool.name === 'string' ? byNamespace.get(tool.name) : undefined; - if (children === undefined) throw new TranslatorInputError(`Cannot select undeclared namespace '${String(tool.name)}'.`); - if (Object.keys(tool).some(key => key !== 'type' && key !== 'name')) throw new TranslatorInputError('Cannot translate namespace selector extensions.'); + if (children === undefined) throw new TranslateInputException(`Cannot select undeclared namespace '${String(tool.name)}'.`); + if (Object.keys(tool).some(key => key !== 'type' && key !== 'name')) throw new TranslateInputException('Cannot translate namespace selector extensions.'); return children; } if (tool.type !== 'function' && tool.type !== 'custom') return [tool]; - if (!isNamedToolChoice(tool)) throw new TranslatorInputError('Cannot translate malformed allowed_tools selector.'); + if (!isNamedToolChoice(tool)) throw new TranslateInputException('Cannot translate malformed allowed_tools selector.'); const selected = selector(tool); return selected === tool ? [tool] : [{ ...selected }]; }), diff --git a/packages/translate/src/protocols/openai-responses/as-source/programmatic-tooling.ts b/packages/translate/src/protocols/openai-responses/as-source/programmatic-tooling.ts index dd6ab4be57..5e17e8a741 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/programmatic-tooling.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/programmatic-tooling.ts @@ -1,13 +1,13 @@ -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { CanonicalOpenAIResponsesInputItem, OpenAIResponsesPayloadEx } from '@floway-dev/protocols/openai-responses'; export const rejectProgrammaticOpenAIResponsesPayload = (payload: OpenAIResponsesPayloadEx, target: string): void => { const toolChoice = payload.tool_choice; if (payload.tools?.some(hasProgrammaticCaller) === true || (toolChoice !== null && typeof toolChoice === 'object' && toolChoice.type === 'programmatic_tool_calling')) { - throw new TranslatorInputError(`Programmatic OpenAI Responses tooling cannot be translated to ${target}.`); + throw new TranslateInputException(`Programmatic OpenAI Responses tooling cannot be translated to ${target}.`); } if (payload.tools?.some(hasDeferredTool) === true) { - throw new TranslatorInputError(`Deferred OpenAI Responses tooling cannot be translated to ${target}.`); + throw new TranslateInputException(`Deferred OpenAI Responses tooling cannot be translated to ${target}.`); } }; @@ -34,6 +34,6 @@ const isProgramCaller = (item: CanonicalOpenAIResponsesInputItem): item is Canon export const rejectProgramCaller = (item: CanonicalOpenAIResponsesInputItem): void => { if (isProgramCaller(item)) { - throw new TranslatorInputError(`Cannot translate ${item.type} '${item.call_id}' with a program caller.`); + throw new TranslateInputException(`Cannot translate ${item.type} '${item.call_id}' with a program caller.`); } }; diff --git a/packages/translate/src/protocols/openai-responses/canonicalize-payload.ts b/packages/translate/src/protocols/openai-responses/canonicalize-payload.ts index 4831160ec3..f7896b7681 100644 --- a/packages/translate/src/protocols/openai-responses/canonicalize-payload.ts +++ b/packages/translate/src/protocols/openai-responses/canonicalize-payload.ts @@ -1,4 +1,5 @@ -import { TranslatorInputError } from '../../translator-input-error.ts'; +import { TranslateInputException } from '../../shared/input-error.ts'; +import { type TranslateInputError } from '../../types.ts'; import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesEasyInputMessageEx, CanonicalOpenAIResponsesInputItem, OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; // Wire `OpenAIResponsesRequestPayload.input` accepts a bare string and EasyInputMessage @@ -10,7 +11,16 @@ import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesEasyInputMessageEx // boundary that produces a payload destined for internal use and by direct // OpenAI-Responses-source translators; cross-protocol translators already construct // `CanonicalOpenAIResponsesPayload` with explicit message discriminators. -export function canonicalizeOpenAIResponsesPayload(value: unknown): CanonicalOpenAIResponsesPayload { +export const canonicalizeOpenAIResponsesPayload = (value: unknown): CanonicalOpenAIResponsesPayload | TranslateInputError => { + try { + return requireCanonicalOpenAIResponsesPayload(value); + } catch (error) { + if (error instanceof TranslateInputException) return error.failure; + throw error; + } +}; + +export const requireCanonicalOpenAIResponsesPayload = (value: unknown): CanonicalOpenAIResponsesPayload => { const hasValidPromptCacheBreakpoint = (content: Record): boolean => { const breakpoint = content.prompt_cache_breakpoint; if (breakpoint === undefined || breakpoint === null) return true; @@ -44,7 +54,7 @@ export function canonicalizeOpenAIResponsesPayload(value: unknown): CanonicalOpe }; if (typeof value !== 'object' || value === null) { - throw new TranslatorInputError('OpenAI Responses payload must be an object.'); + throw new TranslateInputException('OpenAI Responses payload must be an object.'); } const payload = value as OpenAIResponsesRequestPayloadEx; // Resolution binds `model` straight into a SQL lookup, so the wire boundary is @@ -52,11 +62,11 @@ export function canonicalizeOpenAIResponsesPayload(value: unknown): CanonicalOpe // and code reproduce OpenAI's own rejection verbatim. // https://github.com/mattermost/mattermost-plugin-agents/issues/476 if (typeof payload.model !== 'string' || payload.model.length === 0) { - throw new TranslatorInputError("Missing required parameter: 'model'.", { param: 'model', code: 'missing_required_parameter' }); + throw new TranslateInputException("Missing required parameter: 'model'.", { param: 'model', code: 'missing_required_parameter' }); } const input: unknown = payload.input; if (typeof input !== 'string' && !Array.isArray(input)) { - throw new TranslatorInputError('OpenAI Responses input must be a string or an array.', { param: 'input' }); + throw new TranslateInputException('OpenAI Responses input must be a string or an array.', { param: 'input' }); } return { ...payload, @@ -66,9 +76,9 @@ export function canonicalizeOpenAIResponsesPayload(value: unknown): CanonicalOpe : input.map((item, index) => { if (isImplicitEasyInputMessage(item)) return { ...item, type: 'message' }; if (typeof item !== 'object' || item === null || (item as { type?: unknown }).type === undefined) { - throw new TranslatorInputError('Untyped OpenAI Responses input items require a valid role and content.', { param: `input[${index}]` }); + throw new TranslateInputException('Untyped OpenAI Responses input items require a valid role and content.', { param: `input[${index}]` }); } return item as CanonicalOpenAIResponsesInputItem; }), }; -} +}; diff --git a/packages/translate/src/protocols/openai-responses/index.ts b/packages/translate/src/protocols/openai-responses/index.ts deleted file mode 100644 index 6067f0d024..0000000000 --- a/packages/translate/src/protocols/openai-responses/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { routeOpenAIResponses } from './route.ts'; -export { canonicalizeOpenAIResponsesPayload } from './canonicalize-payload.ts'; diff --git a/packages/translate/src/protocols/openai-responses/route.ts b/packages/translate/src/protocols/openai-responses/route.ts deleted file mode 100644 index f41836f2d5..0000000000 --- a/packages/translate/src/protocols/openai-responses/route.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { translate as viaAnthropicMessages } from './via-anthropic-messages/translate.ts'; -import { translate as viaOpenAIChatCompletions } from './via-openai-chat-completions/translate.ts'; -import type { OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; - -export const routeOpenAIResponses = (input: OpenAIResponsesRequestPayloadEx) => ({ - viaAnthropicMessages: (ctx: Parameters[1]) => viaAnthropicMessages(input, ctx), - viaOpenAIChatCompletions: (ctx: Parameters[1]) => viaOpenAIChatCompletions(input, ctx), -}); diff --git a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts index ae2be7981b..51164bfa0f 100644 --- a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts +++ b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts @@ -2,8 +2,8 @@ import { klona } from 'klona/json'; import { openaiResponsesReasoningToAnthropicMessagesUpstreamBlock } from './reasoning.ts'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import { parseJSONObject } from '../../../shared/json/object.ts'; -import { TranslatorInputError } from '../../../translator-input-error.ts'; import type { SidecarCodec, RemoteImageLoader } from '../../../types.ts'; import { applyLastMessageCacheBreakpoint, applyLastSystemCacheBreakpoint, applyLastToolCacheBreakpoint } from '../../anthropic-messages/as-target/cache-breakpoints.ts'; import { anthropicMessagesReasoningFieldsFromEffort } from '../../anthropic-messages/as-target/reasoning-effort.ts'; @@ -14,7 +14,7 @@ import { partitionOpenAIResponsesTurns } from '../as-source/conversation-turns.t import { flattenNamespaceTools, type NamespaceToolNames } from '../as-source/namespace-tools.ts'; import { rejectProgramCaller, rejectProgrammaticOpenAIResponsesPayload } from '../as-source/programmatic-tooling.ts'; import { collectOpenAIResponsesReplayContents, findOpenAIResponsesSidecarCarriers, isOpenAIResponsesEncryptedReasoning, verifyOpenAIResponsesTurnDigest } from '../as-source/round-trip.ts'; -import { canonicalizeOpenAIResponsesPayload } from '../canonicalize-payload.ts'; +import { requireCanonicalOpenAIResponsesPayload } from '../canonicalize-payload.ts'; import { buildCustomToolInputSchema } from '../common/custom-tool-wrap.ts'; import { ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS, @@ -94,18 +94,18 @@ const translateUserMessage = async (message: OpenAIResponsesInputMessageEx, load } if (block.type === 'input_file') { - throw new TranslatorInputError('Cannot translate input_file message content to Anthropic Messages.'); + throw new TranslateInputException('Cannot translate input_file message content to Anthropic Messages.'); } if (block.type === 'refusal') { - throw new TranslatorInputError('Cannot translate refusal content in a user message to Anthropic Messages.'); + throw new TranslateInputException('Cannot translate refusal content in a user message to Anthropic Messages.'); } if (block.type !== 'input_image') continue; const imageUrl = (block as OpenAIResponsesInputImage).image_url; if (typeof imageUrl !== 'string') { - throw new TranslatorInputError('Cannot translate file_id-only image content to Anthropic Messages.'); + throw new TranslateInputException('Cannot translate file_id-only image content to Anthropic Messages.'); } const image = await resolveImageUrlToAnthropicMessagesImage(imageUrl, loadRemoteImage); if (image) content.push(image); @@ -127,17 +127,17 @@ const translateToolOutput = async (output: string | OpenAIResponsesInputContent[ for (const part of output) { if (part.type === 'input_image') { if (typeof part.image_url !== 'string') { - throw new TranslatorInputError('Cannot translate file_id-only image tool output to Anthropic Messages.'); + throw new TranslateInputException('Cannot translate file_id-only image tool output to Anthropic Messages.'); } const image = await resolveImageUrlToAnthropicMessagesImage(part.image_url, loadRemoteImage); if (image === null) { - throw new TranslatorInputError('Cannot translate unavailable or unsupported image tool output to Anthropic Messages.'); + throw new TranslateInputException('Cannot translate unavailable or unsupported image tool output to Anthropic Messages.'); } blocks.push(image); } else if (part.type === 'input_file') { - throw new TranslatorInputError('Cannot translate input_file tool output to Anthropic Messages.'); + throw new TranslateInputException('Cannot translate input_file tool output to Anthropic Messages.'); } else if (part.type === 'refusal') { - throw new TranslatorInputError('Cannot translate refusal content in a tool output to Anthropic Messages.'); + throw new TranslateInputException('Cannot translate refusal content in a tool output to Anthropic Messages.'); } else { blocks.push({ type: 'text', text: part.text }); } @@ -155,10 +155,10 @@ const translateAssistantMessage = (message: OpenAIResponsesInputMessageEx): Anth for (const block of message.content) { if (block.type === 'input_image') { - throw new TranslatorInputError('Cannot translate input_image assistant content to Anthropic Messages.'); + throw new TranslateInputException('Cannot translate input_image assistant content to Anthropic Messages.'); } if (block.type === 'input_file') { - throw new TranslatorInputError('Cannot translate input_file assistant content to Anthropic Messages.'); + throw new TranslateInputException('Cannot translate input_file assistant content to Anthropic Messages.'); } if (block.type === 'refusal') { content.push({ type: 'text', text: block.refusal }); @@ -185,12 +185,12 @@ const openaiResponsesSystemBlocks = (message: OpenAIResponsesInputMessageEx): An const blocks: AnthropicMessagesTextBlockParam[] = []; for (const block of message.content) { if (block.type === 'input_image') { - throw new TranslatorInputError(`Invalid 'input_image' content part in ${message.role} message. Only 'input_text' content parts are supported in ${message.role} messages on this model.`); + throw new TranslateInputException(`Invalid 'input_image' content part in ${message.role} message. Only 'input_text' content parts are supported in ${message.role} messages on this model.`); } if (block.type !== 'input_text' && block.type !== 'output_text') { // Every non-text content variant must opt into translator behavior // rather than be silently dropped from system content. - throw new TranslatorInputError(`Invalid content block type '${(block as { type: string }).type}' in ${message.role} message.`); + throw new TranslateInputException(`Invalid content block type '${(block as { type: string }).type}' in ${message.role} message.`); } blocks.push({ type: 'text', text: block.text }); } @@ -252,7 +252,7 @@ const translateTools = ( }); continue; } - throw new TranslatorInputError(`Cannot translate OpenAI Responses tool type '${tool.type}' to Anthropic Messages.`); + throw new TranslateInputException(`Cannot translate OpenAI Responses tool type '${tool.type}' to Anthropic Messages.`); } return out.length > 0 ? out : undefined; }; @@ -271,7 +271,7 @@ const translateToolChoice = ( case 'required': return { type: 'any' }; default: - throw new TranslatorInputError(`Cannot translate OpenAI Responses tool choice '${toolChoice}' to Anthropic Messages.`); + throw new TranslateInputException(`Cannot translate OpenAI Responses tool choice '${toolChoice}' to Anthropic Messages.`); } } @@ -280,7 +280,7 @@ const translateToolChoice = ( if (toolChoice.type === 'function' || toolChoice.type === 'custom') { return toolChoice.name ? { type: 'tool', name: toolChoice.name } : undefined; } - throw new TranslatorInputError(`Cannot translate OpenAI Responses tool choice type '${toolChoice.type}' to Anthropic Messages.`); + throw new TranslateInputException(`Cannot translate OpenAI Responses tool choice type '${toolChoice.type}' to Anthropic Messages.`); }; export const buildTargetRequest = async ( @@ -288,7 +288,7 @@ export const buildTargetRequest = async ( codec: SidecarCodec, options: BuildTargetRequestOptions, ): Promise => { - const canonical = canonicalizeOpenAIResponsesPayload(source); + const canonical = requireCanonicalOpenAIResponsesPayload(source); const { payload, names: namespaceToolNames, flattenInput } = flattenNamespaceTools(canonical); rejectProgrammaticOpenAIResponsesPayload(payload, 'Anthropic Messages'); const customToolNames = new Set(); @@ -309,7 +309,7 @@ export const buildTargetRequest = async ( rejectProgramCaller(item); switch (item.type) { case 'message': - if (item.role !== 'assistant') throw new TranslatorInputError(`Invalid assistant message role '${item.role}'.`); + if (item.role !== 'assistant') throw new TranslateInputException(`Invalid assistant message role '${item.role}'.`); messages.push(translateAssistantMessage(item)); break; case 'function_call': @@ -330,9 +330,9 @@ export const buildTargetRequest = async ( case 'item_reference': case 'web_search_call': case 'image_generation_call': - throw new TranslatorInputError(`Invalid input item type '${item.type}'.`); + throw new TranslateInputException(`Invalid input item type '${item.type}'.`); default: - throw new TranslatorInputError(`Invalid input item: ${JSON.stringify(item)}`); + throw new TranslateInputException(`Invalid input item: ${JSON.stringify(item)}`); } } for (const message of messages) bareMessages.add(message); @@ -353,7 +353,7 @@ export const buildTargetRequest = async ( messages.push({ role: 'system', content: blocks.length > 0 ? blocks : '' }); break; } - default: throw new TranslatorInputError(`Invalid role '${item.role}' in input message.`); + default: throw new TranslateInputException(`Invalid role '${item.role}' in input message.`); } break; case 'function_call_output': @@ -367,9 +367,9 @@ export const buildTargetRequest = async ( case 'item_reference': case 'web_search_call': case 'image_generation_call': - throw new TranslatorInputError(`Invalid input item type '${item.type}'.`); + throw new TranslateInputException(`Invalid input item type '${item.type}'.`); default: - throw new TranslatorInputError(`Invalid input item: ${JSON.stringify(item)}`); + throw new TranslateInputException(`Invalid input item: ${JSON.stringify(item)}`); } } for (const message of messages) bareMessages.add(message); diff --git a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/translate.ts b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/translate.ts index 018e070dce..7f71c1c4ed 100644 --- a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/translate.ts +++ b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/translate.ts @@ -1,26 +1,34 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; -import type { RemoteImageLoader, TranslateTrip } from '../../../types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import type { RemoteImageLoader, Translator } from '../../../types.ts'; import { restoreNamespaceEvents } from '../as-source/namespace-tools.ts'; import { createRandomOpenAIResponsesResponseId } from '../as-source/response-id.ts'; import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; -export const translate: TranslateTrip< +export const translate: Translator< OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx, AnthropicMessagesPayload, AnthropicMessagesStreamEventEx, { fallbackMaxOutputTokens?: number; loadRemoteImage: RemoteImageLoader } > = async (src, ctx) => { - const responseId = createRandomOpenAIResponsesResponseId(); - // Tool-name maps are produced inside the request translator (it sees the - // tools first) and read by the events translator so wrapped custom calls and - // flattened namespace calls recover their source OpenAI Responses identities. - const { target, customToolNames, namespaceToolNames } = await buildTargetRequest(src, ctx.sidecarCodec, { - fallbackMaxOutputTokens: ctx.fallbackMaxOutputTokens, - loadRemoteImage: ctx.loadRemoteImage, - }); + try { + const responseId = createRandomOpenAIResponsesResponseId(); + // Tool-name maps are produced inside the request translator (it sees the + // tools first) and read by the events translator so wrapped custom calls and + // flattened namespace calls recover their source OpenAI Responses identities. + const { target, customToolNames, namespaceToolNames } = await buildTargetRequest(src, ctx.sidecarCodec, { + fallbackMaxOutputTokens: ctx.fallbackMaxOutputTokens, + loadRemoteImage: ctx.loadRemoteImage, + }); - return { - target, - events: frames => restoreNamespaceEvents(translateToSourceEvents(frames, responseId, customToolNames, ctx.sidecarCodec, namespaceToolNames), namespaceToolNames), - }; + return { + type: 'trip', + error: () => undefined, + target, + events: frames => restoreNamespaceEvents(translateToSourceEvents(frames, responseId, customToolNames, ctx.sidecarCodec, namespaceToolNames), namespaceToolNames), + }; + } catch (error) { + if (error instanceof TranslateInputException) return error.failure; + throw error; + } }; diff --git a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/content.ts b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/content.ts index 472fb53df5..041f45bb9d 100644 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/content.ts +++ b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/content.ts @@ -1,4 +1,4 @@ -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { OpenAIChatCompletionsContentPart } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesInputContent } from '@floway-dev/protocols/openai-responses'; @@ -17,7 +17,7 @@ export const openaiResponsesContentToText = (content: string | OpenAIResponsesIn export const openaiResponsesContentToOpenAIChatCompletionsContent = (content: string | OpenAIResponsesInputContent[]): string | OpenAIChatCompletionsContentPart[] => { if (typeof content === 'string') return content; if (!content.every((part): part is Exclude => part.type !== 'input_file')) { - throw new TranslatorInputError('Cannot translate input_file content to OpenAI Chat Completions.'); + throw new TranslateInputException('Cannot translate input_file content to OpenAI Chat Completions.'); } return content.some(part => part.type === 'input_image' || part.type === 'refusal') @@ -25,7 +25,7 @@ export const openaiResponsesContentToOpenAIChatCompletionsContent = (content: st (part): OpenAIChatCompletionsContentPart => { if (part.type === 'input_image') { if (typeof part.image_url !== 'string') { - throw new TranslatorInputError('Cannot translate file_id-only image content to OpenAI Chat Completions.'); + throw new TranslateInputException('Cannot translate file_id-only image content to OpenAI Chat Completions.'); } return { type: 'image_url', diff --git a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/request.ts b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/request.ts index 4ffa89b60d..8af6a7491f 100644 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/request.ts +++ b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/request.ts @@ -2,14 +2,14 @@ import { klona } from 'klona/json'; import { openaiResponsesContentToOpenAIChatCompletionsContent, openaiResponsesContentToText } from './content.ts'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslatorInputError } from '../../../translator-input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; import type { SidecarCodec } from '../../../types.ts'; import { restrictAllowedTools } from '../as-source/allowed-tools.ts'; import { partitionOpenAIResponsesTurns } from '../as-source/conversation-turns.ts'; import { flattenNamespaceTools, type NamespaceToolNames } from '../as-source/namespace-tools.ts'; import { rejectProgramCaller, rejectProgrammaticOpenAIResponsesPayload } from '../as-source/programmatic-tooling.ts'; import { collectOpenAIResponsesReplayContents, findOpenAIResponsesSidecarCarriers, isOpenAIResponsesEncryptedReasoning, verifyOpenAIResponsesTurnDigest } from '../as-source/round-trip.ts'; -import { canonicalizeOpenAIResponsesPayload } from '../canonicalize-payload.ts'; +import { requireCanonicalOpenAIResponsesPayload } from '../canonicalize-payload.ts'; import { buildCustomToolInputSchema } from '../common/custom-tool-wrap.ts'; import type { OpenAIChatCompletionsAssistantMessage, OpenAIChatCompletionsResponseFormat, OpenAIChatCompletionsUserContentPart, OpenAIChatCompletionsPayload, OpenAIChatCompletionsMessage, OpenAIChatCompletionsTool, OpenAIChatCompletionsToolCall } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesCustomToolCallOutputItem, OpenAIResponsesFunctionCallOutputItem, OpenAIResponsesInputImage, CanonicalOpenAIResponsesText, CanonicalOpenAIResponsesInputItem, OpenAIResponsesPayloadEx, OpenAIResponsesRequestPayloadEx, OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; @@ -65,7 +65,7 @@ interface ToolCallOutputProjection { const projectToolCallOutput = (item: OpenAIResponsesFunctionCallOutputItem | OpenAIResponsesCustomToolCallOutputItem): ToolCallOutputProjection => { if (typeof item.output === 'string') return { toolContent: item.output, liftedImageContent: [] }; if (item.output.some(part => part.type === 'input_file')) { - throw new TranslatorInputError('Cannot translate input_file tool output to OpenAI Chat Completions.'); + throw new TranslateInputException('Cannot translate input_file tool output to OpenAI Chat Completions.'); } const images = item.output.filter((part): part is OpenAIResponsesInputImage => part.type === 'input_image'); @@ -123,7 +123,7 @@ const translateOpenAIResponsesTools = (tools: OpenAIResponsesTool[] | null | und }); continue; } - throw new TranslatorInputError(`Cannot translate OpenAI Responses tool type '${tool.type}' to OpenAI Chat Completions.`); + throw new TranslateInputException(`Cannot translate OpenAI Responses tool type '${tool.type}' to OpenAI Chat Completions.`); } return out.length > 0 ? out : undefined; @@ -133,11 +133,11 @@ const translateOpenAIResponsesToolChoice = (choice?: OpenAIResponsesToolChoice | if (choice == null) return undefined; if (typeof choice === 'string') { if (choice === 'auto' || choice === 'none' || choice === 'required') return choice; - throw new TranslatorInputError(`Cannot translate OpenAI Responses tool choice '${choice}' to OpenAI Chat Completions.`); + throw new TranslateInputException(`Cannot translate OpenAI Responses tool choice '${choice}' to OpenAI Chat Completions.`); } // Both function and wrapped custom tools land on the target as named function // choices since they share the function-tool wire shape after translation. - if (choice.type !== 'function' && choice.type !== 'custom') throw new TranslatorInputError(`Cannot translate OpenAI Responses tool choice type '${choice.type}' to OpenAI Chat Completions.`); + if (choice.type !== 'function' && choice.type !== 'custom') throw new TranslateInputException(`Cannot translate OpenAI Responses tool choice type '${choice.type}' to OpenAI Chat Completions.`); return { type: 'function', function: { name: choice.name } }; }; @@ -179,7 +179,7 @@ export interface TargetRequestResult { } export const buildTargetRequest = async (source: OpenAIResponsesRequestPayloadEx, codec: SidecarCodec): Promise => { - const canonical = canonicalizeOpenAIResponsesPayload(source); + const canonical = requireCanonicalOpenAIResponsesPayload(source); const { payload, names: namespaceToolNames, flattenInput } = flattenNamespaceTools(canonical); rejectProgrammaticOpenAIResponsesPayload(payload, 'OpenAI Chat Completions'); const customToolNames = new Set(); @@ -200,10 +200,10 @@ export const buildTargetRequest = async (source: OpenAIResponsesRequestPayloadEx else if (item.type === 'message' && item.role === 'assistant') { if (Array.isArray(item.content)) { const unsupported = item.content.find(part => part.type === 'input_file' || part.type === 'input_image'); - if (unsupported !== undefined) throw new TranslatorInputError(`Cannot translate ${unsupported.type} assistant content to OpenAI Chat Completions.`); + if (unsupported !== undefined) throw new TranslateInputException(`Cannot translate ${unsupported.type} assistant content to OpenAI Chat Completions.`); } assistant = appendAssistantText(assistant, openaiResponsesContentToText(item.content)); - } else throw new TranslatorInputError(`Invalid input item type '${item.type}'.`); + } else throw new TranslateInputException(`Invalid input item type '${item.type}'.`); } return assistant === null ? [] : [{ ...assistant.message, ...(assistant.reasoningText === undefined ? {} : { reasoning_text: assistant.reasoningText }) }]; }; @@ -231,12 +231,12 @@ export const buildTargetRequest = async (source: OpenAIResponsesRequestPayloadEx if (item.role === 'user') messages.push({ role: 'user', content: openaiResponsesContentToOpenAIChatCompletionsContent(item.content) as OpenAIChatCompletionsUserContentPart[] | string }); else { const content = typeof item.content === 'string' ? item.content : item.content.map(part => { - if (part.type !== 'input_text' && part.type !== 'output_text') throw new TranslatorInputError('Only text content is supported in Chat system/developer messages.'); + if (part.type !== 'input_text' && part.type !== 'output_text') throw new TranslateInputException('Only text content is supported in Chat system/developer messages.'); return { type: 'text' as const, text: part.text }; }); messages.push({ role: item.role, content }); } - } else throw new TranslatorInputError(`Invalid input item type '${item.type}'.`); + } else throw new TranslateInputException(`Invalid input item type '${item.type}'.`); } flushToolOutputImages(); return messages; diff --git a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/translate.ts b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/translate.ts index 728fdd3ac0..7ad9108450 100644 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/translate.ts +++ b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/translate.ts @@ -1,19 +1,27 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; -import type { TranslateTrip } from '../../../types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import type { Translator } from '../../../types.ts'; import { restoreNamespaceEvents } from '../as-source/namespace-tools.ts'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; -export const translate: TranslateTrip< +export const translate: Translator< OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx, OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent > = async (src, ctx) => { - const { target, customToolNames, namespaceToolNames } = await buildTargetRequest(src, ctx.sidecarCodec); - return { - target, - events: frames => restoreNamespaceEvents( - translateToSourceEvents(frames, customToolNames, ctx.sidecarCodec, namespaceToolNames, target.n ?? 1), - namespaceToolNames, - ), - }; + try { + const { target, customToolNames, namespaceToolNames } = await buildTargetRequest(src, ctx.sidecarCodec); + return { + type: 'trip', + error: () => undefined, + target, + events: frames => restoreNamespaceEvents( + translateToSourceEvents(frames, customToolNames, ctx.sidecarCodec, namespaceToolNames, target.n ?? 1), + namespaceToolNames, + ), + }; + } catch (error) { + if (error instanceof TranslateInputException) return error.failure; + throw error; + } }; diff --git a/packages/translate/src/protocols/routes.ts b/packages/translate/src/protocols/routes.ts new file mode 100644 index 0000000000..d547865e59 --- /dev/null +++ b/packages/translate/src/protocols/routes.ts @@ -0,0 +1,9 @@ +export { translate as routeAnthropicMessagesViaOpenAIChatCompletions } from './anthropic-messages/via-openai-chat-completions/translate.ts'; +export { translate as routeAnthropicMessagesViaOpenAIResponses } from './anthropic-messages/via-openai-responses/translate.ts'; +export { translate as routeGeminiGenerateContentViaAnthropicMessages } from './gemini-generate-content/via-anthropic-messages/translate.ts'; +export { translate as routeGeminiGenerateContentViaOpenAIChatCompletions } from './gemini-generate-content/via-openai-chat-completions/translate.ts'; +export { translate as routeGeminiGenerateContentViaOpenAIResponses } from './gemini-generate-content/via-openai-responses/translate.ts'; +export { translate as routeOpenAIChatCompletionsViaAnthropicMessages } from './openai-chat-completions/via-anthropic-messages/translate.ts'; +export { translate as routeOpenAIChatCompletionsViaOpenAIResponses } from './openai-chat-completions/via-openai-responses/translate.ts'; +export { translate as routeOpenAIResponsesViaAnthropicMessages } from './openai-responses/via-anthropic-messages/translate.ts'; +export { translate as routeOpenAIResponsesViaOpenAIChatCompletions } from './openai-responses/via-openai-chat-completions/translate.ts'; diff --git a/packages/translate/src/shared/input-error.ts b/packages/translate/src/shared/input-error.ts new file mode 100644 index 0000000000..5fd9b72b53 --- /dev/null +++ b/packages/translate/src/shared/input-error.ts @@ -0,0 +1,14 @@ +import type { TranslateInputError } from '../types.ts'; + +// Expected input failures are caught at the package's public operations. Internal +// reader and writer helpers can abort without spreading a result union through +// every intermediate transformation. +export class TranslateInputException extends Error { + readonly failure: TranslateInputError; + + constructor(message: string, options?: Omit) { + super(message); + this.name = 'TranslateInputException'; + this.failure = { type: 'input-error', message, ...options }; + } +} diff --git a/packages/translate/src/translator-input-error.ts b/packages/translate/src/translator-input-error.ts deleted file mode 100644 index efa10a41b4..0000000000 --- a/packages/translate/src/translator-input-error.ts +++ /dev/null @@ -1,19 +0,0 @@ -// A caller-input validation failure surfaced by a translator: the caller -// sent something the target protocol cannot represent (an unsupported -// content-part type, a role the target does not accept, a missing field -// the target requires, etc.). Distinct from a plain `Error` so the -// data-plane http handlers can return a protocol-shaped 400 envelope -// instead of routing the failure through the generic internal-error 502 -// path. The optional `param` and `code` follow the OpenAI / Anthropic -// error-body convention. -export class TranslatorInputError extends Error { - readonly param: string | undefined; - readonly code: string | undefined; - - constructor(message: string, options?: { param?: string; code?: string }) { - super(message); - this.name = 'TranslatorInputError'; - this.param = options?.param; - this.code = options?.code; - } -} diff --git a/packages/translate/src/types.ts b/packages/translate/src/types.ts index 2c7735dbb1..d78efd00fa 100644 --- a/packages/translate/src/types.ts +++ b/packages/translate/src/types.ts @@ -13,72 +13,38 @@ export interface RemoteImageData { export type RemoteImageLoader = (url: string) => Promise; -/** - * Per-trip context. Carries the model name, the codec used by private sidecars, - * and a per-pair-declared `TExtras` - * shape that lists exactly the capability fields and runtime adapters the trip - * reads. Pairs that need no extra fields use the default `unknown`. Callers - * construct the context at the protocol boundary and inject runtime-owned - * dependencies without making this package import their implementation. - * - * The client's stream preference is intentionally not in this context. - * Translation always emits `stream: true` on the target payload; the LLM - * upstream layer enforces SSE streaming and source `respond.ts` boundaries - * collect a non-streamed downstream response when the client did not ask - * for SSE. - */ -export type TranslationContext = { +/** Runtime dependencies and target-specific settings for one translation. */ +export type TranslateOptions = { readonly model: string; readonly sidecarCodec: SidecarCodec; } & TExtras; -/** - * A wire-shaped upstream error body handed to `TranslateTrip.apiError`. The - * pair returns a same-shaped object to rewrite the outbound envelope, or - * `undefined` to pass it through unchanged. The provider layer's - * `ApiErrorResult` shape is intentionally not imported here — translate is a - * leaf below provider, so we express the contract in bare HTTP-response - * primitives and let the gateway compose it back into an `ApiErrorResult`. - */ -export interface TranslatedApiError { +export type ApiErrorTranslator = (status: number, headers: Headers, body: Uint8Array) => { readonly status: number; readonly headers: Headers; - readonly body: Uint8Array; -} + readonly body: unknown; +} | undefined; /** - * What one translation trip hands back: the target payload, an events - * translator closure mapping target-protocol events into source-protocol - * events, and an optional upstream-error rewriter. - * - * The target owns its mutable JSON objects and arrays. Downstream rules and - * providers can mutate it while the source request and other trips remain - * live, so pair builders clone values borrowed from their source. - * - * `apiError` is optional: when the target upstream returns a non-2xx HTTP - * body (rather than an SSE stream), the pair may rewrite it into the source - * protocol's envelope. Returning `undefined` — or omitting the field - * entirely — passes the upstream body through verbatim, which is what most - * pairs want. + * The target owns its mutable JSON objects and arrays. Providers can mutate it + * while the source request and other trips remain live, so builders clone values + * borrowed from their source. */ -export interface TranslateTripResult { - target: TgtPayload; - events: (frames: AsyncIterable>) => AsyncIterable>; - apiError?: (upstream: TranslatedApiError) => TranslatedApiError | undefined; +export interface TranslateTrip { + readonly type: 'trip'; + readonly target: TargetPayload; + readonly events: (frames: AsyncIterable>) => AsyncIterable>; + readonly error: ApiErrorTranslator; } -/** - * One internal target-specific translation trip. The function body owns the trip: it builds - * the target payload and returns an events translator closure that maps - * target-protocol events back into source-protocol events. Trip-scoped state - * (synthetic ids, custom-tool name sets, etc.) lives as locals captured by - * the returned closure — the source route never exposes them. - * - * `TExtras` is the pair-declared context surface: each pair lists exactly the - * capabilities and injected runtime adapters it reads. Pairs that need no - * extra context leave it as `unknown` (default). - */ -export type TranslateTrip = ( - src: SrcPayload, - ctx: TranslationContext, -) => Promise>; +export interface TranslateInputError { + readonly type: 'input-error'; + readonly message: string; + readonly param?: string; + readonly code?: string; +} + +export type Translator = ( + payload: SourcePayload, + options: TranslateOptions, +) => Promise | TranslateInputError>; From 604abea3fda922b57aaf0a88178a47e81668c55e Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 04:49:56 +0800 Subject: [PATCH 03/66] fix(protocols): condition input content on its message role Keep refusals on assistant history and use role-specific content contracts in translation and provider consumers. Preserve retained refusal text when compaction restates history as input content. --- .../items/normalize-assistant-content.ts | 6 +++--- .../src/openai-chat-completions/index.ts | 1 - .../protocols/src/openai-responses/index.ts | 13 +++++++++---- packages/provider-codex/src/responses-lite.ts | 4 +++- .../__tests__/compaction_test.ts | 10 ++++++++++ .../openai-responses/compress-images_test.ts | 4 ++-- packages/provider-copilot/src/compaction.ts | 14 +++++++++----- .../openai-chat-completions/compress-images.ts | 6 +++--- .../openai-responses/compress-images.ts | 8 ++++---- .../via-openai-responses/content.ts | 12 +++++------- .../via-openai-responses/request.ts | 6 +++--- .../via-anthropic-messages/request.ts | 2 -- .../via-openai-chat-completions/content.ts | 17 ++++++++--------- 13 files changed, 59 insertions(+), 44 deletions(-) diff --git a/packages/gateway/src/data-plane/chat/openai-responses/items/normalize-assistant-content.ts b/packages/gateway/src/data-plane/chat/openai-responses/items/normalize-assistant-content.ts index 3a238dace2..bc02c811b5 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/items/normalize-assistant-content.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/items/normalize-assistant-content.ts @@ -12,9 +12,9 @@ // content type. Only `role: 'assistant'` is rewritten; user/system/developer // messages keep `input_text` because that IS the correct type on those roles. -import type { OpenAIResponsesInputContent, CanonicalOpenAIResponsesInputItem } from '@floway-dev/protocols/openai-responses'; +import type { OpenAIResponsesAssistantInputContent, CanonicalOpenAIResponsesInputItem } from '@floway-dev/protocols/openai-responses'; -const normalizeAssistantContentBlocks = (content: string | OpenAIResponsesInputContent[]): string | OpenAIResponsesInputContent[] => { +const normalizeAssistantContentBlocks = (content: string | OpenAIResponsesAssistantInputContent[]): string | OpenAIResponsesAssistantInputContent[] => { if (typeof content === 'string') return content; let mutated = false; const next = content.map(block => { @@ -28,7 +28,7 @@ const normalizeAssistantContentBlocks = (content: string | OpenAIResponsesInputC const normalizeItem = (item: CanonicalOpenAIResponsesInputItem): CanonicalOpenAIResponsesInputItem => { if (item.type !== 'message' || item.role !== 'assistant') return item; const next = normalizeAssistantContentBlocks(item.content); - return next === item.content ? item : { ...item, content: next }; + return next === item.content ? item : { ...item, content: next } as CanonicalOpenAIResponsesInputItem; }; export const normalizeAssistantInputText = (input: CanonicalOpenAIResponsesInputItem[]): CanonicalOpenAIResponsesInputItem[] => { diff --git a/packages/protocols/src/openai-chat-completions/index.ts b/packages/protocols/src/openai-chat-completions/index.ts index 17bfc4fc53..e9f9284a2b 100644 --- a/packages/protocols/src/openai-chat-completions/index.ts +++ b/packages/protocols/src/openai-chat-completions/index.ts @@ -102,7 +102,6 @@ export interface OpenAIChatCompletionsFilePart { type: 'file'; file: { file_data export interface OpenAIChatCompletionsRefusalPart { type: 'refusal'; refusal: string } export type OpenAIChatCompletionsUserContentPart = OpenAIChatCompletionsTextPart | OpenAIChatCompletionsImagePart | OpenAIChatCompletionsInputAudioPart | OpenAIChatCompletionsFilePart; export type OpenAIChatCompletionsAssistantContentPart = OpenAIChatCompletionsTextPart | OpenAIChatCompletionsRefusalPart; -export type OpenAIChatCompletionsContentPart = OpenAIChatCompletionsUserContentPart | OpenAIChatCompletionsRefusalPart; // Provider extension types belong only at the assistant boundary. export interface OpenAIChatCompletionsAssistantExtensions { diff --git a/packages/protocols/src/openai-responses/index.ts b/packages/protocols/src/openai-responses/index.ts index 4b750d5384..ed6828cf73 100644 --- a/packages/protocols/src/openai-responses/index.ts +++ b/packages/protocols/src/openai-responses/index.ts @@ -140,18 +140,21 @@ export type CanonicalOpenAIResponsesInputItem = export type OpenAIResponsesMessagePhase = 'commentary' | 'final_answer' | (string & {}) | null; -export interface OpenAIResponsesInputMessageEx { +interface OpenAIResponsesInputMessageFields { type: 'message'; id?: string; status?: string; - role: 'user' | 'assistant' | 'system' | 'developer'; - content: string | OpenAIResponsesInputContent[]; phase?: OpenAIResponsesMessagePhase; // Codex marks base-instruction fragments on ordinary developer messages. // https://github.com/openai/codex/blob/0462dcc062b822bb8fff16cc31ce6eeab69823b9/codex-rs/core/tests/suite/responses_lite.rs#L136-L155 internal_chat_message_metadata_passthrough?: Record; } +export type OpenAIResponsesInputMessageEx = OpenAIResponsesInputMessageFields & ( + | { role: 'user' | 'system' | 'developer'; content: string | OpenAIResponsesInputContent[] } + | { role: 'assistant'; content: string | OpenAIResponsesAssistantInputContent[] } +); + // The OpenAI Responses request schema's EasyInputMessage makes the constant // `type: "message"` discriminator optional. Wire-facing payloads accept that // shorthand; gateway and translator boundaries normalize it before internal @@ -178,7 +181,9 @@ export type CanonicalOpenAIResponsesPayload = Omit(parts: T[]): T[] => const removeLiteImageDetail = (item: CanonicalOpenAIResponsesInputItem): CanonicalOpenAIResponsesInputItem => { if (item.type === 'message' && Array.isArray(item.content)) { const content = removeInputImageDetails(item.content); - return content === item.content ? item : { ...item, content }; + // The role conditions `content`'s shape, so an assistant turn keeps its + // refusal arm while a client role cannot gain one here. + return content === item.content ? item : { ...item, content } as CanonicalOpenAIResponsesInputItem; } if ((item.type === 'function_call_output' || item.type === 'custom_tool_call_output') && Array.isArray(item.output)) { const output = removeInputImageDetails(item.output); diff --git a/packages/provider-copilot/__tests__/compaction_test.ts b/packages/provider-copilot/__tests__/compaction_test.ts index a99e48bdf7..851af1227c 100644 --- a/packages/provider-copilot/__tests__/compaction_test.ts +++ b/packages/provider-copilot/__tests__/compaction_test.ts @@ -55,6 +55,16 @@ test('normalizes every retained text part to input_text — assistant output_tex expect(userPart.type).toBe('input_text'); }); +test('retains assistant refusal text in the compacted input spelling', () => { + const result = compactionResponse([ + { type: 'message', role: 'assistant', content: [{ type: 'refusal', refusal: 'Cannot provide that.' }] }, + ], generatedResult([compaction])); + + expect(result.output[0]).toMatchObject({ + type: 'message', role: 'assistant', content: [{ type: 'input_text', text: 'Cannot provide that.' }], + }); +}); + test('assigns final random ids to retained messages instead of reusing input ids', () => { const result = compactionResponse( [{ type: 'message', id: 'msg_input', role: 'user', content: 'hello' }], diff --git a/packages/provider-copilot/__tests__/interceptors/openai-responses/compress-images_test.ts b/packages/provider-copilot/__tests__/interceptors/openai-responses/compress-images_test.ts index 3e1965f3d9..108f449441 100644 --- a/packages/provider-copilot/__tests__/interceptors/openai-responses/compress-images_test.ts +++ b/packages/provider-copilot/__tests__/interceptors/openai-responses/compress-images_test.ts @@ -4,7 +4,7 @@ import { withInlineImagesCompressed } from '../../../src/interceptors/openai-res import type { OpenAIResponsesBoundaryCtx } from '../../../src/interceptors/openai-responses/types.ts'; import { type ImageProcessor, initImageProcessor } from '@floway-dev/platform'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesInputContent, OpenAIResponsesInputImage, CanonicalOpenAIResponsesInputItem, OpenAIResponsesStreamEventEx, OpenAIResponsesToolOutputContent } from '@floway-dev/protocols/openai-responses'; +import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesInputContent, OpenAIResponsesAssistantInputContent, OpenAIResponsesInputImage, CanonicalOpenAIResponsesInputItem, OpenAIResponsesStreamEventEx, OpenAIResponsesToolOutputContent } from '@floway-dev/protocols/openai-responses'; import type { ExecuteResult } from '@floway-dev/provider'; import { eventResult } from '@floway-dev/provider'; import { assert, assertEquals, stubProviderModel, testTelemetryModelIdentity } from '@floway-dev/test-utils'; @@ -38,7 +38,7 @@ const imageUrlOf = (item: CanonicalOpenAIResponsesInputItem): string | null | un return Array.isArray(content) ? content.find(part => part.type === 'input_image')?.image_url : undefined; }; -const contentOf = (item: CanonicalOpenAIResponsesInputItem): string | OpenAIResponsesInputContent[] | undefined => +const contentOf = (item: CanonicalOpenAIResponsesInputItem): string | OpenAIResponsesAssistantInputContent[] | undefined => item.type === 'message' ? item.content : item.type === 'function_call_output' || item.type === 'custom_tool_call_output' ? item.output : undefined; diff --git a/packages/provider-copilot/src/compaction.ts b/packages/provider-copilot/src/compaction.ts index 63acfa1a6c..f9598745e5 100644 --- a/packages/provider-copilot/src/compaction.ts +++ b/packages/provider-copilot/src/compaction.ts @@ -8,7 +8,7 @@ // https://github.com/openai/codex/blob/3d805abdf09093bfa806f359a5adc6514766c420/codex-rs/core/src/compact_remote_v2.rs#L439-L501 // https://github.com/openai/codex/blob/3d805abdf09093bfa806f359a5adc6514766c420/codex-rs/utils/string/src/truncate.rs#L71-L74 -import { isOpenAIResponsesCompactionItem, createRandomOpenAIResponsesItemId, type OpenAIResponsesCompactionResultEx, type OpenAIResponsesCompactionTriggerItem, type OpenAIResponsesInputContent, type CanonicalOpenAIResponsesInputItem, type OpenAIResponsesInputMessageEx, type OpenAIResponsesOutputItemEx, type OpenAIResponsesResultEx } from '@floway-dev/protocols/openai-responses'; +import { isOpenAIResponsesCompactionItem, createRandomOpenAIResponsesItemId, type OpenAIResponsesCompactionResultEx, type OpenAIResponsesCompactionTriggerItem, type OpenAIResponsesInputContent, type OpenAIResponsesAssistantInputContent, type CanonicalOpenAIResponsesInputItem, type OpenAIResponsesInputMessageEx, type OpenAIResponsesOutputItemEx, type OpenAIResponsesResultEx } from '@floway-dev/protocols/openai-responses'; export const COMPACTION_TRIGGER: OpenAIResponsesCompactionTriggerItem = { type: 'compaction_trigger' }; @@ -28,11 +28,15 @@ const encoder = new TextEncoder(); // Native compact echoes every text part — including assistant `output_text` — // as `input_text` so the client can resend `output` verbatim as next-turn -// `input`. Normalize unconditionally; non-text content passes through and costs -// 0 tokens against the retained budget. -const normalizeContent = (content: OpenAIResponsesInputMessageEx['content']): OpenAIResponsesInputContent[] => { +// `input`. A replayed refusal has no input spelling, so its text is retained as +// ordinary input text; dropping it would shorten the history the client resends. +const normalizeContent = (content: string | OpenAIResponsesAssistantInputContent[]): OpenAIResponsesInputContent[] => { if (typeof content === 'string') return [{ type: 'input_text', text: content }]; - return content.map(part => (part.type === 'output_text' ? { ...part, type: 'input_text' } : part)); + return content.map(part => { + if (part.type === 'output_text') return { ...part, type: 'input_text' as const }; + if (part.type === 'refusal') return { type: 'input_text' as const, text: part.refusal }; + return part; + }); }; const isRetainedMessage = (item: CanonicalOpenAIResponsesInputItem): item is OpenAIResponsesInputMessageEx => diff --git a/packages/provider-copilot/src/interceptors/openai-chat-completions/compress-images.ts b/packages/provider-copilot/src/interceptors/openai-chat-completions/compress-images.ts index d1922a4b88..6d43b035c6 100644 --- a/packages/provider-copilot/src/interceptors/openai-chat-completions/compress-images.ts +++ b/packages/provider-copilot/src/interceptors/openai-chat-completions/compress-images.ts @@ -1,10 +1,10 @@ import { memoizedDataUrlCompressor } from '../image-compression.ts'; import { targetSizeForOpenAIResponsesChat } from '../image-size.ts'; import type { OpenAIChatCompletionsBoundaryCtx, CopilotOpenAIChatCompletionsBoundaryInterceptor } from './types.ts'; -import type { OpenAIChatCompletionsContentPart, OpenAIChatCompletionsMessage } from '@floway-dev/protocols/openai-chat-completions'; +import type { OpenAIChatCompletionsUserContentPart, OpenAIChatCompletionsMessage } from '@floway-dev/protocols/openai-chat-completions'; import { isBase64ImageDataUrl } from '@floway-dev/provider'; -type OpenAIChatCompletionsImagePart = Extract; +type OpenAIChatCompletionsImagePart = Extract; const compressInlineImages = async (ctx: OpenAIChatCompletionsBoundaryCtx): Promise => { const targets: OpenAIChatCompletionsImagePart[] = []; @@ -24,7 +24,7 @@ const compressInlineImages = async (ctx: OpenAIChatCompletionsBoundaryCtx): Prom compressedUrls.set(target, await compress(target.image_url.url)); }), ); - const hasCompressedImage = (part: OpenAIChatCompletionsContentPart): part is OpenAIChatCompletionsImagePart => + const hasCompressedImage = (part: OpenAIChatCompletionsUserContentPart): part is OpenAIChatCompletionsImagePart => part.type === 'image_url' && compressedUrls.has(part); const rewriteImage = (part: OpenAIChatCompletionsImagePart): OpenAIChatCompletionsImagePart => { const url = compressedUrls.get(part); diff --git a/packages/provider-copilot/src/interceptors/openai-responses/compress-images.ts b/packages/provider-copilot/src/interceptors/openai-responses/compress-images.ts index 55dcb79ee4..6d5a0146c4 100644 --- a/packages/provider-copilot/src/interceptors/openai-responses/compress-images.ts +++ b/packages/provider-copilot/src/interceptors/openai-responses/compress-images.ts @@ -1,7 +1,7 @@ import { memoizedDataUrlCompressor } from '../image-compression.ts'; import { targetSizeForOpenAIResponsesChat } from '../image-size.ts'; import type { OpenAIResponsesBoundaryCtx } from './types.ts'; -import type { OpenAIResponsesInputContent, OpenAIResponsesInputImage, OpenAIResponsesToolOutputContent } from '@floway-dev/protocols/openai-responses'; +import type { CanonicalOpenAIResponsesInputItem, OpenAIResponsesInputContent, OpenAIResponsesInputImage, OpenAIResponsesOutputRefusal, OpenAIResponsesToolOutputContent } from '@floway-dev/protocols/openai-responses'; import { isBase64ImageDataUrl } from '@floway-dev/provider'; // A single client request can reach this boundary more than once — a @@ -38,7 +38,7 @@ const compressInlineImages = async (ctx: OpenAIResponsesBoundaryCtx): Promise + const hasCompressedImage = (part: OpenAIResponsesInputContent | OpenAIResponsesOutputRefusal): part is CompressibleImagePart => part.type === 'input_image' && compressedUrls.has(part as CompressibleImagePart); const rewriteImage = (part: CompressibleImagePart): CompressibleImagePart => { const imageUrl = compressedUrls.get(part); @@ -51,7 +51,7 @@ const compressInlineImages = async (ctx: OpenAIResponsesBoundaryCtx): Promise(parts: TPart[]): TPart[] => + const rewriteParts = (parts: TPart[]): TPart[] => parts.map(part => hasCompressedImage(part) ? rewriteImage(part) as TPart : part); ctx.payload = { @@ -59,7 +59,7 @@ const compressInlineImages = async (ctx: OpenAIResponsesBoundaryCtx): Promise { if (item.type === 'message' && Array.isArray(item.content)) { return item.content.some(hasCompressedImage) - ? { ...item, content: rewriteParts(item.content) } + ? { ...item, content: rewriteParts(item.content) } as CanonicalOpenAIResponsesInputItem : item; } if ((item.type === 'function_call_output' || item.type === 'custom_tool_call_output') && Array.isArray(item.output)) { diff --git a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/content.ts b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/content.ts index 5c901126c2..59890ae6b3 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,20 +1,20 @@ import { TranslatorInputError } from '../../../translator-input-error.ts'; -import type { OpenAIChatCompletionsContentPart } from '@floway-dev/protocols/openai-chat-completions'; +import type { OpenAIChatCompletionsUserContentPart } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesInputContent } from '@floway-dev/protocols/openai-responses'; // OpenAI Chat Completions and OpenAI Responses text arrays are transport fragments of one message, not // paragraph blocks. Preserve the existing no-separator flattening. -const contentPartText = (part: OpenAIChatCompletionsContentPart | OpenAIResponsesInputContent): string | null => (part.type === 'text' || part.type === 'input_text' || part.type === 'output_text' ? part.text : null); +const contentPartText = (part: OpenAIChatCompletionsUserContentPart | OpenAIResponsesInputContent): string | null => (part.type === 'text' || part.type === 'input_text' || part.type === 'output_text' ? part.text : null); -const contentPartsToText = (parts: readonly (OpenAIChatCompletionsContentPart | OpenAIResponsesInputContent)[]): string => +const contentPartsToText = (parts: readonly (OpenAIChatCompletionsUserContentPart | OpenAIResponsesInputContent)[]): string => parts .map(contentPartText) .filter((text): text is string => text !== null) .join(''); -export const openaiChatCompletionsContentToText = (content: string | OpenAIChatCompletionsContentPart[] | null | undefined): string => (typeof content === 'string' ? content : Array.isArray(content) ? contentPartsToText(content) : ''); +export const openaiChatCompletionsContentToText = (content: string | OpenAIChatCompletionsUserContentPart[] | null | undefined): string => (typeof content === 'string' ? content : Array.isArray(content) ? contentPartsToText(content) : ''); -export const openaiChatCompletionsContentToOpenAIResponsesInputContent = (content: string | OpenAIChatCompletionsContentPart[] | null | undefined): string | OpenAIResponsesInputContent[] => { +export const openaiChatCompletionsContentToOpenAIResponsesInputContent = (content: string | OpenAIChatCompletionsUserContentPart[] | null | undefined): string | OpenAIResponsesInputContent[] => { if (typeof content === 'string') return content; if (!Array.isArray(content) || content.length === 0) return ''; @@ -23,8 +23,6 @@ export const openaiChatCompletionsContentToOpenAIResponsesInputContent = (conten switch (part.type) { case 'text': return { type: 'input_text', text: part.text }; - case 'refusal': - return { type: 'refusal', refusal: part.refusal }; case 'input_audio': throw new TranslatorInputError('Cannot translate input_audio content to OpenAI Responses.'); case 'file': diff --git a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/request.ts b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/request.ts index 66a4928365..e345fbf574 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 @@ -7,7 +7,7 @@ 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'; import type { OpenAIChatCompletionsAssistantMessage, OpenAIChatCompletionsMessage, OpenAIChatCompletionsPayload, OpenAIChatCompletionsTool } from '@floway-dev/protocols/openai-chat-completions'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesInputContent, CanonicalOpenAIResponsesInputItem, OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; +import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesAssistantInputContent, CanonicalOpenAIResponsesInputItem, OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; const translateChatTools = (tools?: OpenAIChatCompletionsTool[] | null): OpenAIResponsesTool[] => tools?.length @@ -36,8 +36,8 @@ const translateChatToolChoice = (choice: NonNullable { - const content: OpenAIResponsesInputContent[] = []; +const translateAssistantContent = (message: OpenAIChatCompletionsAssistantMessage): OpenAIResponsesAssistantInputContent[] => { + const content: OpenAIResponsesAssistantInputContent[] = []; let hasRefusalPart = false; if (typeof message.content === 'string') { diff --git a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts index ae2be7981b..dc14613a82 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 @@ -136,8 +136,6 @@ const translateToolOutput = async (output: string | OpenAIResponsesInputContent[ blocks.push(image); } else if (part.type === 'input_file') { throw new TranslatorInputError('Cannot translate input_file tool output to Anthropic Messages.'); - } else if (part.type === 'refusal') { - throw new TranslatorInputError('Cannot translate refusal content in a tool output to Anthropic Messages.'); } else { blocks.push({ type: 'text', text: part.text }); } diff --git a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/content.ts b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/content.ts index 472fb53df5..53433ed362 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,28 +1,28 @@ import { TranslatorInputError } from '../../../translator-input-error.ts'; -import type { OpenAIChatCompletionsContentPart } from '@floway-dev/protocols/openai-chat-completions'; -import type { OpenAIResponsesInputContent } from '@floway-dev/protocols/openai-responses'; +import type { OpenAIChatCompletionsUserContentPart } from '@floway-dev/protocols/openai-chat-completions'; +import type { OpenAIResponsesInputContent, OpenAIResponsesAssistantInputContent } from '@floway-dev/protocols/openai-responses'; // OpenAI Chat Completions and OpenAI Responses text arrays are transport fragments of one message, not // paragraph blocks. Preserve the existing no-separator flattening. -const contentPartText = (part: OpenAIChatCompletionsContentPart | OpenAIResponsesInputContent): string | null => (part.type === 'text' || part.type === 'input_text' || part.type === 'output_text' ? part.text : null); +const contentPartText = (part: OpenAIChatCompletionsUserContentPart | OpenAIResponsesAssistantInputContent): string | null => (part.type === 'text' || part.type === 'input_text' || part.type === 'output_text' ? part.text : null); -const contentPartsToText = (parts: readonly (OpenAIChatCompletionsContentPart | OpenAIResponsesInputContent)[]): string => +const contentPartsToText = (parts: readonly (OpenAIChatCompletionsUserContentPart | OpenAIResponsesAssistantInputContent)[]): string => parts .map(contentPartText) .filter((text): text is string => text !== null) .join(''); -export const openaiResponsesContentToText = (content: string | OpenAIResponsesInputContent[]): string => (typeof content === 'string' ? content : contentPartsToText(content)); +export const openaiResponsesContentToText = (content: string | OpenAIResponsesAssistantInputContent[]): string => (typeof content === 'string' ? content : contentPartsToText(content)); -export const openaiResponsesContentToOpenAIChatCompletionsContent = (content: string | OpenAIResponsesInputContent[]): string | OpenAIChatCompletionsContentPart[] => { +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.'); } - return content.some(part => part.type === 'input_image' || part.type === 'refusal') + return content.some(part => part.type === 'input_image') ? content.map( - (part): OpenAIChatCompletionsContentPart => { + (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.'); @@ -41,7 +41,6 @@ export const openaiResponsesContentToOpenAIChatCompletionsContent = (content: st }, }; } - if (part.type === 'refusal') return { type: 'refusal', refusal: part.refusal }; return { type: 'text', text: part.text }; }, ) From 6238b81b05a699631ab67d52a8c863a855f05ec4 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 04:52:54 +0800 Subject: [PATCH 04/66] refactor(translate): clarify assistant-turn replay ownership Split native replay coordination into reader, writer, and coordinator owners. Distinguish native input turns, read and emitted reference states, text fragments, content-reference tags, and the transport codec without changing replay wire behavior. Preserve the pairwise request replay signature and reasoning warnings. Keep native output-to-input normalization and Gemini target slots, and move converter assertions to their protocol owners. Validated 38 named test files (307 tests), translate typecheck, and touched-file ESLint. Production HMAC/CBOR sidecar bytes, digests, selected references, restored turns, and native converter outputs match the main baseline for literal, astral, and unpaired-surrogate fixtures. --- packages/translate/__tests__/ir/helpers.ts | 7 +- .../translate/__tests__/ir/part-id_test.ts | 4 +- .../__tests__/ir/translation-cases.ts | 4 +- .../as-source/sse-to_test.ts | 56 +-- .../as-target/sse-from_test.ts | 14 +- .../as-source/sse-to_test.ts | 32 +- .../as-source/sse-to_test.ts | 20 +- .../as-target/assistant-turn_test.ts | 25 ++ .../as-target/sse-from_test.ts | 18 +- .../openai-responses/as-source/sse-to_test.ts | 40 +- .../as-target/assistant-turn_test.ts | 61 +++ .../as-target/sse-from_test.ts | 46 +- .../round-trip-request_test.ts | 14 +- .../protocols/round-trip-receive_test.ts | 22 +- .../__tests__/protocols/routes_test.ts | 16 +- .../{sidecar-codec_test.ts => codec_test.ts} | 2 +- .../__tests__/round-trip/content-hash_test.ts | 22 - .../__tests__/round-trip/extraction_test.ts | 26 +- .../__tests__/round-trip/hash_test.ts | 12 + .../__tests__/round-trip/protocols_test.ts | 128 +++--- ...-provenance_test.ts => provenance_test.ts} | 22 +- ..._test.ts => content-reference-tag_test.ts} | 14 +- .../reference/reference-builder_test.ts | 420 +++++++++--------- .../reference/reference-cost-json_test.ts | 14 +- .../reference/reference-cost_test.ts | 78 ++-- .../round-trip/reference/restore_test.ts | 28 +- .../{assistant-turn_test.ts => types_test.ts} | 18 +- .../__tests__/round-trip/replay-text_test.ts | 50 +-- .../__tests__/round-trip/resolve_test.ts | 20 +- .../round-trip/sidecar-candidates_test.ts | 33 +- .../__tests__/round-trip/sidecar-codec.ts | 4 +- .../__tests__/round-trip/test-helpers.ts | 23 +- .../__tests__/round-trip/turn-digest_test.ts | 10 + packages/translate/src/index.ts | 4 +- packages/translate/src/ir/stream.ts | 10 +- .../as-source/round-trip.ts | 24 +- .../anthropic-messages/as-source/sse-to.ts | 38 +- .../as-target/assistant-turn.ts | 10 +- .../anthropic-messages/as-target/sse-from.ts | 36 +- .../via-openai-chat-completions/events.ts | 6 +- .../via-openai-chat-completions/request.ts | 6 +- .../via-openai-responses/events.ts | 6 +- .../via-openai-responses/request.ts | 6 +- .../as-source/round-trip.ts | 22 +- .../as-source/sse-to.ts | 50 +-- .../as-target/assistant-turn.ts | 6 +- .../via-anthropic-messages/events.ts | 6 +- .../via-anthropic-messages/request.ts | 6 +- .../via-openai-chat-completions/events.ts | 6 +- .../via-openai-chat-completions/request.ts | 6 +- .../via-openai-responses/events.ts | 6 +- .../via-openai-responses/request.ts | 6 +- .../as-source/round-trip.ts | 34 +- .../as-source/sse-to.ts | 44 +- .../as-target/assistant-turn.ts | 17 +- .../as-target/sse-from.ts | 52 +-- .../via-anthropic-messages/events.ts | 6 +- .../via-anthropic-messages/request.ts | 7 +- .../via-openai-responses/events.ts | 6 +- .../via-openai-responses/request.ts | 6 +- .../openai-responses/as-source/round-trip.ts | 30 +- .../openai-responses/as-source/sse-to.ts | 44 +- .../as-target/assistant-turn.ts | 21 +- .../openai-responses/as-target/sse-from.ts | 30 +- .../via-anthropic-messages/events.ts | 6 +- .../via-anthropic-messages/request.ts | 7 +- .../via-openai-chat-completions/events.ts | 6 +- .../via-openai-chat-completions/request.ts | 6 +- .../{sidecar-tagged-value.ts => codec.ts} | 6 + .../translate/src/round-trip/coordinator.ts | 47 ++ packages/translate/src/round-trip/hash.ts | 9 + ...istant-turn.ts => input-assistant-turn.ts} | 10 +- .../text-provenance.ts => provenance.ts} | 12 +- packages/translate/src/round-trip/reader.ts | 12 + .../reference/assistant-turn-types.ts | 22 + ...gged-value.ts => content-reference-tag.ts} | 12 +- .../round-trip/reference/reference-builder.ts | 169 +++---- .../reference/reference-selection.ts | 89 ++-- .../src/round-trip/reference/restore.ts | 15 +- .../reference/{assistant-turn.ts => types.ts} | 21 - packages/translate/src/round-trip/resolve.ts | 24 +- packages/translate/src/round-trip/sidecar.ts | 47 +- packages/translate/src/round-trip/stream.ts | 65 --- .../translate/src/round-trip/text-groups.ts | 12 +- .../translate/src/round-trip/turn-digest.ts | 11 +- packages/translate/src/round-trip/writer.ts | 16 + .../src/shared/event-writer-options.ts | 8 + packages/translate/src/types.ts | 7 +- 88 files changed, 1292 insertions(+), 1207 deletions(-) create mode 100644 packages/translate/__tests__/protocols/openai-chat-completions/as-target/assistant-turn_test.ts create mode 100644 packages/translate/__tests__/protocols/openai-responses/as-target/assistant-turn_test.ts rename packages/translate/__tests__/round-trip/{sidecar-codec_test.ts => codec_test.ts} (94%) delete mode 100644 packages/translate/__tests__/round-trip/content-hash_test.ts create mode 100644 packages/translate/__tests__/round-trip/hash_test.ts rename packages/translate/__tests__/round-trip/{reference/text-provenance_test.ts => provenance_test.ts} (59%) rename packages/translate/__tests__/round-trip/reference/{tagged-value_test.ts => content-reference-tag_test.ts} (60%) rename packages/translate/__tests__/round-trip/reference/{assistant-turn_test.ts => types_test.ts} (61%) rename packages/translate/src/round-trip/{sidecar-tagged-value.ts => codec.ts} (52%) create mode 100644 packages/translate/src/round-trip/coordinator.ts create mode 100644 packages/translate/src/round-trip/hash.ts rename packages/translate/src/round-trip/{assistant-turn.ts => input-assistant-turn.ts} (57%) rename packages/translate/src/round-trip/{reference/text-provenance.ts => provenance.ts} (73%) create mode 100644 packages/translate/src/round-trip/reader.ts create mode 100644 packages/translate/src/round-trip/reference/assistant-turn-types.ts rename packages/translate/src/round-trip/reference/{tagged-value.ts => content-reference-tag.ts} (86%) rename packages/translate/src/round-trip/reference/{assistant-turn.ts => types.ts} (57%) delete mode 100644 packages/translate/src/round-trip/stream.ts create mode 100644 packages/translate/src/round-trip/writer.ts create mode 100644 packages/translate/src/shared/event-writer-options.ts diff --git a/packages/translate/__tests__/ir/helpers.ts b/packages/translate/__tests__/ir/helpers.ts index 396b11e24d..2d5e92759b 100644 --- a/packages/translate/__tests__/ir/helpers.ts +++ b/packages/translate/__tests__/ir/helpers.ts @@ -8,8 +8,9 @@ import { irFromOpenAIChatCompletions as irFromOpenAIChatCompletionsProduction } import { flattenNamespaceTools } from '../../src/protocols/openai-responses/as-source/namespace-tools.ts'; import { openaiResponsesFromIR as openaiResponsesFromIRProduction } from '../../src/protocols/openai-responses/as-source/sse-to.ts'; import { irFromOpenAIResponses as irFromOpenAIResponsesProduction } from '../../src/protocols/openai-responses/as-target/sse-from.ts'; +import type { RoundTripReader } from '../../src/round-trip/reader.ts'; import { createReferenceBuilder } from '../../src/round-trip/reference/reference-builder.ts'; -import type { RoundTripReader, RoundTripWriter } from '../../src/round-trip/stream.ts'; +import type { RoundTripWriter } from '../../src/round-trip/writer.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; export const iterate = async function* (values: readonly T[]): AsyncGenerator { yield* values; }; @@ -22,7 +23,7 @@ const reader = (): RoundTripWriter => ({ builder: createReferenceBuilder(), - prepareSidecar: async () => 'test-sidecar', + encodeSidecar: async () => 'test-sidecar', }); const namespaceToolNames = flattenNamespaceTools({ model: 'test', input: [], tools: [] }).names; @@ -31,7 +32,7 @@ const namespaceToolNames = flattenNamespaceTools({ model: 'test', input: [], too const withoutProvenance = async function* (records: AsyncIterable): AsyncGenerator { for await (const record of records) { if (record.type === 'write' && record.kind === 'append') { - const { referencePieceIds: _referencePieceIds, ...operation } = record; + const { textFragmentIds: _textFragmentIds, ...operation } = record; yield operation; } else yield record; } diff --git a/packages/translate/__tests__/ir/part-id_test.ts b/packages/translate/__tests__/ir/part-id_test.ts index f6450cc581..fa4fb30254 100644 --- a/packages/translate/__tests__/ir/part-id_test.ts +++ b/packages/translate/__tests__/ir/part-id_test.ts @@ -12,7 +12,7 @@ test('carries optional source part IDs on append operations', () => { part.append('text', 'second'); expect(builder.drain()).toEqual([ - { type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'first', referencePieceIds: [7] }, + { type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'first', textFragmentIds: [7] }, { type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'second' }, ]); }); @@ -25,6 +25,6 @@ test('retains every original ID when a native buffer is merged into one IR appen part.append('text', 'merged', ids); ids.length = 0; expect(builder.drain()).toEqual([ - { type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'merged', referencePieceIds: [7, 9] }, + { type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'merged', textFragmentIds: [7, 9] }, ]); }); diff --git a/packages/translate/__tests__/ir/translation-cases.ts b/packages/translate/__tests__/ir/translation-cases.ts index eacb60b174..f55e0a0f22 100644 --- a/packages/translate/__tests__/ir/translation-cases.ts +++ b/packages/translate/__tests__/ir/translation-cases.ts @@ -1,8 +1,8 @@ import { expect, test } from 'vitest'; import { collect, iterate } from './helpers.ts'; +import type { AssistantTurnProtocol } from '../../src/round-trip/input-assistant-turn.ts'; import { decodeRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; -import type { RoundTripProtocol } from '../../src/round-trip/sidecar.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { collectAnthropicMessagesProtocolEventsToResult } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, doneFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; @@ -12,7 +12,7 @@ import { collectOpenAIResponsesProtocolEventsToResult, openaiResponsesResultToEv type Wire = Record; export type IRTestProtocol = 'openai-chat-completions' | 'openai-responses' | 'anthropic-messages' | 'gemini-generate-content'; -const protocolKeys: Record = { +const protocolKeys: Record = { 'openai-chat-completions': 'openaiChatCompletions', 'openai-responses': 'openaiResponses', 'anthropic-messages': 'anthropicMessages', diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/sse-to_test.ts index 20ab1c350c..bc253b3917 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-source/sse-to_test.ts @@ -2,15 +2,15 @@ import { expect, test } from 'vitest'; import { createIRBuilder } from '../../../../src/ir/stream.ts'; import { anthropicMessagesFromIR } from '../../../../src/protocols/anthropic-messages/as-source/sse-to.ts'; -import { appendField, collectContentViews, createReferenceBuilder, createFieldState, createReferenceFinalizer } from '../../../../src/round-trip/reference/reference-builder.ts'; -import { type RoundTripWriter } from '../../../../src/round-trip/stream.ts'; -import { hashRoundTripContent } from '../../../../src/round-trip/turn-digest.ts'; +import { hashRoundTripContent } from '../../../../src/round-trip/hash.ts'; +import { appendReadField, collectEmittedFieldPlacements, createReferenceBuilder, createReadFieldState, createFieldReferencer } from '../../../../src/round-trip/reference/reference-builder.ts'; +import { type RoundTripWriter } from '../../../../src/round-trip/writer.ts'; import { type AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; test.each(['text', 'refusal', 'audio'] as const)('resolves blocked %s against the emitted Messages text field', async kind => { const references = createReferenceBuilder(); - const field = createFieldState('text'); - const parts = ['H', 'i'].map(text => appendField(references, field, text)); + const field = createReadFieldState('text'); + const parts = ['H', 'i'].map(text => appendReadField(references, field, text)); const source = createIRBuilder(); source.choice(0); source.assign(['model'], 'model'); @@ -31,11 +31,11 @@ test.each(['text', 'refusal', 'audio'] as const)('resolves blocked %s against th let prepared = false; const roundTrip: RoundTripWriter<'anthropicMessages'> = { builder: references, - prepareSidecar: async (_choice, _check, turn, state, textViews) => { - const views = collectContentViews(turn, state, textViews); - expect([...views.values()]).toContainEqual({ text: 'Hi', offset: 0 }); - const finalizer = createReferenceFinalizer(references, views); - expect(await finalizer.finalizeField('Hi', field)).toMatchObject({ parts: [0] }); + encodeSidecar: async (_choice, _check, turn, state, textViews) => { + const views = collectEmittedFieldPlacements(turn, state, textViews); + expect([...views.values()]).toContainEqual({ enclosingText: 'Hi', offset: 0 }); + const finalizer = createFieldReferencer(references, views); + expect(await finalizer.referenceField('Hi', field)).toMatchObject({ parts: [0] }); expect(finalizer.referencedContentHashes).toEqual([await hashRoundTripContent('Hi')]); prepared = true; return 'sidecar'; @@ -49,12 +49,12 @@ test.each(['text', 'refusal', 'audio'] as const)('resolves blocked %s against th test('resolves blocked reasoning in emitted summary/content order before live appends', async () => { const references = createReferenceBuilder(); - const summary = createFieldState('text'); - const content = createFieldState('text'); - const a = appendField(references, summary, 'A'); - const x = appendField(references, content, 'X'); - const b = appendField(references, summary, 'B'); - const c = appendField(references, summary, 'C'); + const summary = createReadFieldState('text'); + const content = createReadFieldState('text'); + const a = appendReadField(references, summary, 'A'); + const x = appendReadField(references, content, 'X'); + const b = appendReadField(references, summary, 'B'); + const c = appendReadField(references, summary, 'C'); const source = createIRBuilder(); source.choice(0); source.assign(['model'], 'model'); @@ -72,10 +72,10 @@ test('resolves blocked reasoning in emitted summary/content order before live ap source.choice(0).end('tool_calls'); source.event({ type: 'finish', status: 'completed' }); - let prepared: Parameters['prepareSidecar']> | undefined; + let prepared: Parameters['encodeSidecar']> | undefined; const roundTrip: RoundTripWriter<'anthropicMessages'> = { builder: references, - prepareSidecar: async (...args) => { prepared = args; return 'sidecar'; }, + encodeSidecar: async (...args) => { prepared = args; return 'sidecar'; }, }; const events: AnthropicMessagesStreamEventEx[] = []; for await (const frame of anthropicMessagesFromIR((async function* () { yield* source.drain(); })(), { roundTrip })) events.push(frame.event); @@ -83,16 +83,16 @@ test('resolves blocked reasoning in emitted summary/content order before live ap if (prepared === undefined) throw new Error('Messages sidecar was not prepared'); const [, , turn, state] = prepared; expect(turn.content).toContainEqual(expect.objectContaining({ type: 'thinking', thinking: 'ABXC' })); - const finalizer = createReferenceFinalizer(references, collectContentViews(turn, state)); - expect(await finalizer.finalizeField('ABC', summary)).toMatchObject({ parts: [[0, 0, 2], [0, 3, 4]] }); - expect(await finalizer.finalizeField('X', content)).toMatchObject({ parts: [[0, 2, 3]] }); + const finalizer = createFieldReferencer(references, collectEmittedFieldPlacements(turn, state)); + expect(await finalizer.referenceField('ABC', summary)).toMatchObject({ parts: [[0, 0, 2], [0, 3, 4]] }); + expect(await finalizer.referenceField('X', content)).toMatchObject({ parts: [[0, 2, 3]] }); expect(finalizer.referencedContentHashes).toEqual([await hashRoundTripContent('ABXC')]); }); test.each(['{"x":1,}', '{"x": 1}'])('tracks the emitted canonical JSON view without resolving transformed atoms: %s', async original => { const references = createReferenceBuilder(); - const field = createFieldState('text'); - const partId = appendField(references, field, original); + const field = createReadFieldState('text'); + const partId = appendReadField(references, field, original); const source = createIRBuilder(); source.choice(0); source.assign(['model'], 'model'); @@ -105,11 +105,11 @@ test.each(['{"x":1,}', '{"x": 1}'])('tracks the emitted canonical JSON view with let prepared = false; const roundTrip: RoundTripWriter<'anthropicMessages'> = { builder: references, - prepareSidecar: async (_choice, _check, turn, state, textViews) => { - const views = collectContentViews(turn, state, textViews); - expect([...views.values()]).toContainEqual({ text: '{"x":1}', offset: 0 }); - const finalizer = createReferenceFinalizer(references, views); - expect(await finalizer.finalizeField(original, field)).toBe(original); + encodeSidecar: async (_choice, _check, turn, state, textViews) => { + const views = collectEmittedFieldPlacements(turn, state, textViews); + expect([...views.values()]).toContainEqual({ enclosingText: '{"x":1}', offset: 0 }); + const finalizer = createFieldReferencer(references, views); + expect(await finalizer.referenceField(original, field)).toBe(original); prepared = true; return 'sidecar'; }, diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-target/sse-from_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-target/sse-from_test.ts index db364566b8..78f272ef22 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-target/sse-from_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-target/sse-from_test.ts @@ -1,9 +1,9 @@ import { expect, test } from 'vitest'; -import type { AnthropicMessagesAssistantState, AnthropicMessagesInputAssistantTurn } from '../../../../src/protocols/anthropic-messages/as-target/assistant-turn.ts'; +import type { AnthropicMessagesInputAssistantTurnState, AnthropicMessagesInputAssistantTurn } from '../../../../src/protocols/anthropic-messages/as-target/assistant-turn.ts'; import { irFromAnthropicMessages } from '../../../../src/protocols/anthropic-messages/as-target/sse-from.ts'; -import { appendContent, collectContentViews, createContentState, createReferenceBuilder, type FieldStates } from '../../../../src/round-trip/reference/reference-builder.ts'; -import { finalizeAssistantTurnReferences } from '../../../../src/round-trip/reference/reference-selection.ts'; +import { appendEmittedField, collectEmittedFieldPlacements, createEmittedFieldState, createReferenceBuilder, type ReadFieldStates } from '../../../../src/round-trip/reference/reference-builder.ts'; +import { referenceAssistantTurn } from '../../../../src/round-trip/reference/reference-selection.ts'; import { fixtureFrames } from '../../../ir/translation-cases.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; @@ -24,12 +24,12 @@ test('keeps native-shaped field state aligned through sparse block ordering and ...end, ]; const builder = createReferenceBuilder(); - const content = createContentState(builder, 'text'); - let captured: { turn: AnthropicMessagesInputAssistantTurn; state: AnthropicMessagesAssistantState } | undefined; + const content = createEmittedFieldState(builder, 'text'); + let captured: { turn: AnthropicMessagesInputAssistantTurn; state: AnthropicMessagesInputAssistantTurnState } | undefined; for await (const record of irFromAnthropicMessages((async function* () { yield* frames; })(), { roundTrip: { builder, onAssistantTurn: (_choice, turn, state) => { captured = { turn, state }; } }, })) { - if (record.type === 'write' && record.kind === 'append' && record.path.at(-1) === 'text') appendContent(builder, content, record.value, record.referencePieceIds); + if (record.type === 'write' && record.kind === 'append' && record.path.at(-1) === 'text') appendEmittedField(builder, content, record.value, record.textFragmentIds); } if (captured === undefined) throw new Error('Native assistant turn was not collected'); expect(captured.turn.content).toEqual([ @@ -38,7 +38,7 @@ test('keeps native-shaped field state aligned through sparse block ordering and { type: 'text', text: 'late', citations: null }, ]); expect(Object.hasOwn(captured.state.content ?? [], 1)).toBe(false); - const result = await finalizeAssistantTurnReferences<'anthropicMessages'>(captured.turn, captured.state, builder, collectContentViews('lateearly', content)); + const result = await referenceAssistantTurn<'anthropicMessages'>(captured.turn, captured.state, builder, collectEmittedFieldPlacements('lateearly', content)); expect(result.targetTurn.content).toMatchObject([ { text: 'early' }, { type: 'redacted_thinking', data: 'private' }, diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/sse-to_test.ts index a354be3ec3..557280bfa2 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/sse-to_test.ts @@ -2,13 +2,13 @@ import { expect, test } from 'vitest'; import { createIRBuilder } from '../../../../src/ir/stream.ts'; import { geminiGenerateContentFromIR } from '../../../../src/protocols/gemini-generate-content/as-source/sse-to.ts'; -import { appendField, collectContentViews, createReferenceBuilder, createFieldState, createReferenceFinalizer } from '../../../../src/round-trip/reference/reference-builder.ts'; -import { type RoundTripWriter } from '../../../../src/round-trip/stream.ts'; +import { appendReadField, collectEmittedFieldPlacements, createReferenceBuilder, createReadFieldState, createFieldReferencer } from '../../../../src/round-trip/reference/reference-builder.ts'; +import { type RoundTripWriter } from '../../../../src/round-trip/writer.ts'; test('resolves delayed Gemini audio atoms within the complete emitted data field', async () => { const references = createReferenceBuilder(); - const field = createFieldState('text'); - const partId = appendField(references, field, 'Q=='); + const field = createReadFieldState('text'); + const partId = appendReadField(references, field, 'Q=='); const source = createIRBuilder(); source.choice(0); source.assign(['model'], 'model'); @@ -24,11 +24,11 @@ test('resolves delayed Gemini audio atoms within the complete emitted data field let prepared = false; const roundTrip: RoundTripWriter<'geminiGenerateContent'> = { builder: references, - prepareSidecar: async (_choice, _check, turn, state, textViews) => { - const views = collectContentViews(turn, state, textViews); - expect([...views.values()]).toEqual([{ text: 'YQ==', offset: 0 }]); - const finalizer = createReferenceFinalizer(references, views); - expect(await finalizer.finalizeField('Q==', field)).toMatchObject({ parts: [[0, 1, 4]] }); + encodeSidecar: async (_choice, _check, turn, state, textViews) => { + const views = collectEmittedFieldPlacements(turn, state, textViews); + expect([...views.values()]).toEqual([{ enclosingText: 'YQ==', offset: 0 }]); + const finalizer = createFieldReferencer(references, views); + expect(await finalizer.referenceField('Q==', field)).toMatchObject({ parts: [[0, 1, 4]] }); prepared = true; return 'sidecar'; }, @@ -39,8 +39,8 @@ test('resolves delayed Gemini audio atoms within the complete emitted data field test.each(['{"x":1,}', '{"x": 1}'])('tracks the emitted canonical JSON view without resolving transformed atoms: %s', async original => { const references = createReferenceBuilder(); - const field = createFieldState('text'); - const partId = appendField(references, field, original); + const field = createReadFieldState('text'); + const partId = appendReadField(references, field, original); const source = createIRBuilder(); source.choice(0); source.assign(['model'], 'model'); @@ -53,11 +53,11 @@ test.each(['{"x":1,}', '{"x": 1}'])('tracks the emitted canonical JSON view with let prepared = false; const roundTrip: RoundTripWriter<'geminiGenerateContent'> = { builder: references, - prepareSidecar: async (_choice, _check, turn, state, textViews) => { - const views = collectContentViews(turn, state, textViews); - expect([...views.values()]).toContainEqual({ text: '{"x":1}', offset: 0 }); - const finalizer = createReferenceFinalizer(references, views); - expect(await finalizer.finalizeField(original, field)).toBe(original); + encodeSidecar: async (_choice, _check, turn, state, textViews) => { + const views = collectEmittedFieldPlacements(turn, state, textViews); + expect([...views.values()]).toContainEqual({ enclosingText: '{"x":1}', offset: 0 }); + const finalizer = createFieldReferencer(references, views); + expect(await finalizer.referenceField(original, field)).toBe(original); prepared = true; return 'sidecar'; }, diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts index 7fe2a02421..53d8c9a3e4 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts @@ -2,8 +2,8 @@ import { expect, test } from 'vitest'; import { createIRBuilder } from '../../../../src/ir/stream.ts'; import { openaiChatCompletionsFromIR } from '../../../../src/protocols/openai-chat-completions/as-source/sse-to.ts'; -import { createReferenceBuilder, collectContentViews, createFieldState, createReferenceFinalizer, appendField } from '../../../../src/round-trip/reference/reference-builder.ts'; -import { type RoundTripWriter } from '../../../../src/round-trip/stream.ts'; +import { createReferenceBuilder, collectEmittedFieldPlacements, createReadFieldState, createFieldReferencer, appendReadField } from '../../../../src/round-trip/reference/reference-builder.ts'; +import { type RoundTripWriter } from '../../../../src/round-trip/writer.ts'; test('prepares each Chat choice independently while another choice has partial audio', async () => { const source = createIRBuilder(2); @@ -34,7 +34,7 @@ test('prepares each Chat choice independently while another choice has partial a const prepared: number[] = []; const roundTrip: RoundTripWriter<'openaiChatCompletions'> = { builder: createReferenceBuilder(), - prepareSidecar: async choice => { prepared.push(choice); return `sidecar-${choice}`; }, + encodeSidecar: async choice => { prepared.push(choice); return `sidecar-${choice}`; }, }; const frames = []; for await (const frame of openaiChatCompletionsFromIR((async function* () { yield* source.drain(); })(), { @@ -49,9 +49,9 @@ test('prepares each Chat choice independently while another choice has partial a test.each([false, true])('resolves delayed transcript provenance at actual projection (audio data: %s)', async hasAudioData => { const references = createReferenceBuilder(); - const transcript = createFieldState('text'); - const first = appendField(references, transcript, 'late '); - const second = appendField(references, transcript, 'text'); + const transcript = createReadFieldState('text'); + const first = appendReadField(references, transcript, 'late '); + const second = appendReadField(references, transcript, 'text'); const source = createIRBuilder(); source.choice(0); source.assign(['model'], 'model'); @@ -74,18 +74,18 @@ test.each([false, true])('resolves delayed transcript provenance at actual proje source.choice(0).end('stop'); source.event({ type: 'finish', status: 'completed' }); - let prepared: Parameters['prepareSidecar']> | undefined; + let prepared: Parameters['encodeSidecar']> | undefined; const roundTrip: RoundTripWriter<'openaiChatCompletions'> = { builder: references, - prepareSidecar: async (...args) => { prepared = args; return 'sidecar'; }, + encodeSidecar: async (...args) => { prepared = args; return 'sidecar'; }, }; for await (const _frame of openaiChatCompletionsFromIR((async function* () { yield* source.drain(); })(), { roundTrip, audioMetadata: () => ({ id: 'audio', expires_at: 10 }), })) {} if (prepared === undefined) throw new Error('ChatCompletions sidecar was not prepared'); const [, , turn, state] = prepared; - const finalizer = createReferenceFinalizer(references, collectContentViews(turn, state)); + const finalizer = createFieldReferencer(references, collectEmittedFieldPlacements(turn, state)); expect(turn.content).toBe(hasAudioData ? 'early ' : 'early late text'); - expect(await finalizer.finalizeField('late text', transcript)).toEqual(hasAudioData ? 'late text' : expect.objectContaining({ parts: [[0, 6, 15]] })); + expect(await finalizer.referenceField('late text', transcript)).toEqual(hasAudioData ? 'late text' : expect.objectContaining({ parts: [[0, 6, 15]] })); if (hasAudioData) expect(turn.audio).toEqual({ id: 'audio' }); }); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/assistant-turn_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/assistant-turn_test.ts new file mode 100644 index 0000000000..c571e28856 --- /dev/null +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/assistant-turn_test.ts @@ -0,0 +1,25 @@ +import { expect, test } from 'vitest'; + +import { toOpenAIChatCompletionsInputAssistantTurn } from '../../../../src/protocols/openai-chat-completions/as-target/assistant-turn.ts'; +import { createReadFieldState } from '../../../../src/round-trip/reference/reference-builder.ts'; +import type { OpenAIChatCompletionsAssistantOutputMessageEx } from '@floway-dev/protocols/openai-chat-completions'; + +test('keeps native Chat fields and reference state while reducing audio to its input identity', () => { + const output: OpenAIChatCompletionsAssistantOutputMessageEx = { + role: 'assistant', content: 'answer', refusal: null, + audio: { id: 'audio-a', data: 'bytes', expires_at: 4, transcript: 'text' }, + reasoning_details: [{ type: 'reasoning.summary', summary: 'native summary' }], + provider_specific_fields: { native: true }, + }; + const before = structuredClone(output); + const content = createReadFieldState('text'); + const state = { content }; + const input = toOpenAIChatCompletionsInputAssistantTurn(output, state); + + expect(input.turn).toEqual({ ...output, audio: { id: 'audio-a' } }); + expect(input.turn.provider_specific_fields).toBe(output.provider_specific_fields); + expect(input.turn.reasoning_details).toBe(output.reasoning_details); + expect(input.state).not.toBe(state); + expect(input.state.content).toBe(content); + expect(output).toEqual(before); +}); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts index 134b73403f..bac4d22ca8 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts @@ -3,10 +3,10 @@ import { expect, test } from 'vitest'; import type { IRRecord } from '../../../../src/ir/stream.ts'; import type { OpenAIChatCompletionsInputAssistantTurn } from '../../../../src/protocols/openai-chat-completions/as-target/assistant-turn.ts'; import { irFromOpenAIChatCompletions } from '../../../../src/protocols/openai-chat-completions/as-target/sse-from.ts'; -import { appendContent, collectContentViews, createContentState, createReferenceBuilder } from '../../../../src/round-trip/reference/reference-builder.ts'; -import { finalizeAssistantTurnReferences } from '../../../../src/round-trip/reference/reference-selection.ts'; +import type { RoundTripReader } from '../../../../src/round-trip/reader.ts'; +import { appendEmittedField, collectEmittedFieldPlacements, createEmittedFieldState, createReferenceBuilder } from '../../../../src/round-trip/reference/reference-builder.ts'; +import { referenceAssistantTurn } from '../../../../src/round-trip/reference/reference-selection.ts'; import { restoreAssistantTurn } from '../../../../src/round-trip/reference/restore.ts'; -import type { RoundTripReader } from '../../../../src/round-trip/stream.ts'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; @@ -72,9 +72,9 @@ test('collects every native Chat choice when a sibling omits delta', async () => const traceTools = async (frames: ReturnType[], options: { prefix?: string; customToolNames?: ReadonlySet } = {}) => { const builder = createReferenceBuilder(); - const content = createContentState(builder, 'text'); + const content = createEmittedFieldState(builder, 'text'); let text = options.prefix ?? ''; - appendContent(builder, content, text); + appendEmittedField(builder, content, text); let captured: Parameters['onAssistantTurn']> | undefined; const appends: Extract[] = []; const assigns: Extract[] = []; @@ -88,12 +88,12 @@ const traceTools = async (frames: ReturnType[], options: { prefix? const field = record.path.at(-1); if (field !== 'arguments' && field !== 'input') continue; appends.push(record); - appendContent(builder, content, record.value, record.referencePieceIds); + appendEmittedField(builder, content, record.value, record.textFragmentIds); text += record.value; } if (captured === undefined) throw new Error('Native Chat turn was not collected'); const [, turn, fieldState] = captured; - const finalized = await finalizeAssistantTurnReferences(turn, fieldState, builder, collectContentViews(text, content)); + const finalized = await referenceAssistantTurn(turn, fieldState, builder, collectEmittedFieldPlacements(text, content)); const hydrated = await restoreAssistantTurn('openaiChatCompletions', [text], finalized.targetTurn, finalized.referencedContentHashes); expect(hydrated).toEqual({ ok: true, turn }); return { turn, fieldState, appends, assigns, finalized }; @@ -106,7 +106,7 @@ test('keeps merged pre-name arguments in one IR append with every original atom chunk([{ index: 0, delta: { tool_calls: [{ index: 0, function: { name: 'lookup' } }] }, finish_reason: 'tool_calls' }]), ], { prefix: 'p:' }); - expect(result.appends.map(record => ({ text: record.value, referencePieceIds: record.referencePieceIds }))).toEqual([{ text: '{"x":1}', referencePieceIds: [0, 1] }]); + expect(result.appends.map(record => ({ text: record.value, textFragmentIds: record.textFragmentIds }))).toEqual([{ text: '{"x":1}', textFragmentIds: [0, 1] }]); expect(result.fieldState).toMatchObject({ tool_calls: [{ function: { arguments: expect.any(Object) } }] }); expect(result.finalized.targetTurn).toMatchObject({ tool_calls: [{ function: { arguments: '{"x":1}' } }] }); expect(result.finalized).not.toHaveProperty('referencedContentHashes'); @@ -152,7 +152,7 @@ test('keeps a transformed wrapped tool field literal', async () => { const result = await traceTools([ chunk([{ index: 0, delta: { tool_calls: [{ index: 0, id: 'wrapped', type: 'function', function: { name: 'wrapped', arguments: '{"input":"hello"}' } }] }, finish_reason: 'tool_calls' }]), ], { customToolNames: new Set(['wrapped']) }); - expect(result.appends.map(record => ({ text: record.value, referencePieceIds: record.referencePieceIds }))).toEqual([{ text: 'hello', referencePieceIds: undefined }]); + expect(result.appends.map(record => ({ text: record.value, textFragmentIds: record.textFragmentIds }))).toEqual([{ text: 'hello', textFragmentIds: undefined }]); expect(result.finalized.targetTurn).toEqual(result.turn); expect(result.finalized).not.toHaveProperty('referencedContentHashes'); }); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/sse-to_test.ts index 70cc9ccb60..ac1f20e5df 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/sse-to_test.ts @@ -3,8 +3,8 @@ import { expect, test } from 'vitest'; import { createIRBuilder } from '../../../../src/ir/stream.ts'; import { flattenNamespaceTools } from '../../../../src/protocols/openai-responses/as-source/namespace-tools.ts'; import { openaiResponsesFromIR } from '../../../../src/protocols/openai-responses/as-source/sse-to.ts'; -import { appendField, collectContentViews, createReferenceBuilder, createFieldState, createReferenceFinalizer } from '../../../../src/round-trip/reference/reference-builder.ts'; -import { type RoundTripWriter } from '../../../../src/round-trip/stream.ts'; +import { appendReadField, collectEmittedFieldPlacements, createReferenceBuilder, createReadFieldState, createFieldReferencer } from '../../../../src/round-trip/reference/reference-builder.ts'; +import { type RoundTripWriter } from '../../../../src/round-trip/writer.ts'; test('collects sparse namespace-restored Responses state from emitted IR atoms', async () => { const namespace = flattenNamespaceTools({ @@ -18,9 +18,9 @@ test('collects sparse namespace-restored Responses state from emitted IR atoms', const references = createReferenceBuilder(); const field = (parts: readonly string[]) => { - const state = createFieldState('text'); - const referencePieceIds = parts.map(part => appendField(references, state, part)); - return { state, referencePieceIds, original: parts.join('') }; + const state = createReadFieldState('text'); + const textFragmentIds = parts.map(part => appendReadField(references, state, part)); + return { state, textFragmentIds, original: parts.join('') }; }; const functionArguments = field(['{"code":', '1}']); const messageText = field(['A', 'B']); @@ -28,10 +28,10 @@ test('collects sparse namespace-restored Responses state from emitted IR atoms', const reasoningSummary = field(['S', 'C']); const reasoningContent = field(['D']); const customInput = field(['{"q":', '1}']); - let prepared: Parameters['prepareSidecar']> | undefined; + let prepared: Parameters['encodeSidecar']> | undefined; const roundTrip: RoundTripWriter<'openaiResponses'> = { builder: references, - prepareSidecar: async (...args) => { + encodeSidecar: async (...args) => { prepared = args; return 'sidecar'; }, @@ -43,25 +43,25 @@ test('collects sparse namespace-restored Responses state from emitted IR atoms', builder.event({ type: 'start', id: 'response' }); builder.initialize(['choices', 0, 'items', 0], { type: 'function_call', name: flatScript, arguments: '' }); - builder.append(['choices', 0, 'items', 0, 'arguments'], '{"code":', [functionArguments.referencePieceIds[0]]); - builder.append(['choices', 0, 'items', 0, 'arguments'], '1}', [functionArguments.referencePieceIds[1]]); + builder.append(['choices', 0, 'items', 0, 'arguments'], '{"code":', [functionArguments.textFragmentIds[0]]); + builder.append(['choices', 0, 'items', 0, 'arguments'], '1}', [functionArguments.textFragmentIds[1]]); builder.initialize(['choices', 0, 'items', 1], { type: 'message', content: [] }); builder.initialize(['choices', 0, 'items', 1, 'content', 0], { type: 'text', text: '' }); - builder.append(['choices', 0, 'items', 1, 'content', 0, 'text'], 'A', [messageText.referencePieceIds[0]]); - builder.append(['choices', 0, 'items', 1, 'content', 0, 'text'], 'B', [messageText.referencePieceIds[1]]); + builder.append(['choices', 0, 'items', 1, 'content', 0, 'text'], 'A', [messageText.textFragmentIds[0]]); + builder.append(['choices', 0, 'items', 1, 'content', 0, 'text'], 'B', [messageText.textFragmentIds[1]]); builder.initialize(['choices', 0, 'items', 1, 'content', 1], { type: 'refusal', refusal: '' }); - builder.append(['choices', 0, 'items', 1, 'content', 1, 'refusal'], 'N', [refusal.referencePieceIds[0]]); - builder.append(['choices', 0, 'items', 1, 'content', 1, 'refusal'], 'O', [refusal.referencePieceIds[1]]); + builder.append(['choices', 0, 'items', 1, 'content', 1, 'refusal'], 'N', [refusal.textFragmentIds[0]]); + builder.append(['choices', 0, 'items', 1, 'content', 1, 'refusal'], 'O', [refusal.textFragmentIds[1]]); builder.initialize(['choices', 0, 'items', 2], { type: 'reasoning', summary: [''], content: [''] }); - builder.append(['choices', 0, 'items', 2, 'summary', 0], 'S', [reasoningSummary.referencePieceIds[0]]); - builder.append(['choices', 0, 'items', 2, 'summary', 0], 'C', [reasoningSummary.referencePieceIds[1]]); - builder.append(['choices', 0, 'items', 2, 'content', 0], 'D', [reasoningContent.referencePieceIds[0]]); + builder.append(['choices', 0, 'items', 2, 'summary', 0], 'S', [reasoningSummary.textFragmentIds[0]]); + builder.append(['choices', 0, 'items', 2, 'summary', 0], 'C', [reasoningSummary.textFragmentIds[1]]); + builder.append(['choices', 0, 'items', 2, 'content', 0], 'D', [reasoningContent.textFragmentIds[0]]); builder.initialize(['choices', 0, 'items', 3], { type: 'custom_tool_call', call_id: 'lookup', name: flatLookup, input: '' }); - builder.append(['choices', 0, 'items', 3, 'input'], '{"q":', [customInput.referencePieceIds[0]]); - builder.append(['choices', 0, 'items', 3, 'input'], '1}', [customInput.referencePieceIds[1]]); + builder.append(['choices', 0, 'items', 3, 'input'], '{"q":', [customInput.textFragmentIds[0]]); + builder.append(['choices', 0, 'items', 3, 'input'], '1}', [customInput.textFragmentIds[1]]); builder.assign(['choices', 0, 'items', 0, 'call_id'], 'script'); builder.event({ type: 'part_end', choice: 0, item: 1, part: 0 }); @@ -96,8 +96,8 @@ test('collects sparse namespace-restored Responses state from emitted IR atoms', expect(state[3]).toMatchObject({ arguments: {} }); expect(4 in state).toBe(false); - const finalizer = createReferenceFinalizer(references, collectContentViews(turn, state)); + const finalizer = createFieldReferencer(references, collectEmittedFieldPlacements(turn, state)); for (const source of [functionArguments, messageText, refusal, reasoningSummary, reasoningContent, customInput]) { - expect(await finalizer.finalizeField(source.original, source.state)).not.toBe(source.original); + expect(await finalizer.referenceField(source.original, source.state)).not.toBe(source.original); } }); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-target/assistant-turn_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-target/assistant-turn_test.ts new file mode 100644 index 0000000000..370387ba19 --- /dev/null +++ b/packages/translate/__tests__/protocols/openai-responses/as-target/assistant-turn_test.ts @@ -0,0 +1,61 @@ +import { expect, test } from 'vitest'; + +import { toOpenAIResponsesInputAssistantTurn } from '../../../../src/protocols/openai-responses/as-target/assistant-turn.ts'; +import type { OpenAIResponsesOutputItemEx } from '@floway-dev/protocols/openai-responses'; + +test('normalizes Responses output into native history while preserving replayable fields', () => { + const imageCall: Extract = { + type: 'image_generation_call', id: 'ig-data', result: 'image-base64', status: 'completed', action: 'generate', output_format: 'png', + }; + const messageWithSDKFields = { + type: 'message', id: 'message-a', role: 'assistant', status: 'completed', phase: 'final_answer', created_by: 'sdk', + content: [{ type: 'output_text', text: 'answer', annotations: [{ type: 'url_citation', url: 'https://example.test', title: 'source', start_index: 0, end_index: 6 }], logprobs: [], parsed: { answer: true } }], + } satisfies Extract & { created_by: string; content: (Extract['content'][number], { type: 'output_text' }> & { parsed: { answer: boolean } })[] }; + const functionCallWithSDKFields = { + type: 'function_call', id: 'function-a', call_id: 'call-a', name: 'lookup', arguments: '{"q":"a"}', parsed_arguments: { q: 'a' }, status: 'completed', created_by: 'sdk', + } satisfies Extract & { parsed_arguments: { q: string }; created_by: string }; + const outputHistory: OpenAIResponsesOutputItemEx[] = [ + messageWithSDKFields, + functionCallWithSDKFields, + { type: 'shell_call_output', id: 'shell-output', call_id: 'shell-call', output: [{ stdout: 'ok', stderr: '', outcome: { type: 'exit', exit_code: 0 }, created_by: 'sdk' }], status: 'completed', created_by: 'sdk' }, + { type: 'computer_call_output', call_id: 'computer-call', output: { type: 'computer_screenshot', file_id: 'screenshot' }, status: 'failed', created_by: 'sdk' }, + { type: 'custom_tool_call_output', call_id: 'custom-incomplete', output: 'incomplete', status: 'incomplete' }, + { type: 'custom_tool_call_output', id: 'custom-completed', call_id: 'custom-completed-call', output: 'complete', status: 'completed' }, + { type: 'additional_tools', id: 'additional-tools-developer', role: 'developer', tools: [] }, + { type: 'image_generation_call', id: 'ig-null', result: null, status: 'completed' }, + imageCall, + ]; + expect(toOpenAIResponsesInputAssistantTurn(outputHistory)).toEqual([ + { type: 'message', id: 'message-a', role: 'assistant', status: 'completed', phase: 'final_answer', content: [{ type: 'output_text', text: 'answer', annotations: [{ type: 'url_citation', url: 'https://example.test', title: 'source', start_index: 0, end_index: 6 }], logprobs: [] }] }, + { type: 'function_call', id: 'function-a', call_id: 'call-a', name: 'lookup', arguments: '{"q":"a"}', status: 'completed' }, + { type: 'shell_call_output', id: 'shell-output', call_id: 'shell-call', output: [{ stdout: 'ok', stderr: '', outcome: { type: 'exit', exit_code: 0 } }], status: 'completed' }, + { type: 'custom_tool_call_output', id: 'custom-completed', call_id: 'custom-completed-call', output: 'complete' }, + { type: 'additional_tools', id: 'additional-tools-developer', role: 'developer', tools: [] }, + { type: 'image_generation_call', id: 'ig-null', result: null, status: 'completed' }, + imageCall, + ]); +}); + +test('keeps Responses state aligned when output-only items are removed', () => { + const message: Extract = { + type: 'message', id: 'msg', role: 'assistant', status: 'completed', + content: [{ type: 'output_text', text: 'answer', annotations: [] }], + }; + const call = { + type: 'function_call', id: 'fn', call_id: 'call', name: 'lookup', arguments: '{}', status: 'completed', parsed_arguments: {}, provider_specific_fields: { native: true }, + } satisfies Extract & { parsed_arguments: object; provider_specific_fields: { native: boolean } }; + const output: OpenAIResponsesOutputItemEx[] = [ + { type: 'computer_call_output', call_id: 'failed', output: { type: 'computer_screenshot', file_id: 'screenshot' }, status: 'failed' }, + message, + call, + ]; + const state = [undefined, { content: [{ text: 'message-state' }] }, { arguments: 'tool-state' }]; + const input = toOpenAIResponsesInputAssistantTurn(output, state); + + expect(input.turn).toHaveLength(2); + expect(input.turn[0]).toBe(message); + expect(input.turn[1]).toEqual({ type: 'function_call', id: 'fn', call_id: 'call', name: 'lookup', arguments: '{}', status: 'completed', provider_specific_fields: { native: true } }); + expect(input.state).toEqual([state[1], state[2]]); + expect(input.state[0]).toBe(state[1]); + expect(input.state[1]).not.toBe(state[2]); +}); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-target/sse-from_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-target/sse-from_test.ts index b37e944ccb..ab2cb3bd23 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-target/sse-from_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-target/sse-from_test.ts @@ -1,8 +1,8 @@ import { expect, test } from 'vitest'; import { irFromOpenAIResponses } from '../../../../src/protocols/openai-responses/as-target/sse-from.ts'; -import { appendContent, collectContentViews, createContentState } from '../../../../src/round-trip/reference/reference-builder.ts'; -import { finalizeAssistantTurnReferences } from '../../../../src/round-trip/reference/reference-selection.ts'; +import { appendEmittedField, collectEmittedFieldPlacements, createEmittedFieldState } from '../../../../src/round-trip/reference/reference-builder.ts'; +import { referenceAssistantTurn } from '../../../../src/round-trip/reference/reference-selection.ts'; import { restoreAssistantTurn } from '../../../../src/round-trip/reference/restore.ts'; import { createRoundTripReaderCapture } from '../../../round-trip/test-helpers.ts'; import { eventFrame } from '@floway-dev/protocols/common'; @@ -40,16 +40,16 @@ const refusalMessage = (refusal: string, status: OutputMessage['status']): Outpu const collectProvenance = async (events: readonly OpenAIResponsesStreamEventEx[], field: 'text' | 'refusal' | 'arguments' | 'input' = 'text') => { const capture = createRoundTripReaderCapture<'openaiResponses'>(); - const content = createContentState(capture.builder, 'text'); - const appends: { text: string; referencePieceIds?: readonly number[] }[] = []; + const content = createEmittedFieldState(capture.builder, 'text'); + const appends: { text: string; textFragmentIds?: readonly number[] }[] = []; const frames = (async function* () { for (const event of events) yield eventFrame(event); })(); for await (const record of irFromOpenAIResponses(frames, { roundTrip: capture.roundTrip })) { if (record.type !== 'write' || record.kind !== 'append' || record.path.at(-1) !== field) continue; - appends.push({ text: record.value, ...(record.referencePieceIds === undefined ? {} : { referencePieceIds: record.referencePieceIds }) }); - appendContent(capture.builder, content, record.value, record.referencePieceIds); + appends.push({ text: record.value, ...(record.textFragmentIds === undefined ? {} : { textFragmentIds: record.textFragmentIds }) }); + appendEmittedField(capture.builder, content, record.value, record.textFragmentIds); } const captured = capture.turns.get(0); @@ -69,18 +69,18 @@ test('keeps the whole field literal when a done snapshot supplies a suffix witho { type: 'response.completed', response: response([done], 'completed') }, ]); - expect(captured.appends).toEqual([{ text: 'hel', referencePieceIds: [0] }, { text: 'lo' }]); - expect([...collectContentViews('hello', captured.content).values()]).toEqual([{ text: 'hello', offset: 0 }]); + expect(captured.appends).toEqual([{ text: 'hel', textFragmentIds: [0] }, { text: 'lo' }]); + expect([...collectEmittedFieldPlacements('hello', captured.content).values()]).toEqual([{ enclosingText: 'hello', offset: 0 }]); const itemState = captured.fieldState[0]; if (itemState === undefined || !('content' in itemState)) throw new Error('Responses message state was not collected'); if (!Array.isArray(itemState.content)) throw new Error('Responses message content state was not collected'); expect(itemState.content[0]).toMatchObject({ text: { valid: false } }); - const finalized = await finalizeAssistantTurnReferences<'openaiResponses'>( + const finalized = await referenceAssistantTurn<'openaiResponses'>( captured.turn, captured.fieldState, captured.builder, - collectContentViews('hello', captured.content), + collectEmittedFieldPlacements('hello', captured.content), ); expect(finalized.targetTurn).toEqual(captured.turn); expect(Object.hasOwn(finalized, 'referencedContentHashes')).toBe(false); @@ -96,12 +96,12 @@ test('keeps a tiny complete snapshot-only atom literal and hydrates the native R { type: 'response.completed', response: response([done], 'completed') }, ]); - expect(captured.appends).toEqual([{ text: 'hello', referencePieceIds: [0] }]); - const finalized = await finalizeAssistantTurnReferences<'openaiResponses'>( + expect(captured.appends).toEqual([{ text: 'hello', textFragmentIds: [0] }]); + const finalized = await referenceAssistantTurn<'openaiResponses'>( captured.turn, captured.fieldState, captured.builder, - collectContentViews('hello', captured.content), + collectEmittedFieldPlacements('hello', captured.content), ); const item = finalized.targetTurn[0]; if (item?.type !== 'message' || !Array.isArray(item.content)) throw new Error('Expected a Responses message item'); @@ -130,13 +130,13 @@ test('invalidates streamed refusal provenance when its done snapshot supplies a { type: 'response.completed', response: response([done], 'completed') }, ], 'refusal'); - expect(captured.appends).toEqual([{ text: 'refu', referencePieceIds: [0] }, { text: 'sed' }]); + expect(captured.appends).toEqual([{ text: 'refu', textFragmentIds: [0] }, { text: 'sed' }]); const itemState = captured.fieldState[0]; if (itemState === undefined || !('content' in itemState) || !Array.isArray(itemState.content)) { throw new Error('Responses message content state was not collected'); } expect(itemState.content[0]).toMatchObject({ refusal: { valid: false } }); - const finalized = await finalizeAssistantTurnReferences<'openaiResponses'>(captured.turn, captured.fieldState, captured.builder, collectContentViews('refused', captured.content)); + const finalized = await referenceAssistantTurn<'openaiResponses'>(captured.turn, captured.fieldState, captured.builder, collectEmittedFieldPlacements('refused', captured.content)); expect(finalized).not.toHaveProperty('referencedContentHashes'); expect(await restoreAssistantTurn('openaiResponses', ['refused'], finalized.targetTurn, finalized.referencedContentHashes)).toEqual({ ok: true, turn: captured.turn }); }); @@ -151,8 +151,8 @@ test('collects a snapshot-only refusal through its refusal field state', async ( { type: 'response.completed', response: response([done], 'completed') }, ], 'refusal'); - expect(captured.appends).toEqual([{ text: 'refused', referencePieceIds: [0] }]); - const finalized = await finalizeAssistantTurnReferences<'openaiResponses'>(captured.turn, captured.fieldState, captured.builder, collectContentViews('refused', captured.content)); + expect(captured.appends).toEqual([{ text: 'refused', textFragmentIds: [0] }]); + const finalized = await referenceAssistantTurn<'openaiResponses'>(captured.turn, captured.fieldState, captured.builder, collectEmittedFieldPlacements('refused', captured.content)); expect(finalized).not.toHaveProperty('referencedContentHashes'); expect(await restoreAssistantTurn('openaiResponses', ['refused'], finalized.targetTurn, finalized.referencedContentHashes)).toEqual({ ok: true, turn: captured.turn }); }); @@ -186,9 +186,9 @@ test.each(toolCases)('collects streamed native $type text through its existing s { type: 'response.output_item.done', output_index: 0, item: done }, { type: 'response.completed', response: response([done], 'completed') }, ], field); - expect(captured.appends).toEqual([{ text: prefix, referencePieceIds: [0] }, { text: suffix, referencePieceIds: [1] }]); + expect(captured.appends).toEqual([{ text: prefix, textFragmentIds: [0] }, { text: suffix, textFragmentIds: [1] }]); expect(captured.fieldState[0]).toHaveProperty(field, expect.any(Object)); - const finalized = await finalizeAssistantTurnReferences<'openaiResponses'>(captured.turn, captured.fieldState, captured.builder, collectContentViews(text, captured.content)); + const finalized = await referenceAssistantTurn<'openaiResponses'>(captured.turn, captured.fieldState, captured.builder, collectEmittedFieldPlacements(text, captured.content)); expect(finalized.targetTurn[0]).toHaveProperty(field, text); expect(finalized).not.toHaveProperty('referencedContentHashes'); expect(await restoreAssistantTurn('openaiResponses', [text], finalized.targetTurn, finalized.referencedContentHashes)).toEqual({ ok: true, turn: captured.turn }); @@ -204,8 +204,8 @@ test.each(toolCases)('collects a snapshot-only native $type atom and inlines the { type: 'response.output_item.done', output_index: 0, item: done }, { type: 'response.completed', response: response([done], 'completed') }, ], field); - expect(captured.appends).toEqual([{ text, referencePieceIds: [0] }]); - const finalized = await finalizeAssistantTurnReferences<'openaiResponses'>(captured.turn, captured.fieldState, captured.builder, collectContentViews(text, captured.content)); + expect(captured.appends).toEqual([{ text, textFragmentIds: [0] }]); + const finalized = await referenceAssistantTurn<'openaiResponses'>(captured.turn, captured.fieldState, captured.builder, collectEmittedFieldPlacements(text, captured.content)); expect(finalized.targetTurn[0]).toHaveProperty(field, text); expect(finalized).not.toHaveProperty('referencedContentHashes'); expect(await restoreAssistantTurn('openaiResponses', [text], finalized.targetTurn, finalized.referencedContentHashes)).toEqual({ ok: true, turn: captured.turn }); @@ -221,9 +221,9 @@ test.each(toolCases)('invalidates native $type provenance when its done snapshot { type: 'response.output_item.done', output_index: 0, item: done }, { type: 'response.completed', response: response([done], 'completed') }, ], field); - expect(captured.appends).toEqual([{ text: prefix, referencePieceIds: [0] }, { text: suffix }]); + expect(captured.appends).toEqual([{ text: prefix, textFragmentIds: [0] }, { text: suffix }]); expect(captured.fieldState[0]).toMatchObject({ [field]: { valid: false } }); - const finalized = await finalizeAssistantTurnReferences<'openaiResponses'>(captured.turn, captured.fieldState, captured.builder, collectContentViews(text, captured.content)); + const finalized = await referenceAssistantTurn<'openaiResponses'>(captured.turn, captured.fieldState, captured.builder, collectEmittedFieldPlacements(text, captured.content)); expect(finalized.targetTurn).toEqual(captured.turn); expect(finalized).not.toHaveProperty('referencedContentHashes'); }); diff --git a/packages/translate/__tests__/protocols/openai-responses/round-trip-request_test.ts b/packages/translate/__tests__/protocols/openai-responses/round-trip-request_test.ts index 23d3990887..7c55acfbc3 100644 --- a/packages/translate/__tests__/protocols/openai-responses/round-trip-request_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/round-trip-request_test.ts @@ -1,14 +1,14 @@ import { expect, it } from 'vitest'; -import { computeOpenAIResponsesTurnDigest, createOpenAIResponsesSidecarCarrier } from '../../../src/protocols/openai-responses/as-source/round-trip.ts'; -import type { OpenAIResponsesAssistantTurn } from '../../../src/protocols/openai-responses/as-source/round-trip.ts'; +import { computeOpenAIResponsesTurnDigest, createOpenAIResponsesSidecarItem } from '../../../src/protocols/openai-responses/as-source/round-trip.ts'; +import type { OpenAIResponsesSourceAssistantTurn } from '../../../src/protocols/openai-responses/as-source/round-trip.ts'; import { buildTargetRequest as buildMessagesRequest } from '../../../src/protocols/openai-responses/via-anthropic-messages/request.ts'; import { buildTargetRequest as buildChatRequest } from '../../../src/protocols/openai-responses/via-openai-chat-completions/request.ts'; import { encodeRoundTripSidecar } from '../../../src/round-trip/sidecar.ts'; import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; import type { CanonicalOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; -const replayedTurn: OpenAIResponsesAssistantTurn = [ +const replayedTurn: OpenAIResponsesSourceAssistantTurn = [ { type: 'function_call', call_id: 'native', name: 'run', namespace: 'web', arguments: '{}', status: 'completed' }, ]; @@ -21,7 +21,7 @@ const request = (sidecar: string): CanonicalOpenAIResponsesPayload => ({ { type: 'message', role: 'system', content: 'instructions' }, { type: 'additional_tools', role: 'developer', tools: [{ type: 'function', name: 'web_run', parameters: { type: 'object', properties: {} } }] }, { type: 'function_call', call_id: 'native', name: 'run', namespace: 'web', arguments: '{}', status: 'completed' }, - createOpenAIResponsesSidecarCarrier('sidecar', sidecar), + createOpenAIResponsesSidecarItem('sidecar', sidecar), { type: 'function_call_output', call_id: 'native', output: 'native result' }, { type: 'function_call', call_id: 'bare', name: 'run', namespace: 'web', arguments: '{}', status: 'completed' }, ], @@ -92,7 +92,7 @@ it.each(['openaiChatCompletions', 'anthropicMessages'] as const)('bypasses ordin const source: CanonicalOpenAIResponsesPayload = { model: 'test', input: [ - ...calls, createOpenAIResponsesSidecarCarrier('carrier', sidecar), + ...calls, createOpenAIResponsesSidecarItem('carrier', sidecar), { type: 'message', role: 'user', content: 'next' }, { type: 'function_call', namespace: 'x', name: 'y.f', call_id: 'bare', arguments: '{}', status: 'completed' }, ], @@ -120,7 +120,7 @@ it.each(['openaiChatCompletions', 'anthropicMessages'] as const)('allocates bare model: 'test', input: [ { type: 'function_call', namespace: 'a.b', name: 'run', call_id: 'first', arguments: '{}' }, - createOpenAIResponsesSidecarCarrier('carrier', sidecar), + createOpenAIResponsesSidecarItem('carrier', sidecar), { type: 'message', role: 'user', content: 'next' }, { type: 'function_call', namespace: 'a_b', name: 'run', call_id: 'second', arguments: '{}' }, ], @@ -142,7 +142,7 @@ it.each(['additional_tools', 'tool_search_output'] as const)('restores assistant const turns: CanonicalOpenAIResponsesPayload['input'][] = ['first', 'second'].map(text => [ { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }] }, ]); - const carriers = await Promise.all(turns.map(async (turn, index) => createOpenAIResponsesSidecarCarrier(`carrier_${index}`, await encodeRoundTripSidecar(codec, { + const carriers = await Promise.all(turns.map(async (turn, index) => createOpenAIResponsesSidecarItem(`carrier_${index}`, await encodeRoundTripSidecar(codec, { v: 1, source: 'openaiResponses', target, targetTurn: nativeTurns[index], sourceTurnDigest: await computeOpenAIResponsesTurnDigest(turn), diff --git a/packages/translate/__tests__/protocols/round-trip-receive_test.ts b/packages/translate/__tests__/protocols/round-trip-receive_test.ts index ef74461ed8..04f61235b5 100644 --- a/packages/translate/__tests__/protocols/round-trip-receive_test.ts +++ b/packages/translate/__tests__/protocols/round-trip-receive_test.ts @@ -5,12 +5,12 @@ import { computeAnthropicMessagesTurnDigest } from '../../src/protocols/anthropi import { computeGeminiGenerateContentTurnDigest } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; import { computeOpenAIChatCompletionsTurnDigest } from '../../src/protocols/openai-chat-completions/as-source/round-trip.ts'; import { computeOpenAIResponsesTurnDigest } from '../../src/protocols/openai-responses/as-source/round-trip.ts'; -import type { AssistantTurnFor } from '../../src/round-trip/assistant-turn.ts'; -import type { ReferenceableAssistantTurnFor } from '../../src/round-trip/reference/assistant-turn.ts'; -import { ContentReference } from '../../src/round-trip/reference/assistant-turn.ts'; -import type { RoundTripProtocol } from '../../src/round-trip/sidecar.ts'; +import { hashRoundTripContent } from '../../src/round-trip/hash.ts'; +import type { InputAssistantTurnFor, AssistantTurnProtocol } from '../../src/round-trip/input-assistant-turn.ts'; +import type { ReferenceableInputAssistantTurnFor } from '../../src/round-trip/reference/assistant-turn-types.ts'; +import { ContentReference } from '../../src/round-trip/reference/types.ts'; import { encodeRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; -import { hashRoundTripContent, replayMaterialsByType } from '../../src/round-trip/turn-digest.ts'; +import { replayMaterialsByType } from '../../src/round-trip/turn-digest.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import type { AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; import type { GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; @@ -30,7 +30,7 @@ const messages = (carrier: string): AnthropicMessagesPayload => ({ model: 'sourc const responses = (carrier: string): OpenAIResponsesRequestPayloadEx => ({ model: 'source', input: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'answer' }] }, { type: 'reasoning', id: 'carrier', summary: [], encrypted_content: carrier }] }); const gemini = (carrier: string): GeminiGenerateContentPayload => ({ contents: [{ role: 'model', parts: [{ text: 'answer', thoughtSignature: carrier }] }] }); -const turnDigest = async (source: RoundTripProtocol) => source === 'openaiChatCompletions' +const turnDigest = async (source: AssistantTurnProtocol) => source === 'openaiChatCompletions' ? await computeOpenAIChatCompletionsTurnDigest({ role: 'assistant', content: 'answer' }) : source === 'anthropicMessages' ? await computeAnthropicMessagesTurnDigest([{ role: 'assistant', content: [{ type: 'text', text: 'answer' }] }]) @@ -39,7 +39,7 @@ const turnDigest = async (source: RoundTripProtocol) => source === 'openaiChatCo : await computeGeminiGenerateContentTurnDigest([{ role: 'model', parts: [{ text: 'answer' }] }]); type NativeProtocol = 'anthropicMessages' | 'openaiChatCompletions' | 'openaiResponses'; -const nativeTurn = (target: NativeProtocol, text = 'restored'): AssistantTurnFor => target === 'openaiChatCompletions' +const nativeTurn = (target: NativeProtocol, text = 'restored'): InputAssistantTurnFor => target === 'openaiChatCompletions' ? { role: 'assistant', content: text } : target === 'anthropicMessages' ? { role: 'assistant', content: [{ type: 'text', text, citations: [] }] } @@ -66,7 +66,7 @@ test.each(cases)('receives recognized $source history via $target', async ({ sou test.each(cases)('hydrates $source text references before restoring $target history', async ({ source, target, run }) => { const reference = new ContentReference('text', [[0, 0, 3]]); - const targetTurn: ReferenceableAssistantTurnFor = target === 'openaiChatCompletions' + const targetTurn: ReferenceableInputAssistantTurnFor = target === 'openaiChatCompletions' ? { role: 'assistant', content: reference } : target === 'anthropicMessages' ? { role: 'assistant', content: [{ type: 'text', text: reference, citations: [] }] } @@ -82,7 +82,7 @@ test.each(cases)('hydrates $source text references before restoring $target hist test('hydrates a native JSON field from a replayed GenerateContent argument candidate', async () => { const args = { x: 1 }; const reference = new ContentReference('json', [0]); - const targetTurn: ReferenceableAssistantTurnFor<'anthropicMessages', ContentReference> = { role: 'assistant', content: [{ type: 'tool_use', id: 'native-call', name: 'native-tool', input: reference }] }; + const targetTurn: ReferenceableInputAssistantTurnFor<'anthropicMessages', ContentReference> = { role: 'assistant', content: [{ type: 'tool_use', id: 'native-call', name: 'native-tool', input: reference }] }; const checkMaterial = { contents: [{ inlineData: [], functionCall: [{ name: 'lookup', args }] }] }; const carrier = await encodeRoundTripSidecar(createTestSidecarCodec(), { v: 1, @@ -115,7 +115,7 @@ test.each(cases)('leaves a terminal sidecar unrecognized by the injected decoder test.each(['different codec scope', 'changed sidecar handle', 'changed replay'] as const)('uses ordinary translation for %s', async failure => { const codec = createTestSidecarCodec(failure === 'different codec scope' ? 'other-scope' : 'test-codec-scope'); - const native: AssistantTurnFor<'anthropicMessages'> = { role: 'assistant', content: [{ type: 'text', text: 'restored', citations: [] }] }; + const native: InputAssistantTurnFor<'anthropicMessages'> = { role: 'assistant', content: [{ type: 'text', text: 'restored', citations: [] }] }; 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'; @@ -171,7 +171,7 @@ test('accepts incomplete Responses call history and requires its replayed status const expected = replayMaterialsByType([ { type: call.type, status: call.status, call_id: call.call_id, name: call.name, arguments: call.arguments }, ]); - const native: AssistantTurnFor<'anthropicMessages'> = { role: 'assistant', content: [{ type: 'text', text: 'restored', citations: [] }] }; + const native: InputAssistantTurnFor<'anthropicMessages'> = { role: 'assistant', content: [{ type: 'text', text: 'restored', citations: [] }] }; const carrier = await encodeRoundTripSidecar(createTestSidecarCodec(), { v: 1, source: 'openaiResponses', diff --git a/packages/translate/__tests__/protocols/routes_test.ts b/packages/translate/__tests__/protocols/routes_test.ts index 129b7a61c5..4b8ceefead 100644 --- a/packages/translate/__tests__/protocols/routes_test.ts +++ b/packages/translate/__tests__/protocols/routes_test.ts @@ -1,13 +1,13 @@ import { test } from 'vitest'; 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'; +import { findAnthropicMessagesSidecarOccurrences } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; +import { findGeminiGenerateContentSidecarOccurrences } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; import { partitionOpenAIChatCompletionsTurns } from '../../src/protocols/openai-chat-completions/as-source/conversation-turns.ts'; -import { findOpenAIChatCompletionsSidecarCarriers } from '../../src/protocols/openai-chat-completions/as-source/round-trip.ts'; +import { findOpenAIChatCompletionsSidecarOccurrences } from '../../src/protocols/openai-chat-completions/as-source/round-trip.ts'; import { toOpenAIChatCompletionsInputAssistantTurn } from '../../src/protocols/openai-chat-completions/as-target/assistant-turn.ts'; import { partitionOpenAIResponsesTurns } from '../../src/protocols/openai-responses/as-source/conversation-turns.ts'; -import { findOpenAIResponsesSidecarCarriers } from '../../src/protocols/openai-responses/as-source/round-trip.ts'; +import { findOpenAIResponsesSidecarOccurrences } from '../../src/protocols/openai-responses/as-source/round-trip.ts'; import { toOpenAIResponsesInputAssistantTurn } from '../../src/protocols/openai-responses/as-target/assistant-turn.ts'; import { decodeRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; import { requireJSONObject } from '../../src/shared/json/object.ts'; @@ -176,10 +176,10 @@ const assistantText = (assistant: AssistantTurn): string => { }; const sidecarPayload = (assistant: AssistantTurn): string | undefined => { - const candidates = assistant.protocol === 'openai-responses' ? findOpenAIResponsesSidecarCarriers(assistant.turn) - : assistant.protocol === 'openai-chat-completions' ? findOpenAIChatCompletionsSidecarCarriers(assistant.turn) - : assistant.protocol === 'anthropic-messages' ? findAnthropicMessagesSidecarCarriers([assistant.turn]) - : findGeminiGenerateContentSidecarCarriers(assistant.turn); + const candidates = assistant.protocol === 'openai-responses' ? findOpenAIResponsesSidecarOccurrences(assistant.turn) + : assistant.protocol === 'openai-chat-completions' ? findOpenAIChatCompletionsSidecarOccurrences(assistant.turn) + : assistant.protocol === 'anthropic-messages' ? findAnthropicMessagesSidecarOccurrences([assistant.turn]) + : findGeminiGenerateContentSidecarOccurrences(assistant.turn); assertEquals(candidates.length, 1); return candidates[0].data; }; diff --git a/packages/translate/__tests__/round-trip/sidecar-codec_test.ts b/packages/translate/__tests__/round-trip/codec_test.ts similarity index 94% rename from packages/translate/__tests__/round-trip/sidecar-codec_test.ts rename to packages/translate/__tests__/round-trip/codec_test.ts index b0d1055fde..051e8c424a 100644 --- a/packages/translate/__tests__/round-trip/sidecar-codec_test.ts +++ b/packages/translate/__tests__/round-trip/codec_test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; import { createTestSidecarCodec } from './sidecar-codec.ts'; -import { SidecarTaggedValue } from '../../src/round-trip/sidecar-tagged-value.ts'; +import { SidecarTaggedValue } from '../../src/round-trip/codec.ts'; test.each([65_536, 65_537])('the test codec snapshots application tag %s values and owns every array', async tag => { const parts: unknown[] = [0, [1, 2, 3]]; diff --git a/packages/translate/__tests__/round-trip/content-hash_test.ts b/packages/translate/__tests__/round-trip/content-hash_test.ts deleted file mode 100644 index 9853a02354..0000000000 --- a/packages/translate/__tests__/round-trip/content-hash_test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { hashRoundTripContent, verifyRoundTripTurnDigest } from '../../src/round-trip/turn-digest.ts'; - -describe('round-trip content hashes', () => { - it.each([ - ['hello', [6, 228, 77, 193, 185, 92, 70, 159]], - ['\ud800a\udc00?', [255, 13, 52, 82, 149, 33, 105, 132]], - ])('uses the first eight SHA-256 bytes of UTF-16LE code units for %j', async (text, prefix) => { - expect(await hashRoundTripContent(text)).toEqual(new Uint8Array(prefix)); - }); - - it('verifies exactly eight replay digest bytes', async () => { - const material = { content: ['answer'] }; - const sourceTurnDigest = new Uint8Array([68, 150, 67, 115, 244, 243, 164, 21]); - - expect(await verifyRoundTripTurnDigest(material, sourceTurnDigest)).toBe(true); - expect(await verifyRoundTripTurnDigest({ content: ['edited'] }, sourceTurnDigest)).toBe(false); - expect(await verifyRoundTripTurnDigest(material, sourceTurnDigest.subarray(0, 7))).toBe(false); - expect(await verifyRoundTripTurnDigest(material, new Uint8Array([...sourceTurnDigest, 0]))).toBe(false); - }); -}); diff --git a/packages/translate/__tests__/round-trip/extraction_test.ts b/packages/translate/__tests__/round-trip/extraction_test.ts index 7ad3dd5ed8..52109e1c22 100644 --- a/packages/translate/__tests__/round-trip/extraction_test.ts +++ b/packages/translate/__tests__/round-trip/extraction_test.ts @@ -1,15 +1,15 @@ import { expect, test } from 'vitest'; -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'; -import { findOpenAIChatCompletionsSidecarCarriers } from '../../src/protocols/openai-chat-completions/as-source/round-trip.ts'; -import { findOpenAIResponsesSidecarCarriers } from '../../src/protocols/openai-responses/as-source/round-trip.ts'; +import { findAnthropicMessagesSidecarOccurrences } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; +import { findGeminiGenerateContentSidecarOccurrences } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; +import { findOpenAIChatCompletionsSidecarOccurrences } from '../../src/protocols/openai-chat-completions/as-source/round-trip.ts'; +import { findOpenAIResponsesSidecarOccurrences } from '../../src/protocols/openai-responses/as-source/round-trip.ts'; test('Chat enumerates encrypted details and removes only the chosen detail', () => { const details = [{ type: 'reasoning.encrypted', data: 'before' }, { type: 'reasoning.encrypted', data: 'chosen' }, { type: 'reasoning.summary', summary: 'summary' }, { type: 'reasoning.encrypted', data: 'after' }]; const turn = { role: 'assistant' as const, content: 'answer', reasoning_content: 'thought', reasoning_details: details }; const before = structuredClone(turn); - const candidates = findOpenAIChatCompletionsSidecarCarriers(turn); + const candidates = findOpenAIChatCompletionsSidecarOccurrences(turn); expect(candidates.map(candidate => candidate.data)).toEqual(['before', 'chosen', 'after']); expect(candidates[1].withoutSidecar()).toEqual({ ...turn, reasoning_details: [details[0], details[2], details[3]] }); expect(turn).toEqual(before); @@ -21,23 +21,23 @@ test('Messages enumerates every block across string and empty trailing messages' { type: 'text' as const, text: 'answer' }, { type: 'thinking' as const, thinking: 'thought', signature: 'native' }, { type: 'redacted_thinking' as const, data: 'after' }, ]; - const turn: Parameters[0] = [{ role: 'assistant', content }, { role: 'assistant', content: '' }, { role: 'assistant', content: [] }]; + const turn: Parameters[0] = [{ role: 'assistant', content }, { role: 'assistant', content: '' }, { role: 'assistant', content: [] }]; const before = structuredClone(turn); - const candidates = findAnthropicMessagesSidecarCarriers(turn); + const candidates = findAnthropicMessagesSidecarOccurrences(turn); expect(candidates.map(candidate => candidate.data)).toEqual(['before', 'chosen', 'after']); expect(candidates[1].withoutSidecar()).toEqual([{ ...turn[0], content: [content[0], ...content.slice(2)] }, ...turn.slice(1)]); expect(turn).toEqual(before); }); test('Responses enumerates every encrypted item and preserves the selected readable body and metadata', () => { - const turn: Parameters[0] = [ + const turn: Parameters[0] = [ { type: 'reasoning', id: 'before', summary: [], encrypted_content: 'before' }, { type: 'reasoning', id: 'native', status: 'incomplete', summary: [{ type: 'summary_text', text: 'summary' }], content: [{ type: 'reasoning_text', text: 'thought' }], encrypted_content: 'chosen' }, { type: 'reasoning', id: 'after', status: 'completed', summary: [], encrypted_content: 'after' }, { type: 'message', role: 'assistant', content: [] }, ]; const before = structuredClone(turn); - const candidates = findOpenAIResponsesSidecarCarriers(turn); + const candidates = findOpenAIResponsesSidecarOccurrences(turn); expect(candidates.map(candidate => candidate.data)).toEqual(['before', 'chosen', 'after']); expect(candidates[1].withoutSidecar()).toEqual([turn[0], { type: 'reasoning', id: 'native', status: 'incomplete', summary: [{ type: 'summary_text', text: 'summary' }], content: [{ type: 'reasoning_text', text: 'thought' }], @@ -49,8 +49,8 @@ test('Responses enumerates every encrypted item and preserves the selected reada test('Responses treats Codex null reasoning content as absent when removing a sidecar', () => { const turn = [{ type: 'reasoning', id: 'native', summary: [], content: null, encrypted_content: 'sidecar', - }] as unknown as Parameters[0]; - const candidates = findOpenAIResponsesSidecarCarriers(turn); + }] as unknown as Parameters[0]; + const candidates = findOpenAIResponsesSidecarOccurrences(turn); expect(candidates).toHaveLength(1); expect(candidates[0].withoutSidecar()).toEqual([]); }); @@ -61,9 +61,9 @@ test('GenerateContent enumerates attached and standalone signatures across empty { functionCall: { name: 'lookup', args: {} }, thoughtSignature: 'on-call' }, { thoughtSignature: 'standalone-part' }, ]; - const turn: Parameters[0] = [{ role: 'model', parts }, { role: 'model', parts: [{ thoughtSignature: 'standalone-content' }] }, { role: 'model' }, { role: 'model', parts: [] }]; + const turn: Parameters[0] = [{ role: 'model', parts }, { role: 'model', parts: [{ thoughtSignature: 'standalone-content' }] }, { role: 'model' }, { role: 'model', parts: [] }]; const before = structuredClone(turn); - const candidates = findGeminiGenerateContentSidecarCarriers(turn); + const candidates = findGeminiGenerateContentSidecarOccurrences(turn); expect(candidates.map(candidate => candidate.data)).toEqual(['on-text', 'on-call', 'standalone-part', 'standalone-content']); expect(candidates[0].withoutSidecar()).toEqual([{ ...turn[0], parts: [{ text: 'answer' }, ...parts.slice(1)] }, ...turn.slice(1)]); expect(candidates[1].withoutSidecar()).toEqual([{ ...turn[0], parts: [parts[0], { functionCall: { name: 'lookup', args: {} } }, parts[2]] }, ...turn.slice(1)]); diff --git a/packages/translate/__tests__/round-trip/hash_test.ts b/packages/translate/__tests__/round-trip/hash_test.ts new file mode 100644 index 0000000000..d0b49a947e --- /dev/null +++ b/packages/translate/__tests__/round-trip/hash_test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; + +import { hashRoundTripContent } from '../../src/round-trip/hash.ts'; + +describe('round-trip content hashes', () => { + it.each([ + ['hello', [6, 228, 77, 193, 185, 92, 70, 159]], + ['\ud800a\udc00?', [255, 13, 52, 82, 149, 33, 105, 132]], + ])('uses the first eight SHA-256 bytes of UTF-16LE code units for %j', async (text, prefix) => { + expect(await hashRoundTripContent(text)).toEqual(new Uint8Array(prefix)); + }); +}); diff --git a/packages/translate/__tests__/round-trip/protocols_test.ts b/packages/translate/__tests__/round-trip/protocols_test.ts index fafb28fe8c..a889465ef1 100644 --- a/packages/translate/__tests__/round-trip/protocols_test.ts +++ b/packages/translate/__tests__/round-trip/protocols_test.ts @@ -1,15 +1,14 @@ import { describe, expect, it } from 'vitest'; import { partitionAnthropicMessagesTurns } from '../../src/protocols/anthropic-messages/as-source/conversation-turns.ts'; -import { computeAnthropicMessagesTurnDigest, createAnthropicMessagesSidecarCarrier, findAnthropicMessagesSidecarCarriers, verifyAnthropicMessagesTurnDigest } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; +import { computeAnthropicMessagesTurnDigest, createAnthropicMessagesSidecarBlock, findAnthropicMessagesSidecarOccurrences, verifyAnthropicMessagesTurnDigest } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; import { partitionGeminiGenerateContentTurns } from '../../src/protocols/gemini-generate-content/as-source/conversation-turns.ts'; -import { computeGeminiGenerateContentTurnDigest, createGeminiGenerateContentSidecarCarrier, findGeminiGenerateContentSidecarCarriers, verifyGeminiGenerateContentTurnDigest } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; +import { computeGeminiGenerateContentTurnDigest, createGeminiGenerateContentSidecarPart, findGeminiGenerateContentSidecarOccurrences, verifyGeminiGenerateContentTurnDigest } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; import { partitionOpenAIChatCompletionsTurns } from '../../src/protocols/openai-chat-completions/as-source/conversation-turns.ts'; -import { computeOpenAIChatCompletionsTurnDigest, createOpenAIChatCompletionsSidecarCarrier, findOpenAIChatCompletionsSidecarCarriers, verifyOpenAIChatCompletionsTurnDigest } from '../../src/protocols/openai-chat-completions/as-source/round-trip.ts'; +import { computeOpenAIChatCompletionsTurnDigest, createOpenAIChatCompletionsSidecarDetails, findOpenAIChatCompletionsSidecarOccurrences, verifyOpenAIChatCompletionsTurnDigest } from '../../src/protocols/openai-chat-completions/as-source/round-trip.ts'; import { partitionOpenAIResponsesTurns } from '../../src/protocols/openai-responses/as-source/conversation-turns.ts'; -import { computeOpenAIResponsesTurnDigest, createOpenAIResponsesSidecarCarrier, findOpenAIResponsesSidecarCarriers, verifyOpenAIResponsesTurnDigest } from '../../src/protocols/openai-responses/as-source/round-trip.ts'; -import { toOpenAIResponsesInputAssistantTurn } from '../../src/protocols/openai-responses/as-target/assistant-turn.ts'; -import type { EncodedRoundTripSidecar } from '../../src/round-trip/resolve.ts'; +import { computeOpenAIResponsesTurnDigest, createOpenAIResponsesSidecarItem, findOpenAIResponsesSidecarOccurrences, verifyOpenAIResponsesTurnDigest } from '../../src/protocols/openai-responses/as-source/round-trip.ts'; +import type { SidecarOccurrence } from '../../src/round-trip/resolve.ts'; import type { AnyRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; import type { AnthropicMessagesAssistantContentBlock, AnthropicMessagesAssistantMessage, AnthropicMessagesMessage } from '@floway-dev/protocols/anthropic-messages'; import type { GeminiGenerateContentContent } from '@floway-dev/protocols/gemini-generate-content'; @@ -24,7 +23,7 @@ const chatTurn = (): OpenAIChatCompletionsAssistantMessageEx => ({ annotations: [{ type: 'url_citation', url_citation: { start_index: 0, end_index: 6, title: 'source', url: 'https://example.test' } }], reasoning_details: [ { type: 'reasoning.summary', summary: 'readable summary', format: 'unknown', index: 0 }, - ...createOpenAIChatCompletionsSidecarCarrier('opaque-sidecar'), + ...createOpenAIChatCompletionsSidecarDetails('opaque-sidecar'), ], tool_calls: [ { id: 'call-a', type: 'function', function: { name: 'lookup', arguments: '{"q":"a"}' }, provider_specific_fields: { trace: 'a' } }, @@ -45,7 +44,7 @@ const messagesTurn = (): AnthropicMessagesAssistantMessage[] => [{ { type: 'text', text: 'answer', citations: null }, { type: 'thinking', thinking: 'reasoning', signature: 'native-message-signature' }, { type: 'tool_use', id: 'call-a', name: 'lookup', input: { q: 'a' } }, - createAnthropicMessagesSidecarCarrier('opaque-sidecar'), + createAnthropicMessagesSidecarBlock('opaque-sidecar'), ], }]; @@ -58,42 +57,42 @@ const geminiTurn = (): GeminiGenerateContentContent[] => [ { functionCall: { id: 'call-b', name: 'lookup', args: { q: 'b' } } }, ], }, - { role: 'model', parts: [createGeminiGenerateContentSidecarCarrier('opaque-sidecar')] }, + { role: 'model', parts: [createGeminiGenerateContentSidecarPart('opaque-sidecar')] }, ]; -const fixtureSidecar = (candidates: EncodedRoundTripSidecar[], data = 'opaque-sidecar') => { +const fixtureSidecar = (candidates: SidecarOccurrence[], data = 'opaque-sidecar') => { const candidate = candidates.find(candidate => candidate.data === data); if (candidate === undefined) throw new Error(`Missing fixture sidecar: ${data}`); return { sidecar: candidate.data, turn: candidate.withoutSidecar() }; }; const verifyChatTurn = ( - turn: Parameters[0], + turn: Parameters[0], sourceTurnDigest: Parameters[1], data = 'opaque-sidecar', version = 1, -) => verifyOpenAIChatCompletionsTurnDigest(fixtureSidecar(findOpenAIChatCompletionsSidecarCarriers(turn), data).turn, sourceTurnDigest, version); +) => verifyOpenAIChatCompletionsTurnDigest(fixtureSidecar(findOpenAIChatCompletionsSidecarOccurrences(turn), data).turn, sourceTurnDigest, version); const verifyResponsesTurn = ( - turn: Parameters[0], + turn: Parameters[0], sourceTurnDigest: Parameters[1], data = 'opaque-sidecar', version = 1, -) => verifyOpenAIResponsesTurnDigest(fixtureSidecar(findOpenAIResponsesSidecarCarriers(turn), data).turn, sourceTurnDigest, version); +) => verifyOpenAIResponsesTurnDigest(fixtureSidecar(findOpenAIResponsesSidecarOccurrences(turn), data).turn, sourceTurnDigest, version); const verifyMessagesTurn = ( - turn: Parameters[0], + turn: Parameters[0], sourceTurnDigest: Parameters[1], data = 'opaque-sidecar', version = 1, -) => verifyAnthropicMessagesTurnDigest(fixtureSidecar(findAnthropicMessagesSidecarCarriers(turn), data).turn, sourceTurnDigest, version); +) => verifyAnthropicMessagesTurnDigest(fixtureSidecar(findAnthropicMessagesSidecarOccurrences(turn), data).turn, sourceTurnDigest, version); const verifyGeminiTurn = ( - turn: Parameters[0], + turn: Parameters[0], sourceTurnDigest: Parameters[1], data = 'opaque-sidecar', version = 1, -) => verifyGeminiGenerateContentTurnDigest(fixtureSidecar(findGeminiGenerateContentSidecarCarriers(turn), data).turn, sourceTurnDigest, version); +) => verifyGeminiGenerateContentTurnDigest(fixtureSidecar(findGeminiGenerateContentSidecarOccurrences(turn), data).turn, sourceTurnDigest, version); describe('assistant-turn sidecar protocols', () => { it('extracts Chat encrypted details without changing ordinary reasoning details or the input', async () => { @@ -102,14 +101,14 @@ describe('assistant-turn sidecar protocols', () => { role: 'assistant', content: 'answer', reasoning_content: 'thought', reasoning_details: details, }; - const turn = { ...ordinary, reasoning_details: [...details, ...createOpenAIChatCompletionsSidecarCarrier('opaque')] }; + const turn = { ...ordinary, reasoning_details: [...details, ...createOpenAIChatCompletionsSidecarDetails('opaque')] }; const before = structuredClone(turn); - const inspection = fixtureSidecar(findOpenAIChatCompletionsSidecarCarriers(turn), 'opaque'); + const inspection = fixtureSidecar(findOpenAIChatCompletionsSidecarOccurrences(turn), 'opaque'); expect(inspection).toEqual({ sidecar: 'opaque', turn: ordinary }); expect(turn).toEqual(before); const check = await computeOpenAIChatCompletionsTurnDigest(ordinary); expect(await verifyOpenAIChatCompletionsTurnDigest(inspection.turn, check, 1)).toBe(true); - const changedCarrier = { ...turn, reasoning_details: [...details, ...createOpenAIChatCompletionsSidecarCarrier('different')] }; + const changedCarrier = { ...turn, reasoning_details: [...details, ...createOpenAIChatCompletionsSidecarDetails('different')] }; expect(await verifyChatTurn(changedCarrier, check, 'different')).toBe(true); expect(await verifyChatTurn({ ...turn, reasoning_content: 'edited thought' }, check, 'opaque')).toBe(false); }); @@ -117,15 +116,15 @@ describe('assistant-turn sidecar protocols', () => { it('extracts Messages redacted blocks while preserving ordinary thinking and the input', async () => { const blocks: AnthropicMessagesAssistantContentBlock[] = [{ type: 'text', text: 'answer', citations: null }, { type: 'thinking', thinking: 'thought', signature: 'native' }]; const ordinary: AnthropicMessagesAssistantMessage[] = [{ role: 'assistant', content: blocks }]; - const turn: AnthropicMessagesAssistantMessage[] = [{ ...ordinary[0], content: [...blocks, createAnthropicMessagesSidecarCarrier('opaque')] }]; + const turn: AnthropicMessagesAssistantMessage[] = [{ ...ordinary[0], content: [...blocks, createAnthropicMessagesSidecarBlock('opaque')] }]; const before = structuredClone(turn); - const inspection = fixtureSidecar(findAnthropicMessagesSidecarCarriers(turn), 'opaque'); + const inspection = fixtureSidecar(findAnthropicMessagesSidecarOccurrences(turn), 'opaque'); expect(inspection).toEqual({ sidecar: 'opaque', turn: ordinary }); expect(turn).toEqual(before); const check = await computeAnthropicMessagesTurnDigest(ordinary); expect(await verifyAnthropicMessagesTurnDigest(inspection.turn, check, 1)).toBe(true); - expect(await verifyMessagesTurn([{ ...ordinary[0], content: [...blocks, createAnthropicMessagesSidecarCarrier('different')] }], check, 'different')).toBe(true); - expect(await verifyMessagesTurn([{ role: 'assistant', content: [{ type: 'text', text: 'edited answer', citations: null }, ...blocks.slice(1), createAnthropicMessagesSidecarCarrier('opaque')] }], check, 'opaque')).toBe(false); + expect(await verifyMessagesTurn([{ ...ordinary[0], content: [...blocks, createAnthropicMessagesSidecarBlock('different')] }], check, 'different')).toBe(true); + expect(await verifyMessagesTurn([{ role: 'assistant', content: [{ type: 'text', text: 'edited answer', citations: null }, ...blocks.slice(1), createAnthropicMessagesSidecarBlock('opaque')] }], check, 'opaque')).toBe(false); }); it('extracts Responses encrypted items without retaining carrier metadata or changing ordinary reasoning', async () => { @@ -134,10 +133,10 @@ describe('assistant-turn sidecar protocols', () => { { type: 'message', id: 'message', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: 'answer', annotations: [] }] }, reasoning, ]; - const carrier = { ...createOpenAIResponsesSidecarCarrier('carrier', 'opaque'), status: 'incomplete' }; + const carrier = { ...createOpenAIResponsesSidecarItem('carrier', 'opaque'), status: 'incomplete' }; const turn = [...ordinary, carrier]; const before = structuredClone(turn); - const inspection = fixtureSidecar(findOpenAIResponsesSidecarCarriers(turn), 'opaque'); + const inspection = fixtureSidecar(findOpenAIResponsesSidecarOccurrences(turn), 'opaque'); expect(inspection).toEqual({ sidecar: 'opaque', turn: ordinary }); expect(turn).toEqual(before); const check = await computeOpenAIResponsesTurnDigest(ordinary); @@ -154,7 +153,7 @@ describe('assistant-turn sidecar protocols', () => { const attached = { ...reasoning, encrypted_content: 'opaque' }; const turn = [attached]; const before = structuredClone(turn); - const inspection = fixtureSidecar(findOpenAIResponsesSidecarCarriers(turn), 'opaque'); + const inspection = fixtureSidecar(findOpenAIResponsesSidecarOccurrences(turn), 'opaque'); expect(inspection).toEqual({ sidecar: 'opaque', turn: [reasoning] }); expect(turn).toEqual(before); const check = await computeOpenAIResponsesTurnDigest([reasoning]); @@ -167,13 +166,13 @@ describe('assistant-turn sidecar protocols', () => { it('extracts Gemini signatures from attached bodies and removes standalone carriers without mutating input', async () => { const functionCall = { id: 'call', name: 'lookup', args: { q: 'a' } }; - const ordinary: GeminiGenerateContentContent[] = [{ role: 'model', parts: [{ text: 'answer', thoughtSignature: 'on-text' }, { functionCall, thoughtSignature: 'on-call' }, { text: 'native thought', thought: true }, createGeminiGenerateContentSidecarCarrier('standalone-part')] }]; + const ordinary: GeminiGenerateContentContent[] = [{ role: 'model', parts: [{ text: 'answer', thoughtSignature: 'on-text' }, { functionCall, thoughtSignature: 'on-call' }, { text: 'native thought', thought: true }, createGeminiGenerateContentSidecarPart('standalone-part')] }]; const turn: GeminiGenerateContentContent[] = [ - { role: 'model', parts: [{ text: 'answer', thoughtSignature: 'on-text' }, { functionCall, thoughtSignature: 'on-call' }, { text: 'native thought', thought: true }, createGeminiGenerateContentSidecarCarrier('standalone-part')] }, - { role: 'model', parts: [createGeminiGenerateContentSidecarCarrier('standalone-content')] }, + { role: 'model', parts: [{ text: 'answer', thoughtSignature: 'on-text' }, { functionCall, thoughtSignature: 'on-call' }, { text: 'native thought', thought: true }, createGeminiGenerateContentSidecarPart('standalone-part')] }, + { role: 'model', parts: [createGeminiGenerateContentSidecarPart('standalone-content')] }, ]; const before = structuredClone(turn); - const inspection = fixtureSidecar(findGeminiGenerateContentSidecarCarriers(turn), 'standalone-content'); + const inspection = fixtureSidecar(findGeminiGenerateContentSidecarOccurrences(turn), 'standalone-content'); expect(inspection).toEqual({ sidecar: 'standalone-content', turn: ordinary }); expect(turn).toEqual(before); const check = await computeGeminiGenerateContentTurnDigest(ordinary); @@ -212,7 +211,7 @@ describe('assistant-turn sidecar protocols', () => { it('checks complete Chat body, readable summary, and ordered tool identities and arguments', async () => { const turn = chatTurn(); - const inspection = fixtureSidecar(findOpenAIChatCompletionsSidecarCarriers(turn)); + const inspection = fixtureSidecar(findOpenAIChatCompletionsSidecarOccurrences(turn)); expect(inspection.sidecar).toBe('opaque-sidecar'); const check = await computeOpenAIChatCompletionsTurnDigest(inspection.turn); expect(await verifyChatTurn(turn, check)).toBe(true); @@ -220,7 +219,7 @@ describe('assistant-turn sidecar protocols', () => { const firstFunctionCall = turn.tool_calls![0] as Extract[number], { type: 'function' }>; for (const changed of [ { ...turn, content: 'edited answer' }, - { ...turn, reasoning_details: [{ type: 'reasoning.summary', summary: 'edited summary' }, ...createOpenAIChatCompletionsSidecarCarrier('opaque-sidecar')] }, + { ...turn, reasoning_details: [{ type: 'reasoning.summary', summary: 'edited summary' }, ...createOpenAIChatCompletionsSidecarDetails('opaque-sidecar')] }, { ...turn, tool_calls: [...turn.tool_calls!].reverse() }, { ...turn, tool_calls: [{ ...turn.tool_calls![0], id: 'edited-call' }, turn.tool_calls![1]] }, { ...turn, tool_calls: [{ ...firstFunctionCall, function: { ...firstFunctionCall.function, name: 'edited-name' } }, turn.tool_calls![1]] }, @@ -230,7 +229,7 @@ describe('assistant-turn sidecar protocols', () => { ]) expect(await verifyChatTurn(changed, check)).toBe(false); const reasoningDetails = [ { type: 'reasoning.summary', summary: 'readable summary', format: 'unknown', index: 0 }, - ...createOpenAIChatCompletionsSidecarCarrier('opaque-sidecar'), + ...createOpenAIChatCompletionsSidecarDetails('opaque-sidecar'), ]; expect(await verifyChatTurn({ ...turn, @@ -245,7 +244,7 @@ describe('assistant-turn sidecar protocols', () => { reasoning_details: [reasoningDetails[0], { ...reasoningDetails[1], format: 'edited' }], }, check)).toBe(true); expect(await verifyChatTurn(turn, check, 'opaque-sidecar', 99)).toBe(false); - expect(createOpenAIChatCompletionsSidecarCarrier('opaque')).toEqual([ + expect(createOpenAIChatCompletionsSidecarDetails('opaque')).toEqual([ { type: 'reasoning.encrypted', data: 'opaque', format: 'unknown', index: 1 }, ]); }); @@ -267,7 +266,7 @@ describe('assistant-turn sidecar protocols', () => { it('checks Responses bodies and statuses in order within each item type independently of other metadata', async () => { const turn = responseTurn(); - const check = await computeOpenAIResponsesTurnDigest(fixtureSidecar(findOpenAIResponsesSidecarCarriers(turn)).turn); + const check = await computeOpenAIResponsesTurnDigest(fixtureSidecar(findOpenAIResponsesSidecarOccurrences(turn)).turn); expect(await verifyResponsesTurn(turn, check)).toBe(true); for (const changed of [ [{ ...turn[0] as Extract, content: [{ type: 'output_text' as const, text: 'edited answer', annotations: [], logprobs: [] }] }, ...turn.slice(1)], @@ -299,7 +298,7 @@ describe('assistant-turn sidecar protocols', () => { const secondCall = { ...turn[1] as Extract, call_id: 'call-b', name: 'other', arguments: '{"q":"b"}' }; const twoCalls = [turn[0], turn[1], secondCall, turn[2]]; - const twoCallsCheck = await computeOpenAIResponsesTurnDigest(fixtureSidecar(findOpenAIResponsesSidecarCarriers(twoCalls)).turn); + const twoCallsCheck = await computeOpenAIResponsesTurnDigest(fixtureSidecar(findOpenAIResponsesSidecarOccurrences(twoCalls)).turn); expect(await verifyResponsesTurn([turn[0], secondCall, turn[1], turn[2]], twoCallsCheck)).toBe(false); expect(await verifyResponsesTurn([turn[1], secondCall, turn[0], turn[2]], twoCallsCheck)).toBe(true); @@ -329,7 +328,7 @@ describe('assistant-turn sidecar protocols', () => { const custom: Extract = { type: 'custom_tool_call', id: 'custom-record', call_id: 'call-custom', name: 'format', input: 'plain input', }; - const carrier = createOpenAIResponsesSidecarCarrier('rs-sidecar', 'opaque-sidecar'); + const carrier = createOpenAIResponsesSidecarItem('rs-sidecar', 'opaque-sidecar'); const check = await computeOpenAIResponsesTurnDigest([reasoning, custom]); expect(await verifyResponsesTurn([{ ...reasoning, id: 'different' }, { ...custom, id: 'different' }, carrier], check)).toBe(true); for (const changed of [ @@ -387,40 +386,13 @@ describe('assistant-turn sidecar protocols', () => { expect(await verifyOpenAIResponsesTurnDigest(cases[2].original, statuslessCheck, 1)).toBe(false); }); - it('builds canonical Responses history items', async () => { - expect(createOpenAIResponsesSidecarCarrier('rs_sidecar', 'opaque-sidecar')).toEqual({ + it('preserves the Responses sidecar shape and checks generated image replay', async () => { + expect(createOpenAIResponsesSidecarItem('rs_sidecar', 'opaque-sidecar')).toEqual({ type: 'reasoning', id: 'rs_sidecar', summary: [], encrypted_content: 'opaque-sidecar', }); const imageCall: Extract = { type: 'image_generation_call', id: 'ig-data', result: 'image-base64', status: 'completed', action: 'generate', output_format: 'png', }; - const messageWithSDKFields = { - type: 'message', id: 'message-a', role: 'assistant', status: 'completed', phase: 'final_answer', created_by: 'sdk', - content: [{ type: 'output_text', text: 'answer', annotations: [{ type: 'url_citation', url: 'https://example.test', title: 'source', start_index: 0, end_index: 6 }], logprobs: [], parsed: { answer: true } }], - } satisfies Extract & { created_by: string; content: (Extract['content'][number], { type: 'output_text' }> & { parsed: { answer: boolean } })[] }; - const functionCallWithSDKFields = { - type: 'function_call', id: 'function-a', call_id: 'call-a', name: 'lookup', arguments: '{"q":"a"}', parsed_arguments: { q: 'a' }, status: 'completed', created_by: 'sdk', - } satisfies Extract & { parsed_arguments: { q: string }; created_by: string }; - const outputHistory: OpenAIResponsesOutputItemEx[] = [ - messageWithSDKFields, - functionCallWithSDKFields, - { type: 'shell_call_output', id: 'shell-output', call_id: 'shell-call', output: [{ stdout: 'ok', stderr: '', outcome: { type: 'exit', exit_code: 0 }, created_by: 'sdk' }], status: 'completed', created_by: 'sdk' }, - { type: 'computer_call_output', call_id: 'computer-call', output: { type: 'computer_screenshot', file_id: 'screenshot' }, status: 'failed', created_by: 'sdk' }, - { type: 'custom_tool_call_output', call_id: 'custom-incomplete', output: 'incomplete', status: 'incomplete' }, - { type: 'custom_tool_call_output', id: 'custom-completed', call_id: 'custom-completed-call', output: 'complete', status: 'completed' }, - { type: 'additional_tools', id: 'additional-tools-developer', role: 'developer', tools: [] }, - { type: 'image_generation_call', id: 'ig-null', result: null, status: 'completed' }, - imageCall, - ]; - expect(toOpenAIResponsesInputAssistantTurn(outputHistory)).toEqual([ - { type: 'message', id: 'message-a', role: 'assistant', status: 'completed', phase: 'final_answer', content: [{ type: 'output_text', text: 'answer', annotations: [{ type: 'url_citation', url: 'https://example.test', title: 'source', start_index: 0, end_index: 6 }], logprobs: [] }] }, - { type: 'function_call', id: 'function-a', call_id: 'call-a', name: 'lookup', arguments: '{"q":"a"}', status: 'completed' }, - { type: 'shell_call_output', id: 'shell-output', call_id: 'shell-call', output: [{ stdout: 'ok', stderr: '', outcome: { type: 'exit', exit_code: 0 } }], status: 'completed' }, - { type: 'custom_tool_call_output', id: 'custom-completed', call_id: 'custom-completed-call', output: 'complete' }, - { type: 'additional_tools', id: 'additional-tools-developer', role: 'developer', tools: [] }, - { type: 'image_generation_call', id: 'ig-null', result: null, status: 'completed' }, - imageCall, - ]); const imageTurn: OpenAIResponsesOutputItemEx[] = [ imageCall, { type: 'reasoning', id: 'rs-image', summary: [], encrypted_content: 'opaque-sidecar' }, @@ -448,7 +420,7 @@ describe('assistant-turn sidecar protocols', () => { expect(turns.map(({ role, items }) => [role, items.length])).toEqual([['assistant', 2], ['user', 1]]); expect(turns.map(({ items }) => items).flat()).toEqual([first, second, user]); const targetTurn = [first, second]; - const inspection = fixtureSidecar(findAnthropicMessagesSidecarCarriers(targetTurn)); + const inspection = fixtureSidecar(findAnthropicMessagesSidecarOccurrences(targetTurn)); expect(inspection.sidecar).toBe('opaque-sidecar'); const check = await computeAnthropicMessagesTurnDigest(inspection.turn); expect(await verifyMessagesTurn(targetTurn, check)).toBe(true); @@ -466,11 +438,11 @@ describe('assistant-turn sidecar protocols', () => { expect(await verifyMessagesTurn(changed([continued, text, thinking, call]), check)).toBe(false); const secondTool = { ...call, id: 'tool-b', name: 'other', input: { q: 'b' } }; const twoTools = changed([text, thinking, call, secondTool, continued]); - const twoToolsCheck = await computeAnthropicMessagesTurnDigest(fixtureSidecar(findAnthropicMessagesSidecarCarriers(twoTools)).turn); + const twoToolsCheck = await computeAnthropicMessagesTurnDigest(fixtureSidecar(findAnthropicMessagesSidecarOccurrences(twoTools)).turn); expect(await verifyMessagesTurn(changed([text, thinking, secondTool, call, continued]), twoToolsCheck)).toBe(false); expect(await verifyMessagesTurn(changed([text, call, secondTool, thinking, continued]), twoToolsCheck)).toBe(true); const settingsTurn: AnthropicMessagesAssistantMessage[] = [{ ...first, clear_at: 'never', output_config: null }, second]; - const settingsCheck = await computeAnthropicMessagesTurnDigest(fixtureSidecar(findAnthropicMessagesSidecarCarriers(settingsTurn)).turn); + const settingsCheck = await computeAnthropicMessagesTurnDigest(fixtureSidecar(findAnthropicMessagesSidecarOccurrences(settingsTurn)).turn); expect(await verifyMessagesTurn(settingsTurn, settingsCheck)).toBe(true); expect(await verifyMessagesTurn([{ ...settingsTurn[0], clear_at: 'next_user_message' }, second], settingsCheck)).toBe(true); const stringContentCheck = await computeAnthropicMessagesTurnDigest([{ role: 'assistant', content: 'plain text' }]); @@ -495,10 +467,10 @@ describe('assistant-turn sidecar protocols', () => { { type: 'text', text: 'answer', citations: [textCitation] }, { type: 'thinking', thinking: 'reasoning', signature: 'native-message-signature' }, { type: 'tool_use', id: 'call-a', name: 'lookup', input: { q: 'a' } }, - createAnthropicMessagesSidecarCarrier('opaque-sidecar'), + createAnthropicMessagesSidecarBlock('opaque-sidecar'), ] satisfies AnthropicMessagesAssistantContentBlock[]; const citedTurn: AnthropicMessagesAssistantMessage[] = [{ role: 'assistant', content: responseContent }]; - const citedCheck = await computeAnthropicMessagesTurnDigest(fixtureSidecar(findAnthropicMessagesSidecarCarriers(citedTurn)).turn); + const citedCheck = await computeAnthropicMessagesTurnDigest(fixtureSidecar(findAnthropicMessagesSidecarOccurrences(citedTurn)).turn); const responseText = responseContent[0] as Extract; expect(await verifyMessagesTurn([{ role: 'assistant', @@ -514,7 +486,7 @@ describe('assistant-turn sidecar protocols', () => { { role: 'model', parts: [{ text: 'next turn' }] }, ]); expect(turns.map(({ role, items }) => [role, items.length])).toEqual([['assistant', 2], ['bare', 1], ['assistant', 1]]); - const check = await computeGeminiGenerateContentTurnDigest(fixtureSidecar(findGeminiGenerateContentSidecarCarriers(turn)).turn); + const check = await computeGeminiGenerateContentTurnDigest(fixtureSidecar(findGeminiGenerateContentSidecarOccurrences(turn)).turn); expect(await verifyGeminiTurn([{ ...turn[0], parts: [turn[0].parts![1], turn[0].parts![0], turn[0].parts![3], turn[0].parts![2]] }, turn[1]], check)).toBe(true); const moved = [ { @@ -525,7 +497,7 @@ describe('assistant-turn sidecar protocols', () => { turn[0].parts![3], ], }, - { role: 'model', parts: [createGeminiGenerateContentSidecarCarrier('opaque-sidecar')] }, + { role: 'model', parts: [createGeminiGenerateContentSidecarPart('opaque-sidecar')] }, ]; expect(await verifyGeminiTurn(moved, check)).toBe(true); expect(await verifyGeminiTurn([ @@ -553,9 +525,9 @@ describe('assistant-turn sidecar protocols', () => { it('checks emitted Gemini media and transcript while excluding part metadata', async () => { const part = { inlineData: { mimeType: 'audio/wav', data: 'audio-a' }, audioTranscription: { text: 'transcript-a' } }; const secondPart = { inlineData: { mimeType: 'image/png', data: 'image-b' } }; - const carrier = createGeminiGenerateContentSidecarCarrier('opaque-sidecar'); + const carrier = createGeminiGenerateContentSidecarPart('opaque-sidecar'); const turn = [{ role: 'model' as const, parts: [part, secondPart, carrier] }]; - const check = await computeGeminiGenerateContentTurnDigest(fixtureSidecar(findGeminiGenerateContentSidecarCarriers(turn)).turn); + const check = await computeGeminiGenerateContentTurnDigest(fixtureSidecar(findGeminiGenerateContentSidecarOccurrences(turn)).turn); expect(await verifyGeminiTurn([{ ...turn[0], parts: [{ ...part, inlineData: { ...part.inlineData, displayName: 'label' }, audioTranscription: { ...part.audioTranscription, speakerLabel: 'speaker' }, @@ -579,7 +551,7 @@ describe('assistant-turn sidecar protocols', () => { { text: 'answer', thoughtSignature: 'opaque-sidecar' }, ], }]; - const check = await computeGeminiGenerateContentTurnDigest(fixtureSidecar(findGeminiGenerateContentSidecarCarriers(turn)).turn); + const check = await computeGeminiGenerateContentTurnDigest(fixtureSidecar(findGeminiGenerateContentSidecarOccurrences(turn)).turn); const replay = [{ role: 'model' as const, parts: [ { functionCall }, diff --git a/packages/translate/__tests__/round-trip/reference/text-provenance_test.ts b/packages/translate/__tests__/round-trip/provenance_test.ts similarity index 59% rename from packages/translate/__tests__/round-trip/reference/text-provenance_test.ts rename to packages/translate/__tests__/round-trip/provenance_test.ts index f5f7a8f02e..e3b39625db 100644 --- a/packages/translate/__tests__/round-trip/reference/text-provenance_test.ts +++ b/packages/translate/__tests__/round-trip/provenance_test.ts @@ -1,19 +1,19 @@ import { expect, it } from 'vitest'; -import { provenanceSpanForAppend, replayTextProvenance } from '../../../src/round-trip/reference/text-provenance.ts'; +import { provenanceSpanForAppend, replayTextProvenance } from '../../src/round-trip/provenance.ts'; it('captures appended suffixes and replays literal gaps around provenance spans', () => { const spans = [ provenanceSpanForAppend('prefix tracked', 'tracked', [3]), provenanceSpanForAppend('prefix tracked suffix', ' suffix'), ]; - const output: Array<{ text: string; referencePieceIds?: readonly number[] }> = []; - replayTextProvenance('prefix tracked suffix', spans, (text, referencePieceIds) => { - output.push({ text, ...(referencePieceIds === undefined ? {} : { referencePieceIds }) }); + const output: Array<{ text: string; textFragmentIds?: readonly number[] }> = []; + replayTextProvenance('prefix tracked suffix', spans, (text, textFragmentIds) => { + output.push({ text, ...(textFragmentIds === undefined ? {} : { textFragmentIds }) }); }); expect(output).toEqual([ { text: 'prefix ' }, - { text: 'tracked', referencePieceIds: [3] }, + { text: 'tracked', textFragmentIds: [3] }, { text: ' suffix' }, ]); }); @@ -28,17 +28,17 @@ it('rejects mismatched appends and invalid span sequences', () => { }); it('preserves literal gaps between and after tracked spans', () => { - const output: Array<{ text: string; referencePieceIds?: readonly number[] }> = []; + const output: Array<{ text: string; textFragmentIds?: readonly number[] }> = []; replayTextProvenance('aXXbYYc', [ - { start: 1, endExclusive: 3, referencePieceIds: [1] }, - { start: 4, endExclusive: 6, referencePieceIds: [2] }, - ], (text, referencePieceIds) => output.push({ text, ...(referencePieceIds === undefined ? {} : { referencePieceIds }) })); + { start: 1, endExclusive: 3, textFragmentIds: [1] }, + { start: 4, endExclusive: 6, textFragmentIds: [2] }, + ], (text, textFragmentIds) => output.push({ text, ...(textFragmentIds === undefined ? {} : { textFragmentIds }) })); expect(output).toEqual([ { text: 'a' }, - { text: 'XX', referencePieceIds: [1] }, + { text: 'XX', textFragmentIds: [1] }, { text: 'b' }, - { text: 'YY', referencePieceIds: [2] }, + { text: 'YY', textFragmentIds: [2] }, { text: 'c' }, ]); }); diff --git a/packages/translate/__tests__/round-trip/reference/tagged-value_test.ts b/packages/translate/__tests__/round-trip/reference/content-reference-tag_test.ts similarity index 60% rename from packages/translate/__tests__/round-trip/reference/tagged-value_test.ts rename to packages/translate/__tests__/round-trip/reference/content-reference-tag_test.ts index f67d1705a1..b6a1839b25 100644 --- a/packages/translate/__tests__/round-trip/reference/tagged-value_test.ts +++ b/packages/translate/__tests__/round-trip/reference/content-reference-tag_test.ts @@ -1,23 +1,23 @@ import { expect, test } from 'vitest'; -import { ContentReference } from '../../../src/round-trip/reference/assistant-turn.ts'; -import { encodeSidecarTaggedValues, restoreSidecarTaggedValues } from '../../../src/round-trip/reference/tagged-value.ts'; -import { SidecarTaggedValue } from '../../../src/round-trip/sidecar-tagged-value.ts'; +import { SidecarTaggedValue } from '../../../src/round-trip/codec.ts'; +import { tagContentReferences, untagContentReferences } from '../../../src/round-trip/reference/content-reference-tag.ts'; +import { ContentReference } from '../../../src/round-trip/reference/types.ts'; test.each(['text', 'json'] as const)('maps %s references to their transport tag and back', kind => { const reference = new ContentReference(kind, [0, [1, 2, 3]]); - const encoded = encodeSidecarTaggedValues({ reference, ordinary: { kind, parts: [0] } }); + const encoded = tagContentReferences({ reference, ordinary: { kind, parts: [0] } }); expect(encoded).toEqual({ reference: new SidecarTaggedValue(kind === 'text' ? 65_536 : 65_537, [0, [1, 2, 3]]), ordinary: { kind, parts: [0] }, }); - expect(restoreSidecarTaggedValues(encoded)).toEqual({ reference, ordinary: { kind, parts: [0] } }); + expect(untagContentReferences(encoded)).toEqual({ reference, ordinary: { kind, parts: [0] } }); }); test('restores nested tagged values in place and leaves unrelated tags to their owner', () => { const nested = { reference: new SidecarTaggedValue(65_536, [0]) }; const encoded = { nested, unknown: new SidecarTaggedValue(65_540, { opaque: true }) }; - const restored = restoreSidecarTaggedValues(encoded) as typeof encoded & { nested: { reference: ContentReference } }; + const restored = untagContentReferences(encoded) as typeof encoded & { nested: { reference: ContentReference } }; expect(restored).toBe(encoded); expect(restored.nested).toBe(nested); expect(restored.nested.reference).toEqual(new ContentReference('text', [0])); @@ -25,5 +25,5 @@ test('restores nested tagged values in place and leaves unrelated tags to their }); test.each([null, 'reference', [-1], [0.5], [[0, 1]], [[0, 4, 2]], [[0, -1, 2]], [[0, 1, Infinity]]])('rejects malformed thin reference parts %j', parts => { - expect(() => restoreSidecarTaggedValues(new SidecarTaggedValue(65_536, parts))).toThrow('Thin reference sidecar tag requires reference parts'); + expect(() => untagContentReferences(new SidecarTaggedValue(65_536, parts))).toThrow('Thin reference sidecar tag requires reference parts'); }); diff --git a/packages/translate/__tests__/round-trip/reference/reference-builder_test.ts b/packages/translate/__tests__/round-trip/reference/reference-builder_test.ts index d57e84ffb2..b3181500ab 100644 --- a/packages/translate/__tests__/round-trip/reference/reference-builder_test.ts +++ b/packages/translate/__tests__/round-trip/reference/reference-builder_test.ts @@ -1,85 +1,85 @@ import { describe, expect, it, vi } from 'vitest'; import type { IRJSONObject } from '../../../src/ir/types.ts'; -import { collectAnthropicMessagesTextViews } from '../../../src/protocols/anthropic-messages/as-source/round-trip.ts'; -import type { AnthropicMessagesReferenceableAssistantTurn } from '../../../src/protocols/anthropic-messages/as-target/assistant-turn.ts'; -import { collectGeminiGenerateContentTextViews } from '../../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; -import type { GeminiGenerateContentAssistantState, GeminiGenerateContentInputAssistantTurn } from '../../../src/protocols/gemini-generate-content/as-target/assistant-turn.ts'; -import type { OpenAIChatCompletionsAssistantState, OpenAIChatCompletionsInputAssistantTurn } from '../../../src/protocols/openai-chat-completions/as-target/assistant-turn.ts'; -import { ContentReference } from '../../../src/round-trip/reference/assistant-turn.ts'; +import { collectAnthropicMessagesTextPlacements } from '../../../src/protocols/anthropic-messages/as-source/round-trip.ts'; +import type { AnthropicMessagesReferenceableInputAssistantTurn } from '../../../src/protocols/anthropic-messages/as-target/assistant-turn.ts'; +import { collectGeminiGenerateContentTextPlacements } from '../../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; +import type { GeminiGenerateContentInputAssistantTurnState, GeminiGenerateContentInputAssistantTurn } from '../../../src/protocols/gemini-generate-content/as-target/assistant-turn.ts'; +import type { OpenAIChatCompletionsInputAssistantTurnState, OpenAIChatCompletionsInputAssistantTurn } from '../../../src/protocols/openai-chat-completions/as-target/assistant-turn.ts'; +import { hashRoundTripContent } from '../../../src/round-trip/hash.ts'; import { - appendContent, - appendField, - collectContentViews, + appendEmittedField, + appendReadField, + collectEmittedFieldPlacements, createReferenceBuilder, - createContentState, - createFieldState, - createReferenceFinalizer, - invalidateField, - type ContentStates, - type FieldStates, + createEmittedFieldState, + createReadFieldState, + createFieldReferencer, + invalidateReadField, + type EmittedFieldStates, + type ReadFieldStates, } from '../../../src/round-trip/reference/reference-builder.ts'; -import { finalizeAssistantTurnReferences } from '../../../src/round-trip/reference/reference-selection.ts'; +import { referenceAssistantTurn } from '../../../src/round-trip/reference/reference-selection.ts'; import { restoreAssistantTurn } from '../../../src/round-trip/reference/restore.ts'; -import { hashRoundTripContent } from '../../../src/round-trip/turn-digest.ts'; +import { ContentReference } from '../../../src/round-trip/reference/types.ts'; import { parseJSONObject } from '../../../src/shared/json/object.ts'; describe('ReferenceBuilder', () => { it('applies channel views only to their owning field on a multi-field part', () => { const builder = createReferenceBuilder(); - const visible = createContentState(builder, 'text'); - const transcript = createContentState(builder, 'text'); - appendContent(builder, visible, 'visible'); - appendContent(builder, transcript, 'transcript'); + const visible = createEmittedFieldState(builder, 'text'); + const transcript = createEmittedFieldState(builder, 'text'); + appendEmittedField(builder, visible, 'visible'); + appendEmittedField(builder, transcript, 'transcript'); const turn: GeminiGenerateContentInputAssistantTurn = [{ role: 'model', parts: [{ text: 'visible', audioTranscription: { text: 'transcript' } }], }]; - const state: GeminiGenerateContentAssistantState = [{ + const state: GeminiGenerateContentInputAssistantTurnState = [{ parts: [{ text: visible, audioTranscription: { text: transcript } }], }]; - expect(collectContentViews(turn, state, collectGeminiGenerateContentTextViews(turn))).toEqual(new Map([ - [visible.contentId, { text: 'visible', offset: 0 }], - [transcript.contentId, { text: 'transcript', offset: 0 }], + expect(collectEmittedFieldPlacements(turn, state, collectGeminiGenerateContentTextPlacements(turn))).toEqual(new Map([ + [visible.emittedFieldId, { enclosingText: 'visible', offset: 0 }], + [transcript.emittedFieldId, { enclosingText: 'transcript', offset: 0 }], ])); }); it('keeps provenance directly in sparse state leaves across shallow copies', async () => { const builder = createReferenceBuilder(); - const field = createFieldState('text'); - const first = appendField(builder, field, 'hel'); - const second = appendField(builder, field, 'lo'); - const content = createContentState(builder, 'text'); - appendContent(builder, content, '['); - appendContent(builder, content, 'hello', [first, second]); - appendContent(builder, content, ']'); + const field = createReadFieldState('text'); + const first = appendReadField(builder, field, 'hel'); + const second = appendReadField(builder, field, 'lo'); + const content = createEmittedFieldState(builder, 'text'); + appendEmittedField(builder, content, '['); + appendEmittedField(builder, content, 'hello', [first, second]); + appendEmittedField(builder, content, ']'); const fieldState = { content: [{ text: { ...field } }] }; const contentState = { content: [{ text: { ...content } }] }; - expect(fieldState.content[0].text).toMatchObject({ kind: 'text', text: 'hello', referencePieceIds: [first, second] }); - expect(contentState.content[0].text).toMatchObject({ kind: 'text', contentId: 0, text: '[hello]' }); + expect(fieldState.content[0].text).toMatchObject({ kind: 'text', text: 'hello', textFragmentIds: [first, second] }); + expect(contentState.content[0].text).toMatchObject({ kind: 'text', emittedFieldId: 0, text: '[hello]' }); const turn = { role: 'assistant' as const, content: [{ type: 'text' as const, text: '[hello]', citations: null }] }; - const finalizer = createReferenceFinalizer(builder, collectContentViews(turn, contentState)); - expect(await finalizer.finalizeField('hello', fieldState.content[0].text)).toEqual(new ContentReference('text', [[0, 1, 6]])); + const finalizer = createFieldReferencer(builder, collectEmittedFieldPlacements(turn, contentState)); + expect(await finalizer.referenceField('hello', fieldState.content[0].text)).toEqual(new ContentReference('text', [[0, 1, 6]])); }); it('hashes one canonical text run only once across multiple content IDs and shifts their ranges', async () => { const builder = createReferenceBuilder(); const left = 'left segment '.repeat(30); const right = 'right segment '.repeat(30); - const fields = [createFieldState('text'), createFieldState('text')]; - const contents = [createContentState(builder, 'text'), createContentState(builder, 'text')]; - appendContent(builder, contents[0], left, [appendField(builder, fields[0], left)]); - appendContent(builder, contents[1], right, [appendField(builder, fields[1], right)]); + const fields = [createReadFieldState('text'), createReadFieldState('text')]; + const contents = [createEmittedFieldState(builder, 'text'), createEmittedFieldState(builder, 'text')]; + appendEmittedField(builder, contents[0], left, [appendReadField(builder, fields[0], left)]); + appendEmittedField(builder, contents[1], right, [appendReadField(builder, fields[1], right)]); const turn = { role: 'assistant' as const, content: [{ type: 'text' as const, text: left, citations: null }, { type: 'text' as const, text: right, citations: null }] }; - const views = collectContentViews(turn, { content: contents.map(text => ({ text })) }, collectAnthropicMessagesTextViews([turn])); - const finalizer = createReferenceFinalizer(builder, views); + const views = collectEmittedFieldPlacements(turn, { content: contents.map(text => ({ text })) }, collectAnthropicMessagesTextPlacements([turn])); + const finalizer = createFieldReferencer(builder, views); const digest = vi.spyOn(crypto.subtle, 'digest'); try { - expect(await finalizer.finalizeField(left, fields[0])).toMatchObject({ parts: [[0, 0, left.length]] }); - expect(await finalizer.finalizeField(right, fields[1])).toMatchObject({ parts: [[0, left.length, left.length + right.length]] }); + expect(await finalizer.referenceField(left, fields[0])).toMatchObject({ parts: [[0, 0, left.length]] }); + expect(await finalizer.referenceField(right, fields[1])).toMatchObject({ parts: [[0, left.length, left.length + right.length]] }); expect(digest).toHaveBeenCalledTimes(1); } finally { digest.mockRestore(); @@ -89,23 +89,23 @@ describe('ReferenceBuilder', () => { it('looks up equal complete texts once per content ID rather than once per SSE atom', async () => { const builder = createReferenceBuilder(); - const fields = [createFieldState('text'), createFieldState('text')]; - const contents = fields.map(() => createContentState(builder, 'text')); + const fields = [createReadFieldState('text'), createReadFieldState('text')]; + const contents = fields.map(() => createEmittedFieldState(builder, 'text')); const texts = contents.map(() => Array.from({ length: 128 }, () => 'abcdefgh').join('')); for (const [index, field] of fields.entries()) { for (let offset = 0; offset < texts[index].length; offset += 8) { const piece = texts[index].slice(offset, offset + 8); - appendContent(builder, contents[index], piece, [appendField(builder, field, piece)]); + appendEmittedField(builder, contents[index], piece, [appendReadField(builder, field, piece)]); } } - const views = new Map(contents.flatMap((content, index) => [...collectContentViews(texts[index], content)])); - const finalizer = createReferenceFinalizer(builder, views); + const views = new Map(contents.flatMap((content, index) => [...collectEmittedFieldPlacements(texts[index], content)])); + const finalizer = createFieldReferencer(builder, views); const digest = vi.spyOn(crypto.subtle, 'digest'); const lookups = vi.spyOn(Map.prototype, 'get'); let completeTextLookups: number; let hashCalls: number; try { - for (const [index, field] of fields.entries()) await finalizer.finalizeField(texts[index], field); + for (const [index, field] of fields.entries()) await finalizer.referenceField(texts[index], field); completeTextLookups = lookups.mock.calls.filter(([key]) => key === texts[0]).length; hashCalls = digest.mock.calls.length; } finally { @@ -119,48 +119,48 @@ describe('ReferenceBuilder', () => { it('coalesces shifted ranges across canonical parts and preserves original-leaf validation', async () => { const builder = createReferenceBuilder(); - const field = createFieldState('text'); - const contents = [createContentState(builder, 'text'), createContentState(builder, 'text')]; + const field = createReadFieldState('text'); + const contents = [createEmittedFieldState(builder, 'text'), createEmittedFieldState(builder, 'text')]; const left = 'prefix \ud83d'; const right = '\ude00 suffix'; - appendContent(builder, contents[0], left, [appendField(builder, field, left)]); - appendContent(builder, contents[1], right, [appendField(builder, field, right)]); + appendEmittedField(builder, contents[0], left, [appendReadField(builder, field, left)]); + appendEmittedField(builder, contents[1], right, [appendReadField(builder, field, right)]); const turn = { role: 'assistant' as const, content: [{ type: 'text' as const, text: left, citations: null }, { type: 'text' as const, text: right, citations: null }] }; const state = { content: contents.map(text => ({ text })) }; - const finalizer = createReferenceFinalizer(builder, collectContentViews(turn, state, collectAnthropicMessagesTextViews([turn]))); - expect(await finalizer.finalizeField(left + right, field)).toMatchObject({ parts: [0] }); + const finalizer = createFieldReferencer(builder, collectEmittedFieldPlacements(turn, state, collectAnthropicMessagesTextPlacements([turn]))); + expect(await finalizer.referenceField(left + right, field)).toMatchObject({ parts: [0] }); const changed = { ...turn, content: [{ ...turn.content[0], text: 'changed' }, turn.content[1]] }; - const changedFinalizer = createReferenceFinalizer(builder, collectContentViews(changed, state, collectAnthropicMessagesTextViews([changed]))); - expect(await changedFinalizer.finalizeField(left + right, field)).toBe(left + right); + const changedFinalizer = createFieldReferencer(builder, collectEmittedFieldPlacements(changed, state, collectAnthropicMessagesTextPlacements([changed]))); + expect(await changedFinalizer.referenceField(left + right, field)).toBe(left + right); expect(changedFinalizer.referencedContentHashes).toEqual([]); }); it('resolves every original SSE atom within one merged IR append', async () => { const builder = createReferenceBuilder(); - const field = createFieldState('text'); - const first = appendField(builder, field, 'hel'); - const second = appendField(builder, field, 'lo'); - const content = createContentState(builder, 'text'); - appendContent(builder, content, '['); - appendContent(builder, content, 'hello', [first, second]); - appendContent(builder, content, ']'); - const finalizer = createReferenceFinalizer(builder, collectContentViews('[hello]', content)); - expect(await finalizer.finalizeField('hello', field)).toMatchObject({ parts: [[0, 1, 6]] }); + const field = createReadFieldState('text'); + const first = appendReadField(builder, field, 'hel'); + const second = appendReadField(builder, field, 'lo'); + const content = createEmittedFieldState(builder, 'text'); + appendEmittedField(builder, content, '['); + appendEmittedField(builder, content, 'hello', [first, second]); + appendEmittedField(builder, content, ']'); + const finalizer = createFieldReferencer(builder, collectEmittedFieldPlacements('[hello]', content)); + expect(await finalizer.referenceField('hello', field)).toMatchObject({ parts: [[0, 1, 6]] }); }); it('resolves B SSE atoms directly into one A content occurrence', async () => { const builder = createReferenceBuilder(); - const field = createFieldState('text'); - const first = appendField(builder, field, 'hel'); - const second = appendField(builder, field, 'lo'); - const content = createContentState(builder, 'text'); + const field = createReadFieldState('text'); + const first = appendReadField(builder, field, 'hel'); + const second = appendReadField(builder, field, 'lo'); + const content = createEmittedFieldState(builder, 'text'); - appendContent(builder, content, '['); - appendContent(builder, content, 'hel', [first]); - appendContent(builder, content, 'lo', [second]); - appendContent(builder, content, ']'); + appendEmittedField(builder, content, '['); + appendEmittedField(builder, content, 'hel', [first]); + appendEmittedField(builder, content, 'lo', [second]); + appendEmittedField(builder, content, ']'); - const finalizer = createReferenceFinalizer(builder, collectContentViews('[hello]', content)); - const reference = await finalizer.finalizeField('hello', field); + const finalizer = createFieldReferencer(builder, collectEmittedFieldPlacements('[hello]', content)); + const reference = await finalizer.referenceField('hello', field); if (!(reference instanceof ContentReference)) throw new Error('Expected a thin reference'); expect(reference.kind).toBe('text'); @@ -170,15 +170,15 @@ describe('ReferenceBuilder', () => { it('simplifies a reference covering the whole hashed content', async () => { const builder = createReferenceBuilder(); - const field = createFieldState('text'); - const first = appendField(builder, field, 'hel'); - const second = appendField(builder, field, 'lo'); - const content = createContentState(builder, 'text'); - appendContent(builder, content, 'hel', [first]); - appendContent(builder, content, 'lo', [second]); + const field = createReadFieldState('text'); + const first = appendReadField(builder, field, 'hel'); + const second = appendReadField(builder, field, 'lo'); + const content = createEmittedFieldState(builder, 'text'); + appendEmittedField(builder, content, 'hel', [first]); + appendEmittedField(builder, content, 'lo', [second]); - const finalizer = createReferenceFinalizer(builder, collectContentViews('hello', content)); - const reference = await finalizer.finalizeField('hello', field); + const finalizer = createFieldReferencer(builder, collectEmittedFieldPlacements('hello', content)); + const reference = await finalizer.referenceField('hello', field); if (!(reference instanceof ContentReference)) throw new Error('Expected a thin reference'); expect(reference.parts).toEqual([0]); @@ -186,75 +186,75 @@ describe('ReferenceBuilder', () => { it('keeps the whole original field when any SSE atom is unresolved', async () => { const builder = createReferenceBuilder(); - const field = createFieldState('text'); - const first = appendField(builder, field, 'hel'); - appendField(builder, field, 'lo'); - const content = createContentState(builder, 'text'); - appendContent(builder, content, 'hel', [first]); - - const finalizer = createReferenceFinalizer(builder, collectContentViews('hel', content)); - expect(await finalizer.finalizeField('hello', field)).toBe('hello'); + const field = createReadFieldState('text'); + const first = appendReadField(builder, field, 'hel'); + appendReadField(builder, field, 'lo'); + const content = createEmittedFieldState(builder, 'text'); + appendEmittedField(builder, content, 'hel', [first]); + + const finalizer = createFieldReferencer(builder, collectEmittedFieldPlacements('hel', content)); + expect(await finalizer.referenceField('hello', field)).toBe('hello'); expect(finalizer.referencedContentHashes).toEqual([]); }); it('keeps an invalidated field literal and rejects later provenance capture', async () => { const builder = createReferenceBuilder(); - const field = createFieldState('text'); - const partId = appendField(builder, field, 'hello'); - const content = createContentState(builder, 'text'); - appendContent(builder, content, 'hello', [partId]); + const field = createReadFieldState('text'); + const partId = appendReadField(builder, field, 'hello'); + const content = createEmittedFieldState(builder, 'text'); + appendEmittedField(builder, content, 'hello', [partId]); const copiedParent = { text: field }; - invalidateField(field); + invalidateReadField(field); expect(copiedParent.text).toBe(field); expect(copiedParent.text.valid).toBe(false); - const finalizer = createReferenceFinalizer(builder, collectContentViews('hello', content)); - expect(await finalizer.finalizeField('hello', field)).toBe('hello'); + const finalizer = createFieldReferencer(builder, collectEmittedFieldPlacements('hello', content)); + expect(await finalizer.referenceField('hello', field)).toBe('hello'); expect(finalizer.referencedContentHashes).toEqual([]); - expect(() => appendField(builder, field, '!')).toThrowError('Cannot append provenance to an invalid field'); + expect(() => appendReadField(builder, field, '!')).toThrowError('Cannot append provenance to an invalid field'); }); it('discards resolutions when the final input text view changed', async () => { const builder = createReferenceBuilder(); - const field = createFieldState('text'); - const partId = appendField(builder, field, 'hello'); - const content = createContentState(builder, 'text'); - appendContent(builder, content, 'hello', [partId]); + const field = createReadFieldState('text'); + const partId = appendReadField(builder, field, 'hello'); + const content = createEmittedFieldState(builder, 'text'); + appendEmittedField(builder, content, 'hello', [partId]); - const contentViews = collectContentViews('changed', content); - const finalizer = createReferenceFinalizer(builder, contentViews); + const contentViews = collectEmittedFieldPlacements('changed', content); + const finalizer = createFieldReferencer(builder, contentViews); expect(contentViews).toEqual(new Map()); - expect(await finalizer.finalizeField('hello', field)).toBe('hello'); + expect(await finalizer.referenceField('hello', field)).toBe('hello'); }); it('rejects a second output occurrence for the same SSE atom', () => { const builder = createReferenceBuilder(); - const field = createFieldState('text'); - const partId = appendField(builder, field, 'same'); - const first = createContentState(builder, 'text'); - const second = createContentState(builder, 'text'); - appendContent(builder, first, 'same', [partId]); + const field = createReadFieldState('text'); + const partId = appendReadField(builder, field, 'same'); + const first = createEmittedFieldState(builder, 'text'); + const second = createEmittedFieldState(builder, 'text'); + appendEmittedField(builder, first, 'same', [partId]); - expect(() => appendContent(builder, second, 'same', [partId])).toThrowError('ReferenceBuilder part 0 has multiple output occurrences'); + expect(() => appendEmittedField(builder, second, 'same', [partId])).toThrowError('ReferenceBuilder fragment 0 has multiple output occurrences'); }); it('deduplicates equal final content hashes', async () => { const builder = createReferenceBuilder(); - const firstField = createFieldState('text'); - const secondField = createFieldState('text'); - const firstPart = appendField(builder, firstField, 'same'); - const secondPart = appendField(builder, secondField, 'same'); - const firstContent = createContentState(builder, 'text'); - const secondContent = createContentState(builder, 'text'); - appendContent(builder, firstContent, 'same', [firstPart]); - appendContent(builder, secondContent, 'same', [secondPart]); + const firstField = createReadFieldState('text'); + const secondField = createReadFieldState('text'); + const firstPart = appendReadField(builder, firstField, 'same'); + const secondPart = appendReadField(builder, secondField, 'same'); + const firstContent = createEmittedFieldState(builder, 'text'); + const secondContent = createEmittedFieldState(builder, 'text'); + appendEmittedField(builder, firstContent, 'same', [firstPart]); + appendEmittedField(builder, secondContent, 'same', [secondPart]); const contentViews = new Map([ - ...collectContentViews('same', firstContent), - ...collectContentViews('same', secondContent), + ...collectEmittedFieldPlacements('same', firstContent), + ...collectEmittedFieldPlacements('same', secondContent), ]); - const finalizer = createReferenceFinalizer(builder, contentViews); - const firstReference = await finalizer.finalizeField('same', firstField); - const secondReference = await finalizer.finalizeField('same', secondField); + const finalizer = createFieldReferencer(builder, contentViews); + const firstReference = await finalizer.referenceField('same', firstField); + const secondReference = await finalizer.referenceField('same', secondField); if (!(firstReference instanceof ContentReference) || !(secondReference instanceof ContentReference)) throw new Error('Expected thin references'); expect(firstReference.parts).toEqual([0]); @@ -269,27 +269,27 @@ describe('ReferenceBuilder', () => { it('finalizes nested assistant state without materializing sparse holes', async () => { const builder = createReferenceBuilder(); - const contentField = createFieldState('text'); - const argumentsField = createFieldState('text'); - const contentPart = appendField(builder, contentField, 'answer'); - const argumentsPart = appendField(builder, argumentsField, '{}'); - const content = createContentState(builder, 'text'); - const argumentsContent = createContentState(builder, 'text'); - appendContent(builder, content, 'answer', [contentPart]); - appendContent(builder, argumentsContent, '{}', [argumentsPart]); + const contentField = createReadFieldState('text'); + const argumentsField = createReadFieldState('text'); + const contentPart = appendReadField(builder, contentField, 'answer'); + const argumentsPart = appendReadField(builder, argumentsField, '{}'); + const content = createEmittedFieldState(builder, 'text'); + const argumentsContent = createEmittedFieldState(builder, 'text'); + appendEmittedField(builder, content, 'answer', [contentPart]); + appendEmittedField(builder, argumentsContent, '{}', [argumentsPart]); const toolCalls: NonNullable = []; toolCalls.length = 2; toolCalls[1] = { id: 'call', type: 'function', function: { name: 'lookup', arguments: '{}' } }; const turn: OpenAIChatCompletionsInputAssistantTurn = { role: 'assistant', name: 'assistant', content: 'answer', tool_calls: toolCalls }; - const fieldToolCalls: NonNullable['tool_calls']> = []; + const fieldToolCalls: NonNullable['tool_calls']> = []; fieldToolCalls.length = 2; fieldToolCalls[1] = { function: { arguments: argumentsField } }; - const fieldState: OpenAIChatCompletionsAssistantState = { content: contentField, tool_calls: fieldToolCalls }; - const contentToolCalls: NonNullable['tool_calls']> = []; + const fieldState: OpenAIChatCompletionsInputAssistantTurnState = { content: contentField, tool_calls: fieldToolCalls }; + const contentToolCalls: NonNullable['tool_calls']> = []; contentToolCalls.length = 2; contentToolCalls[1] = { function: { arguments: argumentsContent } }; - const contentState: OpenAIChatCompletionsAssistantState = { content, tool_calls: contentToolCalls }; - const finalized = await finalizeAssistantTurnReferences<'openaiChatCompletions'>(turn, fieldState, builder, collectContentViews(turn, contentState)); + const contentState: OpenAIChatCompletionsInputAssistantTurnState = { content, tool_calls: contentToolCalls }; + const finalized = await referenceAssistantTurn<'openaiChatCompletions'>(turn, fieldState, builder, collectEmittedFieldPlacements(turn, contentState)); expect(finalized.targetTurn.name).toBe('assistant'); expect(finalized.targetTurn.content).toBe('answer'); @@ -303,19 +303,19 @@ describe('ReferenceBuilder', () => { it('omits referenced contents when every B field remains literal despite valid A views', async () => { const builder = createReferenceBuilder(); - const field = createFieldState('text'); - appendField(builder, field, 'literal B text'); - const content = createContentState(builder, 'text'); - appendContent(builder, content, 'visible A text'); + const field = createReadFieldState('text'); + appendReadField(builder, field, 'literal B text'); + const content = createEmittedFieldState(builder, 'text'); + appendEmittedField(builder, content, 'visible A text'); const turn: OpenAIChatCompletionsInputAssistantTurn = { role: 'assistant', content: 'literal B text' }; - const fieldState: OpenAIChatCompletionsAssistantState = { content: field }; - const contentState: OpenAIChatCompletionsAssistantState = { content }; + const fieldState: OpenAIChatCompletionsInputAssistantTurnState = { content: field }; + const contentState: OpenAIChatCompletionsInputAssistantTurnState = { content }; - const finalized = await finalizeAssistantTurnReferences<'openaiChatCompletions'>( + const finalized = await referenceAssistantTurn<'openaiChatCompletions'>( turn, fieldState, builder, - collectContentViews({ role: 'assistant', content: 'visible A text' }, contentState), + collectEmittedFieldPlacements({ role: 'assistant', content: 'visible A text' }, contentState), ); expect(finalized.targetTurn).toEqual(turn); @@ -324,59 +324,59 @@ describe('ReferenceBuilder', () => { it('lists only referenced contents when other valid views stay literal', async () => { const builder = createReferenceBuilder(); - const referenced = createFieldState('text'); - const literal = createFieldState('text'); - const part = appendField(builder, referenced, 'referenced'); - appendField(builder, literal, 'literal B text'); - const referencedContent = createContentState(builder, 'text'); - const literalContent = createContentState(builder, 'text'); - appendContent(builder, referencedContent, 'referenced', [part]); - appendContent(builder, literalContent, 'visible A text'); + const referenced = createReadFieldState('text'); + const literal = createReadFieldState('text'); + const part = appendReadField(builder, referenced, 'referenced'); + appendReadField(builder, literal, 'literal B text'); + const referencedContent = createEmittedFieldState(builder, 'text'); + const literalContent = createEmittedFieldState(builder, 'text'); + appendEmittedField(builder, referencedContent, 'referenced', [part]); + appendEmittedField(builder, literalContent, 'visible A text'); const views = new Map([ - ...collectContentViews('referenced', referencedContent), - ...collectContentViews('visible A text', literalContent), + ...collectEmittedFieldPlacements('referenced', referencedContent), + ...collectEmittedFieldPlacements('visible A text', literalContent), ]); - const finalizer = createReferenceFinalizer(builder, views); + const finalizer = createFieldReferencer(builder, views); - expect(await finalizer.finalizeField('referenced', referenced)).toBeInstanceOf(ContentReference); - expect(await finalizer.finalizeField('literal B text', literal)).toBe('literal B text'); + expect(await finalizer.referenceField('referenced', referenced)).toBeInstanceOf(ContentReference); + expect(await finalizer.referenceField('literal B text', literal)).toBe('literal B text'); expect(finalizer.referencedContentHashes).toEqual([await hashRoundTripContent('referenced')]); }); it('rejects a paired state whose container shape does not match the turn', async () => { const builder = createReferenceBuilder(); const turn: OpenAIChatCompletionsInputAssistantTurn = { role: 'assistant', tool_calls: [] }; - const state: OpenAIChatCompletionsAssistantState = {}; + const state: OpenAIChatCompletionsInputAssistantTurnState = {}; Reflect.set(state, 'tool_calls', {}); - await expect(finalizeAssistantTurnReferences<'openaiChatCompletions'>(turn, state, builder, new Map())).rejects.toThrow('ReferenceBuilder state object does not match its assistant turn'); + await expect(referenceAssistantTurn<'openaiChatCompletions'>(turn, state, builder, new Map())).rejects.toThrow('ReferenceBuilder state object does not match its assistant turn'); }); it('creates slices across two A fields in UTF-16 coordinates', async () => { const builder = createReferenceBuilder(); - const field = createFieldState('text'); + const field = createReadFieldState('text'); const text = '\ud800a\udc00?'; const firstText = text.slice(0, 2); const secondText = text.slice(2); - const firstPart = appendField(builder, field, firstText); - const secondPart = appendField(builder, field, secondText); + const firstPart = appendReadField(builder, field, firstText); + const secondPart = appendReadField(builder, field, secondText); const firstCarrier = `x${firstText}y`; const secondCarrier = `[${secondText}]`; - const firstResolved = createContentState(builder, 'text'); - const secondResolved = createContentState(builder, 'text'); - appendContent(builder, firstResolved, 'x'); - appendContent(builder, firstResolved, firstText, [firstPart]); - appendContent(builder, firstResolved, 'y'); - appendContent(builder, secondResolved, '['); - appendContent(builder, secondResolved, secondText, [secondPart]); - appendContent(builder, secondResolved, ']'); + const firstResolved = createEmittedFieldState(builder, 'text'); + const secondResolved = createEmittedFieldState(builder, 'text'); + appendEmittedField(builder, firstResolved, 'x'); + appendEmittedField(builder, firstResolved, firstText, [firstPart]); + appendEmittedField(builder, firstResolved, 'y'); + appendEmittedField(builder, secondResolved, '['); + appendEmittedField(builder, secondResolved, secondText, [secondPart]); + appendEmittedField(builder, secondResolved, ']'); const views = new Map([ - ...collectContentViews(firstCarrier, firstResolved), - ...collectContentViews(secondCarrier, secondResolved), + ...collectEmittedFieldPlacements(firstCarrier, firstResolved), + ...collectEmittedFieldPlacements(secondCarrier, secondResolved), ]); - const finalizer = createReferenceFinalizer(builder, views); - const reference = await finalizer.finalizeField(text, field); + const finalizer = createFieldReferencer(builder, views); + const reference = await finalizer.referenceField(text, field); if (!(reference instanceof ContentReference)) throw new Error('Expected a thin reference'); expect(reference.parts).toEqual([[0, 1, 3], [1, 1, 3]]); }); @@ -385,18 +385,18 @@ describe('ReferenceBuilder', () => { it('uses the final JSON text view to reference and restore a B object', async () => { const builder = createReferenceBuilder(); - const field = createFieldState('json'); + const field = createReadFieldState('json'); const json = '{"x":1}'; - const partId = appendField(builder, field, json); - const content = createContentState(builder, 'json'); - appendContent(builder, content, json, [partId]); + const partId = appendReadField(builder, field, json); + const content = createEmittedFieldState(builder, 'json'); + appendEmittedField(builder, content, json, [partId]); const original = parseJSONObject(json); - const finalizer = createReferenceFinalizer(builder, collectContentViews(original, content)); - const reference = await finalizer.finalizeField(original, field); + const finalizer = createFieldReferencer(builder, collectEmittedFieldPlacements(original, content)); + const reference = await finalizer.referenceField(original, field); expect(reference).toBeInstanceOf(ContentReference); if (!(reference instanceof ContentReference) || reference.kind !== 'json') throw new Error('Expected a JSON reference'); - const turn: AnthropicMessagesReferenceableAssistantTurn = { + const turn: AnthropicMessagesReferenceableInputAssistantTurn = { role: 'assistant', content: [{ type: 'tool_use', id: 'call', name: 'lookup', input: reference }], }; @@ -409,51 +409,51 @@ it('uses the final JSON text view to reference and restore a B object', async () it('round-trips raw JSON numbers and keeps changed key order literal', async () => { const builder = createReferenceBuilder(); const json = '{"n":9007199254740993}'; - const field = createFieldState('json'); - const partId = appendField(builder, field, json); - const content = createContentState(builder, 'json'); - appendContent(builder, content, json, [partId]); + const field = createReadFieldState('json'); + const partId = appendReadField(builder, field, json); + const content = createEmittedFieldState(builder, 'json'); + appendEmittedField(builder, content, json, [partId]); const original = parseJSONObject(json); - const finalizer = createReferenceFinalizer(builder, collectContentViews(original, content)); - const reference = await finalizer.finalizeField(original, field); + const finalizer = createFieldReferencer(builder, collectEmittedFieldPlacements(original, content)); + const reference = await finalizer.referenceField(original, field); if (!(reference instanceof ContentReference) || reference.kind !== 'json') throw new Error('Expected a JSON reference'); - const turn: AnthropicMessagesReferenceableAssistantTurn = { role: 'assistant', content: [{ type: 'tool_use', id: 'call', name: 'lookup', input: reference }] }; + const turn: AnthropicMessagesReferenceableInputAssistantTurn = { role: 'assistant', content: [{ type: 'tool_use', id: 'call', name: 'lookup', input: reference }] }; const hydrated = await restoreAssistantTurn('anthropicMessages', [original], turn, finalizer.referencedContentHashes); expect(hydrated.ok).toBe(true); if (!hydrated.ok) throw new Error('Expected JSON hydration'); expect(JSON.stringify(hydrated.turn.content[0])).toContain('9007199254740993'); - const reorderedField = createFieldState('json'); - const reorderedPart = appendField(builder, reorderedField, '{"a":1,"b":2}'); - const reorderedContent = createContentState(builder, 'text'); - appendContent(builder, reorderedContent, '{"a":1,"b":2}', [reorderedPart]); - const reorderedFinalizer = createReferenceFinalizer(builder, collectContentViews('{"a":1,"b":2}', reorderedContent)); + const reorderedField = createReadFieldState('json'); + const reorderedPart = appendReadField(builder, reorderedField, '{"a":1,"b":2}'); + const reorderedContent = createEmittedFieldState(builder, 'text'); + appendEmittedField(builder, reorderedContent, '{"a":1,"b":2}', [reorderedPart]); + const reorderedFinalizer = createFieldReferencer(builder, collectEmittedFieldPlacements('{"a":1,"b":2}', reorderedContent)); const reorderedOriginal: IRJSONObject = { b: 2, a: 1 }; - expect(await reorderedFinalizer.finalizeField(reorderedOriginal, reorderedField)).toBe(reorderedOriginal); + expect(await reorderedFinalizer.referenceField(reorderedOriginal, reorderedField)).toBe(reorderedOriginal); }); it('keeps JSON fields literal when their string pieces do not restore the original object', async () => { const builder = createReferenceBuilder(); - const field = createFieldState('json'); - const partId = appendField(builder, field, '{"x":1}'); - const content = createContentState(builder, 'text'); - appendContent(builder, content, '{"x":1}', [partId]); + const field = createReadFieldState('json'); + const partId = appendReadField(builder, field, '{"x":1}'); + const content = createEmittedFieldState(builder, 'text'); + appendEmittedField(builder, content, '{"x":1}', [partId]); const original: IRJSONObject = { x: 2 }; - const finalizer = createReferenceFinalizer(builder, collectContentViews('{"x":1}', content)); + const finalizer = createFieldReferencer(builder, collectEmittedFieldPlacements('{"x":1}', content)); - expect(await finalizer.finalizeField(original, field)).toBe(original); + expect(await finalizer.referenceField(original, field)).toBe(original); expect(finalizer.referencedContentHashes).toEqual([]); }); it('keeps the original JSON object when the collected B text is incomplete', async () => { const builder = createReferenceBuilder(); - const field = createFieldState('json'); + const field = createReadFieldState('json'); const incomplete = '{"x":1'; - const partId = appendField(builder, field, incomplete); - const content = createContentState(builder, 'text'); - appendContent(builder, content, incomplete, [partId]); + const partId = appendReadField(builder, field, incomplete); + const content = createEmittedFieldState(builder, 'text'); + appendEmittedField(builder, content, incomplete, [partId]); const original: IRJSONObject = { x: 1 }; - const finalizer = createReferenceFinalizer(builder, collectContentViews(incomplete, content)); + const finalizer = createFieldReferencer(builder, collectEmittedFieldPlacements(incomplete, content)); - expect(await finalizer.finalizeField(original, field)).toBe(original); + expect(await finalizer.referenceField(original, field)).toBe(original); }); diff --git a/packages/translate/__tests__/round-trip/reference/reference-cost-json_test.ts b/packages/translate/__tests__/round-trip/reference/reference-cost-json_test.ts index d6f73aa6de..86867400e3 100644 --- a/packages/translate/__tests__/round-trip/reference/reference-cost-json_test.ts +++ b/packages/translate/__tests__/round-trip/reference/reference-cost-json_test.ts @@ -2,10 +2,10 @@ import { expect, it } from 'vitest'; import type { IRJSONObject } from '../../../src/ir/types.ts'; import type { AnthropicMessagesInputAssistantTurn } from '../../../src/protocols/anthropic-messages/as-target/assistant-turn.ts'; -import { ContentReference } from '../../../src/round-trip/reference/assistant-turn.ts'; -import { appendContent, appendField, collectContentViews, createContentState, createFieldState, createReferenceBuilder } from '../../../src/round-trip/reference/reference-builder.ts'; -import { finalizeAssistantTurnReferences } from '../../../src/round-trip/reference/reference-selection.ts'; +import { appendEmittedField, appendReadField, collectEmittedFieldPlacements, createEmittedFieldState, createReadFieldState, createReferenceBuilder } from '../../../src/round-trip/reference/reference-builder.ts'; +import { referenceAssistantTurn } from '../../../src/round-trip/reference/reference-selection.ts'; import { restoreAssistantTurn } from '../../../src/round-trip/reference/restore.ts'; +import { ContentReference } from '../../../src/round-trip/reference/types.ts'; import { rawJSON } from '../../../src/shared/json/raw-json.ts'; it.each<{ input: IRJSONObject; referenced: boolean }>([ @@ -18,12 +18,12 @@ it.each<{ input: IRJSONObject; referenced: boolean }>([ { input: { amount: rawJSON.rawJSON(`1e${'9'.repeat(80)}`) }, referenced: true }, ])('selects JSON references with a codec-independent semantic budget: %j', async ({ input, referenced }) => { const builder = createReferenceBuilder(); - const field = createFieldState('json'); - const content = createContentState(builder, 'json'); + const field = createReadFieldState('json'); + const content = createEmittedFieldState(builder, 'json'); const text = JSON.stringify(input); - appendContent(builder, content, text, [appendField(builder, field, text)]); + appendEmittedField(builder, content, text, [appendReadField(builder, field, text)]); const turn: AnthropicMessagesInputAssistantTurn = { role: 'assistant', content: [{ type: 'tool_use', id: 'call', name: 'lookup', input }] }; - const result = await finalizeAssistantTurnReferences<'anthropicMessages'>(turn, { content: [{ input: field }] }, builder, collectContentViews(input, content)); + const result = await referenceAssistantTurn<'anthropicMessages'>(turn, { content: [{ input: field }] }, builder, collectEmittedFieldPlacements(input, content)); if (!referenced) { expect(result.targetTurn).toEqual(turn); expect(result).not.toHaveProperty('referencedContentHashes'); diff --git a/packages/translate/__tests__/round-trip/reference/reference-cost_test.ts b/packages/translate/__tests__/round-trip/reference/reference-cost_test.ts index 16ec8e82bd..e035a0dcca 100644 --- a/packages/translate/__tests__/round-trip/reference/reference-cost_test.ts +++ b/packages/translate/__tests__/round-trip/reference/reference-cost_test.ts @@ -1,27 +1,27 @@ import { describe, expect, it, vi } from 'vitest'; -import type { AnthropicMessagesAssistantState, AnthropicMessagesInputAssistantTurn } from '../../../src/protocols/anthropic-messages/as-target/assistant-turn.ts'; -import { ContentReference } from '../../../src/round-trip/reference/assistant-turn.ts'; -import { appendContent, appendField, collectContentViews, createContentState, createFieldState, createReferenceBuilder, type FieldStates } from '../../../src/round-trip/reference/reference-builder.ts'; -import { finalizeAssistantTurnReferences } from '../../../src/round-trip/reference/reference-selection.ts'; +import type { AnthropicMessagesInputAssistantTurnState, AnthropicMessagesInputAssistantTurn } from '../../../src/protocols/anthropic-messages/as-target/assistant-turn.ts'; +import { hashRoundTripContent } from '../../../src/round-trip/hash.ts'; +import { appendEmittedField, appendReadField, collectEmittedFieldPlacements, createEmittedFieldState, createReadFieldState, createReferenceBuilder, type ReadFieldStates } from '../../../src/round-trip/reference/reference-builder.ts'; +import { referenceAssistantTurn } from '../../../src/round-trip/reference/reference-selection.ts'; import { restoreAssistantTurn } from '../../../src/round-trip/reference/restore.ts'; +import { ContentReference } from '../../../src/round-trip/reference/types.ts'; import type { TextPlacement } from '../../../src/round-trip/text-groups.ts'; -import { hashRoundTripContent } from '../../../src/round-trip/turn-digest.ts'; const finalizeTexts = async (texts: readonly string[]) => { const builder = createReferenceBuilder(); - const contentState: NonNullable['content']> = []; + const contentState: NonNullable['content']> = []; const views = new Map(); for (const text of texts) { - const field = createFieldState('text'); - const content = createContentState(builder, 'text'); - const part = appendField(builder, field, text); - appendContent(builder, content, text, [part]); + const field = createReadFieldState('text'); + const content = createEmittedFieldState(builder, 'text'); + const part = appendReadField(builder, field, text); + appendEmittedField(builder, content, text, [part]); contentState.push({ text: field }); - for (const [id, view] of collectContentViews(text, content)) views.set(id, view); + for (const [id, view] of collectEmittedFieldPlacements(text, content)) views.set(id, view); } const turn: AnthropicMessagesInputAssistantTurn = { role: 'assistant', content: texts.map(text => ({ type: 'text', text, citations: null })) }; - return { turn, ...await finalizeAssistantTurnReferences<'anthropicMessages'>(turn, { content: contentState }, builder, views) }; + return { turn, ...await referenceAssistantTurn<'anthropicMessages'>(turn, { content: contentState }, builder, views) }; }; describe('thin reference selection', () => { @@ -33,15 +33,15 @@ describe('thin reference selection', () => { it('skips hashing a large A field when its B reference cannot beat the literal', async () => { const builder = createReferenceBuilder(); - const field = createFieldState('text'); - const content = createContentState(builder, 'text'); + const field = createReadFieldState('text'); + const content = createEmittedFieldState(builder, 'text'); const prefix = 'Large A content. '.repeat(100); - appendContent(builder, content, prefix); - appendContent(builder, content, 'hello', [appendField(builder, field, 'hello')]); + appendEmittedField(builder, content, prefix); + appendEmittedField(builder, content, 'hello', [appendReadField(builder, field, 'hello')]); const turn: AnthropicMessagesInputAssistantTurn = { role: 'assistant', content: [{ type: 'text', text: 'hello', citations: null }] }; const digest = vi.spyOn(crypto.subtle, 'digest'); try { - const result = await finalizeAssistantTurnReferences<'anthropicMessages'>(turn, { content: [{ text: field }] }, builder, collectContentViews(`${prefix}hello`, content)); + const result = await referenceAssistantTurn<'anthropicMessages'>(turn, { content: [{ text: field }] }, builder, collectEmittedFieldPlacements(`${prefix}hello`, content)); expect(result.targetTurn).toEqual(turn); expect(result).not.toHaveProperty('referencedContentHashes'); expect(digest).not.toHaveBeenCalled(); @@ -85,20 +85,20 @@ describe('thin reference selection', () => { it('selects the hashes of one atomic mixed reference together after merging adjacent ranges', async () => { const builder = createReferenceBuilder(); - const field = createFieldState('text'); - const first = createContentState(builder, 'text'); - const second = createContentState(builder, 'text'); + const field = createReadFieldState('text'); + const first = createEmittedFieldState(builder, 'text'); + const second = createEmittedFieldState(builder, 'text'); const text = 'One long native string. '.repeat(5); const middle = Math.floor(text.length / 2); - const left = appendField(builder, field, text.slice(0, middle)); - const right = appendField(builder, field, text.slice(middle)); - const suffix = appendField(builder, field, '!'); - appendContent(builder, first, text.slice(0, middle), [left]); - appendContent(builder, first, text.slice(middle), [right]); - appendContent(builder, second, '!', [suffix]); + const left = appendReadField(builder, field, text.slice(0, middle)); + const right = appendReadField(builder, field, text.slice(middle)); + const suffix = appendReadField(builder, field, '!'); + appendEmittedField(builder, first, text.slice(0, middle), [left]); + appendEmittedField(builder, first, text.slice(middle), [right]); + appendEmittedField(builder, second, '!', [suffix]); const turn: AnthropicMessagesInputAssistantTurn = { role: 'assistant', content: [{ type: 'text', text: `${text}!`, citations: null }] }; - const views = new Map([...collectContentViews(text, first), ...collectContentViews('!', second)]); - const result = await finalizeAssistantTurnReferences<'anthropicMessages'>(turn, { content: [{ text: field }] }, builder, views); + const views = new Map([...collectEmittedFieldPlacements(text, first), ...collectEmittedFieldPlacements('!', second)]); + const result = await referenceAssistantTurn<'anthropicMessages'>(turn, { content: [{ text: field }] }, builder, views); expect(result.referencedContentHashes).toEqual([await hashRoundTripContent(text), await hashRoundTripContent('!')]); expect(result.targetTurn.content).toEqual([{ type: 'text', text: new ContentReference('text', [0, 1]), citations: null }]); expect(await restoreAssistantTurn('anthropicMessages', [text, '!'], result.targetTurn, result.referencedContentHashes)).toEqual({ ok: true, turn }); @@ -109,18 +109,18 @@ describe('thin reference selection', () => { const shared = 'a'.repeat(length); const suffix = 'b'.repeat(30); const mixed = shared.slice(0, 30) + suffix; - const wholeField = createFieldState('text'); - const mixedField = createFieldState('text'); - const wholeContent = createContentState(builder, 'text'); - const sharedContent = createContentState(builder, 'text'); - const suffixContent = createContentState(builder, 'text'); - appendContent(builder, wholeContent, shared, [appendField(builder, wholeField, shared)]); - appendContent(builder, sharedContent, shared.slice(0, 30), [appendField(builder, mixedField, shared.slice(0, 30))]); - appendContent(builder, sharedContent, shared.slice(30)); - appendContent(builder, suffixContent, suffix, [appendField(builder, mixedField, suffix)]); + const wholeField = createReadFieldState('text'); + const mixedField = createReadFieldState('text'); + const wholeContent = createEmittedFieldState(builder, 'text'); + const sharedContent = createEmittedFieldState(builder, 'text'); + const suffixContent = createEmittedFieldState(builder, 'text'); + appendEmittedField(builder, wholeContent, shared, [appendReadField(builder, wholeField, shared)]); + appendEmittedField(builder, sharedContent, shared.slice(0, 30), [appendReadField(builder, mixedField, shared.slice(0, 30))]); + appendEmittedField(builder, sharedContent, shared.slice(30)); + appendEmittedField(builder, suffixContent, suffix, [appendReadField(builder, mixedField, suffix)]); const turn: AnthropicMessagesInputAssistantTurn = { role: 'assistant', content: [{ type: 'text', text: shared, citations: null }, { type: 'text', text: mixed, citations: null }] }; - const views = new Map([...collectContentViews(shared, wholeContent), ...collectContentViews(shared, sharedContent), ...collectContentViews(suffix, suffixContent)]); - const result = await finalizeAssistantTurnReferences<'anthropicMessages'>(turn, { content: [{ text: wholeField }, { text: mixedField }] }, builder, views); + const views = new Map([...collectEmittedFieldPlacements(shared, wholeContent), ...collectEmittedFieldPlacements(shared, sharedContent), ...collectEmittedFieldPlacements(suffix, suffixContent)]); + const result = await referenceAssistantTurn<'anthropicMessages'>(turn, { content: [{ text: wholeField }, { text: mixedField }] }, builder, views); expect(result.targetTurn.content).toEqual([ { type: 'text', text: referenced ? new ContentReference('text', [0]) : shared, citations: null }, { type: 'text', text: mixed, citations: null }, diff --git a/packages/translate/__tests__/round-trip/reference/restore_test.ts b/packages/translate/__tests__/round-trip/reference/restore_test.ts index d58f8b1695..7cd25968cf 100644 --- a/packages/translate/__tests__/round-trip/reference/restore_test.ts +++ b/packages/translate/__tests__/round-trip/reference/restore_test.ts @@ -1,11 +1,11 @@ import { describe, expect, it, vi } from 'vitest'; -import type { AnthropicMessagesReferenceableAssistantTurn } from '../../../src/protocols/anthropic-messages/as-target/assistant-turn.ts'; -import type { GeminiGenerateContentReferenceableAssistantTurn } from '../../../src/protocols/gemini-generate-content/as-target/assistant-turn.ts'; -import type { OpenAIChatCompletionsReferenceableAssistantTurn } from '../../../src/protocols/openai-chat-completions/as-target/assistant-turn.ts'; -import { ContentReference } from '../../../src/round-trip/reference/assistant-turn.ts'; +import type { AnthropicMessagesReferenceableInputAssistantTurn } from '../../../src/protocols/anthropic-messages/as-target/assistant-turn.ts'; +import type { GeminiGenerateContentReferenceableInputAssistantTurn } from '../../../src/protocols/gemini-generate-content/as-target/assistant-turn.ts'; +import type { OpenAIChatCompletionsReferenceableInputAssistantTurn } from '../../../src/protocols/openai-chat-completions/as-target/assistant-turn.ts'; +import { hashRoundTripContent } from '../../../src/round-trip/hash.ts'; import { restoreAssistantTurn } from '../../../src/round-trip/reference/restore.ts'; -import { hashRoundTripContent } from '../../../src/round-trip/turn-digest.ts'; +import { ContentReference } from '../../../src/round-trip/reference/types.ts'; import { rawJSON } from '../../../src/shared/json/raw-json.ts'; describe('restoreAssistantTurn', () => { @@ -14,7 +14,7 @@ describe('restoreAssistantTurn', () => { kind: 'text', parts: [0], nested: [0, [0, 1, 2]], object: { kind: 'json', parts: [[0, 0, 1]] }, }; - const turn: AnthropicMessagesReferenceableAssistantTurn = { + const turn: AnthropicMessagesReferenceableInputAssistantTurn = { role: 'assistant', content: [{ type: 'tool_use', id: 'call', name: 'echo', input }], }; const before = structuredClone(turn); @@ -38,7 +38,7 @@ describe('restoreAssistantTurn', () => { const part = { text: 'unchanged' }; const parts = [part]; const content = { role: 'model' as const, parts }; - const turn: GeminiGenerateContentReferenceableAssistantTurn = [content]; + const turn: GeminiGenerateContentReferenceableInputAssistantTurn = [content]; const hydrated = await restoreAssistantTurn('geminiGenerateContent', [], turn); if (!hydrated.ok) throw new Error('Expected a literal assistant turn'); expect(hydrated.turn).toBe(turn); @@ -52,7 +52,7 @@ describe('restoreAssistantTurn', () => { const text = { type: 'text' as const, text: new ContentReference('text', [[0, 1, 7]]), citations: null }; const tool = { type: 'tool_use' as const, id: 'call', name: 'lookup', input: new ContentReference('json', [1]) }; const blocks = [text, tool]; - const turn: AnthropicMessagesReferenceableAssistantTurn = { role: 'assistant', content: blocks }; + const turn: AnthropicMessagesReferenceableInputAssistantTurn = { role: 'assistant', content: blocks }; const hydrated = await restoreAssistantTurn('anthropicMessages', ['[answer]', candidate], turn, [ await hashRoundTripContent('[answer]'), await hashRoundTripContent(JSON.stringify(candidate)), ]); @@ -72,7 +72,7 @@ describe('restoreAssistantTurn', () => { const metadata = { bytes, number, nested: [{ kind: 'text', parts: [0] }] }; const nested = metadata.nested; const ordinary = nested[0]; - const turn: OpenAIChatCompletionsReferenceableAssistantTurn = { + const turn: OpenAIChatCompletionsReferenceableInputAssistantTurn = { role: 'assistant', content: new ContentReference('text', [0]), provider_specific_fields: metadata, }; const hydrated = await restoreAssistantTurn('openaiChatCompletions', ['answer'], turn, [await hashRoundTripContent('answer')]); @@ -89,7 +89,7 @@ describe('restoreAssistantTurn', () => { }); it('does not hash candidates when a full turn has no referenced contents', async () => { - const turn: OpenAIChatCompletionsReferenceableAssistantTurn = { role: 'assistant', content: 'unchanged' }; + const turn: OpenAIChatCompletionsReferenceableInputAssistantTurn = { role: 'assistant', content: 'unchanged' }; const digest = vi.spyOn(crypto.subtle, 'digest'); try { expect(await restoreAssistantTurn('openaiChatCompletions', ['unused candidate'], turn)).toEqual({ ok: true, turn }); @@ -100,7 +100,7 @@ describe('restoreAssistantTurn', () => { }); it.each([{ payload: [] }, { payload: [0] }])('rejects an authenticated text reference without referenced contents: $payload', async ({ payload }) => { - const turn: OpenAIChatCompletionsReferenceableAssistantTurn = { + const turn: OpenAIChatCompletionsReferenceableInputAssistantTurn = { role: 'assistant', content: new ContentReference('text', payload), }; @@ -108,7 +108,7 @@ describe('restoreAssistantTurn', () => { }); it('fails the whole turn when a referenced candidate is missing', async () => { - const turn: OpenAIChatCompletionsReferenceableAssistantTurn = { + const turn: OpenAIChatCompletionsReferenceableInputAssistantTurn = { role: 'assistant', content: new ContentReference('text', [0]), }; @@ -116,7 +116,7 @@ describe('restoreAssistantTurn', () => { }); it('propagates malformed JSON reference errors', async () => { - const turn: AnthropicMessagesReferenceableAssistantTurn = { + const turn: AnthropicMessagesReferenceableInputAssistantTurn = { role: 'assistant', content: [{ type: 'tool_use', id: 'call', name: 'lookup', input: new ContentReference('json', [0]) }], }; @@ -124,7 +124,7 @@ describe('restoreAssistantTurn', () => { }); it('propagates authenticated hash-index invariant failures', async () => { - const turn: OpenAIChatCompletionsReferenceableAssistantTurn = { + const turn: OpenAIChatCompletionsReferenceableInputAssistantTurn = { role: 'assistant', content: new ContentReference('text', [1]), }; diff --git a/packages/translate/__tests__/round-trip/reference/assistant-turn_test.ts b/packages/translate/__tests__/round-trip/reference/types_test.ts similarity index 61% rename from packages/translate/__tests__/round-trip/reference/assistant-turn_test.ts rename to packages/translate/__tests__/round-trip/reference/types_test.ts index 813e58146c..45b6bbbdb4 100644 --- a/packages/translate/__tests__/round-trip/reference/assistant-turn_test.ts +++ b/packages/translate/__tests__/round-trip/reference/types_test.ts @@ -1,7 +1,7 @@ import { expectTypeOf, test } from 'vitest'; -import type { JSONRule, TextRule, ReferenceState, ReferenceableValue } from '../../../src/round-trip/reference/assistant-turn.ts'; -import { createFieldState, type FieldState, type FieldStates } from '../../../src/round-trip/reference/reference-builder.ts'; +import { createReadFieldState, type ReadFieldState, type ReadFieldStates } from '../../../src/round-trip/reference/reference-builder.ts'; +import type { JSONRule, TextRule, ReferenceState, ReferenceableValue } from '../../../src/round-trip/reference/types.ts'; test('derives selected text, JSON, and sparse state leaves from one nested rule', () => { type Source = { @@ -23,13 +23,13 @@ test('derives selected text, JSON, and sparse state leaves from one nested rule' input: { ordered: number } | Reference; encrypted_content: string; }>(); - expectTypeOf>().toEqualTypeOf<{ - text?: FieldState<'text'>; - items?: Array<{ text?: FieldState<'text'> } | undefined>; - input?: FieldState<'json'>; + expectTypeOf>().toEqualTypeOf<{ + text?: ReadFieldState<'text'>; + items?: Array<{ text?: ReadFieldState<'text'> } | undefined>; + input?: ReadFieldState<'json'>; }>(); - const jsonState = createFieldState('json'); + const jsonState = createReadFieldState('json'); // @ts-expect-error A JSON field state cannot occupy a text rule leaf. - const invalid: ReferenceState = { text: jsonState }; - expectTypeOf(invalid).toMatchTypeOf>(); + const invalid: ReferenceState = { text: jsonState }; + expectTypeOf(invalid).toMatchTypeOf>(); }); diff --git a/packages/translate/__tests__/round-trip/replay-text_test.ts b/packages/translate/__tests__/round-trip/replay-text_test.ts index c0802c64a4..db05776f03 100644 --- a/packages/translate/__tests__/round-trip/replay-text_test.ts +++ b/packages/translate/__tests__/round-trip/replay-text_test.ts @@ -1,48 +1,48 @@ import { describe, expect, it } from 'vitest'; -import { computeAnthropicMessagesTurnDigest, verifyAnthropicMessagesTurnDigest, type AnthropicMessagesAssistantTurn } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; -import { computeGeminiGenerateContentTurnDigest, verifyGeminiGenerateContentTurnDigest, type GeminiGenerateContentAssistantTurn } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; -import { computeOpenAIChatCompletionsTurnDigest, verifyOpenAIChatCompletionsTurnDigest, type OpenAIChatCompletionsAssistantTurn } from '../../src/protocols/openai-chat-completions/as-source/round-trip.ts'; -import { computeOpenAIResponsesTurnDigest, verifyOpenAIResponsesTurnDigest, type OpenAIResponsesAssistantTurn } from '../../src/protocols/openai-responses/as-source/round-trip.ts'; +import { computeAnthropicMessagesTurnDigest, verifyAnthropicMessagesTurnDigest, type AnthropicMessagesSourceAssistantTurn } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; +import { computeGeminiGenerateContentTurnDigest, verifyGeminiGenerateContentTurnDigest, type GeminiGenerateContentSourceAssistantTurn } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; +import { computeOpenAIChatCompletionsTurnDigest, verifyOpenAIChatCompletionsTurnDigest, type OpenAIChatCompletionsSourceAssistantTurn } from '../../src/protocols/openai-chat-completions/as-source/round-trip.ts'; +import { computeOpenAIResponsesTurnDigest, verifyOpenAIResponsesTurnDigest, type OpenAIResponsesSourceAssistantTurn } from '../../src/protocols/openai-responses/as-source/round-trip.ts'; describe('semantic replay text channels', () => { it('merges Chat text across ignored metadata while keeping refusal separate', async () => { - const split: OpenAIChatCompletionsAssistantTurn = { role: 'assistant', content: [{ type: 'text', text: 'hel' }, { type: 'text', text: 'lo' }] }; - const merged: OpenAIChatCompletionsAssistantTurn = { role: 'assistant', content: [{ type: 'text', text: 'hello' }] }; + const split: OpenAIChatCompletionsSourceAssistantTurn = { role: 'assistant', content: [{ type: 'text', text: 'hel' }, { type: 'text', text: 'lo' }] }; + const merged: OpenAIChatCompletionsSourceAssistantTurn = { role: 'assistant', content: [{ type: 'text', text: 'hello' }] }; const before = structuredClone(split); expect(await verifyOpenAIChatCompletionsTurnDigest(merged, await computeOpenAIChatCompletionsTurnDigest(split), 1)).toBe(true); expect(await verifyOpenAIChatCompletionsTurnDigest(split, await computeOpenAIChatCompletionsTurnDigest(merged), 1)).toBe(true); expect(split).toEqual(before); - const interrupted: OpenAIChatCompletionsAssistantTurn = { role: 'assistant', content: [{ type: 'text', text: 'hel' }, { type: 'refusal', refusal: 'no' }, { type: 'text', text: 'lo' }] }; + const interrupted: OpenAIChatCompletionsSourceAssistantTurn = { role: 'assistant', content: [{ type: 'text', text: 'hel' }, { type: 'refusal', refusal: 'no' }, { type: 'text', text: 'lo' }] }; expect(await verifyOpenAIChatCompletionsTurnDigest({ role: 'assistant', content: [{ type: 'text', text: 'hello' }, { type: 'refusal', refusal: 'no' }] }, await computeOpenAIChatCompletionsTurnDigest(interrupted), 1)).toBe(true); expect(await verifyOpenAIChatCompletionsTurnDigest({ role: 'assistant', content: [{ type: 'text', text: 'hellono' }] }, await computeOpenAIChatCompletionsTurnDigest(interrupted), 1)).toBe(false); }); it('merges Messages text with empty citations within one message', async () => { - const split: AnthropicMessagesAssistantTurn = [{ role: 'assistant', content: [{ type: 'text', text: 'hel', citations: null }, { type: 'text', text: 'lo', citations: [] }] }]; - const merged: AnthropicMessagesAssistantTurn = [{ role: 'assistant', content: [{ type: 'text', text: 'hello' }] }]; + const split: AnthropicMessagesSourceAssistantTurn = [{ role: 'assistant', content: [{ type: 'text', text: 'hel', citations: null }, { type: 'text', text: 'lo', citations: [] }] }]; + const merged: AnthropicMessagesSourceAssistantTurn = [{ role: 'assistant', content: [{ type: 'text', text: 'hello' }] }]; expect(await verifyAnthropicMessagesTurnDigest(merged, await computeAnthropicMessagesTurnDigest(split), 1)).toBe(true); expect(await verifyAnthropicMessagesTurnDigest(split, await computeAnthropicMessagesTurnDigest(merged), 1)).toBe(true); - const separate: AnthropicMessagesAssistantTurn = [{ role: 'assistant', content: 'hel' }, { role: 'assistant', content: 'lo' }]; + const separate: AnthropicMessagesSourceAssistantTurn = [{ role: 'assistant', content: 'hel' }, { role: 'assistant', content: 'lo' }]; expect(await verifyAnthropicMessagesTurnDigest(merged, await computeAnthropicMessagesTurnDigest(separate), 1)).toBe(false); }); it('merges Messages text across citations, cache metadata and tool blocks', async () => { const citation = { type: 'char_location' as const, cited_text: 'source', document_index: 0, document_title: null, start_char_index: 0, end_char_index: 6 }; - const cited: AnthropicMessagesAssistantTurn = [{ role: 'assistant', content: [{ type: 'text', text: 'hel', citations: [citation] }, { type: 'text', text: 'lo' }] }]; - const merged: AnthropicMessagesAssistantTurn = [{ role: 'assistant', content: [{ type: 'text', text: 'hello', citations: [citation] }] }]; + const cited: AnthropicMessagesSourceAssistantTurn = [{ role: 'assistant', content: [{ type: 'text', text: 'hel', citations: [citation] }, { type: 'text', text: 'lo' }] }]; + const merged: AnthropicMessagesSourceAssistantTurn = [{ role: 'assistant', content: [{ type: 'text', text: 'hello', citations: [citation] }] }]; expect(await verifyAnthropicMessagesTurnDigest(merged, await computeAnthropicMessagesTurnDigest(cited), 1)).toBe(true); - const cached: AnthropicMessagesAssistantTurn = [{ role: 'assistant', content: [{ type: 'text', text: 'hel', cache_control: { type: 'ephemeral' } }, { type: 'text', text: 'lo' }] }]; + const cached: AnthropicMessagesSourceAssistantTurn = [{ role: 'assistant', content: [{ type: 'text', text: 'hel', cache_control: { type: 'ephemeral' } }, { type: 'text', text: 'lo' }] }]; expect(await verifyAnthropicMessagesTurnDigest([{ role: 'assistant', content: 'hello' }], await computeAnthropicMessagesTurnDigest(cached), 1)).toBe(true); const tool = { type: 'tool_use' as const, id: 'call', name: 'lookup', input: {} }; - const interrupted: AnthropicMessagesAssistantTurn = [{ role: 'assistant', content: [{ type: 'text', text: 'hel' }, tool, { type: 'text', text: 'lo' }] }]; + const interrupted: AnthropicMessagesSourceAssistantTurn = [{ role: 'assistant', content: [{ type: 'text', text: 'hel' }, tool, { type: 'text', text: 'lo' }] }]; expect(await verifyAnthropicMessagesTurnDigest([{ role: 'assistant', content: [{ type: 'text', text: 'hello' }, tool] }], await computeAnthropicMessagesTurnDigest(interrupted), 1)).toBe(true); }); it('keeps Messages nontext channels inside their owning message', async () => { const thinking = { type: 'thinking' as const, thinking: 'thought', signature: 'signature' }; const tool = { type: 'tool_use' as const, id: 'call', name: 'lookup', input: {} }; - const original: AnthropicMessagesAssistantTurn = [ + const original: AnthropicMessagesSourceAssistantTurn = [ { role: 'assistant', content: [{ type: 'text', text: 'A' }, thinking, tool] }, { role: 'assistant', content: [{ type: 'text', text: 'B' }] }, ]; @@ -54,14 +54,14 @@ describe('semantic replay text channels', () => { }); it('merges Responses text by channel while retaining item boundaries', async () => { - const split: OpenAIResponsesAssistantTurn = [{ type: 'message', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: 'hel', annotations: [], logprobs: [] }, { type: 'output_text', text: 'lo', annotations: [] }] }]; - const merged: OpenAIResponsesAssistantTurn = [{ type: 'message', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: 'hello' }] }]; + const split: OpenAIResponsesSourceAssistantTurn = [{ type: 'message', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: 'hel', annotations: [], logprobs: [] }, { type: 'output_text', text: 'lo', annotations: [] }] }]; + const merged: OpenAIResponsesSourceAssistantTurn = [{ type: 'message', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: 'hello' }] }]; expect(await verifyOpenAIResponsesTurnDigest(merged, await computeOpenAIResponsesTurnDigest(split), 1)).toBe(true); expect(await verifyOpenAIResponsesTurnDigest(split, await computeOpenAIResponsesTurnDigest(merged), 1)).toBe(true); - const interrupted: OpenAIResponsesAssistantTurn = [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'hel' }, { type: 'refusal', refusal: 'no' }, { type: 'output_text', text: 'lo' }] }]; + const interrupted: OpenAIResponsesSourceAssistantTurn = [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'hel' }, { type: 'refusal', refusal: 'no' }, { type: 'output_text', text: 'lo' }] }]; expect(await verifyOpenAIResponsesTurnDigest([{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'hello' }, { type: 'refusal', refusal: 'no' }] }], await computeOpenAIResponsesTurnDigest(interrupted), 1)).toBe(true); expect(await verifyOpenAIResponsesTurnDigest([{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'hellono' }] }], await computeOpenAIResponsesTurnDigest(interrupted), 1)).toBe(false); - const separate: OpenAIResponsesAssistantTurn = [{ type: 'message', role: 'assistant', status: 'completed', content: 'hel' }, { type: 'message', role: 'assistant', status: 'completed', content: 'lo' }]; + const separate: OpenAIResponsesSourceAssistantTurn = [{ type: 'message', role: 'assistant', status: 'completed', content: 'hel' }, { type: 'message', role: 'assistant', status: 'completed', content: 'lo' }]; expect(await verifyOpenAIResponsesTurnDigest(merged, await computeOpenAIResponsesTurnDigest(separate), 1)).toBe(false); }); @@ -69,14 +69,14 @@ describe('semantic replay text channels', () => { const annotation = { type: 'url_citation' as const, start_index: 0, end_index: 3, title: 'source', url: 'https://example.test' }; const logprob = { token: 'hel', logprob: -0.1, bytes: [104, 101, 108], top_logprobs: [] }; for (const metadata of [{ annotations: [annotation] }, { logprobs: [logprob] }]) { - const split: OpenAIResponsesAssistantTurn = [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'hel', ...metadata }, { type: 'output_text', text: 'lo' }] }]; + const split: OpenAIResponsesSourceAssistantTurn = [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'hel', ...metadata }, { type: 'output_text', text: 'lo' }] }]; expect(await verifyOpenAIResponsesTurnDigest([{ type: 'message', role: 'assistant', content: 'hello' }], await computeOpenAIResponsesTurnDigest(split), 1)).toBe(true); } }); it('merges reasoning parts only within the same Responses item and channel', async () => { - const split: OpenAIResponsesAssistantTurn = [{ type: 'reasoning', summary: [{ type: 'summary_text', text: 'hel' }, { type: 'summary_text', text: 'lo' }], content: [{ type: 'reasoning_text', text: 'think' }, { type: 'reasoning_text', text: 'ing' }] }]; - const merged: OpenAIResponsesAssistantTurn = [{ type: 'reasoning', summary: [{ type: 'summary_text', text: 'hello' }], content: [{ type: 'reasoning_text', text: 'thinking' }] }]; + const split: OpenAIResponsesSourceAssistantTurn = [{ type: 'reasoning', summary: [{ type: 'summary_text', text: 'hel' }, { type: 'summary_text', text: 'lo' }], content: [{ type: 'reasoning_text', text: 'think' }, { type: 'reasoning_text', text: 'ing' }] }]; + const merged: OpenAIResponsesSourceAssistantTurn = [{ type: 'reasoning', summary: [{ type: 'summary_text', text: 'hello' }], content: [{ type: 'reasoning_text', text: 'thinking' }] }]; expect(await verifyOpenAIResponsesTurnDigest(merged, await computeOpenAIResponsesTurnDigest(split), 1)).toBe(true); expect(await verifyOpenAIResponsesTurnDigest(split, await computeOpenAIResponsesTurnDigest(merged), 1)).toBe(true); expect(await verifyOpenAIResponsesTurnDigest([{ type: 'reasoning', summary: [{ type: 'summary_text', text: 'hellothinking' }] }], await computeOpenAIResponsesTurnDigest(split), 1)).toBe(false); @@ -84,8 +84,8 @@ describe('semantic replay text channels', () => { }); it('merges visible Gemini text across ignored metadata and nontext parts within one Content', async () => { - const merged: GeminiGenerateContentAssistantTurn = [{ role: 'model', parts: [{ text: 'hello' }] }]; - const split: GeminiGenerateContentAssistantTurn = [{ role: 'model', parts: [{ text: 'hel' }, { text: 'lo' }] }]; + const merged: GeminiGenerateContentSourceAssistantTurn = [{ role: 'model', parts: [{ text: 'hello' }] }]; + const split: GeminiGenerateContentSourceAssistantTurn = [{ role: 'model', parts: [{ text: 'hel' }, { text: 'lo' }] }]; expect(await verifyGeminiGenerateContentTurnDigest(merged, await computeGeminiGenerateContentTurnDigest(split), 1)).toBe(true); expect(await verifyGeminiGenerateContentTurnDigest(split, await computeGeminiGenerateContentTurnDigest(merged), 1)).toBe(true); for (const metadata of [{ thought: false }, { thoughtSignature: 'native-signature' }]) { @@ -99,7 +99,7 @@ describe('semantic replay text channels', () => { it('keeps Gemini nontext channels inside their owning Content', async () => { const call = { functionCall: { name: 'lookup', args: { q: 'x' } } }; const image = { inlineData: { mimeType: 'image/png', data: 'AQID' } }; - const original: GeminiGenerateContentAssistantTurn = [ + const original: GeminiGenerateContentSourceAssistantTurn = [ { role: 'model', parts: [{ text: 'A' }, call, image] }, { role: 'model', parts: [{ text: 'B' }] }, ]; diff --git a/packages/translate/__tests__/round-trip/resolve_test.ts b/packages/translate/__tests__/round-trip/resolve_test.ts index cbc474140a..b09d957ea9 100644 --- a/packages/translate/__tests__/round-trip/resolve_test.ts +++ b/packages/translate/__tests__/round-trip/resolve_test.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import { createTestSidecarCodec } from './sidecar-codec.ts'; -import { computeOpenAIChatCompletionsTurnDigest, createOpenAIChatCompletionsSidecarCarrier, findOpenAIChatCompletionsSidecarCarriers, verifyOpenAIChatCompletionsTurnDigest } from '../../src/protocols/openai-chat-completions/as-source/round-trip.ts'; +import { computeOpenAIChatCompletionsTurnDigest, createOpenAIChatCompletionsSidecarDetails, findOpenAIChatCompletionsSidecarOccurrences, verifyOpenAIChatCompletionsTurnDigest } from '../../src/protocols/openai-chat-completions/as-source/round-trip.ts'; import { roundTripAssistantTurn } from '../../src/round-trip/resolve.ts'; import { encodeRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; import type { OpenAIChatCompletionsAssistantMessageEx } from '@floway-dev/protocols/openai-chat-completions'; @@ -13,7 +13,7 @@ describe('assistant-turn replay boundary', () => { 'anthropicMessages', { role: 'assistant', content: 'answer' }, createTestSidecarCodec(), - findOpenAIChatCompletionsSidecarCarriers, + findOpenAIChatCompletionsSidecarOccurrences, verifyOpenAIChatCompletionsTurnDigest, () => [], )).resolves.toBeUndefined(); @@ -36,7 +36,7 @@ describe('assistant-turn replay boundary', () => { targetTurn, sourceTurnDigest: await computeOpenAIChatCompletionsTurnDigest(sourceTurn), }); - sourceTurn.reasoning_details = createOpenAIChatCompletionsSidecarCarrier(data); + sourceTurn.reasoning_details = createOpenAIChatCompletionsSidecarDetails(data); const verify = vi.fn(verifyOpenAIChatCompletionsTurnDigest); await expect(roundTripAssistantTurn( @@ -44,7 +44,7 @@ describe('assistant-turn replay boundary', () => { 'anthropicMessages', sourceTurn, codec, - findOpenAIChatCompletionsSidecarCarriers, + findOpenAIChatCompletionsSidecarOccurrences, verify, () => [], )).resolves.toEqual({ turn: targetTurn }); @@ -54,7 +54,7 @@ describe('assistant-turn replay boundary', () => { it('restores an owned sidecar while preserving an earlier foreign carrier', async () => { const codec = createTestSidecarCodec(); const foreign = 'native opaque state'; - const earlier = createOpenAIChatCompletionsSidecarCarrier(foreign); + const earlier = createOpenAIChatCompletionsSidecarDetails(foreign); const source: OpenAIChatCompletionsAssistantMessageEx = { role: 'assistant', content: 'answer', reasoning_details: earlier }; const targetTurn = { role: 'assistant' as const, content: 'native target answer' }; const data = await encodeRoundTripSidecar(codec, { @@ -63,9 +63,9 @@ describe('assistant-turn replay boundary', () => { target: 'openaiChatCompletions', targetTurn, sourceTurnDigest: await computeOpenAIChatCompletionsTurnDigest(source), }); - const turn = { ...source, reasoning_details: [...earlier, ...createOpenAIChatCompletionsSidecarCarrier(data)] }; + const turn = { ...source, reasoning_details: [...earlier, ...createOpenAIChatCompletionsSidecarDetails(data)] }; const verify = vi.fn(verifyOpenAIChatCompletionsTurnDigest); - await expect(roundTripAssistantTurn('openaiChatCompletions', 'openaiChatCompletions', turn, codec, findOpenAIChatCompletionsSidecarCarriers, verify, () => [])).resolves.toEqual({ turn: targetTurn }); + await expect(roundTripAssistantTurn('openaiChatCompletions', 'openaiChatCompletions', turn, codec, findOpenAIChatCompletionsSidecarOccurrences, verify, () => [])).resolves.toEqual({ turn: targetTurn }); expect(verify).toHaveBeenCalledExactlyOnceWith(source, expect.any(Object), 1); expect(turn.reasoning_details[0].data).toBe(foreign); }); @@ -73,16 +73,16 @@ describe('assistant-turn replay boundary', () => { it('restores an owned sidecar before a foreign candidate without stripping foreign state', async () => { const codec = createTestSidecarCodec(); const foreign = 'unrecognized sidecar'; - const source: OpenAIChatCompletionsAssistantMessageEx = { role: 'assistant', content: 'answer', reasoning_details: createOpenAIChatCompletionsSidecarCarrier(foreign) }; + const source: OpenAIChatCompletionsAssistantMessageEx = { role: 'assistant', content: 'answer', reasoning_details: createOpenAIChatCompletionsSidecarDetails(foreign) }; const data = await encodeRoundTripSidecar(codec, { v: 1, source: 'openaiChatCompletions', target: 'openaiChatCompletions', targetTurn: source, sourceTurnDigest: await computeOpenAIChatCompletionsTurnDigest(source), }); - const turn = { ...source, reasoning_details: [...createOpenAIChatCompletionsSidecarCarrier(data), ...createOpenAIChatCompletionsSidecarCarrier(foreign)] }; + const turn = { ...source, reasoning_details: [...createOpenAIChatCompletionsSidecarDetails(data), ...createOpenAIChatCompletionsSidecarDetails(foreign)] }; const verify = vi.fn(verifyOpenAIChatCompletionsTurnDigest); - await expect(roundTripAssistantTurn('openaiChatCompletions', 'openaiChatCompletions', turn, codec, findOpenAIChatCompletionsSidecarCarriers, verify, () => [])).resolves.toEqual({ turn: source }); + await expect(roundTripAssistantTurn('openaiChatCompletions', 'openaiChatCompletions', turn, codec, findOpenAIChatCompletionsSidecarOccurrences, verify, () => [])).resolves.toEqual({ turn: source }); expect(verify).toHaveBeenCalledExactlyOnceWith(source, expect.any(Object), 1); }); }); diff --git a/packages/translate/__tests__/round-trip/sidecar-candidates_test.ts b/packages/translate/__tests__/round-trip/sidecar-candidates_test.ts index 8ef5a9f817..b7aa48caec 100644 --- a/packages/translate/__tests__/round-trip/sidecar-candidates_test.ts +++ b/packages/translate/__tests__/round-trip/sidecar-candidates_test.ts @@ -1,18 +1,19 @@ import { expect, test, vi } from 'vitest'; import { createTestSidecarCodec } from './sidecar-codec.ts'; -import { computeAnthropicMessagesTurnDigest, findAnthropicMessagesSidecarCarriers, verifyAnthropicMessagesTurnDigest, type AnthropicMessagesAssistantTurn } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; -import { computeGeminiGenerateContentTurnDigest, findGeminiGenerateContentSidecarCarriers, verifyGeminiGenerateContentTurnDigest, type GeminiGenerateContentAssistantTurn } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; -import { computeOpenAIChatCompletionsTurnDigest, findOpenAIChatCompletionsSidecarCarriers, verifyOpenAIChatCompletionsTurnDigest, type OpenAIChatCompletionsAssistantTurn } from '../../src/protocols/openai-chat-completions/as-source/round-trip.ts'; -import { computeOpenAIResponsesTurnDigest, findOpenAIResponsesSidecarCarriers, verifyOpenAIResponsesTurnDigest, type OpenAIResponsesAssistantTurn } from '../../src/protocols/openai-responses/as-source/round-trip.ts'; -import { roundTripAssistantTurn, type EncodedRoundTripSidecar } from '../../src/round-trip/resolve.ts'; -import { encodeRoundTripSidecar, type RoundTripProtocol } from '../../src/round-trip/sidecar.ts'; +import { computeAnthropicMessagesTurnDigest, findAnthropicMessagesSidecarOccurrences, verifyAnthropicMessagesTurnDigest, type AnthropicMessagesSourceAssistantTurn } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; +import { computeGeminiGenerateContentTurnDigest, findGeminiGenerateContentSidecarOccurrences, verifyGeminiGenerateContentTurnDigest, type GeminiGenerateContentSourceAssistantTurn } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; +import { computeOpenAIChatCompletionsTurnDigest, findOpenAIChatCompletionsSidecarOccurrences, verifyOpenAIChatCompletionsTurnDigest, type OpenAIChatCompletionsSourceAssistantTurn } from '../../src/protocols/openai-chat-completions/as-source/round-trip.ts'; +import { computeOpenAIResponsesTurnDigest, findOpenAIResponsesSidecarOccurrences, verifyOpenAIResponsesTurnDigest, type OpenAIResponsesSourceAssistantTurn } from '../../src/protocols/openai-responses/as-source/round-trip.ts'; +import { type AssistantTurnProtocol } from '../../src/round-trip/input-assistant-turn.ts'; +import { roundTripAssistantTurn, type SidecarOccurrence } from '../../src/round-trip/resolve.ts'; +import { encodeRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; import type { RoundTripTurnDigest } from '../../src/round-trip/turn-digest.ts'; const sourceCase = ( - source: RoundTripProtocol, + source: AssistantTurnProtocol, makeTurn: (carriers: string[]) => Turn, - inspect: (turn: Turn) => EncodedRoundTripSidecar[], + inspect: (turn: Turn) => SidecarOccurrence[], compute: (turn: Turn) => Promise, verify: (turn: Turn, check: RoundTripTurnDigest, version: number) => Promise, ) => ({ @@ -59,21 +60,21 @@ const sourceCase = ( }); const cases = [ - sourceCase('openaiChatCompletions', (carriers): OpenAIChatCompletionsAssistantTurn => ({ + sourceCase('openaiChatCompletions', (carriers): OpenAIChatCompletionsSourceAssistantTurn => ({ role: 'assistant', content: 'answer', reasoning_details: [ ...carriers.map(data => ({ type: 'reasoning.encrypted', data })), { type: 'reasoning.summary', summary: 'ordinary summary' }, ], - }), findOpenAIChatCompletionsSidecarCarriers, computeOpenAIChatCompletionsTurnDigest, verifyOpenAIChatCompletionsTurnDigest), - sourceCase('anthropicMessages', (carriers): AnthropicMessagesAssistantTurn => [{ + }), findOpenAIChatCompletionsSidecarOccurrences, computeOpenAIChatCompletionsTurnDigest, verifyOpenAIChatCompletionsTurnDigest), + sourceCase('anthropicMessages', (carriers): AnthropicMessagesSourceAssistantTurn => [{ role: 'assistant', content: [{ type: 'text', text: 'answer' }, ...carriers.map(data => ({ type: 'redacted_thinking' as const, data })), { type: 'text', text: 'tail' }], - }, { role: 'assistant', content: '' }, { role: 'assistant', content: [] }], findAnthropicMessagesSidecarCarriers, computeAnthropicMessagesTurnDigest, verifyAnthropicMessagesTurnDigest), - sourceCase('openaiResponses', (carriers): OpenAIResponsesAssistantTurn => [ + }, { role: 'assistant', content: '' }, { role: 'assistant', content: [] }], findAnthropicMessagesSidecarOccurrences, computeAnthropicMessagesTurnDigest, verifyAnthropicMessagesTurnDigest), + sourceCase('openaiResponses', (carriers): OpenAIResponsesSourceAssistantTurn => [ { type: 'message', role: 'assistant', content: 'answer' }, ...carriers.map(encrypted_content => ({ type: 'reasoning' as const, summary: [], encrypted_content })), { type: 'message', role: 'assistant', content: [] }, - ], findOpenAIResponsesSidecarCarriers, computeOpenAIResponsesTurnDigest, verifyOpenAIResponsesTurnDigest), - sourceCase('geminiGenerateContent', (carriers): GeminiGenerateContentAssistantTurn => [{ + ], findOpenAIResponsesSidecarOccurrences, computeOpenAIResponsesTurnDigest, verifyOpenAIResponsesTurnDigest), + sourceCase('geminiGenerateContent', (carriers): GeminiGenerateContentSourceAssistantTurn => [{ role: 'model', parts: [{ text: 'answer' }, ...carriers.map(thoughtSignature => ({ thoughtSignature })), { text: 'tail' }], - }, { role: 'model' }, { role: 'model', parts: [] }], findGeminiGenerateContentSidecarCarriers, computeGeminiGenerateContentTurnDigest, verifyGeminiGenerateContentTurnDigest), + }, { role: 'model' }, { role: 'model', parts: [] }], findGeminiGenerateContentSidecarOccurrences, computeGeminiGenerateContentTurnDigest, verifyGeminiGenerateContentTurnDigest), ]; const scenarios = ['middle', 'foreign', 'wrong-direction', 'duplicate', 'different-targets', 'malformed'] as const; diff --git a/packages/translate/__tests__/round-trip/sidecar-codec.ts b/packages/translate/__tests__/round-trip/sidecar-codec.ts index b49c9c9cb7..b72d3218f6 100644 --- a/packages/translate/__tests__/round-trip/sidecar-codec.ts +++ b/packages/translate/__tests__/round-trip/sidecar-codec.ts @@ -1,6 +1,6 @@ -import { SidecarTaggedValue } from '../../src/round-trip/sidecar-tagged-value.ts'; +import { SidecarTaggedValue } from '../../src/round-trip/codec.ts'; +import type { SidecarCodec } from '../../src/round-trip/codec.ts'; import { rawJSON } from '../../src/shared/json/raw-json.ts'; -import type { SidecarCodec } from '../../src/types.ts'; const scopes = new Map>(); let nextHandle = 0; diff --git a/packages/translate/__tests__/round-trip/test-helpers.ts b/packages/translate/__tests__/round-trip/test-helpers.ts index 244798f49b..a2ec099d9b 100644 --- a/packages/translate/__tests__/round-trip/test-helpers.ts +++ b/packages/translate/__tests__/round-trip/test-helpers.ts @@ -1,15 +1,16 @@ import { flattenNamespaceTools } from '../../src/protocols/openai-responses/as-source/namespace-tools.ts'; -import type { AssistantTurnByProtocol, AssistantTurnFor } from '../../src/round-trip/assistant-turn.ts'; -import type { AssistantTurnStateFor } from '../../src/round-trip/reference/assistant-turn.ts'; -import { createReferenceBuilder, type ContentStates, type FieldStates } from '../../src/round-trip/reference/reference-builder.ts'; -import type { RoundTripReader, RoundTripWriter } from '../../src/round-trip/stream.ts'; +import type { InputAssistantTurnByProtocol, InputAssistantTurnFor } from '../../src/round-trip/input-assistant-turn.ts'; +import type { RoundTripReader } from '../../src/round-trip/reader.ts'; +import type { InputAssistantTurnStateFor } from '../../src/round-trip/reference/assistant-turn-types.ts'; +import { createReferenceBuilder, type EmittedFieldStates, type ReadFieldStates } from '../../src/round-trip/reference/reference-builder.ts'; import type { RoundTripTurnDigest } from '../../src/round-trip/turn-digest.ts'; +import type { RoundTripWriter } from '../../src/round-trip/writer.ts'; -export const createRoundTripReaderCapture = () => { +export const createRoundTripReaderCapture = () => { const builder = createReferenceBuilder(); const turns = new Map; - fieldState: AssistantTurnStateFor; + turn: InputAssistantTurnFor; + fieldState: InputAssistantTurnStateFor; }>(); const roundTrip: RoundTripReader = { builder, @@ -18,17 +19,17 @@ export const createRoundTripReaderCapture = () => { +export const captureRoundTripWriter = () => { const builder = createReferenceBuilder(); const prepared: Array<{ choice: number; sourceTurnDigest: RoundTripTurnDigest; - turn: AssistantTurnFor; - contentState: AssistantTurnStateFor; + turn: InputAssistantTurnFor; + contentState: InputAssistantTurnStateFor; }> = []; const roundTrip: RoundTripWriter = { builder, - prepareSidecar: async (choice, sourceTurnDigest, turn, contentState) => { + encodeSidecar: async (choice, sourceTurnDigest, turn, contentState) => { prepared.push({ choice, sourceTurnDigest, turn, contentState }); return `sidecar-${choice}`; }, diff --git a/packages/translate/__tests__/round-trip/turn-digest_test.ts b/packages/translate/__tests__/round-trip/turn-digest_test.ts index 2b4bbe2ec4..8d72045124 100644 --- a/packages/translate/__tests__/round-trip/turn-digest_test.ts +++ b/packages/translate/__tests__/round-trip/turn-digest_test.ts @@ -16,4 +16,14 @@ describe('round-trip turn digests', () => { // @ts-expect-error Replay material excludes undefined at the public boundary. await expect(computeRoundTripTurnDigest(undefined)).rejects.toThrowError('Replay material is not JSON-serializable'); }); + + it('verifies exactly eight replay digest bytes', async () => { + const material = { content: ['answer'] }; + const sourceTurnDigest = new Uint8Array([68, 150, 67, 115, 244, 243, 164, 21]); + + expect(await verifyRoundTripTurnDigest(material, sourceTurnDigest)).toBe(true); + expect(await verifyRoundTripTurnDigest({ content: ['edited'] }, sourceTurnDigest)).toBe(false); + expect(await verifyRoundTripTurnDigest(material, sourceTurnDigest.subarray(0, 7))).toBe(false); + expect(await verifyRoundTripTurnDigest(material, new Uint8Array([...sourceTurnDigest, 0]))).toBe(false); + }); }); diff --git a/packages/translate/src/index.ts b/packages/translate/src/index.ts index 347761c865..e50c70539e 100644 --- a/packages/translate/src/index.ts +++ b/packages/translate/src/index.ts @@ -1,3 +1,3 @@ -export type { RemoteImageData, RemoteImageLoader, SidecarCodec, TranslatedApiError, TranslateTripResult, TranslationContext } from './types.ts'; +export { SidecarTaggedValue, type SidecarCodec } from './round-trip/codec.ts'; +export type { RemoteImageData, RemoteImageLoader, TranslatedApiError, TranslateTripResult, TranslationContext } from './types.ts'; export { TranslatorInputError } from './translator-input-error.ts'; -export { SidecarTaggedValue } from './round-trip/sidecar-tagged-value.ts'; diff --git a/packages/translate/src/ir/stream.ts b/packages/translate/src/ir/stream.ts index fec713ba24..02ef33a149 100644 --- a/packages/translate/src/ir/stream.ts +++ b/packages/translate/src/ir/stream.ts @@ -6,7 +6,7 @@ export type IRPath = readonly (string | number)[]; export type IRWrite = Readonly< | { type: 'write'; kind: 'initialize'; path: IRPath; value: IRValue } | { type: 'write'; kind: 'assign'; path: IRPath; value: IRValue } - | { type: 'write'; kind: 'append'; path: IRPath; value: string; referencePieceIds?: readonly number[] }>; + | { type: 'write'; kind: 'append'; path: IRPath; value: string; textFragmentIds?: readonly number[] }>; export type IREvent = Readonly< | { type: 'start'; id?: string; created?: number } | { type: 'item_end'; choice: number; item: number; status?: 'completed' | 'incomplete' } @@ -22,7 +22,7 @@ export type IRRelativePath = string | number | IRPath; export interface IRWriteHandle { readonly path: IRPath; readonly index: number; - append: (path: IRRelativePath, value: string, referencePieceIds?: readonly number[]) => void; + append: (path: IRRelativePath, value: string, textFragmentIds?: readonly number[]) => void; assign: (path: IRRelativePath, value: unknown) => void; initialize: (path: IRRelativePath, value: unknown) => void; ensure: (path: IRRelativePath, initialValue: unknown) => void; @@ -75,12 +75,12 @@ export const createIRBuilder = (maxChoices = 1) => { write({ type: 'write', kind: 'initialize', path, value: value as IRValue }); remember(path, value); }; - const append = (path: IRPath, value: string, referencePieceIds?: readonly number[]): void => write({ + const append = (path: IRPath, value: string, textFragmentIds?: readonly number[]): void => write({ type: 'write', kind: 'append', path, value, - ...(referencePieceIds === undefined ? {} : { referencePieceIds: [...referencePieceIds] }), + ...(textFragmentIds === undefined ? {} : { textFragmentIds: [...textFragmentIds] }), }); const event = (value: IREvent): void => { collector.applyEvent(value); records.push(value); }; const drain = (): readonly IRRecord[] => { const drained = records; records = []; return drained; }; @@ -88,7 +88,7 @@ export const createIRBuilder = (maxChoices = 1) => { const writeHandle = (path: IRPath, index: number): IRWriteHandle => ({ path, index, - append: (subpath, value, referencePieceIds) => append(relativePath(path, subpath), value, referencePieceIds), + append: (subpath, value, textFragmentIds) => append(relativePath(path, subpath), value, textFragmentIds), assign: (subpath, value) => assign(relativePath(path, subpath), value), initialize: (subpath, value) => initialize(relativePath(path, subpath), value), ensure: (subpath, initialValue) => { diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts b/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts index 8eb74e8204..6e27ff9f1d 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts @@ -1,13 +1,13 @@ import type { IRJSONObject, IRJSONValue } from '../../../ir/types.ts'; -import type { ReplayContent } from '../../../round-trip/reference/assistant-turn.ts'; -import type { EncodedRoundTripSidecar } from '../../../round-trip/resolve.ts'; +import type { ReplayContent } from '../../../round-trip/reference/types.ts'; +import type { SidecarOccurrence } from '../../../round-trip/resolve.ts'; import { collectTextGroups, collectTextPlacementIndex, type TextContribution, type TextPlacementIndex } from '../../../round-trip/text-groups.ts'; import { computeRoundTripTurnDigest, replayMaterialsByType, verifyRoundTripTurnDigest } from '../../../round-trip/turn-digest.ts'; import type { RoundTripTurnDigest } from '../../../round-trip/turn-digest.ts'; import { isPlainJSONObject, requireJSONObject } from '../../../shared/json/object.ts'; import type { AnthropicMessagesAssistantContentBlock, AnthropicMessagesAssistantMessage, AnthropicMessagesRedactedThinkingBlock } from '@floway-dev/protocols/anthropic-messages'; -type AnthropicMessagesReplayPart = NonNullable>[number]; +type AnthropicMessagesReplayPart = NonNullable>[number]; type AnthropicMessagesTextGroup = 'text' | 'thinking'; const anthropicMessagesTextGroup = (part: AnthropicMessagesReplayPart): TextContribution | undefined => { @@ -16,11 +16,11 @@ const anthropicMessagesTextGroup = (part: AnthropicMessagesReplayPart): TextCont return undefined; }; -export type AnthropicMessagesAssistantTurn = (AnthropicMessagesAssistantMessage | { role: 'assistant'; content: AnthropicMessagesAssistantContentBlock[] })[]; +export type AnthropicMessagesSourceAssistantTurn = (AnthropicMessagesAssistantMessage | { role: 'assistant'; content: AnthropicMessagesAssistantContentBlock[] })[]; type TypedReplayMaterial = IRJSONObject & { type: string }; -const messageTurnDigestMaterialV1 = (message: AnthropicMessagesAssistantTurn[number]): IRJSONValue => { +const messageTurnDigestMaterialV1 = (message: AnthropicMessagesSourceAssistantTurn[number]): IRJSONValue => { const blocks: TypedReplayMaterial[] = []; if (typeof message.content === 'string') return replayMaterialsByType([{ type: 'text', text: message.content, citations: [] }]); const channels = collectTextGroups(message.content, anthropicMessagesTextGroup); @@ -37,9 +37,9 @@ const messageTurnDigestMaterialV1 = (message: AnthropicMessagesAssistantTurn[num return replayMaterialsByType(blocks); }; -const messagesTurnDigestMaterialV1 = (turn: AnthropicMessagesAssistantTurn): IRJSONValue => turn.map(messageTurnDigestMaterialV1); +const messagesTurnDigestMaterialV1 = (turn: AnthropicMessagesSourceAssistantTurn): IRJSONValue => turn.map(messageTurnDigestMaterialV1); -export const findAnthropicMessagesSidecarCarriers = (turn: AnthropicMessagesAssistantTurn): EncodedRoundTripSidecar[] => +export const findAnthropicMessagesSidecarOccurrences = (turn: AnthropicMessagesSourceAssistantTurn): SidecarOccurrence[] => turn.flatMap((message, messageIndex) => { const content = message.content; return typeof content === 'string' ? [] : content.flatMap((block, blockIndex) => block.type !== 'redacted_thinking' ? [] : [{ @@ -50,10 +50,10 @@ export const findAnthropicMessagesSidecarCarriers = (turn: AnthropicMessagesAssi }]); }); -export const computeAnthropicMessagesTurnDigest = (turn: AnthropicMessagesAssistantTurn): Promise => +export const computeAnthropicMessagesTurnDigest = (turn: AnthropicMessagesSourceAssistantTurn): Promise => computeRoundTripTurnDigest(messagesTurnDigestMaterialV1(turn)); -export const collectAnthropicMessagesReplayContents = (turn: AnthropicMessagesAssistantTurn): ReplayContent[] => { +export const collectAnthropicMessagesReplayContents = (turn: AnthropicMessagesSourceAssistantTurn): ReplayContent[] => { const candidates: ReplayContent[] = []; for (const message of turn) { if (typeof message.content === 'string') candidates.push(message.content); @@ -69,7 +69,7 @@ export const collectAnthropicMessagesReplayContents = (turn: AnthropicMessagesAs }; export const verifyAnthropicMessagesTurnDigest = ( - turn: AnthropicMessagesAssistantTurn, + turn: AnthropicMessagesSourceAssistantTurn, sourceTurnDigest: RoundTripTurnDigest, version: number, ): Promise => { @@ -79,9 +79,9 @@ export const verifyAnthropicMessagesTurnDigest = ( } }; -export const createAnthropicMessagesSidecarCarrier = (data: string): AnthropicMessagesRedactedThinkingBlock => ({ type: 'redacted_thinking', data }); +export const createAnthropicMessagesSidecarBlock = (data: string): AnthropicMessagesRedactedThinkingBlock => ({ type: 'redacted_thinking', data }); -export const collectAnthropicMessagesTextViews = (turn: AnthropicMessagesAssistantTurn): TextPlacementIndex => { +export const collectAnthropicMessagesTextPlacements = (turn: AnthropicMessagesSourceAssistantTurn): TextPlacementIndex => { const views: TextPlacementIndex = new Map(); for (const message of turn) { if (Array.isArray(message.content)) collectTextPlacementIndex(collectTextGroups(message.content, anthropicMessagesTextGroup), views); diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts index d31c3cf762..0728a96d73 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts @@ -1,4 +1,4 @@ -import { collectAnthropicMessagesTextViews, computeAnthropicMessagesTurnDigest, createAnthropicMessagesSidecarCarrier } from './round-trip.ts'; +import { collectAnthropicMessagesTextPlacements, computeAnthropicMessagesTurnDigest, createAnthropicMessagesSidecarBlock } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; import type { IRCollector } from '../../../ir/collector.ts'; import { isIRItemPath, isIRPartPath, irPathKey } from '../../../ir/operation-paths.ts'; @@ -6,12 +6,12 @@ import { createIROrdinalMapper } from '../../../ir/ordinal-mapper.ts'; import { consumeIRRecords, type IRPath, type IRRecord } from '../../../ir/stream.ts'; import { IRAbsent, type IR, type IRErrorPayload, type IRJSONObject } from '../../../ir/types.ts'; import { createIRUnitQueue, createIRUnitRegistry } from '../../../ir/unit-queue.ts'; -import { appendContent, createContentState, type ContentState, type ContentStates } from '../../../round-trip/reference/reference-builder.ts'; -import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; -import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; +import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/provenance.ts'; +import { appendEmittedField, createEmittedFieldState, type EmittedFieldState, type EmittedFieldStates } from '../../../round-trip/reference/reference-builder.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; +import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; import { parseJSONObject, parseJSONObjectAtCompletion, requireJSONObject } from '../../../shared/json/object.ts'; -import type { AnthropicMessagesAssistantState, AnthropicMessagesInputAssistantTurn } from '../as-target/assistant-turn.ts'; +import type { AnthropicMessagesInputAssistantTurnState, AnthropicMessagesInputAssistantTurn } from '../as-target/assistant-turn.ts'; import { generateAnthropicId, createAnthropicMessagesEventAccumulator, @@ -43,7 +43,7 @@ interface MessagesUnit { readonly path: IRPath; readonly kind: 'text' | 'refusal' | 'audio' | 'image' | 'reasoning' | 'function_call'; opened: boolean; - textContentState?: ContentState<'text'>; + textContentState?: EmittedFieldState<'text'>; // Keep source intervals until their native field is emitted, including terminal JSON normalization. pendingText?: Map; nextPending?: MessagesUnit; @@ -83,7 +83,7 @@ const outputTextKind = (unit: MessagesUnit, record: IRAppendRecord): 'text' | 't return undefined; }; -interface AnthropicMessagesSSEOutputOptions extends RoundTripOutputOptions<'anthropicMessages'> { +interface AnthropicMessagesSSEOutputOptions extends SourceEventWriterOptions<'anthropicMessages'> { parseToolArguments?: (text: string) => IRJSONObject; } @@ -98,7 +98,7 @@ export const anthropicMessagesFromIR = async function* ( const pending = createIRUnitQueue(); const ordinals = createIROrdinalMapper(); const nativeAccumulator = createAnthropicMessagesEventAccumulator(); - const contentState: NonNullable['content']> = []; + const contentState: NonNullable['content']> = []; let nextBlockIndex = 0; // Keep only the frontier block wire-open so the finish-time sidecar can close // before it. Clients that require serial blocks reorder this in the gateway. @@ -146,10 +146,10 @@ export const anthropicMessagesFromIR = async function* ( } }; - const appendPendingText = (unit: MessagesUnit, path: IRPath, text: string, content: ContentState): void => { + const appendPendingText = (unit: MessagesUnit, path: IRPath, text: string, content: EmittedFieldState): void => { const key = irPathKey(path); - replayTextProvenance(text, unit.pendingText?.get(key) ?? [], (value, referencePieceIds) => { - appendContent(options.roundTrip.builder, content, value, referencePieceIds); + replayTextProvenance(text, unit.pendingText?.get(key) ?? [], (value, textFragmentIds) => { + appendEmittedField(options.roundTrip.builder, content, value, textFragmentIds); }); unit.pendingText?.delete(key); }; @@ -189,7 +189,7 @@ export const anthropicMessagesFromIR = async function* ( unit.opened = true; const index = ordinals.open(unit.path); if (block.type === 'thinking' || block.type === 'text') { - unit.textContentState ??= createContentState(options.roundTrip.builder, 'text'); + unit.textContentState ??= createEmittedFieldState(options.roundTrip.builder, 'text'); contentState[index] = block.type === 'thinking' ? { thinking: unit.textContentState } : { text: unit.textContentState }; } nextBlockIndex = Math.max(nextBlockIndex, index + 1); @@ -268,11 +268,11 @@ export const anthropicMessagesFromIR = async function* ( const fields = pendingUnit.pendingText ??= new Map(); const key = irPathKey(record.path); const atoms = fields.get(key) ?? []; - atoms.push(provenanceSpanForAppend(text, record.value, record.referencePieceIds)); + atoms.push(provenanceSpanForAppend(text, record.value, record.textFragmentIds)); fields.set(key, atoms); } else if (kind !== undefined) { if (pendingUnit.textContentState === undefined) throw new Error('Missing Messages text content state'); - appendContent(options.roundTrip.builder, pendingUnit.textContentState, record.value, record.referencePieceIds); + appendEmittedField(options.roundTrip.builder, pendingUnit.textContentState, record.value, record.textFragmentIds); } } for (const event of openPendingUnits(state, collector)) { @@ -324,12 +324,12 @@ export const anthropicMessagesFromIR = async function* ( if (block?.type !== 'tool_use') throw new Error('Messages function unit did not produce a native tool block'); serializedInput = JSON.stringify(requireJSONObject(block.input)); } - const content = createContentState(options.roundTrip.builder, 'json'); + const content = createEmittedFieldState(options.roundTrip.builder, 'json'); contentState[index] = { input: content }; const item = state.choices[0].items[unit.path[3] as number]; if (item.type !== 'function_call') throw new Error('Messages function unit requires a function item'); if (serializedInput === item.arguments) appendPendingText(unit, [...unit.path, 'arguments'], serializedInput, content); - else appendContent(options.roundTrip.builder, content, serializedInput); + else appendEmittedField(options.roundTrip.builder, content, serializedInput); } ordinals.close(unit.path); if (nextBlockIndex > index + 1) yield stop; @@ -351,10 +351,10 @@ export const anthropicMessagesFromIR = async function* ( role: 'assistant', content: nativeResult.content.map(block => block.type === 'tool_use' ? { ...block, input: requireJSONObject(block.input) } : block), }; - const assistantState: AnthropicMessagesAssistantState = { content: contentState }; + const assistantState: AnthropicMessagesInputAssistantTurnState = { content: contentState }; const sourceTurnDigest = await computeAnthropicMessagesTurnDigest([targetTurn]); - const data = await options.roundTrip.prepareSidecar(0, sourceTurnDigest, targetTurn, assistantState, collectAnthropicMessagesTextViews([targetTurn])); - const carrier = createAnthropicMessagesSidecarCarrier(data); + const data = await options.roundTrip.encodeSidecar(0, sourceTurnDigest, targetTurn, assistantState, collectAnthropicMessagesTextPlacements([targetTurn])); + const carrier = createAnthropicMessagesSidecarBlock(data); yield emit({ type: 'content_block_start', index: nextBlockIndex, content_block: carrier }); yield emit({ type: 'content_block_stop', index: nextBlockIndex }); if (pendingBlockStop !== undefined) { diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/assistant-turn.ts b/packages/translate/src/protocols/anthropic-messages/as-target/assistant-turn.ts index 0358b85b89..4c7caec708 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/assistant-turn.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/assistant-turn.ts @@ -1,5 +1,5 @@ import type { IRJSONObject } from '../../../ir/types.ts'; -import type { ReferenceState, ReferenceStateLeaves, ReferenceableValue, TextRule, JSONRule } from '../../../round-trip/reference/assistant-turn.ts'; +import type { ReferenceState, ReferenceStateLeaves, ReferenceableValue, TextRule, JSONRule } from '../../../round-trip/reference/types.ts'; import type { AnthropicMessagesAssistantContentBlock } from '@floway-dev/protocols/anthropic-messages'; type AnthropicMessagesBlockRule = Block extends { type: 'text' } @@ -10,16 +10,16 @@ type AnthropicMessagesBlockRule = Block extends { type: 'text' } ? { input: JSONRule } : never; -type AnthropicMessagesThinBlock = Block extends unknown +type AnthropicMessagesReferenceableBlock = Block extends unknown ? AnthropicMessagesBlockRule extends never ? Block : ReferenceableValue, Reference> : never; type AnthropicMessagesBlockState = Block extends unknown ? AnthropicMessagesBlockRule extends never ? never : ReferenceState, States> : never; -export type AnthropicMessagesReferenceableAssistantTurn = { +export type AnthropicMessagesReferenceableInputAssistantTurn = { role: 'assistant'; - content: AnthropicMessagesThinBlock[]; + content: AnthropicMessagesReferenceableBlock[]; }; export type AnthropicMessagesInputAssistantTurn = { role: 'assistant'; @@ -29,6 +29,6 @@ export type AnthropicMessagesInputAssistantTurn = { )[]; }; -export type AnthropicMessagesAssistantState = { +export type AnthropicMessagesInputAssistantTurnState = { content?: Array | undefined>; }; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/sse-from.ts b/packages/translate/src/protocols/anthropic-messages/as-target/sse-from.ts index bc83751297..59daad7091 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/sse-from.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/sse-from.ts @@ -1,9 +1,9 @@ -import type { AnthropicMessagesAssistantState } from './assistant-turn.ts'; +import type { AnthropicMessagesInputAssistantTurnState } from './assistant-turn.ts'; import { type AnthropicMessagesInputCounts, usageToIR } from './usage.ts'; import { createIRBuilder, type IRItemHandle, type IRPartHandle, type IRRecord } from '../../../ir/stream.ts'; import type { IRSourceCitation } from '../../../ir/types.ts'; -import { appendField, createFieldState, type FieldState, type FieldStates } from '../../../round-trip/reference/reference-builder.ts'; -import type { RoundTripReader } from '../../../round-trip/stream.ts'; +import type { RoundTripReader } from '../../../round-trip/reader.ts'; +import { appendReadField, createReadFieldState, type ReadFieldState, type ReadFieldStates } from '../../../round-trip/reference/reference-builder.ts'; import { requireJSONObject } from '../../../shared/json/object.ts'; import { unwrapCustomToolInput } from '../../openai-responses/common/custom-tool-wrap.ts'; import { @@ -47,15 +47,15 @@ const UNTRANSLATED_BLOCK_TYPES: readonly string[] = [ 'container_upload', ]; -type MessagesBlockFieldState = NonNullable['content']>[number]>; +type MessagesBlockFieldState = NonNullable['content']>[number]>; interface MessagesBlockState { readonly item?: IRItemHandle; readonly part?: IRPartHandle; readonly custom: boolean; annotationCount?: number; - textField?: FieldState<'text'>; - jsonField?: FieldState<'json'>; + textField?: ReadFieldState<'text'>; + jsonField?: ReadFieldState<'json'>; fieldState?: MessagesBlockFieldState; wrappedInput: string; } @@ -85,7 +85,7 @@ export const irFromAnthropicMessages = async function* ( let part: IRPartHandle | undefined; let annotationCount: number | undefined; let custom = false; - let textField: FieldState<'text'> | undefined; + let textField: ReadFieldState<'text'> | undefined; if (block.type === 'fallback') { ir.assign(['model'], block.to.model); } else if (block.type === 'text') { @@ -97,14 +97,14 @@ export const irFromAnthropicMessages = async function* ( part = item.part({ type: 'text', text: '', ...(annotations === undefined ? {} : { annotations }) }); annotationCount = Array.isArray(annotations) ? annotations.length : undefined; if (block.text !== '') { - textField = createFieldState('text'); - part.append('text', block.text, [appendField(options.roundTrip.builder, textField, block.text)]); + textField = createReadFieldState('text'); + part.append('text', block.text, [appendReadField(options.roundTrip.builder, textField, block.text)]); } } else if (block.type === 'thinking') { item = ir.item(0, { type: 'reasoning', summary: [''] }); if (block.thinking !== '') { - textField = createFieldState('text'); - item.append(['summary', 0], block.thinking, [appendField(options.roundTrip.builder, textField, block.thinking)]); + textField = createReadFieldState('text'); + item.append(['summary', 0], block.thinking, [appendReadField(options.roundTrip.builder, textField, block.thinking)]); } } else if (block.type === 'tool_use') { custom = options.customToolNames?.has(block.name) === true; @@ -150,10 +150,10 @@ export const irFromAnthropicMessages = async function* ( case 'text_delta': if (d.text !== '') { if (block.textField === undefined) { - block.textField = createFieldState('text'); + block.textField = createReadFieldState('text'); block.fieldState = { text: block.textField }; } - const partId = appendField(options.roundTrip.builder, block.textField, d.text); + const partId = appendReadField(options.roundTrip.builder, block.textField, d.text); if (block.part === undefined) block.item.append(['content', 0, 'text'], d.text, [partId]); else block.part.append('text', d.text, [partId]); } @@ -161,20 +161,20 @@ export const irFromAnthropicMessages = async function* ( case 'thinking_delta': if (d.thinking !== '') { if (block.textField === undefined) { - block.textField = createFieldState('text'); + block.textField = createReadFieldState('text'); block.fieldState = { thinking: block.textField }; } - block.item.append(['summary', 0], d.thinking, [appendField(options.roundTrip.builder, block.textField, d.thinking)]); + block.item.append(['summary', 0], d.thinking, [appendReadField(options.roundTrip.builder, block.textField, d.thinking)]); } break; case 'signature_delta': break; case 'input_json_delta': if (d.partial_json !== '') { if (block.jsonField === undefined) { - block.jsonField = createFieldState('json'); + block.jsonField = createReadFieldState('json'); block.fieldState = { input: block.jsonField }; } - const partId = appendField(options.roundTrip.builder, block.jsonField, d.partial_json); + const partId = appendReadField(options.roundTrip.builder, block.jsonField, d.partial_json); if (block.custom) block.wrappedInput += d.partial_json; else block.item.append('arguments', d.partial_json, [partId]); } @@ -230,7 +230,7 @@ export const irFromAnthropicMessages = async function* ( if (!started) throw new Error('Messages message_stop arrived before message_start'); if (blocks.size > 0) throw new Error('Messages message_stop arrived with open content blocks'); const nativeResult = nativeAccumulator.result(); - const contentFields: NonNullable['content']> = []; + const contentFields: NonNullable['content']> = []; contentFields.length = nativeResult.content.length; [...blockStates.entries()].toSorted(([left], [right]) => left - right).forEach(([, block], index) => { if (block.fieldState !== undefined) contentFields[index] = block.fieldState; diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts index efbdc00b2d..394a8d49a5 100644 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts +++ b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts @@ -1,5 +1,5 @@ -import { createRoundTripStream } from '../../../round-trip/stream.ts'; -import type { SidecarCodec } from '../../../types.ts'; +import type { SidecarCodec } from '../../../round-trip/codec.ts'; +import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import { irFromOpenAIChatCompletions } from '../../openai-chat-completions/as-target/sse-from.ts'; import { anthropicMessagesFromIR } from '../as-source/sse-to.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; @@ -7,6 +7,6 @@ import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec, maxChoices = 1): AsyncIterable> => { - const stream = createRoundTripStream('anthropicMessages', 'openaiChatCompletions', codec); + const stream = createRoundTripCoordinator('anthropicMessages', 'openaiChatCompletions', codec); return anthropicMessagesFromIR(irFromOpenAIChatCompletions(frames, { maxChoices, roundTrip: stream.reader }), { roundTrip: stream.writer }); }; 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..52de864124 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,11 +1,11 @@ import { klona } from 'klona/json'; +import type { SidecarCodec } from '../../../round-trip/codec.ts'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; import { TranslatorInputError } from '../../../translator-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'; -import { collectAnthropicMessagesReplayContents, findAnthropicMessagesSidecarCarriers, verifyAnthropicMessagesTurnDigest } from '../as-source/round-trip.ts'; +import { collectAnthropicMessagesReplayContents, findAnthropicMessagesSidecarOccurrences, verifyAnthropicMessagesTurnDigest } from '../as-source/round-trip.ts'; import type { AnthropicMessagesAssistantContentBlock, AnthropicMessagesAssistantMessage, @@ -290,7 +290,7 @@ export const buildTargetRequest = async ( let messageIndex = 0; for (const turn of partitionAnthropicMessagesTurns(payload.messages)) { if (turn.role === 'assistant') { - const roundTrip = await roundTripAssistantTurn('anthropicMessages', 'openaiChatCompletions', turn.items, codec, findAnthropicMessagesSidecarCarriers, verifyAnthropicMessagesTurnDigest, collectAnthropicMessagesReplayContents); + const roundTrip = await roundTripAssistantTurn('anthropicMessages', 'openaiChatCompletions', turn.items, codec, findAnthropicMessagesSidecarOccurrences, verifyAnthropicMessagesTurnDigest, collectAnthropicMessagesReplayContents); if (roundTrip?.turn.function_call != null) { const modernIds = new Set(roundTrip.turn.tool_calls?.map(call => call.id)); for (const message of turn.items) if (Array.isArray(message.content)) for (const block of message.content) { diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts index 777ceb4971..ca0763c4c1 100644 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts +++ b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts @@ -1,5 +1,5 @@ -import { createRoundTripStream } from '../../../round-trip/stream.ts'; -import type { SidecarCodec } from '../../../types.ts'; +import type { SidecarCodec } from '../../../round-trip/codec.ts'; +import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import { irFromOpenAIResponses } from '../../openai-responses/as-target/sse-from.ts'; import { anthropicMessagesFromIR } from '../as-source/sse-to.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; @@ -7,6 +7,6 @@ import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { - const stream = createRoundTripStream('anthropicMessages', 'openaiResponses', codec); + const stream = createRoundTripCoordinator('anthropicMessages', 'openaiResponses', codec); return anthropicMessagesFromIR(irFromOpenAIResponses(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); }; 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..7416dddd54 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 @@ -1,12 +1,12 @@ import { klona } from 'klona/json'; import { anthropicMessagesReasoningBlockToOpenAIResponsesReasoning } from './reasoning.ts'; +import type { SidecarCodec } from '../../../round-trip/codec.ts'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; import { TranslatorInputError } from '../../../translator-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'; -import { collectAnthropicMessagesReplayContents, findAnthropicMessagesSidecarCarriers, verifyAnthropicMessagesTurnDigest } from '../as-source/round-trip.ts'; +import { collectAnthropicMessagesReplayContents, findAnthropicMessagesSidecarOccurrences, verifyAnthropicMessagesTurnDigest } from '../as-source/round-trip.ts'; import { type AnthropicMessagesAssistantMessage, type AnthropicMessagesClientTool, @@ -290,7 +290,7 @@ export const buildTargetRequest = async ( let messageIndex = 0; for (const turn of partitionAnthropicMessagesTurns(payload.messages)) { if (turn.role === 'assistant') { - const roundTrip = await roundTripAssistantTurn('anthropicMessages', 'openaiResponses', turn.items, codec, findAnthropicMessagesSidecarCarriers, verifyAnthropicMessagesTurnDigest, collectAnthropicMessagesReplayContents); + const roundTrip = await roundTripAssistantTurn('anthropicMessages', 'openaiResponses', turn.items, codec, findAnthropicMessagesSidecarOccurrences, verifyAnthropicMessagesTurnDigest, collectAnthropicMessagesReplayContents); input.push(...(roundTrip ? roundTrip.turn : turn.items.flatMap((message, index) => translateAssistantMessage(message, messageIndex + index)))); } else for (const [index, message] of turn.items.entries()) { switch (message.role) { diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts index c7a6839460..a5ec653f27 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts @@ -1,6 +1,6 @@ import type { IRJSONObject } from '../../../ir/types.ts'; -import type { ReplayContent } from '../../../round-trip/reference/assistant-turn.ts'; -import type { EncodedRoundTripSidecar } from '../../../round-trip/resolve.ts'; +import type { ReplayContent } from '../../../round-trip/reference/types.ts'; +import type { SidecarOccurrence } from '../../../round-trip/resolve.ts'; import { collectTextGroups, collectTextPlacementIndex, type TextContribution, type TextPlacementIndex } from '../../../round-trip/text-groups.ts'; import { computeRoundTripTurnDigest, verifyRoundTripTurnDigest } from '../../../round-trip/turn-digest.ts'; import type { RoundTripTurnDigest } from '../../../round-trip/turn-digest.ts'; @@ -10,13 +10,13 @@ import type { GeminiGenerateContentContent, GeminiGenerateContentPart } from '@f const geminiVisibleTextGroup = (part: GeminiGenerateContentPart): TextContribution<'visible'> | undefined => part.thought !== true && part.text !== undefined ? { channel: 'visible', field: 'text', text: part.text } : undefined; -export type GeminiGenerateContentAssistantTurn = GeminiGenerateContentContent[]; -export type GeminiGenerateContentSidecarCarrier = GeminiGenerateContentPart & { thoughtSignature: string }; +export type GeminiGenerateContentSourceAssistantTurn = GeminiGenerateContentContent[]; +export type GeminiGenerateContentSidecarPart = GeminiGenerateContentPart & { thoughtSignature: string }; export const hasGeminiGenerateContentThoughtSignature = (content: GeminiGenerateContentContent): boolean => content.parts?.some(part => part.thoughtSignature !== undefined) ?? false; -const geminiTurnDigestMaterialV1 = (turn: GeminiGenerateContentAssistantTurn): IRJSONObject => { +const geminiTurnDigestMaterialV1 = (turn: GeminiGenerateContentSourceAssistantTurn): IRJSONObject => { const contents = turn.map(content => { const visible = collectTextGroups(content.parts ?? [], geminiVisibleTextGroup).get('visible'); const inlineData: { mimeType: string; data: string; audioTranscription?: string }[] = []; @@ -39,7 +39,7 @@ const geminiTurnDigestMaterialV1 = (turn: GeminiGenerateContentAssistantTurn): I return { contents }; }; -export const findGeminiGenerateContentSidecarCarriers = (turn: GeminiGenerateContentAssistantTurn): EncodedRoundTripSidecar[] => +export const findGeminiGenerateContentSidecarOccurrences = (turn: GeminiGenerateContentSourceAssistantTurn): SidecarOccurrence[] => turn.flatMap((content, contentIndex) => { const nativeParts = content.parts; return nativeParts?.flatMap((part, partIndex) => typeof part.thoughtSignature !== 'string' ? [] : [{ @@ -52,10 +52,10 @@ export const findGeminiGenerateContentSidecarCarriers = (turn: GeminiGenerateCon }]) ?? []; }); -export const computeGeminiGenerateContentTurnDigest = (turn: GeminiGenerateContentAssistantTurn): Promise => +export const computeGeminiGenerateContentTurnDigest = (turn: GeminiGenerateContentSourceAssistantTurn): Promise => computeRoundTripTurnDigest(geminiTurnDigestMaterialV1(turn)); -export const collectGeminiGenerateContentReplayContents = (turn: GeminiGenerateContentAssistantTurn): ReplayContent[] => { +export const collectGeminiGenerateContentReplayContents = (turn: GeminiGenerateContentSourceAssistantTurn): ReplayContent[] => { const candidates: ReplayContent[] = []; for (const content of turn) { const visible = collectTextGroups(content.parts ?? [], geminiVisibleTextGroup).get('visible'); @@ -71,7 +71,7 @@ export const collectGeminiGenerateContentReplayContents = (turn: GeminiGenerateC }; export const verifyGeminiGenerateContentTurnDigest = ( - turn: GeminiGenerateContentAssistantTurn, + turn: GeminiGenerateContentSourceAssistantTurn, sourceTurnDigest: RoundTripTurnDigest, version: number, ): Promise => { @@ -83,9 +83,9 @@ export const verifyGeminiGenerateContentTurnDigest = ( // Gemini returns model-side thought signatures as Part metadata. // https://ai.google.dev/gemini-api/docs/generate-content/thought-signatures -export const createGeminiGenerateContentSidecarCarrier = (data: string): GeminiGenerateContentSidecarCarrier => ({ thoughtSignature: data }); +export const createGeminiGenerateContentSidecarPart = (data: string): GeminiGenerateContentSidecarPart => ({ thoughtSignature: data }); -export const collectGeminiGenerateContentTextViews = (turn: GeminiGenerateContentAssistantTurn): TextPlacementIndex => { +export const collectGeminiGenerateContentTextPlacements = (turn: GeminiGenerateContentSourceAssistantTurn): TextPlacementIndex => { const views: TextPlacementIndex = new Map(); for (const content of turn) { if (content.parts !== undefined) collectTextPlacementIndex(collectTextGroups(content.parts, geminiVisibleTextGroup), views); diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts index 7e224bee09..b9b89b64c1 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts @@ -1,17 +1,17 @@ -import { collectGeminiGenerateContentTextViews, computeGeminiGenerateContentTurnDigest, createGeminiGenerateContentSidecarCarrier } from './round-trip.ts'; +import { collectGeminiGenerateContentTextPlacements, computeGeminiGenerateContentTurnDigest, createGeminiGenerateContentSidecarPart } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; import type { IRCollector } from '../../../ir/collector.ts'; import { irItemPath, irPartPath, irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/operation-paths.ts'; import { consumeIRRecords, type IRPath, type IRRecord } from '../../../ir/stream.ts'; import { IRAbsent, type IR, type IRErrorPayload, type IRJSONObject, type IRSourceCitation, type IRTokenLogprob } from '../../../ir/types.ts'; import { createIRUnitQueue, createIRUnitRegistry, type IRUnitQueue } from '../../../ir/unit-queue.ts'; -import { appendContent, createContentState, type ContentStates, type ContentState as ReferenceContentState } from '../../../round-trip/reference/reference-builder.ts'; -import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; -import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; +import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/provenance.ts'; +import { appendEmittedField, createEmittedFieldState, type EmittedFieldStates, type EmittedFieldState } from '../../../round-trip/reference/reference-builder.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; +import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; import { isCompleteJSONObject, parseJSONObjectAtCompletion } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; -import type { GeminiGenerateContentAssistantState, GeminiGenerateContentInputAssistantTurn } from '../as-target/assistant-turn.ts'; +import type { GeminiGenerateContentInputAssistantTurnState, GeminiGenerateContentInputAssistantTurn } from '../as-target/assistant-turn.ts'; import { eventFrame, FAST_SERVICE_TIER, type EventFrame } from '@floway-dev/protocols/common'; import { type GeminiGenerateContentCandidate, @@ -53,7 +53,7 @@ interface TextSegment { targetStart: number; } -interface GeminiGenerateContentSSEOutputOptions extends RoundTripOutputOptions<'geminiGenerateContent'> { +interface GeminiGenerateContentSSEOutputOptions extends SourceEventWriterOptions<'geminiGenerateContent'> { parseToolArguments?: (text: string) => IRJSONObject; } @@ -72,8 +72,8 @@ export const geminiGenerateContentFromIR = async function* ( const pendingText = new Map(); const sourceLengths = new Map(); const nativeAccumulator = createGeminiGenerateContentEventAccumulator(); - type ContentState = NonNullable[number]>; - type PartsState = NonNullable; + type GeminiContentState = NonNullable[number]>; + type PartsState = NonNullable; const partStates = new Map(); const partsState = (choice: number): PartsState => { @@ -82,8 +82,8 @@ export const geminiGenerateContentFromIR = async function* ( return state; }; - const appendPendingContent = (content: ReferenceContentState, text: string, spans: readonly TextProvenanceSpan[]): void => - replayTextProvenance(text, spans, (value, referencePieceIds) => appendContent(options.roundTrip.builder, content, value, referencePieceIds)); + const appendPendingContent = (content: EmittedFieldState, text: string, spans: readonly TextProvenanceSpan[]): void => + replayTextProvenance(text, spans, (value, textFragmentIds) => appendEmittedField(options.roundTrip.builder, content, value, textFragmentIds)); const emit = (event: GeminiGenerateContentResult): EventFrame => { const frame = eventFrame({ responseId: metadata.id, modelVersion: metadata.model, ...event }); @@ -107,7 +107,7 @@ export const geminiGenerateContentFromIR = async function* ( return next; }; - const appendText = (choice: number, source: IRPath, text: string, thought: boolean, referencePieceIds?: readonly number[]): GeminiGenerateContentPart | undefined => { + const appendText = (choice: number, source: IRPath, text: string, thought: boolean, textFragmentIds?: readonly number[]): GeminiGenerateContentPart | undefined => { if (text === '') return undefined; const kind = thought ? 'thought' : 'text'; const partIndex = occupyPart(choice, kind); @@ -135,9 +135,9 @@ export const geminiGenerateContentFromIR = async function* ( const state = partsState(choice); const partState = state[partIndex]; const existing = partState !== undefined && 'text' in partState ? partState.text : undefined; - const content = existing ?? createContentState(options.roundTrip.builder, 'text'); + const content = existing ?? createEmittedFieldState(options.roundTrip.builder, 'text'); if (existing === undefined) state[partIndex] = { text: content }; - appendContent(options.roundTrip.builder, content, text, referencePieceIds); + appendEmittedField(options.roundTrip.builder, content, text, textFragmentIds); } return { text, ...(thought ? { thought: true } : {}) }; }; @@ -163,7 +163,7 @@ export const geminiGenerateContentFromIR = async function* ( const pending = queue.values[queue.head++]; const source = collector.value(pending.source); if (source === undefined) throw new TypeError(`GenerateContent pending text source is missing: ${JSON.stringify(pending.source)}`); - const part = appendText(choice, pending.source, source.slice(pending.span.start, pending.span.endExclusive), pending.thought, pending.span.referencePieceIds); + const part = appendText(choice, pending.source, source.slice(pending.span.start, pending.span.endExclusive), pending.thought, pending.span.textFragmentIds); if (part !== undefined) parts.push(part); } if (queue.head === queue.values.length) pendingText.delete(choice); @@ -236,11 +236,11 @@ export const geminiGenerateContentFromIR = async function* ( }; const transcriptAtoms = content.type === 'audio' ? takePendingText(choice, unit) : []; const partIndex = occupyPart(choice, 'other'); - const dataState = createContentState(options.roundTrip.builder, 'text'); + const dataState = createEmittedFieldState(options.roundTrip.builder, 'text'); appendPendingContent(dataState, inlineMedia.data, unit.pendingData ?? []); - let transcriptState: ReferenceContentState<'text'> | undefined; + let transcriptState: EmittedFieldState<'text'> | undefined; if (content.type === 'audio' && content.audio.transcript !== undefined) { - transcriptState = createContentState(options.roundTrip.builder, 'text'); + transcriptState = createEmittedFieldState(options.roundTrip.builder, 'text'); appendPendingContent(transcriptState, content.audio.transcript, transcriptAtoms.map(atom => atom.span)); } partsState(choice)[partIndex] = { @@ -277,10 +277,10 @@ export const geminiGenerateContentFromIR = async function* ( args: parsed.value, }, }; - const argumentState = createContentState(options.roundTrip.builder, 'json'); + const argumentState = createEmittedFieldState(options.roundTrip.builder, 'json'); const serialized = JSON.stringify(parsed.value); if (serialized === item.arguments) appendPendingContent(argumentState, serialized, unit.pendingArguments ?? []); - else appendContent(options.roundTrip.builder, argumentState, serialized); + else appendEmittedField(options.roundTrip.builder, argumentState, serialized); partsState(choice)[partIndex] = { functionCall: { args: argumentState } }; } } @@ -370,7 +370,7 @@ export const geminiGenerateContentFromIR = async function* ( if (field !== undefined) { const text = collector.value(record.path); if (text === undefined) throw new Error('Missing GenerateContent pending field'); - (pendingUnit[field] ??= []).push(provenanceSpanForAppend(text, record.value, record.referencePieceIds)); + (pendingUnit[field] ??= []).push(provenanceSpanForAppend(text, record.value, record.textFragmentIds)); } } yield* openPendingUnits(record, state, collector); @@ -395,11 +395,11 @@ export const geminiGenerateContentFromIR = async function* ( queue.values.push({ unit, source: record.path, - span: provenanceSpanForAppend(source, record.value, record.referencePieceIds), + span: provenanceSpanForAppend(source, record.value, record.textFragmentIds), thought: kind === 'thought', }); } else { - const part = appendText(choice, record.path, record.value, kind === 'thought', record.referencePieceIds); + const part = appendText(choice, record.path, record.value, kind === 'thought', record.textFragmentIds); if (part !== undefined) yield emitPart(choice, part); } } @@ -416,10 +416,10 @@ export const geminiGenerateContentFromIR = async function* ( const targetTurn: GeminiGenerateContentInputAssistantTurn = [{ role: 'model', parts: nativeCandidate?.content?.parts ?? [] }]; const stateParts = partsState(record.choice); stateParts.length = targetTurn[0].parts?.length ?? 0; - const assistantState: GeminiGenerateContentAssistantState = [{ parts: stateParts }]; + const assistantState: GeminiGenerateContentInputAssistantTurnState = [{ parts: stateParts }]; const sourceTurnDigest = await computeGeminiGenerateContentTurnDigest(targetTurn); - const data = await options.roundTrip.prepareSidecar(record.choice, sourceTurnDigest, targetTurn, assistantState, collectGeminiGenerateContentTextViews(targetTurn)); - yield emitPart(record.choice, createGeminiGenerateContentSidecarCarrier(data)); + const data = await options.roundTrip.encodeSidecar(record.choice, sourceTurnDigest, targetTurn, assistantState, collectGeminiGenerateContentTextPlacements(targetTurn)); + yield emitPart(record.choice, createGeminiGenerateContentSidecarPart(data)); yield emit({ candidates: [terminalCandidate(record, state, collector)] }); } diff --git a/packages/translate/src/protocols/gemini-generate-content/as-target/assistant-turn.ts b/packages/translate/src/protocols/gemini-generate-content/as-target/assistant-turn.ts index 8e8f2fcbcb..bdb0c2a72e 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-target/assistant-turn.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-target/assistant-turn.ts @@ -1,4 +1,4 @@ -import type { ReferenceState, ReferenceStateLeaves, ReferenceableValue, TextRule, JSONRule } from '../../../round-trip/reference/assistant-turn.ts'; +import type { ReferenceState, ReferenceStateLeaves, ReferenceableValue, TextRule, JSONRule } from '../../../round-trip/reference/types.ts'; import type { GeminiGenerateContentContent } from '@floway-dev/protocols/gemini-generate-content'; type GeminiPartRule = { @@ -9,5 +9,5 @@ type GeminiPartRule = { }; export type GeminiGenerateContentInputAssistantTurn = Array & { role: 'model' }>; -export type GeminiGenerateContentReferenceableAssistantTurn = ReferenceableValue; -export type GeminiGenerateContentAssistantState = ReferenceState; +export type GeminiGenerateContentReferenceableInputAssistantTurn = ReferenceableValue; +export type GeminiGenerateContentInputAssistantTurnState = ReferenceState; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts index b80a1d6eb8..9249949aae 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts @@ -1,5 +1,5 @@ -import { createRoundTripStream } from '../../../round-trip/stream.ts'; -import type { SidecarCodec } from '../../../types.ts'; +import type { SidecarCodec } from '../../../round-trip/codec.ts'; +import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import { irFromAnthropicMessages } from '../../anthropic-messages/as-target/sse-from.ts'; import { geminiGenerateContentFromIR } from '../as-source/sse-to.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; @@ -7,6 +7,6 @@ import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { - const stream = createRoundTripStream('geminiGenerateContent', 'anthropicMessages', codec); + const stream = createRoundTripCoordinator('geminiGenerateContent', 'anthropicMessages', codec); return geminiGenerateContentFromIR(irFromAnthropicMessages(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); }; 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..f96d1941e3 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,14 +1,14 @@ import { klona } from 'klona/json'; +import type { SidecarCodec } from '../../../round-trip/codec.ts'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; import { TranslatorInputError } from '../../../translator-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 { 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'; +import { collectGeminiGenerateContentReplayContents, hasGeminiGenerateContentThoughtSignature, findGeminiGenerateContentSidecarOccurrences, 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 { @@ -215,7 +215,7 @@ export const buildTargetRequest = async ( const restoredMessages = new Set(); let contentIndex = 0; for (const turn of partitionGeminiGenerateContentTurns(payload.contents ?? [])) { - const roundTrip = turn.role === 'assistant' && await roundTripAssistantTurn('geminiGenerateContent', 'anthropicMessages', turn.items, codec, findGeminiGenerateContentSidecarCarriers, verifyGeminiGenerateContentTurnDigest, collectGeminiGenerateContentReplayContents); + const roundTrip = turn.role === 'assistant' && await roundTripAssistantTurn('geminiGenerateContent', 'anthropicMessages', turn.items, codec, findGeminiGenerateContentSidecarOccurrences, verifyGeminiGenerateContentTurnDigest, collectGeminiGenerateContentReplayContents); if (!roundTrip) { if (turn.role === 'assistant') { for (const [index, content] of turn.items.entries()) { diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts index 98467f304d..718863caf3 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts @@ -1,5 +1,5 @@ -import { createRoundTripStream } from '../../../round-trip/stream.ts'; -import type { SidecarCodec } from '../../../types.ts'; +import type { SidecarCodec } from '../../../round-trip/codec.ts'; +import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import { irFromOpenAIChatCompletions } from '../../openai-chat-completions/as-target/sse-from.ts'; import { geminiGenerateContentFromIR } from '../as-source/sse-to.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; @@ -7,6 +7,6 @@ import type { GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gem import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec, maxChoices = 1): AsyncIterable> => { - const stream = createRoundTripStream('geminiGenerateContent', 'openaiChatCompletions', codec); + const stream = createRoundTripCoordinator('geminiGenerateContent', 'openaiChatCompletions', codec); return geminiGenerateContentFromIR(irFromOpenAIChatCompletions(frames, { maxChoices, roundTrip: stream.reader }), { roundTrip: stream.writer }); }; 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..c04d4a3bfc 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,12 +1,12 @@ import { klona } from 'klona/json'; +import type { SidecarCodec } from '../../../round-trip/codec.ts'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; import { TranslatorInputError } from '../../../translator-input-error.ts'; -import type { SidecarCodec } from '../../../types.ts'; import { geminiGenerateContentInlineData, geminiGenerateContentPartKind, geminiGenerateContentPartText, geminiGenerateContentText, geminiGenerateContentThoughtText, geminiGenerateContentVisibleText } from '../as-source/content.ts'; import { partitionGeminiGenerateContentTurns } from '../as-source/conversation-turns.ts'; import { geminiGenerateContentReasoningEffort } from '../as-source/reasoning.ts'; -import { collectGeminiGenerateContentReplayContents, hasGeminiGenerateContentThoughtSignature, findGeminiGenerateContentSidecarCarriers, verifyGeminiGenerateContentTurnDigest } from '../as-source/round-trip.ts'; +import { collectGeminiGenerateContentReplayContents, hasGeminiGenerateContentThoughtSignature, findGeminiGenerateContentSidecarOccurrences, 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 type { GeminiGenerateContentContent, GeminiGenerateContentPayload, GeminiGenerateContentGenerationConfig, GeminiGenerateContentPart } from '@floway-dev/protocols/gemini-generate-content'; @@ -239,7 +239,7 @@ export const buildTargetRequest = async ( let contentIndex = 0; for (const turn of partitionGeminiGenerateContentTurns(payload.contents ?? [])) { - const roundTrip = turn.role === 'assistant' && await roundTripAssistantTurn('geminiGenerateContent', 'openaiChatCompletions', turn.items, codec, findGeminiGenerateContentSidecarCarriers, verifyGeminiGenerateContentTurnDigest, collectGeminiGenerateContentReplayContents); + const roundTrip = turn.role === 'assistant' && await roundTripAssistantTurn('geminiGenerateContent', 'openaiChatCompletions', turn.items, codec, findGeminiGenerateContentSidecarOccurrences, verifyGeminiGenerateContentTurnDigest, collectGeminiGenerateContentReplayContents); if (!roundTrip) { if (turn.role === 'assistant') { for (const [index, content] of turn.items.entries()) { diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts index 7d472247a2..5573f665a3 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts @@ -1,5 +1,5 @@ -import { createRoundTripStream } from '../../../round-trip/stream.ts'; -import type { SidecarCodec } from '../../../types.ts'; +import type { SidecarCodec } from '../../../round-trip/codec.ts'; +import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import { irFromOpenAIResponses } from '../../openai-responses/as-target/sse-from.ts'; import { geminiGenerateContentFromIR } from '../as-source/sse-to.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; @@ -7,6 +7,6 @@ import type { GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gem import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { - const stream = createRoundTripStream('geminiGenerateContent', 'openaiResponses', codec); + const stream = createRoundTripCoordinator('geminiGenerateContent', 'openaiResponses', codec); return geminiGenerateContentFromIR(irFromOpenAIResponses(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); }; 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..3c8d552c73 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,10 +1,10 @@ +import type { SidecarCodec } from '../../../round-trip/codec.ts'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; import { TranslatorInputError } from '../../../translator-input-error.ts'; -import type { SidecarCodec } from '../../../types.ts'; import { geminiGenerateContentInlineData, geminiGenerateContentPartKind, geminiGenerateContentPartText, geminiGenerateContentText, geminiGenerateContentThoughtText, geminiGenerateContentVisibleText } from '../as-source/content.ts'; import { partitionGeminiGenerateContentTurns } from '../as-source/conversation-turns.ts'; import { geminiGenerateContentReasoningEffort } from '../as-source/reasoning.ts'; -import { collectGeminiGenerateContentReplayContents, hasGeminiGenerateContentThoughtSignature, findGeminiGenerateContentSidecarCarriers, verifyGeminiGenerateContentTurnDigest } from '../as-source/round-trip.ts'; +import { collectGeminiGenerateContentReplayContents, hasGeminiGenerateContentThoughtSignature, findGeminiGenerateContentSidecarOccurrences, 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 type { GeminiGenerateContentContent, GeminiGenerateContentPayload, GeminiGenerateContentGenerationConfig, GeminiGenerateContentPart } from '@floway-dev/protocols/gemini-generate-content'; @@ -193,7 +193,7 @@ export const buildTargetRequest = async ( let contentIndex = 0; for (const turn of partitionGeminiGenerateContentTurns(payload.contents ?? [])) { - const roundTrip = turn.role === 'assistant' && await roundTripAssistantTurn('geminiGenerateContent', 'openaiResponses', turn.items, codec, findGeminiGenerateContentSidecarCarriers, verifyGeminiGenerateContentTurnDigest, collectGeminiGenerateContentReplayContents); + const roundTrip = turn.role === 'assistant' && await roundTripAssistantTurn('geminiGenerateContent', 'openaiResponses', turn.items, codec, findGeminiGenerateContentSidecarOccurrences, verifyGeminiGenerateContentTurnDigest, collectGeminiGenerateContentReplayContents); if (!roundTrip) { if (turn.role === 'assistant') { for (const [index, content] of turn.items.entries()) { diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts index c9577cba28..d20f61abdb 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts @@ -1,12 +1,12 @@ import type { IRJSONObject, IRJSONValue } from '../../../ir/types.ts'; -import type { ReplayContent } from '../../../round-trip/reference/assistant-turn.ts'; -import type { EncodedRoundTripSidecar } from '../../../round-trip/resolve.ts'; +import type { ReplayContent } from '../../../round-trip/reference/types.ts'; +import type { SidecarOccurrence } from '../../../round-trip/resolve.ts'; import { collectTextGroups, collectTextPlacementIndex, type TextContribution, type TextPlacementIndex } from '../../../round-trip/text-groups.ts'; import { computeRoundTripTurnDigest, verifyRoundTripTurnDigest } from '../../../round-trip/turn-digest.ts'; import type { RoundTripTurnDigest } from '../../../round-trip/turn-digest.ts'; import type { OpenAIChatCompletionsAssistantExtensions, OpenAIChatCompletionsAssistantMessage, OpenAIChatCompletionsAssistantOutputMessage, OpenAIChatCompletionsToolCallEx } from '@floway-dev/protocols/openai-chat-completions'; -type OpenAIChatCompletionsReplayPart = NonNullable>[number]; +type OpenAIChatCompletionsReplayPart = NonNullable>[number]; type OpenAIChatCompletionsTextGroup = 'text' | 'refusal'; const chatCompletionsTextGroup = (part: OpenAIChatCompletionsReplayPart): TextContribution => @@ -14,18 +14,18 @@ const chatCompletionsTextGroup = (part: OpenAIChatCompletionsReplayPart): TextCo ? { channel: part.type, field: 'text', text: part.text } : { channel: part.type, field: 'refusal', text: part.refusal }; -export type OpenAIChatCompletionsAssistantTurn = (OpenAIChatCompletionsAssistantMessage | OpenAIChatCompletionsAssistantOutputMessage) +export type OpenAIChatCompletionsSourceAssistantTurn = (OpenAIChatCompletionsAssistantMessage | OpenAIChatCompletionsAssistantOutputMessage) & Pick; -export type OpenAIChatCompletionsReasoningDetailsCarrier = { type: 'reasoning.encrypted'; data: string; format: 'unknown'; index: 1 }; +export type OpenAIChatCompletionsSidecarDetail = { type: 'reasoning.encrypted'; data: string; format: 'unknown'; index: 1 }; const reasoningFields = ['reasoning', 'reasoning_text', 'reasoning_content'] as const; const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); -const reasoningDetails = (turn: OpenAIChatCompletionsAssistantTurn): unknown[] => Array.isArray(turn.reasoning_details) ? turn.reasoning_details : []; +const reasoningDetails = (turn: OpenAIChatCompletionsSourceAssistantTurn): unknown[] => Array.isArray(turn.reasoning_details) ? turn.reasoning_details : []; -const reasoningDetailsMaterial = (turn: OpenAIChatCompletionsAssistantTurn): IRJSONObject[] => { +const reasoningDetailsMaterial = (turn: OpenAIChatCompletionsSourceAssistantTurn): IRJSONObject[] => { const materials: IRJSONObject[] = []; for (const value of reasoningDetails(turn)) { const item = isRecord(value) ? value : undefined; @@ -36,7 +36,7 @@ const reasoningDetailsMaterial = (turn: OpenAIChatCompletionsAssistantTurn): IRJ return materials; }; -const messageContentMaterial = (turn: OpenAIChatCompletionsAssistantTurn): IRJSONValue | undefined => { +const messageContentMaterial = (turn: OpenAIChatCompletionsSourceAssistantTurn): IRJSONValue | undefined => { if (typeof turn.content === 'string' || turn.content === null || turn.content === undefined) return turn.content; const channels = collectTextGroups(turn.content, chatCompletionsTextGroup); const parts: IRJSONObject[] = []; @@ -51,7 +51,7 @@ const toolCallMaterial = (call: OpenAIChatCompletionsToolCallEx): IRJSONObject = ? { type: 'function', id: call.id, name: call.function.name, arguments: call.function.arguments } : { type: 'custom', id: call.id, name: call.custom.name, input: call.custom.input }; -const reasoningTextMaterial = (turn: OpenAIChatCompletionsAssistantTurn): IRJSONObject[] => { +const reasoningTextMaterial = (turn: OpenAIChatCompletionsSourceAssistantTurn): IRJSONObject[] => { const materials: IRJSONObject[] = []; for (const field of reasoningFields) { if (!Object.hasOwn(turn, field)) continue; @@ -61,10 +61,10 @@ const reasoningTextMaterial = (turn: OpenAIChatCompletionsAssistantTurn): IRJSON return materials; }; -export const hasOpenAIChatCompletionsReasoning = (turn: OpenAIChatCompletionsAssistantTurn): boolean => +export const hasOpenAIChatCompletionsReasoning = (turn: OpenAIChatCompletionsSourceAssistantTurn): boolean => reasoningFields.some(field => turn[field] != null) || reasoningDetails(turn).length > 0; -const chatTurnDigestMaterialV1 = (turn: OpenAIChatCompletionsAssistantTurn): IRJSONObject => ({ +const chatTurnDigestMaterialV1 = (turn: OpenAIChatCompletionsSourceAssistantTurn): IRJSONObject => ({ ...(Object.hasOwn(turn, 'content') && turn.content !== undefined ? { content: messageContentMaterial(turn) } : {}), ...(Object.hasOwn(turn, 'refusal') && turn.refusal !== undefined ? { refusal: turn.refusal } : {}), ...(turn.function_call != null @@ -75,7 +75,7 @@ const chatTurnDigestMaterialV1 = (turn: OpenAIChatCompletionsAssistantTurn): IRJ reasoningDetails: reasoningDetailsMaterial(turn), }); -export const findOpenAIChatCompletionsSidecarCarriers = (turn: OpenAIChatCompletionsAssistantTurn): EncodedRoundTripSidecar[] => { +export const findOpenAIChatCompletionsSidecarOccurrences = (turn: OpenAIChatCompletionsSourceAssistantTurn): SidecarOccurrence[] => { const details = reasoningDetails(turn); return details.flatMap((detail, detailIndex) => !isRecord(detail) || detail.type !== 'reasoning.encrypted' || typeof detail.data !== 'string' ? [] : [{ data: detail.data, @@ -83,10 +83,10 @@ export const findOpenAIChatCompletionsSidecarCarriers = (turn: OpenAIChatComplet }]); }; -export const computeOpenAIChatCompletionsTurnDigest = (turn: OpenAIChatCompletionsAssistantTurn): Promise => +export const computeOpenAIChatCompletionsTurnDigest = (turn: OpenAIChatCompletionsSourceAssistantTurn): Promise => computeRoundTripTurnDigest(chatTurnDigestMaterialV1(turn)); -export const collectOpenAIChatCompletionsReplayContents = (turn: OpenAIChatCompletionsAssistantTurn): ReplayContent[] => { +export const collectOpenAIChatCompletionsReplayContents = (turn: OpenAIChatCompletionsSourceAssistantTurn): ReplayContent[] => { const candidates: ReplayContent[] = []; const collectString = (value: string | null | undefined): void => { if (typeof value === 'string') candidates.push(value); @@ -106,7 +106,7 @@ export const collectOpenAIChatCompletionsReplayContents = (turn: OpenAIChatCompl }; export const verifyOpenAIChatCompletionsTurnDigest = ( - turn: OpenAIChatCompletionsAssistantTurn, + turn: OpenAIChatCompletionsSourceAssistantTurn, sourceTurnDigest: RoundTripTurnDigest, version: number, ): Promise => { @@ -118,10 +118,10 @@ export const verifyOpenAIChatCompletionsTurnDigest = ( // Our carrier uses indexed OpenRouter reasoning_details. // https://openrouter.ai/docs/guides/best-practices/reasoning-tokens#reasoning-details-api-shape -export const createOpenAIChatCompletionsSidecarCarrier = (data: string): OpenAIChatCompletionsReasoningDetailsCarrier[] => +export const createOpenAIChatCompletionsSidecarDetails = (data: string): OpenAIChatCompletionsSidecarDetail[] => [{ type: 'reasoning.encrypted', data, format: 'unknown', index: 1 }]; -export const collectOpenAIChatCompletionsTextViews = (turn: OpenAIChatCompletionsAssistantTurn): TextPlacementIndex => { +export const collectOpenAIChatCompletionsTextPlacements = (turn: OpenAIChatCompletionsSourceAssistantTurn): TextPlacementIndex => { const views: TextPlacementIndex = new Map(); if (Array.isArray(turn.content)) collectTextPlacementIndex(collectTextGroups(turn.content, chatCompletionsTextGroup), views); return views; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts index 9cba106198..711b141ed2 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts @@ -1,14 +1,14 @@ -import { collectOpenAIChatCompletionsTextViews, computeOpenAIChatCompletionsTurnDigest, createOpenAIChatCompletionsSidecarCarrier } from './round-trip.ts'; +import { collectOpenAIChatCompletionsTextPlacements, computeOpenAIChatCompletionsTurnDigest, createOpenAIChatCompletionsSidecarDetails } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; import { irItemPath, irPartPath, irPathKey } from '../../../ir/operation-paths.ts'; import { createIROrdinalMapper } from '../../../ir/ordinal-mapper.ts'; import { consumeIRRecords, type IRRecord } from '../../../ir/stream.ts'; import { IRAbsent, type IRErrorPayload, type IRItem, type IRMessageItem, type IRTokenLogprob } from '../../../ir/types.ts'; -import { appendContent, createContentState, type ContentStates, type ContentState } from '../../../round-trip/reference/reference-builder.ts'; -import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; -import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; +import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/provenance.ts'; +import { appendEmittedField, createEmittedFieldState, type EmittedFieldStates, type EmittedFieldState } from '../../../round-trip/reference/reference-builder.ts'; +import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; -import { toOpenAIChatCompletionsInputAssistantTurn, type OpenAIChatCompletionsOutputAssistantState } from '../as-target/assistant-turn.ts'; +import { toOpenAIChatCompletionsInputAssistantTurn, type OpenAIChatCompletionsOutputAssistantTurnState } from '../as-target/assistant-turn.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { type OpenAIChatCompletionsAnnotation, @@ -27,7 +27,7 @@ const irChatError = (error: IRErrorPayload): IRErrorPayload => ({ ...(error.provider_specific_fields === undefined ? {} : { provider_specific_fields: error.provider_specific_fields }), }); -interface OpenAIChatCompletionsSSEOutputOptions extends RoundTripOutputOptions<'openaiChatCompletions'> { +interface OpenAIChatCompletionsSSEOutputOptions extends SourceEventWriterOptions<'openaiChatCompletions'> { // Replay identity belongs to the newly emitted target audio, not the source IR content. audioMetadata?: (choice: number) => { id: string; expires_at: number }; continuousUsageStats?: boolean; @@ -100,11 +100,11 @@ export const openaiChatCompletionsFromIR = async function* ( } return accumulator; }; - const outputStates = new Map>(); + const outputStates = new Map>(); const pendingTranscripts = new Map(); let usage: OpenAIChatCompletionsUsageEx | undefined; - const outputState = (choice: number): OpenAIChatCompletionsOutputAssistantState => { + const outputState = (choice: number): OpenAIChatCompletionsOutputAssistantTurnState => { let state = outputStates.get(choice); if (state === undefined) { state = {}; @@ -112,11 +112,11 @@ export const openaiChatCompletionsFromIR = async function* ( } return state; }; - const textState = (choice: number, field: ChatTextField): ContentState<'text'> => { + const textState = (choice: number, field: ChatTextField): EmittedFieldState<'text'> => { const state = outputState(choice); - return state[field] ??= createContentState(options.roundTrip.builder, 'text'); + return state[field] ??= createEmittedFieldState(options.roundTrip.builder, 'text'); }; - const toolState = (choice: number, ordinal: number, custom: boolean): ContentState<'text'> => { + const toolState = (choice: number, ordinal: number, custom: boolean): EmittedFieldState<'text'> => { const choiceState = outputState(choice); const toolCalls = choiceState.tool_calls ??= []; const current = toolCalls[ordinal]; @@ -125,7 +125,7 @@ export const openaiChatCompletionsFromIR = async function* ( if (!custom && 'function' in current && current.function?.arguments !== undefined) return current.function.arguments; throw new TypeError(`ChatCompletions tool state kind changed at ordinal ${ordinal}`); } - const state = createContentState(options.roundTrip.builder, 'text'); + const state = createEmittedFieldState(options.roundTrip.builder, 'text'); toolCalls[ordinal] = custom ? { custom: { input: state } } : { function: { arguments: state } }; return state; }; @@ -255,7 +255,7 @@ export const openaiChatCompletionsFromIR = async function* ( if (record.kind === 'append') { if (path.length === 7 && path[2] === 'items' && path[4] === 'content' && (path[6] === 'text' || path[6] === 'refusal')) { - appendContent(options.roundTrip.builder, textState(choice, path[6] === 'text' ? 'content' : 'refusal'), record.value, record.referencePieceIds); + appendEmittedField(options.roundTrip.builder, textState(choice, path[6] === 'text' ? 'content' : 'refusal'), record.value, record.textFragmentIds); yield deltaFrame(choice, path[6] === 'text' ? { content: record.value } : { refusal: record.value }, tier); continue; } @@ -267,7 +267,7 @@ export const openaiChatCompletionsFromIR = async function* ( const transcript = collector.value(path); if (transcript === undefined) throw new Error('Missing ChatCompletions pending transcript'); const parts = pendingTranscripts.get(audioPath) ?? []; - parts.push(provenanceSpanForAppend(transcript, record.value, record.referencePieceIds)); + parts.push(provenanceSpanForAppend(transcript, record.value, record.textFragmentIds)); pendingTranscripts.set(audioPath, parts); } if (path[7] === 'data') { @@ -286,7 +286,7 @@ export const openaiChatCompletionsFromIR = async function* ( continue; } if (path.length === 6 && path[2] === 'items' && (path[4] === 'summary' || path[4] === 'content')) { - appendContent(options.roundTrip.builder, textState(choice, 'reasoning_text'), record.value, record.referencePieceIds); + appendEmittedField(options.roundTrip.builder, textState(choice, 'reasoning_text'), record.value, record.textFragmentIds); yield deltaFrame(choice, { reasoning_text: record.value }, tier); continue; } @@ -295,7 +295,7 @@ export const openaiChatCompletionsFromIR = async function* ( if (itemType !== 'function_call' && itemType !== 'custom_tool_call') throw new TypeError('ChatCompletions tool delta requires a tool item'); const ordinal = tools.get(choice)!.get(path.slice(0, 4)); const custom = itemType === 'custom_tool_call'; - appendContent(options.roundTrip.builder, toolState(choice, ordinal, custom), record.value, record.referencePieceIds); + appendEmittedField(options.roundTrip.builder, toolState(choice, ordinal, custom), record.value, record.textFragmentIds); const call = custom ? { index: ordinal, custom: { input: record.value } } : { index: ordinal, function: { arguments: record.value } }; @@ -350,8 +350,8 @@ export const openaiChatCompletionsFromIR = async function* ( const part = item.type === 'message' ? item.content[record.part] : undefined; if (part?.type === 'audio' && !startedAudio.has(irPathKey(path)) && part.audio.transcript !== undefined) { const content = textState(record.choice, 'content'); - replayTextProvenance(part.audio.transcript, pendingTranscripts.get(irPathKey(path)) ?? [], (value, referencePieceIds) => { - appendContent(options.roundTrip.builder, content, value, referencePieceIds); + replayTextProvenance(part.audio.transcript, pendingTranscripts.get(irPathKey(path)) ?? [], (value, textFragmentIds) => { + appendEmittedField(options.roundTrip.builder, content, value, textFragmentIds); }); pendingTranscripts.delete(irPathKey(path)); yield deltaFrame(record.choice, { content: part.audio.transcript }, tier); @@ -387,7 +387,7 @@ export const openaiChatCompletionsFromIR = async function* ( }); if (annotations.length > 0) yield deltaFrame(record.choice, { annotations }, tier); if (choice.refusal?.explanation != null) { - appendContent(options.roundTrip.builder, textState(record.choice, 'refusal'), choice.refusal.explanation); + appendEmittedField(options.roundTrip.builder, textState(record.choice, 'refusal'), choice.refusal.explanation); yield deltaFrame(record.choice, { refusal: choice.refusal.explanation }, tier); } const summary = nativeAccumulator(record.choice).result().choices.find(choice => choice.index === record.choice)?.message.reasoning_text; @@ -397,9 +397,9 @@ export const openaiChatCompletionsFromIR = async function* ( const nativeChoice = nativeAccumulator(record.choice).result().choices.find(choice => choice.index === record.choice); if (nativeChoice === undefined) throw new Error(`Missing Chat Completions choice ${record.choice} at choice_end`); const sourceTurnDigest = await computeOpenAIChatCompletionsTurnDigest(nativeChoice.message); - const input = toOpenAIChatCompletionsInputAssistantTurn(nativeChoice.message, outputState(record.choice)); - const data = await options.roundTrip.prepareSidecar(record.choice, sourceTurnDigest, input.turn, input.state, collectOpenAIChatCompletionsTextViews(input.turn)); - yield deltaFrame(record.choice, { reasoning_details: createOpenAIChatCompletionsSidecarCarrier(data) }, tier); + const input = toOpenAIChatCompletionsInputAssistantTurn(nativeChoice.message, outputState(record.choice)); + const data = await options.roundTrip.encodeSidecar(record.choice, sourceTurnDigest, input.turn, input.state, collectOpenAIChatCompletionsTextPlacements(input.turn)); + yield deltaFrame(record.choice, { reasoning_details: createOpenAIChatCompletionsSidecarDetails(data) }, tier); yield chunk([{ index: record.choice, delta: {}, finish_reason: record.finish_reason }], tierFields(tier)); continue; } diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/assistant-turn.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/assistant-turn.ts index f790e05ca6..ae1eb82b0c 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/assistant-turn.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/assistant-turn.ts @@ -1,4 +1,4 @@ -import type { ReferenceState, ReferenceStateLeaves, ReferenceableValue, TextRule } from '../../../round-trip/reference/assistant-turn.ts'; +import type { ReferenceState, ReferenceStateLeaves, ReferenceableValue, TextRule } from '../../../round-trip/reference/types.ts'; import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsAssistantOutputMessageEx } from '@floway-dev/protocols/openai-chat-completions'; type OpenAIChatCompletionsAssistantRule = { @@ -11,26 +11,25 @@ type OpenAIChatCompletionsAssistantRule = { tool_calls: [{ function: { arguments: TextRule }; custom: { input: TextRule } }]; }; -export type OpenAIChatCompletionsReferenceableAssistantTurn = ReferenceableValue; +export type OpenAIChatCompletionsReferenceableInputAssistantTurn = ReferenceableValue; export type OpenAIChatCompletionsOutputAssistantTurn = OpenAIChatCompletionsAssistantOutputMessageEx; export type OpenAIChatCompletionsInputAssistantTurn = OpenAIChatCompletionsAssistantMessageEx; -export type OpenAIChatCompletionsAssistantState = ReferenceState; -export type OpenAIChatCompletionsOutputAssistantState = OpenAIChatCompletionsAssistantState - & ReferenceState; +export type OpenAIChatCompletionsInputAssistantTurnState = ReferenceState; +export type OpenAIChatCompletionsOutputAssistantTurnState = ReferenceState; export function toOpenAIChatCompletionsInputAssistantTurn( outputTurn: OpenAIChatCompletionsOutputAssistantTurn, ): OpenAIChatCompletionsInputAssistantTurn; export function toOpenAIChatCompletionsInputAssistantTurn( outputTurn: OpenAIChatCompletionsOutputAssistantTurn, - outputState: OpenAIChatCompletionsOutputAssistantState, -): { turn: OpenAIChatCompletionsInputAssistantTurn; state: OpenAIChatCompletionsAssistantState }; + outputState: OpenAIChatCompletionsOutputAssistantTurnState, +): { turn: OpenAIChatCompletionsInputAssistantTurn; state: OpenAIChatCompletionsInputAssistantTurnState }; export function toOpenAIChatCompletionsInputAssistantTurn( outputTurn: OpenAIChatCompletionsOutputAssistantTurn, - outputState?: OpenAIChatCompletionsOutputAssistantState, + outputState?: OpenAIChatCompletionsOutputAssistantTurnState, ): OpenAIChatCompletionsInputAssistantTurn | { turn: OpenAIChatCompletionsInputAssistantTurn; - state: OpenAIChatCompletionsAssistantState; + state: OpenAIChatCompletionsInputAssistantTurnState; } { // The SDK serializes supplied message fields directly; prior audio input uses an ID reference. // https://github.com/openai/openai-node/blob/f57c9651c8d22ae82cacca27317cfa7126491011/src/resources/chat/completions/completions.ts#L195-L210 diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts index 26e65ac120..ba04c045d7 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts @@ -1,4 +1,4 @@ -import { toOpenAIChatCompletionsInputAssistantTurn, type OpenAIChatCompletionsOutputAssistantState } from './assistant-turn.ts'; +import { toOpenAIChatCompletionsInputAssistantTurn, type OpenAIChatCompletionsOutputAssistantTurnState } from './assistant-turn.ts'; import { usageToIR } from './usage.ts'; import { createIRBuilder, @@ -9,8 +9,8 @@ import { type IRRelativePath, } from '../../../ir/stream.ts'; import { IRAbsent, type IRErrorPayload, type IRSourceCitation } from '../../../ir/types.ts'; -import { appendField, createFieldState, type FieldStates, type FieldState, type ReferencePieceId } from '../../../round-trip/reference/reference-builder.ts'; -import type { RoundTripReader } from '../../../round-trip/stream.ts'; +import type { RoundTripReader } from '../../../round-trip/reader.ts'; +import { appendReadField, createReadFieldState, type ReadFieldStates, type ReadFieldState, type TextFragmentId } from '../../../round-trip/reference/reference-builder.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { unwrapCustomToolInput } from '../../openai-responses/common/custom-tool-wrap.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; @@ -24,14 +24,14 @@ import { type OpenAIChatCompletionsUsageEx, } from '@floway-dev/protocols/openai-chat-completions'; -type ChatToolFieldState = NonNullable['tool_calls']>[number]>; +type ChatToolFieldState = NonNullable['tool_calls']>[number]>; interface ChatToolState { item?: IRItemHandle; id?: string; name: string; pendingInput: string; - pendingReferencePieceIds: ReferencePieceId[]; + pendingTextFragmentIds: TextFragmentId[]; fieldState?: ChatToolFieldState; custom: boolean; wrapped: boolean; @@ -116,7 +116,7 @@ interface ChatChoiceState { currentTool?: ChatToolState; readonly logprobParts: Map; readonly logprobGroups: Map; - readonly fieldState: OpenAIChatCompletionsOutputAssistantState; + readonly fieldState: OpenAIChatCompletionsOutputAssistantTurnState; ended: boolean; incomplete: boolean; } @@ -163,9 +163,9 @@ export const irFromOpenAIChatCompletions = async function* ( handle: IRWriteHandle, field: IRRelativePath, text: string, - state: FieldState<'text'>, + state: ReadFieldState<'text'>, ): void => { - const partId = appendField(options.roundTrip.builder, state, text); + const partId = appendReadField(options.roundTrip.builder, state, text); handle.append(field, text, [partId]); }; const createItem = (index: number, choice: ChatChoiceState, value: unknown): IRItemHandle => { @@ -198,7 +198,7 @@ export const irFromOpenAIChatCompletions = async function* ( // Append text to the message part that currently owns the text channel, keeping the // choice's concatenated view and (once created) its coordinate index in step. - const appendText = (index: number, choice: ChatChoiceState, text: string, field: FieldState<'text'>): void => { + const appendText = (index: number, choice: ChatChoiceState, text: string, field: ReadFieldState<'text'>): void => { const part = messagePart(index, choice, 'text'); appendFieldAtom(part, 'text', text, field); let segment = choice.textSegments.at(-1); @@ -277,7 +277,7 @@ export const irFromOpenAIChatCompletions = async function* ( if (tool.wrapped) { const input = unwrapCustomToolInput(tool.pendingInput); if (input !== '') tool.item.append('input', input); - tool.pendingReferencePieceIds = []; + tool.pendingTextFragmentIds = []; } tool.item.end(status); }; @@ -307,18 +307,18 @@ export const irFromOpenAIChatCompletions = async function* ( closeMessage(choice); const reasoning = choice.reasoning ??= createItem(index, choice, { type: 'reasoning', summary: [''] }); reasoning.ensure('summary', ['']); - const fieldState = choice.fieldState[field] ??= createFieldState('text'); + const fieldState = choice.fieldState[field] ??= createReadFieldState('text'); appendFieldAtom(reasoning, ['summary', 0], text, fieldState); } }; const readMessage = (index: number, choice: ChatChoiceState, delta: ChatDelta): void => { if (typeof delta.content === 'string' && delta.content !== '') { - const field = choice.fieldState.content ??= createFieldState('text'); + const field = choice.fieldState.content ??= createReadFieldState('text'); appendText(index, choice, delta.content, field); } if (typeof delta.refusal === 'string' && delta.refusal !== '') { - const field = choice.fieldState.refusal ??= createFieldState('text'); + const field = choice.fieldState.refusal ??= createReadFieldState('text'); appendFieldAtom(messagePart(index, choice, 'refusal'), 'refusal', delta.refusal, field); } if (delta.audio != null) { @@ -375,7 +375,7 @@ export const irFromOpenAIChatCompletions = async function* ( tool = { name: '', pendingInput: '', - pendingReferencePieceIds: [], + pendingTextFragmentIds: [], custom: call.type === 'custom' || call.custom !== undefined, wrapped: false, callIdAssigned: false, @@ -391,31 +391,31 @@ export const irFromOpenAIChatCompletions = async function* ( // https://github.com/openai/openai-node/blob/7423ac3e9351c46300cd094479cded5551a72eb4/src/lib/ChatCompletionStream.ts#L2104-L2106 if (fn?.name !== undefined && fn.name !== '') tool.name = fn.name; const fragment = fn?.arguments ?? fn?.input; - let fragmentReferencePieceId: ReferencePieceId | undefined; + let textFragmentId: TextFragmentId | undefined; if (typeof fragment === 'string' && fragment !== '') { - let field: FieldStates['text']; + let field: ReadFieldStates['text']; if (call.index === -1) { const state = choice.fieldState.function_call ??= {}; - field = state.arguments ??= createFieldState('text'); + field = state.arguments ??= createReadFieldState('text'); } else { const state = tool.fieldState; if (call.custom !== undefined) { const existing = state !== undefined && 'custom' in state ? state.custom?.input : undefined; - field = existing ?? createFieldState('text'); + field = existing ?? createReadFieldState('text'); if (existing === undefined) tool.fieldState = { custom: { input: field } }; } else { const existing = state !== undefined && 'function' in state ? state.function?.arguments : undefined; - field = existing ?? createFieldState('text'); + field = existing ?? createReadFieldState('text'); if (existing === undefined) tool.fieldState = { function: { arguments: field } }; } (choice.fieldState.tool_calls ??= [])[call.index] = tool.fieldState; } - fragmentReferencePieceId = appendField(options.roundTrip.builder, field, fragment); + textFragmentId = appendReadField(options.roundTrip.builder, field, fragment); } tool.wrapped = !tool.custom && options.customToolNames?.has(tool.name) === true; if (tool.item === undefined && fragment !== undefined) { tool.pendingInput += fragment; - if (fragmentReferencePieceId !== undefined) tool.pendingReferencePieceIds.push(fragmentReferencePieceId); + if (textFragmentId !== undefined) tool.pendingTextFragmentIds.push(textFragmentId); } if (tool.item === undefined && tool.name !== '') { @@ -435,10 +435,10 @@ export const irFromOpenAIChatCompletions = async function* ( tool.callIdAssigned = callId !== undefined; tool.emittedName = tool.name; if (!tool.wrapped && tool.pendingInput !== '') { - const referencePieceIds = tool.pendingReferencePieceIds; - tool.item.append(custom ? 'input' : 'arguments', tool.pendingInput, referencePieceIds.length > 0 ? referencePieceIds : undefined); + const textFragmentIds = tool.pendingTextFragmentIds; + tool.item.append(custom ? 'input' : 'arguments', tool.pendingInput, textFragmentIds.length > 0 ? textFragmentIds : undefined); tool.pendingInput = ''; - tool.pendingReferencePieceIds = []; + tool.pendingTextFragmentIds = []; } } else if (tool.item !== undefined) { if (tool.id !== undefined && !tool.callIdAssigned) { @@ -449,7 +449,7 @@ export const irFromOpenAIChatCompletions = async function* ( tool.item.assign('name', tool.name); tool.emittedName = tool.name; } - if (fragment !== undefined && fragment !== '' && !tool.wrapped) tool.item.append(tool.custom ? 'input' : 'arguments', fragment, fragmentReferencePieceId === undefined ? undefined : [fragmentReferencePieceId]); + if (fragment !== undefined && fragment !== '' && !tool.wrapped) tool.item.append(tool.custom ? 'input' : 'arguments', fragment, textFragmentId === undefined ? undefined : [textFragmentId]); if (fragment !== undefined && tool.wrapped) tool.pendingInput += fragment; } }; @@ -465,7 +465,7 @@ export const irFromOpenAIChatCompletions = async function* ( .filter(([wireIndex]) => wireIndex >= 0) .toSorted(([left], [right]) => left - right) .map(([, tool]) => tool.fieldState); - const input = toOpenAIChatCompletionsInputAssistantTurn(nativeChoice.message, choice.fieldState); + const input = toOpenAIChatCompletionsInputAssistantTurn(nativeChoice.message, choice.fieldState); options.roundTrip.onAssistantTurn(index, input.turn, input.state); ir.choice(index).end(finishReason === 'function_call' ? 'tool_calls' diff --git a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts index 3a9743ea27..56097f10c8 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts @@ -1,5 +1,5 @@ -import { createRoundTripStream } from '../../../round-trip/stream.ts'; -import type { SidecarCodec } from '../../../types.ts'; +import type { SidecarCodec } from '../../../round-trip/codec.ts'; +import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import { irFromAnthropicMessages } from '../../anthropic-messages/as-target/sse-from.ts'; import { openaiChatCompletionsFromIR } from '../as-source/sse-to.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; @@ -12,6 +12,6 @@ export const translateToSourceEvents = ( codec: SidecarCodec, ): AsyncIterable> => { const outputOptions = { continuousUsageStats: streamOptions.include_usage === true && streamOptions.continuous_usage_stats === true }; - const stream = createRoundTripStream('openaiChatCompletions', 'anthropicMessages', codec); + const stream = createRoundTripCoordinator('openaiChatCompletions', 'anthropicMessages', codec); return openaiChatCompletionsFromIR(irFromAnthropicMessages(frames, { roundTrip: stream.reader }), { ...outputOptions, roundTrip: stream.writer }); }; 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..80e5e04a99 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,14 +1,15 @@ import { klona } from 'klona/json'; +import type { SidecarCodec } from '../../../round-trip/codec.ts'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; import { TranslatorInputError } from '../../../translator-input-error.ts'; -import type { SidecarCodec, RemoteImageLoader } from '../../../types.ts'; +import type { 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'; import { resolveImageUrlToAnthropicMessagesImage } from '../../anthropic-messages/as-target/remote-images.ts'; import { anthropicMessagesToolInputSchema } from '../../anthropic-messages/as-target/tool-input-schema.ts'; import { partitionOpenAIChatCompletionsTurns } from '../as-source/conversation-turns.ts'; -import { collectOpenAIChatCompletionsReplayContents, hasOpenAIChatCompletionsReasoning, findOpenAIChatCompletionsSidecarCarriers, verifyOpenAIChatCompletionsTurnDigest } from '../as-source/round-trip.ts'; +import { collectOpenAIChatCompletionsReplayContents, hasOpenAIChatCompletionsReasoning, findOpenAIChatCompletionsSidecarOccurrences, verifyOpenAIChatCompletionsTurnDigest } from '../as-source/round-trip.ts'; import { ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS, type AnthropicMessagesAssistantInputContentBlock, type AnthropicMessagesMessage, type AnthropicMessagesPayload, type AnthropicMessagesTextBlockParam, type AnthropicMessagesUserContentBlock } from '@floway-dev/protocols/anthropic-messages'; import { isFastServiceTier } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsAssistantMessage, OpenAIChatCompletionsPayload, OpenAIChatCompletionsMessage, OpenAIChatCompletionsTool } from '@floway-dev/protocols/openai-chat-completions'; @@ -226,7 +227,7 @@ export const buildTargetRequest = async ( const messages: AnthropicMessagesMessage[] = []; const restoredMessages = new Set(); for (const turn of partitionOpenAIChatCompletionsTurns(payload.messages.slice(prefixEnd))) { - const roundTrip = turn.role === 'assistant' && await roundTripAssistantTurn('openaiChatCompletions', 'anthropicMessages', turn.items[0], codec, findOpenAIChatCompletionsSidecarCarriers, verifyOpenAIChatCompletionsTurnDigest, collectOpenAIChatCompletionsReplayContents); + const roundTrip = turn.role === 'assistant' && await roundTripAssistantTurn('openaiChatCompletions', 'anthropicMessages', turn.items[0], codec, findOpenAIChatCompletionsSidecarOccurrences, verifyOpenAIChatCompletionsTurnDigest, collectOpenAIChatCompletionsReplayContents); if (!roundTrip) { if (turn.role === 'assistant') { const message = turn.items[0]; diff --git a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts index b09f7e7e74..1a449ff55f 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts @@ -1,5 +1,5 @@ -import { createRoundTripStream } from '../../../round-trip/stream.ts'; -import type { SidecarCodec } from '../../../types.ts'; +import type { SidecarCodec } from '../../../round-trip/codec.ts'; +import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import { irFromOpenAIResponses } from '../../openai-responses/as-target/sse-from.ts'; import { openaiChatCompletionsFromIR } from '../as-source/sse-to.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; @@ -12,6 +12,6 @@ export const translateToSourceEvents = ( codec: SidecarCodec, ): AsyncIterable> => { const outputOptions = { continuousUsageStats: streamOptions.include_usage === true && streamOptions.continuous_usage_stats === true }; - const stream = createRoundTripStream('openaiChatCompletions', 'openaiResponses', codec); + const stream = createRoundTripCoordinator('openaiChatCompletions', 'openaiResponses', codec); return openaiChatCompletionsFromIR(irFromOpenAIResponses(frames, { roundTrip: stream.reader }), { ...outputOptions, roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/request.ts b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/request.ts index 66a4928365..f753dab79f 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 @@ -1,11 +1,11 @@ import { klona } from 'klona/json'; import { openaiChatCompletionsContentToOpenAIResponsesInputContent, openaiChatCompletionsContentToText } from './content.ts'; +import type { SidecarCodec } from '../../../round-trip/codec.ts'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; import { TranslatorInputError } from '../../../translator-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'; +import { collectOpenAIChatCompletionsReplayContents, hasOpenAIChatCompletionsReasoning, findOpenAIChatCompletionsSidecarOccurrences, verifyOpenAIChatCompletionsTurnDigest } from '../as-source/round-trip.ts'; import type { OpenAIChatCompletionsAssistantMessage, OpenAIChatCompletionsMessage, OpenAIChatCompletionsPayload, OpenAIChatCompletionsTool } from '@floway-dev/protocols/openai-chat-completions'; import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesInputContent, CanonicalOpenAIResponsesInputItem, OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; @@ -156,7 +156,7 @@ export const buildTargetRequest = async ( }; for (const turn of partitionOpenAIChatCompletionsTurns(payload.messages)) { - const roundTrip = turn.role === 'assistant' && await roundTripAssistantTurn('openaiChatCompletions', 'openaiResponses', turn.items[0], codec, findOpenAIChatCompletionsSidecarCarriers, verifyOpenAIChatCompletionsTurnDigest, collectOpenAIChatCompletionsReplayContents); + const roundTrip = turn.role === 'assistant' && await roundTripAssistantTurn('openaiChatCompletions', 'openaiResponses', turn.items[0], codec, findOpenAIChatCompletionsSidecarOccurrences, verifyOpenAIChatCompletionsTurnDigest, collectOpenAIChatCompletionsReplayContents); if (!roundTrip) { if (turn.role === 'assistant') translateAssistantMessage(turn.items[0]); else translateNonAssistantMessages(turn.items); diff --git a/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts b/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts index d58668c45b..62a5faa2b9 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts @@ -1,6 +1,6 @@ import type { IRJSONObject, IRJSONValue } from '../../../ir/types.ts'; -import type { ReplayContent } from '../../../round-trip/reference/assistant-turn.ts'; -import type { EncodedRoundTripSidecar } from '../../../round-trip/resolve.ts'; +import type { ReplayContent } from '../../../round-trip/reference/types.ts'; +import type { SidecarOccurrence } from '../../../round-trip/resolve.ts'; import { collectTextGroups, collectTextPlacementIndex, type TextContribution, type TextPlacementIndex } from '../../../round-trip/text-groups.ts'; import { computeRoundTripTurnDigest, replayMaterialsByType, verifyRoundTripTurnDigest } from '../../../round-trip/turn-digest.ts'; import type { RoundTripTurnDigest } from '../../../round-trip/turn-digest.ts'; @@ -18,14 +18,14 @@ const responsesMessageTextGroup = (part: OpenAIResponsesMessagePart): TextContri const responsesReasoningTextGroup = (part: Part): TextContribution<'text'> => ({ channel: 'text', field: 'text', text: part.text }); -export type OpenAIResponsesAssistantTurnItem = CanonicalOpenAIResponsesInputItem | OpenAIResponsesOutputItemEx; -export type OpenAIResponsesAssistantTurn = OpenAIResponsesAssistantTurnItem[]; +export type OpenAIResponsesSourceAssistantItem = CanonicalOpenAIResponsesInputItem | OpenAIResponsesOutputItemEx; +export type OpenAIResponsesSourceAssistantTurn = OpenAIResponsesSourceAssistantItem[]; -export type OpenAIResponsesSidecarCarrier = OpenAIResponsesOutputReasoning & { summary: []; encrypted_content: string }; +export type OpenAIResponsesSidecarItem = OpenAIResponsesOutputReasoning & { summary: []; encrypted_content: string }; type TypedReplayMaterial = IRJSONObject & { type: string }; -export const isOpenAIResponsesEncryptedReasoning = (item: OpenAIResponsesAssistantTurnItem): item is Extract & { encrypted_content: string } => +export const isOpenAIResponsesEncryptedReasoning = (item: OpenAIResponsesSourceAssistantItem): item is Extract & { encrypted_content: string } => item.type === 'reasoning' && typeof item.encrypted_content === 'string'; const responseMessageMaterials = (content: string | OpenAIResponsesMessagePart[]): [string, TypedReplayMaterial[]][] => { @@ -42,10 +42,10 @@ const responseMessageMaterials = (content: string | OpenAIResponsesMessagePart[] const concatenateResponseText = (parts: readonly Part[]): string => collectTextGroups(parts, responsesReasoningTextGroup).get('text')?.text ?? ''; -const replayItemStatus = (item: OpenAIResponsesAssistantTurnItem): { status?: string | null } => +const replayItemStatus = (item: OpenAIResponsesSourceAssistantItem): { status?: string | null } => 'status' in item && item.status !== undefined ? { status: item.status } : {}; -const replayItemMaterial = (item: OpenAIResponsesAssistantTurnItem): TypedReplayMaterial => { +const replayItemMaterial = (item: OpenAIResponsesSourceAssistantItem): TypedReplayMaterial => { if (item.type === 'reasoning') return { type: item.type, ...replayItemStatus(item), @@ -79,9 +79,9 @@ const replayItemMaterial = (item: OpenAIResponsesAssistantTurnItem): TypedReplay return { type: item.type, ...replayItemStatus(item) }; }; -const responsesTurnDigestMaterialV1 = (turn: OpenAIResponsesAssistantTurn): IRJSONValue => replayMaterialsByType(turn.map(replayItemMaterial)); +const responsesTurnDigestMaterialV1 = (turn: OpenAIResponsesSourceAssistantTurn): IRJSONValue => replayMaterialsByType(turn.map(replayItemMaterial)); -export const findOpenAIResponsesSidecarCarriers = (turn: OpenAIResponsesAssistantTurn): EncodedRoundTripSidecar[] => +export const findOpenAIResponsesSidecarOccurrences = (turn: OpenAIResponsesSourceAssistantTurn): SidecarOccurrence[] => turn.flatMap((item, itemIndex) => !isOpenAIResponsesEncryptedReasoning(item) ? [] : [{ data: item.encrypted_content, withoutSidecar: () => { @@ -91,10 +91,10 @@ export const findOpenAIResponsesSidecarCarriers = (turn: OpenAIResponsesAssistan }, }]); -export const computeOpenAIResponsesTurnDigest = (turn: OpenAIResponsesAssistantTurn): Promise => +export const computeOpenAIResponsesTurnDigest = (turn: OpenAIResponsesSourceAssistantTurn): Promise => computeRoundTripTurnDigest(responsesTurnDigestMaterialV1(turn)); -export const collectOpenAIResponsesReplayContents = (turn: OpenAIResponsesAssistantTurn): ReplayContent[] => { +export const collectOpenAIResponsesReplayContents = (turn: OpenAIResponsesSourceAssistantTurn): ReplayContent[] => { const candidates: ReplayContent[] = []; for (const item of turn) { if (item.type === 'message') { @@ -113,7 +113,7 @@ export const collectOpenAIResponsesReplayContents = (turn: OpenAIResponsesAssist }; export const verifyOpenAIResponsesTurnDigest = ( - turn: OpenAIResponsesAssistantTurn, + turn: OpenAIResponsesSourceAssistantTurn, sourceTurnDigest: RoundTripTurnDigest, version: number, ): Promise => { @@ -123,14 +123,14 @@ export const verifyOpenAIResponsesTurnDigest = ( } }; -export const createOpenAIResponsesSidecarCarrier = (id: string, data: string): OpenAIResponsesSidecarCarrier => ({ +export const createOpenAIResponsesSidecarItem = (id: string, data: string): OpenAIResponsesSidecarItem => ({ type: 'reasoning', id, summary: [], encrypted_content: data, }); -export const collectOpenAIResponsesTextViews = (turn: OpenAIResponsesAssistantTurn): TextPlacementIndex => { +export const collectOpenAIResponsesTextPlacements = (turn: OpenAIResponsesSourceAssistantTurn): TextPlacementIndex => { const views: TextPlacementIndex = new Map(); for (const item of turn) { if (item.type === 'message' && Array.isArray(item.content)) { diff --git a/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts b/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts index b08ce80dfe..43ebb76010 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts @@ -1,6 +1,6 @@ import { restoreNamespaceOutputItem, type NamespaceToolNames } from './namespace-tools.ts'; import { createRandomOpenAIResponsesResponseId } from './response-id.ts'; -import { collectOpenAIResponsesTextViews, computeOpenAIResponsesTurnDigest, createOpenAIResponsesSidecarCarrier } from './round-trip.ts'; +import { collectOpenAIResponsesTextPlacements, computeOpenAIResponsesTurnDigest, createOpenAIResponsesSidecarItem } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; import type { IRCollector } from '../../../ir/collector.ts'; import { irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/operation-paths.ts'; @@ -8,12 +8,12 @@ import { createIROrdinalMapper } from '../../../ir/ordinal-mapper.ts'; import { consumeIRRecords, type IRPath, type IRRecord } from '../../../ir/stream.ts'; import { IRAbsent, type IR, type IRItem, type IRMessageItem, type IRSourceCitation, type IRTokenLogprob } from '../../../ir/types.ts'; import { createIRUnitQueue, createIRUnitRegistry } from '../../../ir/unit-queue.ts'; -import { appendContent, createContentState, type ContentState, type ContentStates } from '../../../round-trip/reference/reference-builder.ts'; -import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; -import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; +import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/provenance.ts'; +import { appendEmittedField, createEmittedFieldState, type EmittedFieldState, type EmittedFieldStates } from '../../../round-trip/reference/reference-builder.ts'; +import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; import { isCompleteJSONObject, parseJSONObjectAtCompletion } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; -import { toOpenAIResponsesInputAssistantTurn, type OpenAIResponsesItemState, type OpenAIResponsesOutputAssistantState } from '../as-target/assistant-turn.ts'; +import { toOpenAIResponsesInputAssistantTurn, type OpenAIResponsesItemState, type OpenAIResponsesOutputAssistantTurnState } from '../as-target/assistant-turn.ts'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { createRandomOpenAIResponsesItemId, @@ -45,10 +45,10 @@ type ResponsesOutputUnit = ResponsesOutputUnitBase & ( | { readonly kind: 'image' } | { readonly kind: 'message'; readonly contentState: ResponsesMessageState } | { readonly kind: 'reasoning'; readonly contentState: ResponsesReasoningState } - | { readonly kind: 'function_call' | 'custom_tool_call'; toolContentState?: ContentState<'text'> } + | { readonly kind: 'function_call' | 'custom_tool_call'; toolContentState?: EmittedFieldState<'text'> } ); -type OpenAIResponsesSSEOutputOptions = RoundTripOutputOptions<'openaiResponses'> & { namespaceToolNames: NamespaceToolNames }; +type OpenAIResponsesSSEOutputOptions = SourceEventWriterOptions<'openaiResponses'> & { namespaceToolNames: NamespaceToolNames }; const IMAGE_OUTPUT_FORMATS = { 'image/png': 'png', 'image/jpeg': 'jpeg', 'image/webp': 'webp' } as const; @@ -63,8 +63,8 @@ interface OpenedUnit { type ReasoningField = 'summary' | 'content'; type MessagePart = IRMessageItem['content'][number]; -type ResponsesMessageState = OpenAIResponsesItemState, ContentStates>; -type ResponsesReasoningState = OpenAIResponsesItemState, ContentStates>; +type ResponsesMessageState = OpenAIResponsesItemState, EmittedFieldStates>; +type ResponsesReasoningState = OpenAIResponsesItemState, EmittedFieldStates>; const isRecord = (value: unknown): value is Readonly> => typeof value === 'object' && value !== null && !Array.isArray(value); @@ -185,7 +185,7 @@ export const openaiResponsesFromIR = async function* ( let sequence = 0; let finishReason: Extract['finish_reason'] = 'stop'; const outputUnits: ResponsesOutputUnit[] = []; - const nativeState: OpenAIResponsesOutputAssistantState = []; + const nativeState: OpenAIResponsesOutputAssistantTurnState = []; const units = createIRUnitRegistry(); const messageUnits = new Map(); const logprobGroups = new Map(); @@ -201,15 +201,15 @@ export const openaiResponsesFromIR = async function* ( units.set(unit.path, unit); }; - const appendUnitText = (unit: ResponsesOutputUnit, path: IRPath, text: string, referencePieceIds?: readonly number[]): void => { - let content: ContentState<'text'>; + const appendUnitText = (unit: ResponsesOutputUnit, path: IRPath, text: string, textFragmentIds?: readonly number[]): void => { + let content: EmittedFieldState<'text'>; if (unit.kind === 'message') { const index = unit.contentOrdinals.get(path.slice(0, 6)); const parts = unit.contentState.content ??= []; const part = parts[index]; const refusal = path.at(-1) === 'refusal'; if (part === undefined) { - content = createContentState(options.roundTrip.builder, 'text'); + content = createEmittedFieldState(options.roundTrip.builder, 'text'); parts[index] = refusal ? { refusal: content } : { text: content }; } else if (refusal) { if (!('refusal' in part) || part.refusal === undefined) throw new Error('Responses refusal state has the wrong kind'); @@ -224,17 +224,17 @@ export const openaiResponsesFromIR = async function* ( if ((field !== 'summary' && field !== 'content') || typeof index !== 'number') throw new Error('Responses reasoning text requires a native part'); const parts = unit.contentState[field] ??= []; const part = parts[index] ??= {}; - content = part.text ??= createContentState(options.roundTrip.builder, 'text'); + content = part.text ??= createEmittedFieldState(options.roundTrip.builder, 'text'); } else if (unit.kind === 'function_call' || unit.kind === 'custom_tool_call') { if (unit.toolContentState === undefined) throw new Error('Responses tool text requires an opened native field'); content = unit.toolContentState; } else return; - appendContent(options.roundTrip.builder, content, text, referencePieceIds); + appendEmittedField(options.roundTrip.builder, content, text, textFragmentIds); }; const appendPendingText = (unit: ResponsesOutputUnit, path: IRPath, text: string): void => { const key = irPathKey(path); - replayTextProvenance(text, unit.pendingText?.get(key) ?? [], (value, referencePieceIds) => appendUnitText(unit, path, value, referencePieceIds)); + replayTextProvenance(text, unit.pendingText?.get(key) ?? [], (value, textFragmentIds) => appendUnitText(unit, path, value, textFragmentIds)); unit.pendingText?.delete(key); }; @@ -488,7 +488,7 @@ export const openaiResponsesFromIR = async function* ( ? { type: 'custom_tool_call', id, call_id: resolvedCallId, name: item.name, input: '', status: 'in_progress' } : { type: 'function_call', id, call_id: resolvedCallId, name: item.name, arguments: '', status: 'in_progress' }; const restored = restoreNamespaceOutputItem(added, options.namespaceToolNames, 'in_progress'); - const content = createContentState(options.roundTrip.builder, 'text'); + const content = createEmittedFieldState(options.roundTrip.builder, 'text'); unit.toolContentState = content; nativeState[outputIndex] = restored.type === 'custom_tool_call' ? { input: content } : { arguments: content }; const followUps: OpenAIResponsesStreamEventEx[] = []; @@ -664,14 +664,14 @@ export const openaiResponsesFromIR = async function* ( registerUnits(record, state); const unit = units.forRecord(record, state); if (unit !== undefined && unit.kind !== 'image' && record.type === 'write' && record.kind === 'append') { - if (unit.opened) appendUnitText(unit, record.path, record.value, record.referencePieceIds); + if (unit.opened) appendUnitText(unit, record.path, record.value, record.textFragmentIds); else { const text = collector.value(record.path); if (text === undefined) throw new Error('Missing Responses pending text'); const fields = unit.pendingText ??= new Map(); const key = irPathKey(record.path); const atoms = fields.get(key) ?? []; - atoms.push(provenanceSpanForAppend(text, record.value, record.referencePieceIds)); + atoms.push(provenanceSpanForAppend(text, record.value, record.textFragmentIds)); fields.set(key, atoms); } } @@ -811,9 +811,9 @@ export const openaiResponsesFromIR = async function* ( const nativeOutput = outputUnits.map(unit => restoreNamespaceOutputItem(outputItem(state, unit), options.namespaceToolNames, 'completed')); nativeState.length = nativeOutput.length; const sourceTurnDigest = await computeOpenAIResponsesTurnDigest(nativeOutput); - const input = toOpenAIResponsesInputAssistantTurn(nativeOutput, nativeState); - const data = await options.roundTrip.prepareSidecar(0, sourceTurnDigest, input.turn, input.state, collectOpenAIResponsesTextViews(input.turn)); - sidecar = { ...createOpenAIResponsesSidecarCarrier(createRandomOpenAIResponsesItemId('reasoning'), data), status: 'completed' }; + const input = toOpenAIResponsesInputAssistantTurn(nativeOutput, nativeState); + const data = await options.roundTrip.encodeSidecar(0, sourceTurnDigest, input.turn, input.state, collectOpenAIResponsesTextPlacements(input.turn)); + sidecar = { ...createOpenAIResponsesSidecarItem(createRandomOpenAIResponsesItemId('reasoning'), data), status: 'completed' }; const outputIndex = outputUnits.length; yield emit({ type: 'response.output_item.added', output_index: outputIndex, item: sidecar }); yield emit({ type: 'response.output_item.done', output_index: outputIndex, item: sidecar }); diff --git a/packages/translate/src/protocols/openai-responses/as-target/assistant-turn.ts b/packages/translate/src/protocols/openai-responses/as-target/assistant-turn.ts index e3e6f4cde1..1455e2b918 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/assistant-turn.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/assistant-turn.ts @@ -1,4 +1,4 @@ -import type { ReferenceState, ReferenceStateLeaves, ReferenceableValue, TextRule } from '../../../round-trip/reference/assistant-turn.ts'; +import type { ReferenceState, ReferenceStateLeaves, ReferenceableValue, TextRule } from '../../../round-trip/reference/types.ts'; import type { CanonicalOpenAIResponsesInputItem, OpenAIResponsesOutputItemEx } from '@floway-dev/protocols/openai-responses'; type OpenAIResponsesItemRule = T extends { type: 'message' } @@ -11,17 +11,18 @@ type OpenAIResponsesItemRule = T extends { type: 'message' } ? { input: TextRule } : never; -type OpenAIResponsesThinItem = T extends unknown +type OpenAIResponsesReferenceableItem = T extends unknown ? OpenAIResponsesItemRule extends never ? T : ReferenceableValue, Reference> : never; export type OpenAIResponsesItemState = T extends unknown ? OpenAIResponsesItemRule extends never ? never : ReferenceState, States> : never; -export type OpenAIResponsesReferenceableAssistantTurn = OpenAIResponsesThinItem[]; +export type OpenAIResponsesInputAssistantTurn = CanonicalOpenAIResponsesInputItem[]; +export type OpenAIResponsesReferenceableInputAssistantTurn = OpenAIResponsesReferenceableItem[]; export type OpenAIResponsesOutputAssistantTurn = readonly OpenAIResponsesOutputItemEx[]; -export type OpenAIResponsesOutputAssistantState = Array | undefined>; -export type OpenAIResponsesAssistantState = Array | undefined>; +export type OpenAIResponsesOutputAssistantTurnState = Array | undefined>; +export type OpenAIResponsesInputAssistantTurnState = Array | undefined>; type OpenAIResponsesOutputMessage = Extract; type OpenAIResponsesOutputContent = OpenAIResponsesOutputMessage['content'][number]; @@ -68,14 +69,14 @@ const outputItemToHistoryItem = (item: OpenAIResponsesOutputItemEx): CanonicalOp export function toOpenAIResponsesInputAssistantTurn( turn: OpenAIResponsesOutputAssistantTurn, -): CanonicalOpenAIResponsesInputItem[]; +): OpenAIResponsesInputAssistantTurn; export function toOpenAIResponsesInputAssistantTurn( turn: OpenAIResponsesOutputAssistantTurn, - state: OpenAIResponsesOutputAssistantState, -): { turn: CanonicalOpenAIResponsesInputItem[]; state: OpenAIResponsesAssistantState }; + state: OpenAIResponsesOutputAssistantTurnState, +): { turn: OpenAIResponsesInputAssistantTurn; state: OpenAIResponsesInputAssistantTurnState }; export function toOpenAIResponsesInputAssistantTurn( turn: OpenAIResponsesOutputAssistantTurn, - state?: OpenAIResponsesOutputAssistantState, + state?: OpenAIResponsesOutputAssistantTurnState, ) { // Match the official SDK's history normalization for SDK-owned fields and replay-only item transforms. // https://github.com/openai/openai-node/blob/f57c9651c8d22ae82cacca27317cfa7126491011/src/lib/responses/ResponseInputItems.ts#L41-L67 @@ -86,7 +87,7 @@ export function toOpenAIResponsesInputAssistantTurn item.turn); if (state === undefined) return inputTurn; - const inputState: OpenAIResponsesAssistantState = []; + const inputState: OpenAIResponsesInputAssistantTurnState = []; inputState.length = items.length; items.forEach(({ turn: inputItem, sourceIndex }, index) => { if (!Object.hasOwn(state, sourceIndex)) return; diff --git a/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts b/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts index 8521bdc743..584f704bfb 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts @@ -1,4 +1,4 @@ -import { toOpenAIResponsesInputAssistantTurn, type OpenAIResponsesItemState, type OpenAIResponsesOutputAssistantState } from './assistant-turn.ts'; +import { toOpenAIResponsesInputAssistantTurn, type OpenAIResponsesItemState, type OpenAIResponsesOutputAssistantTurnState } from './assistant-turn.ts'; import { usageToIR } from './usage.ts'; import { createIRBuilder, @@ -9,8 +9,8 @@ import { type IRWriteHandle, } from '../../../ir/stream.ts'; import type { IRSourceCitation } from '../../../ir/types.ts'; -import { appendField, createFieldState, invalidateField, type FieldStates } from '../../../round-trip/reference/reference-builder.ts'; -import type { RoundTripReader } from '../../../round-trip/stream.ts'; +import type { RoundTripReader } from '../../../round-trip/reader.ts'; +import { appendReadField, createReadFieldState, invalidateReadField, type ReadFieldStates } from '../../../round-trip/reference/reference-builder.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { @@ -23,10 +23,10 @@ import type { OpenAIResponsesStreamEventEx, } from '@floway-dev/protocols/openai-responses'; -type ReasoningFieldState = OpenAIResponsesItemState, FieldStates>; -type MessageFieldState = OpenAIResponsesItemState, FieldStates>; -type FunctionFieldState = OpenAIResponsesItemState, FieldStates>; -type CustomFieldState = OpenAIResponsesItemState, FieldStates>; +type ReasoningFieldState = OpenAIResponsesItemState, ReadFieldStates>; +type MessageFieldState = OpenAIResponsesItemState, ReadFieldStates>; +type FunctionFieldState = OpenAIResponsesItemState, ReadFieldStates>; +type CustomFieldState = OpenAIResponsesItemState, ReadFieldStates>; type TextFieldStateNode = NonNullable[number]>; interface WriteTarget { @@ -35,7 +35,7 @@ interface WriteTarget { } interface ResponsesTextTarget extends WriteTarget { - fieldState?: FieldStates['text']; + fieldState?: ReadFieldStates['text']; } interface ResponsesOutputTextPartState extends ResponsesTextTarget { @@ -149,7 +149,7 @@ export const irFromOpenAIResponses = async function* ( ): AsyncGenerator { const ir = createIRBuilder(); const items = new Map(); - const outputState: OpenAIResponsesOutputAssistantState = []; + const outputState: OpenAIResponsesOutputAssistantTurnState = []; let started = false; let finished = false; let responseIdentity: { id: string; model: string } | undefined; @@ -226,7 +226,7 @@ export const irFromOpenAIResponses = async function* ( const ensureToolText = (state: ResponsesItemState): ResponsesTextTarget => { if (state.item === undefined || (state.type !== 'function_call' && state.type !== 'custom_tool_call')) throw new TypeError('Responses tool text requires a tool item'); if (state.toolText !== undefined) return state.toolText; - const fieldState = createFieldState('text'); + const fieldState = createReadFieldState('text'); if (state.type === 'function_call') { const node = state.fieldState; node.arguments = fieldState; @@ -242,7 +242,7 @@ export const irFromOpenAIResponses = async function* ( const appendTextAtom = (target: ResponsesTextTarget, text: string): void => { if (target.fieldState === undefined) target.handle.append(target.path, text); - else target.handle.append(target.path, text, [appendField(options.roundTrip.builder, target.fieldState, text)]); + else target.handle.append(target.path, text, [appendReadField(options.roundTrip.builder, target.fieldState, text)]); }; const reconcileAppend = (target: ResponsesTextTarget, snapshot: unknown): string => { const { handle, path } = target; @@ -271,7 +271,7 @@ export const irFromOpenAIResponses = async function* ( if (current === '') appendTextAtom(target, suffix); else { if (target.fieldState !== undefined) { - invalidateField(target.fieldState); + invalidateReadField(target.fieldState); target.fieldState = undefined; } handle.append(path, suffix); @@ -298,7 +298,7 @@ export const irFromOpenAIResponses = async function* ( if (index > length) throw new TypeError(`Responses ${field} index is not contiguous`); if (index === length) { state.item.initialize([field, index], ''); - const fieldState = createFieldState('text'); + const fieldState = createReadFieldState('text'); const fieldStateNode = { text: fieldState }; const nativeState = state.fieldState; const parts = nativeState[field] ??= []; @@ -327,7 +327,7 @@ export const irFromOpenAIResponses = async function* ( contentIndex: number, path: 'text' | 'refusal', ) => { - const fieldState = createFieldState('text'); + const fieldState = createReadFieldState('text'); const fieldStateNode: TextFieldStateNode = path === 'text' ? { text: fieldState } : { refusal: fieldState }; const content = owner.fieldState.content ??= []; content[contentIndex] = fieldStateNode; @@ -603,7 +603,7 @@ export const irFromOpenAIResponses = async function* ( const policy = response.error?.code === 'cyber_policy' ? 'cyber' : response.error?.code === 'bio_policy' ? 'bio' : undefined; if (policy !== undefined) ir.choice(0).assign('refusal', { category: policy, explanation: response.error?.message }); outputState.length = terminalOutput.length; - const input = toOpenAIResponsesInputAssistantTurn(terminalOutput, outputState); + const input = toOpenAIResponsesInputAssistantTurn(terminalOutput, outputState); options.roundTrip.onAssistantTurn(0, input.turn, input.state); const incompleteReason = response.incomplete_details?.reason === 'content_filter' ? 'content_filter' : 'length'; const finishReason = policy !== undefined ? 'content_filter' diff --git a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts index 2450fc20f0..c65d3a0d14 100644 --- a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts +++ b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts @@ -1,5 +1,5 @@ -import { createRoundTripStream } from '../../../round-trip/stream.ts'; -import type { SidecarCodec } from '../../../types.ts'; +import type { SidecarCodec } from '../../../round-trip/codec.ts'; +import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import { irFromAnthropicMessages } from '../../anthropic-messages/as-target/sse-from.ts'; import type { NamespaceToolNames } from '../as-source/namespace-tools.ts'; import { openaiResponsesFromIR } from '../as-source/sse-to.ts'; @@ -8,6 +8,6 @@ import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; export const translateToSourceEvents = (frames: AsyncIterable>, responseId: string, customToolNames: ReadonlySet, codec: SidecarCodec, namespaceToolNames: NamespaceToolNames): AsyncIterable> => { - const stream = createRoundTripStream('openaiResponses', 'anthropicMessages', codec); + const stream = createRoundTripCoordinator('openaiResponses', 'anthropicMessages', codec); return openaiResponsesFromIR(irFromAnthropicMessages(frames, { customToolNames, roundTrip: stream.reader }), { id: responseId, namespaceToolNames, roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts index ae2be7981b..3f6ecc586a 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 @@ -1,10 +1,11 @@ import { klona } from 'klona/json'; import { openaiResponsesReasoningToAnthropicMessagesUpstreamBlock } from './reasoning.ts'; +import type { SidecarCodec } from '../../../round-trip/codec.ts'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; import { parseJSONObject } from '../../../shared/json/object.ts'; import { TranslatorInputError } from '../../../translator-input-error.ts'; -import type { SidecarCodec, RemoteImageLoader } from '../../../types.ts'; +import type { 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'; import { resolveImageUrlToAnthropicMessagesImage } from '../../anthropic-messages/as-target/remote-images.ts'; @@ -13,7 +14,7 @@ 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 { collectOpenAIResponsesReplayContents, findOpenAIResponsesSidecarOccurrences, isOpenAIResponsesEncryptedReasoning, verifyOpenAIResponsesTurnDigest } from '../as-source/round-trip.ts'; import { canonicalizeOpenAIResponsesPayload } from '../canonicalize-payload.ts'; import { buildCustomToolInputSchema } from '../common/custom-tool-wrap.ts'; import { @@ -434,7 +435,7 @@ export const buildTargetRequest = async ( for (const turn of partitionOpenAIResponsesTurns(canonical.input)) { if (turn.role !== 'assistant') { target.messages.push(...await translateNonAssistantInput(turn.items, options.loadRemoteImage)); continue; } - const roundTrip = await roundTripAssistantTurn('openaiResponses', 'anthropicMessages', turn.items, codec, findOpenAIResponsesSidecarCarriers, verifyOpenAIResponsesTurnDigest, collectOpenAIResponsesReplayContents); + const roundTrip = await roundTripAssistantTurn('openaiResponses', 'anthropicMessages', turn.items, codec, findOpenAIResponsesSidecarOccurrences, verifyOpenAIResponsesTurnDigest, collectOpenAIResponsesReplayContents); target.messages.push(...(roundTrip ? [roundTrip.turn] : translateAssistantTurn(turn.items))); } applyLastMessageCacheBreakpoint(target.messages.filter(message => bareMessages.has(message))); diff --git a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts index 28f41fb471..f0747809ef 100644 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts +++ b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts @@ -1,5 +1,5 @@ -import { createRoundTripStream } from '../../../round-trip/stream.ts'; -import type { SidecarCodec } from '../../../types.ts'; +import type { SidecarCodec } from '../../../round-trip/codec.ts'; +import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import { irFromOpenAIChatCompletions } from '../../openai-chat-completions/as-target/sse-from.ts'; import type { NamespaceToolNames } from '../as-source/namespace-tools.ts'; import { openaiResponsesFromIR } from '../as-source/sse-to.ts'; @@ -8,6 +8,6 @@ import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/ope import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; export const translateToSourceEvents = (frames: AsyncIterable>, customToolNames: ReadonlySet, codec: SidecarCodec, namespaceToolNames: NamespaceToolNames, maxChoices = 1): AsyncIterable> => { - const stream = createRoundTripStream('openaiResponses', 'openaiChatCompletions', codec); + const stream = createRoundTripCoordinator('openaiResponses', 'openaiChatCompletions', codec); return openaiResponsesFromIR(irFromOpenAIChatCompletions(frames, { customToolNames, maxChoices, roundTrip: stream.reader }), { namespaceToolNames, roundTrip: stream.writer }); }; 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..4c1ca86c6e 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 @@ -1,14 +1,14 @@ import { klona } from 'klona/json'; import { openaiResponsesContentToOpenAIChatCompletionsContent, openaiResponsesContentToText } from './content.ts'; +import type { SidecarCodec } from '../../../round-trip/codec.ts'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; import { TranslatorInputError } from '../../../translator-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 { collectOpenAIResponsesReplayContents, findOpenAIResponsesSidecarOccurrences, 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'; @@ -268,7 +268,7 @@ export const buildTargetRequest = async (source: OpenAIResponsesRequestPayloadEx for (const turn of partitionOpenAIResponsesTurns(canonical.input)) { if (turn.role !== 'assistant') { target.messages.push(...translateNonAssistantInput(turn.items)); continue; } - const roundTrip = await roundTripAssistantTurn('openaiResponses', 'openaiChatCompletions', turn.items, codec, findOpenAIResponsesSidecarCarriers, verifyOpenAIResponsesTurnDigest, collectOpenAIResponsesReplayContents); + const roundTrip = await roundTripAssistantTurn('openaiResponses', 'openaiChatCompletions', turn.items, codec, findOpenAIResponsesSidecarOccurrences, verifyOpenAIResponsesTurnDigest, collectOpenAIResponsesReplayContents); if (roundTrip?.turn.function_call != null) { const modernIds = new Set(roundTrip.turn.tool_calls?.map(call => call.id)); for (const item of turn.items) { diff --git a/packages/translate/src/round-trip/sidecar-tagged-value.ts b/packages/translate/src/round-trip/codec.ts similarity index 52% rename from packages/translate/src/round-trip/sidecar-tagged-value.ts rename to packages/translate/src/round-trip/codec.ts index 1fa31f756b..0f62abd99d 100644 --- a/packages/translate/src/round-trip/sidecar-tagged-value.ts +++ b/packages/translate/src/round-trip/codec.ts @@ -1,3 +1,9 @@ +export interface SidecarCodec { + /** Encode and decode must preserve sidecar tagged values, byte arrays, and raw JSON values. */ + encode: (payload: NonNullable | null) => Promise; + decode: (encoded: string) => Promise; +} + /** * A CBOR tag whose meaning belongs to the sidecar payload owner. The transport * codec preserves the tag number and value without interpreting either. diff --git a/packages/translate/src/round-trip/coordinator.ts b/packages/translate/src/round-trip/coordinator.ts new file mode 100644 index 0000000000..a3ebb4ae57 --- /dev/null +++ b/packages/translate/src/round-trip/coordinator.ts @@ -0,0 +1,47 @@ +import type { SidecarCodec } from './codec.ts'; +import type { InputAssistantTurnByProtocol, InputAssistantTurnFor } from './input-assistant-turn.ts'; +import type { RoundTripReader } from './reader.ts'; +import type { InputAssistantTurnStateFor } from './reference/assistant-turn-types.ts'; +import type { ReadFieldStates, ReferenceBuilder } from './reference/reference-builder.ts'; +import { collectEmittedFieldPlacements, createReferenceBuilder } from './reference/reference-builder.ts'; +import { referenceAssistantTurn } from './reference/reference-selection.ts'; +import type { RoundTripSidecar } from './sidecar.ts'; +import { encodeRoundTripSidecar } from './sidecar.ts'; +import type { RoundTripWriter } from './writer.ts'; + +/** + * Pairs the reader a target uses with the writer its source uses. + * + * One builder spans both halves, which is what lets a sidecar refer to text the + * target streamed: the reader records where each emitted field came from, and + * the writer, given that same record, turns it into a reference. + */ +export const createRoundTripCoordinator = < + Source extends keyof InputAssistantTurnByProtocol, + Target extends keyof InputAssistantTurnByProtocol, +>(source: Source, target: Target, codec: SidecarCodec) => { + const builder: ReferenceBuilder = createReferenceBuilder(); + const turns = new Map; + fieldState: InputAssistantTurnStateFor; + }>(); + const reader: RoundTripReader = { + builder, + onAssistantTurn: (choice, turn, fieldState) => { turns.set(choice, { turn, fieldState }); }, + }; + const writer: RoundTripWriter = { + builder, + encodeSidecar: async (choice, sourceTurnDigest, turn, contentState, textPlacements) => { + const pending = turns.get(choice); + if (pending === undefined) throw new Error(`Missing native assistant turn for choice ${choice}`); + const finalized = await referenceAssistantTurn( + pending.turn, + pending.fieldState, + builder, + collectEmittedFieldPlacements(turn, contentState, textPlacements), + ); + return await encodeRoundTripSidecar(codec, { v: 1, source, target, ...finalized, sourceTurnDigest } satisfies RoundTripSidecar); + }, + }; + return { reader, writer }; +}; diff --git a/packages/translate/src/round-trip/hash.ts b/packages/translate/src/round-trip/hash.ts new file mode 100644 index 0000000000..5c5e64c861 --- /dev/null +++ b/packages/translate/src/round-trip/hash.ts @@ -0,0 +1,9 @@ +const utf16Bytes = (text: string): Uint8Array => { + const bytes: Uint8Array = new Uint8Array(text.length * 2); + const view = new DataView(bytes.buffer); + for (let index = 0; index < text.length; index++) view.setUint16(index * 2, text.charCodeAt(index), true); + return bytes; +}; + +export const hashRoundTripContent = async (text: string): Promise => + new Uint8Array(await crypto.subtle.digest('SHA-256', utf16Bytes(text)), 0, 8); diff --git a/packages/translate/src/round-trip/assistant-turn.ts b/packages/translate/src/round-trip/input-assistant-turn.ts similarity index 57% rename from packages/translate/src/round-trip/assistant-turn.ts rename to packages/translate/src/round-trip/input-assistant-turn.ts index 1f02649d16..5bdeecd6f8 100644 --- a/packages/translate/src/round-trip/assistant-turn.ts +++ b/packages/translate/src/round-trip/input-assistant-turn.ts @@ -1,13 +1,15 @@ import type { AnthropicMessagesInputAssistantTurn } from '../protocols/anthropic-messages/as-target/assistant-turn.ts'; import type { GeminiGenerateContentInputAssistantTurn } from '../protocols/gemini-generate-content/as-target/assistant-turn.ts'; import type { OpenAIChatCompletionsInputAssistantTurn } from '../protocols/openai-chat-completions/as-target/assistant-turn.ts'; -import type { CanonicalOpenAIResponsesInputItem } from '@floway-dev/protocols/openai-responses'; +import type { OpenAIResponsesInputAssistantTurn } from '../protocols/openai-responses/as-target/assistant-turn.ts'; -export interface AssistantTurnByProtocol { +export interface InputAssistantTurnByProtocol { openaiChatCompletions: OpenAIChatCompletionsInputAssistantTurn; - openaiResponses: CanonicalOpenAIResponsesInputItem[]; + openaiResponses: OpenAIResponsesInputAssistantTurn; anthropicMessages: AnthropicMessagesInputAssistantTurn; geminiGenerateContent: GeminiGenerateContentInputAssistantTurn; } -export type AssistantTurnFor = AssistantTurnByProtocol[Protocol]; +export type InputAssistantTurnFor = InputAssistantTurnByProtocol[Protocol]; + +export type AssistantTurnProtocol = keyof InputAssistantTurnByProtocol; diff --git a/packages/translate/src/round-trip/reference/text-provenance.ts b/packages/translate/src/round-trip/provenance.ts similarity index 73% rename from packages/translate/src/round-trip/reference/text-provenance.ts rename to packages/translate/src/round-trip/provenance.ts index 8d70e4f39a..40182bdb08 100644 --- a/packages/translate/src/round-trip/reference/text-provenance.ts +++ b/packages/translate/src/round-trip/provenance.ts @@ -1,26 +1,26 @@ -import type { ReferencePieceId } from './reference-builder.ts'; +import type { TextFragmentId } from './reference/reference-builder.ts'; export interface TextProvenanceSpan { readonly start: number; readonly endExclusive: number; - readonly referencePieceIds?: readonly ReferencePieceId[]; + readonly textFragmentIds?: readonly TextFragmentId[]; } export const provenanceSpanForAppend = ( fullText: string, appended: string, - referencePieceIds?: readonly ReferencePieceId[], + textFragmentIds?: readonly TextFragmentId[], ): TextProvenanceSpan => { const endExclusive = fullText.length; const start = endExclusive - appended.length; if (start < 0 || fullText.slice(start) !== appended) throw new Error('Provenance append does not match accumulated text'); - return { start, endExclusive, ...(referencePieceIds === undefined ? {} : { referencePieceIds }) }; + return { start, endExclusive, ...(textFragmentIds === undefined ? {} : { textFragmentIds }) }; }; export const replayTextProvenance = ( finalText: string, spans: readonly TextProvenanceSpan[], - append: (text: string, referencePieceIds?: readonly ReferencePieceId[]) => void, + append: (text: string, textFragmentIds?: readonly TextFragmentId[]) => void, ): void => { let offset = 0; for (const span of spans) { @@ -28,7 +28,7 @@ export const replayTextProvenance = ( throw new RangeError('Text provenance spans must be ordered, disjoint, and in bounds'); } if (span.start > offset) append(finalText.slice(offset, span.start)); - append(finalText.slice(span.start, span.endExclusive), span.referencePieceIds); + append(finalText.slice(span.start, span.endExclusive), span.textFragmentIds); offset = span.endExclusive; } if (offset < finalText.length) append(finalText.slice(offset)); diff --git a/packages/translate/src/round-trip/reader.ts b/packages/translate/src/round-trip/reader.ts new file mode 100644 index 0000000000..6c9a9c4194 --- /dev/null +++ b/packages/translate/src/round-trip/reader.ts @@ -0,0 +1,12 @@ +import type { InputAssistantTurnByProtocol, InputAssistantTurnFor } from './input-assistant-turn.ts'; +import type { InputAssistantTurnStateFor } from './reference/assistant-turn-types.ts'; +import type { ReadFieldStates, ReferenceBuilder } from './reference/reference-builder.ts'; + +export interface RoundTripReader { + builder: ReferenceBuilder; + onAssistantTurn: ( + choice: number, + turn: InputAssistantTurnFor, + fieldState: InputAssistantTurnStateFor, + ) => void; +} diff --git a/packages/translate/src/round-trip/reference/assistant-turn-types.ts b/packages/translate/src/round-trip/reference/assistant-turn-types.ts new file mode 100644 index 0000000000..f191e96b0a --- /dev/null +++ b/packages/translate/src/round-trip/reference/assistant-turn-types.ts @@ -0,0 +1,22 @@ +import type { ReferenceStateLeaves } from './types.ts'; +import type { AnthropicMessagesInputAssistantTurnState, AnthropicMessagesReferenceableInputAssistantTurn } from '../../protocols/anthropic-messages/as-target/assistant-turn.ts'; +import type { GeminiGenerateContentInputAssistantTurnState, GeminiGenerateContentReferenceableInputAssistantTurn } from '../../protocols/gemini-generate-content/as-target/assistant-turn.ts'; +import type { OpenAIChatCompletionsInputAssistantTurnState, OpenAIChatCompletionsReferenceableInputAssistantTurn } from '../../protocols/openai-chat-completions/as-target/assistant-turn.ts'; +import type { OpenAIResponsesInputAssistantTurnState, OpenAIResponsesReferenceableInputAssistantTurn } from '../../protocols/openai-responses/as-target/assistant-turn.ts'; + +interface ReferenceableInputAssistantTurnByProtocol { + openaiChatCompletions: OpenAIChatCompletionsReferenceableInputAssistantTurn; + openaiResponses: OpenAIResponsesReferenceableInputAssistantTurn; + anthropicMessages: AnthropicMessagesReferenceableInputAssistantTurn; + geminiGenerateContent: GeminiGenerateContentReferenceableInputAssistantTurn; +} + +interface InputAssistantTurnStateByProtocol { + openaiChatCompletions: OpenAIChatCompletionsInputAssistantTurnState; + openaiResponses: OpenAIResponsesInputAssistantTurnState; + anthropicMessages: AnthropicMessagesInputAssistantTurnState; + geminiGenerateContent: GeminiGenerateContentInputAssistantTurnState; +} + +export type ReferenceableInputAssistantTurnFor, Reference> = ReferenceableInputAssistantTurnByProtocol[Protocol]; +export type InputAssistantTurnStateFor, States extends ReferenceStateLeaves> = InputAssistantTurnStateByProtocol[Protocol]; diff --git a/packages/translate/src/round-trip/reference/tagged-value.ts b/packages/translate/src/round-trip/reference/content-reference-tag.ts similarity index 86% rename from packages/translate/src/round-trip/reference/tagged-value.ts rename to packages/translate/src/round-trip/reference/content-reference-tag.ts index d82fd17482..dda42da26f 100644 --- a/packages/translate/src/round-trip/reference/tagged-value.ts +++ b/packages/translate/src/round-trip/reference/content-reference-tag.ts @@ -1,6 +1,6 @@ -import { ContentReference, type ContentReferenceSegment } from './assistant-turn.ts'; +import { ContentReference, type ContentReferenceSegment } from './types.ts'; import { rawJSON } from '../../shared/json/raw-json.ts'; -import { SidecarTaggedValue } from '../sidecar-tagged-value.ts'; +import { SidecarTaggedValue } from '../codec.ts'; const THIN_TEXT_TAG = 65_536; const THIN_JSON_TAG = 65_537; @@ -22,7 +22,7 @@ const mapValue = (value: unknown, transform: (value: unknown) => unknown): unkno }; /** Replaces translate-owned references with transport-owned tagged values. */ -export const encodeSidecarTaggedValues = (value: unknown): unknown => mapValue(value, value => { +export const tagContentReferences = (value: unknown): unknown => mapValue(value, value => { if (!(value instanceof ContentReference)) return value; return new SidecarTaggedValue( value.kind === 'text' ? THIN_TEXT_TAG : THIN_JSON_TAG, @@ -31,7 +31,7 @@ export const encodeSidecarTaggedValues = (value: unknown): unknown => mapValue(v }); /** Restores translate-owned references from transport-owned tagged values in place. */ -export const restoreSidecarTaggedValues = (value: unknown): unknown => { +export const untagContentReferences = (value: unknown): unknown => { if (value instanceof SidecarTaggedValue) { if (value.tag === THIN_TEXT_TAG || value.tag === THIN_JSON_TAG) { const parts: unknown = value.value; @@ -42,11 +42,11 @@ export const restoreSidecarTaggedValues = (value: unknown): unknown => { } if (value instanceof Uint8Array || rawJSON.isRawJSON(value)) return value; if (Array.isArray(value)) { - for (let index = 0; index < value.length; index++) value[index] = restoreSidecarTaggedValues(value[index]); + for (let index = 0; index < value.length; index++) value[index] = untagContentReferences(value[index]); return value; } if (typeof value === 'object' && value !== null) { - for (const [key, child] of Object.entries(value)) Reflect.set(value, key, restoreSidecarTaggedValues(child)); + for (const [key, child] of Object.entries(value)) Reflect.set(value, key, untagContentReferences(child)); } return value; }; diff --git a/packages/translate/src/round-trip/reference/reference-builder.ts b/packages/translate/src/round-trip/reference/reference-builder.ts index f4a584c816..8d52040463 100644 --- a/packages/translate/src/round-trip/reference/reference-builder.ts +++ b/packages/translate/src/round-trip/reference/reference-builder.ts @@ -1,96 +1,97 @@ -import { ContentReference, type AssistantTurnStateFor, type ContentReferenceSegment } from './assistant-turn.ts'; +import { type InputAssistantTurnStateFor } from './assistant-turn-types.ts'; +import { ContentReference, type ContentReferenceSegment } from './types.ts'; import type { IRJSONObject } from '../../ir/types.ts'; import { parseJSONObject } from '../../shared/json/object.ts'; -import type { AssistantTurnByProtocol, AssistantTurnFor } from '../assistant-turn.ts'; +import { hashRoundTripContent } from '../hash.ts'; +import type { InputAssistantTurnByProtocol, InputAssistantTurnFor } from '../input-assistant-turn.ts'; import type { TextPlacement, TextPlacementIndex } from '../text-groups.ts'; -import { hashRoundTripContent } from '../turn-digest.ts'; import { encodeHex } from '@floway-dev/protocols/common'; -const stateKind: unique symbol = Symbol('ReferenceState'); +const fieldStatePhase: unique symbol = Symbol('ReferenceFieldStatePhase'); -export type ReferencePieceId = number; -type ContentId = number; +export type TextFragmentId = number; +export type EmittedFieldId = number; type ReferenceKind = 'text' | 'json'; export interface ReferenceBuilder { - readonly pieces: string[]; - readonly resolutions: Map; - nextContentId: number; + readonly fragments: string[]; + readonly fragmentPlacements: Map; + nextEmittedFieldId: number; } type OriginalFor = Kind extends 'text' ? string : IRJSONObject; -export interface FieldState { - readonly [stateKind]: 'field'; +export interface ReadFieldState { + readonly [fieldStatePhase]: 'read'; readonly kind: Kind; valid: boolean; text: string; - readonly referencePieceIds: ReferencePieceId[]; + readonly textFragmentIds: TextFragmentId[]; } -export interface ContentState { - readonly [stateKind]: 'content'; +export interface EmittedFieldState { + readonly [fieldStatePhase]: 'emitted'; readonly kind: Kind; - readonly contentId: ContentId; + readonly emittedFieldId: EmittedFieldId; text: string; } -export interface FieldStates { - readonly text: FieldState<'text'>; - readonly json: FieldState<'json'>; +export interface ReadFieldStates { + readonly text: ReadFieldState<'text'>; + readonly json: ReadFieldState<'json'>; } -export interface ContentStates { - readonly text: ContentState<'text'>; - readonly json: ContentState<'json'>; +export interface EmittedFieldStates { + readonly text: EmittedFieldState<'text'>; + readonly json: EmittedFieldState<'json'>; } -interface Resolution { - readonly contentId: ContentId; +interface FragmentPlacement { + readonly emittedFieldId: EmittedFieldId; readonly start: number; readonly endExclusive: number; } -export const createReferenceBuilder = (): ReferenceBuilder => ({ pieces: [], resolutions: new Map(), nextContentId: 0 }); +export const createReferenceBuilder = (): ReferenceBuilder => ({ fragments: [], fragmentPlacements: new Map(), nextEmittedFieldId: 0 }); -export const createFieldState = (kind: Kind): FieldState => ({ - [stateKind]: 'field', kind, valid: true, text: '', referencePieceIds: [], +export const createReadFieldState = (kind: Kind): ReadFieldState => ({ + [fieldStatePhase]: 'read', kind, valid: true, text: '', textFragmentIds: [], }); -export const appendField = (builder: ReferenceBuilder, field: FieldState, text: string): ReferencePieceId => { +export const appendReadField = (builder: ReferenceBuilder, field: ReadFieldState, text: string): TextFragmentId => { if (!field.valid) throw new Error('Cannot append provenance to an invalid field'); - const partId = builder.pieces.length; - builder.pieces.push(text); + const fragmentId = builder.fragments.length; + builder.fragments.push(text); field.text += text; - field.referencePieceIds.push(partId); - return partId; + field.textFragmentIds.push(fragmentId); + return fragmentId; }; -export const invalidateField = (field: FieldState): void => { field.valid = false; }; +export const invalidateReadField = (field: ReadFieldState): void => { field.valid = false; }; -export const createContentState = (builder: ReferenceBuilder, kind: Kind): ContentState => ({ - [stateKind]: 'content', kind, contentId: builder.nextContentId++, text: '', +export const createEmittedFieldState = (builder: ReferenceBuilder, kind: Kind): EmittedFieldState => ({ + [fieldStatePhase]: 'emitted', kind, emittedFieldId: builder.nextEmittedFieldId++, text: '', }); -export const appendContent = (builder: ReferenceBuilder, content: ContentState, text: string, referencePieceIds?: readonly ReferencePieceId[]): void => { +export const appendEmittedField = (builder: ReferenceBuilder, content: EmittedFieldState, text: string, textFragmentIds?: readonly TextFragmentId[]): void => { let offset = content.text.length; content.text += text; - if (referencePieceIds === undefined || referencePieceIds.length === 0) return; - for (const partId of referencePieceIds) { - const piece = builder.pieces[partId]; - if (piece === undefined) throw new Error(`Unknown ReferenceBuilder part ${partId}`); - if (builder.resolutions.has(partId)) throw new Error(`ReferenceBuilder part ${partId} has multiple output occurrences`); - builder.resolutions.set(partId, { contentId: content.contentId, start: offset, endExclusive: offset + piece.length }); + if (textFragmentIds === undefined || textFragmentIds.length === 0) return; + for (const fragmentId of textFragmentIds) { + const piece = builder.fragments[fragmentId]; + if (piece === undefined) throw new Error(`Unknown ReferenceBuilder fragment ${fragmentId}`); + if (builder.fragmentPlacements.has(fragmentId)) throw new Error(`ReferenceBuilder fragment ${fragmentId} has multiple output occurrences`); + builder.fragmentPlacements.set(fragmentId, { emittedFieldId: content.emittedFieldId, start: offset, endExclusive: offset + piece.length }); offset += piece.length; } - if (offset !== content.text.length) throw new Error('IR text and its original parts have different lengths'); + if (offset !== content.text.length) throw new Error('output IR text and its original parts have different lengths'); }; -export const isFieldState = (value: unknown): value is FieldState => - typeof value === 'object' && value !== null && stateKind in value && value[stateKind] === 'field'; +export const isReadFieldState = (value: unknown): value is ReadFieldState => + typeof value === 'object' && value !== null && fieldStatePhase in value && value[fieldStatePhase] === 'read'; -const isContentState = (value: unknown): value is ContentState => - typeof value === 'object' && value !== null && stateKind in value && value[stateKind] === 'content'; +const isEmittedFieldState = (value: unknown): value is EmittedFieldState => + typeof value === 'object' && value !== null && fieldStatePhase in value && value[fieldStatePhase] === 'emitted'; const textView = (kind: ReferenceKind, value: unknown): string | undefined => { if (kind === 'text') return typeof value === 'string' ? value : undefined; @@ -112,30 +113,30 @@ export const pairedChildren = function* (value: unknown, state: unknown): Genera for (const [key, childState] of Object.entries(state)) yield [key, Reflect.get(value, key), childState]; }; -export function collectContentViews(value: unknown, contentState: ContentState): Map; -export function collectContentViews( - turn: AssistantTurnFor, - contentState: AssistantTurnStateFor, - textViews?: TextPlacementIndex, -): Map; -export function collectContentViews(turn: unknown, contentState: unknown, textViews?: TextPlacementIndex): Map { - const views = new Map(); +export function collectEmittedFieldPlacements(value: unknown, contentState: EmittedFieldState): Map; +export function collectEmittedFieldPlacements( + turn: InputAssistantTurnFor, + contentState: InputAssistantTurnStateFor, + textPlacements?: TextPlacementIndex, +): Map; +export function collectEmittedFieldPlacements(turn: unknown, contentState: unknown, textPlacements?: TextPlacementIndex): Map { + const views = new Map(); const visit = (value: unknown, state: unknown, channel?: TextPlacement): void => { - if (isContentState(state)) { + if (isEmittedFieldState(state)) { const view = textView(state.kind, value); - if (view === state.text) views.set(state.contentId, channel ?? { text: view, offset: 0 }); + if (view === state.text) views.set(state.emittedFieldId, channel ?? { enclosingText: view, offset: 0 }); return; } for (const [key, childValue, childState] of pairedChildren(value, state)) { - const channel = typeof value === 'object' && value !== null ? textViews?.get(value)?.get(String(key)) : undefined; - visit(childValue, childState, channel !== undefined && isContentState(childState) && childState.kind === 'text' ? channel : undefined); + const channel = typeof value === 'object' && value !== null ? textPlacements?.get(value)?.get(String(key)) : undefined; + visit(childValue, childState, channel !== undefined && isEmittedFieldState(childState) && childState.kind === 'text' ? channel : undefined); } }; visit(turn, contentState); return views; } -const restoresOriginal = (field: FieldState, original: OriginalFor): boolean => { +const restoresOriginal = (field: ReadFieldState, original: OriginalFor): boolean => { if (!field.valid) return false; if (field.kind === 'text') return typeof original === 'string' && field.text === original; if (typeof original !== 'object' || original === null || Array.isArray(original)) return false; @@ -147,40 +148,40 @@ const restoresOriginal = (field: FieldState, o } }; -interface ReferenceSegment { +interface ReferenceRun { readonly hashIndex: number; readonly start: number; endExclusive: number; readonly contentLength: number; } -interface ResolvedSegment { - readonly contentId: ContentId; +interface PlacedFragment { + readonly emittedFieldId: EmittedFieldId; readonly content: string; readonly start: number; readonly endExclusive: number; } -interface ReferenceFinalizer { +interface FieldReferencer { readonly referencedContentHashes: Uint8Array[]; - finalizeField: ( + referenceField: ( original: OriginalFor, - field: FieldState, + field: ReadFieldState, ) => Promise | ContentReference>; } -export const createReferenceFinalizer = (builder: ReferenceBuilder, contentViews: ReadonlyMap): ReferenceFinalizer => { +export const createFieldReferencer = (builder: ReferenceBuilder, emittedFieldPlacements: ReadonlyMap): FieldReferencer => { const referencedContentHashes: Uint8Array[] = []; - const hashIndexByContentId = new Map(); + const hashIndexByEmittedFieldId = new Map(); const hashIndexByContent = new Map(); const hashIndexByDigest = new Map(); - const hashIndex = async (contentId: ContentId, content: string): Promise => { - const resolved = hashIndexByContentId.get(contentId); + const hashIndex = async (emittedFieldId: EmittedFieldId, content: string): Promise => { + const resolved = hashIndexByEmittedFieldId.get(emittedFieldId); if (resolved !== undefined) return resolved; const existing = hashIndexByContent.get(content); if (existing !== undefined) { - hashIndexByContentId.set(contentId, existing); + hashIndexByEmittedFieldId.set(emittedFieldId, existing); return existing; } const digest = await hashRoundTripContent(content); @@ -192,30 +193,30 @@ export const createReferenceFinalizer = (builder: ReferenceBuilder, contentViews hashIndexByDigest.set(key, index); } hashIndexByContent.set(content, index); - hashIndexByContentId.set(contentId, index); + hashIndexByEmittedFieldId.set(emittedFieldId, index); return index; }; - const finalizeField = async ( + const referenceField = async ( original: OriginalFor, - field: FieldState, + field: ReadFieldState, ): Promise | ContentReference> => { - if (field.referencePieceIds.length === 0 || !restoresOriginal(field, original)) return original; - const resolved: ResolvedSegment[] = []; - for (const partId of field.referencePieceIds) { - const piece = builder.pieces[partId]; - const resolution = builder.resolutions.get(partId); + if (field.textFragmentIds.length === 0 || !restoresOriginal(field, original)) return original; + const resolved: PlacedFragment[] = []; + for (const fragmentId of field.textFragmentIds) { + const piece = builder.fragments[fragmentId]; + const resolution = builder.fragmentPlacements.get(fragmentId); if (piece === undefined || resolution === undefined) return original; - const view = contentViews.get(resolution.contentId); + const view = emittedFieldPlacements.get(resolution.emittedFieldId); if (view === undefined) return original; const start = view.offset + resolution.start; const endExclusive = view.offset + resolution.endExclusive; - if (view.text.slice(start, endExclusive) !== piece) return original; - resolved.push({ contentId: resolution.contentId, content: view.text, start, endExclusive }); + if (view.enclosingText.slice(start, endExclusive) !== piece) return original; + resolved.push({ emittedFieldId: resolution.emittedFieldId, content: view.enclosingText, start, endExclusive }); } - const segments: ReferenceSegment[] = []; + const segments: ReferenceRun[] = []; for (const resolution of resolved) { - const index = await hashIndex(resolution.contentId, resolution.content); + const index = await hashIndex(resolution.emittedFieldId, resolution.content); const previous = segments.at(-1); if (previous?.hashIndex === index && previous.endExclusive === resolution.start) previous.endExclusive = resolution.endExclusive; else segments.push({ hashIndex: index, start: resolution.start, endExclusive: resolution.endExclusive, contentLength: resolution.content.length }); @@ -226,5 +227,5 @@ export const createReferenceFinalizer = (builder: ReferenceBuilder, contentViews return new ContentReference(field.kind, payload); }; - return { referencedContentHashes, finalizeField }; + return { referencedContentHashes, referenceField }; }; diff --git a/packages/translate/src/round-trip/reference/reference-selection.ts b/packages/translate/src/round-trip/reference/reference-selection.ts index 87114abb1d..3bd96c98f6 100644 --- a/packages/translate/src/round-trip/reference/reference-selection.ts +++ b/packages/translate/src/round-trip/reference/reference-selection.ts @@ -1,20 +1,19 @@ -import { ContentReference, type AssistantTurnStateFor, type ContentReferenceSegment, type ReferenceableAssistantTurnFor } from './assistant-turn.ts'; -import { createReferenceFinalizer, isFieldState, pairedChildren, type FieldState, type FieldStates, type ReferenceBuilder } from './reference-builder.ts'; +import { type InputAssistantTurnStateFor, type ReferenceableInputAssistantTurnFor } from './assistant-turn-types.ts'; +import { createFieldReferencer, isReadFieldState, pairedChildren, type EmittedFieldId, type ReadFieldState, type ReadFieldStates, type ReferenceBuilder } from './reference-builder.ts'; +import { ContentReference, type ContentReferenceSegment } from './types.ts'; import type { IRJSONObject } from '../../ir/types.ts'; import { requireJSONObject } from '../../shared/json/object.ts'; import { rawJSON } from '../../shared/json/raw-json.ts'; -import type { AssistantTurnByProtocol, AssistantTurnFor } from '../assistant-turn.ts'; +import type { InputAssistantTurnByProtocol, InputAssistantTurnFor } from '../input-assistant-turn.ts'; import type { TextPlacement } from '../text-groups.ts'; -type ContentId = number; - -interface FinalizedAssistantTurn { +interface ReferencedAssistantTurn { readonly targetTurn: Turn; readonly referencedContentHashes?: Uint8Array[]; } -interface ReferenceChoice { - readonly field: FieldState; +interface ReferenceCandidate { + readonly field: ReadFieldState; readonly reference: ContentReference; readonly hashes: number[]; readonly savings: number; @@ -23,74 +22,74 @@ interface ReferenceChoice { // Deliberately coarse budgets keep selection independent of the injected codec. // Shared hashes are charged once; indices and ranges use fixed generous costs. const REFERENCE_OVERHEAD = 8; -const HASH_BUDGET = 16; -const HASH_TABLE_BUDGET = 32; +const HASH_COST_ESTIMATE = 16; +const HASH_TABLE_COST_ESTIMATE = 32; -const referenceBudget = (reference: ContentReference): number => REFERENCE_OVERHEAD +const estimateReferenceCost = (reference: ContentReference): number => REFERENCE_OVERHEAD + reference.parts.reduce((cost, part) => cost + (typeof part === 'number' ? 4 : 16), 0); -const jsonLiteralBudget = (value: unknown, ancestors = new Set()): number => { +const estimateJSONLiteralCost = (value: unknown, ancestors = new Set()): number => { if (rawJSON.isRawJSON(value)) return value.rawJSON.length; if (value === null || typeof value === 'boolean' || typeof value === 'number') return 1; if (typeof value === 'string') return value.length; - if (typeof value !== 'object') throw new TypeError('JSON reference budget requires a JSON value'); - if (ancestors.has(value)) throw new TypeError('JSON reference budget requires an acyclic value'); + if (typeof value !== 'object') throw new TypeError('JSON reference cost requires a JSON value'); + if (ancestors.has(value)) throw new TypeError('JSON reference cost requires an acyclic value'); ancestors.add(value); - const budget = Array.isArray(value) - ? value.reduce((sum, child) => sum + jsonLiteralBudget(child, ancestors), 0) - : Object.entries(value).reduce((sum, [key, child]) => sum + key.length + jsonLiteralBudget(child, ancestors), 0); + const cost = Array.isArray(value) + ? value.reduce((sum, child) => sum + estimateJSONLiteralCost(child, ancestors), 0) + : Object.entries(value).reduce((sum, [key, child]) => sum + key.length + estimateJSONLiteralCost(child, ancestors), 0); ancestors.delete(value); - return budget; + return cost; }; -export const finalizeAssistantTurnReferences = async ( - turn: AssistantTurnFor, - fieldState: AssistantTurnStateFor, +export const referenceAssistantTurn = async ( + turn: InputAssistantTurnFor, + fieldState: InputAssistantTurnStateFor, builder: ReferenceBuilder, - contentViews: ReadonlyMap, -): Promise>> => { - const finalizer = createReferenceFinalizer(builder, contentViews); - const minimumReferenceBudget = REFERENCE_OVERHEAD + 4; - const choices: ReferenceChoice[] = []; + emittedFieldPlacements: ReadonlyMap, +): Promise>> => { + const referencer = createFieldReferencer(builder, emittedFieldPlacements); + const minimumReferenceCost = REFERENCE_OVERHEAD + 4; + const candidates: ReferenceCandidate[] = []; const collect = async (value: unknown, state: unknown): Promise => { - if (isFieldState(state)) { + if (isReadFieldState(state)) { let original: string | IRJSONObject; if (state.kind === 'text') { if (typeof value !== 'string') throw new TypeError('Text field state does not match its assistant turn'); original = value; } else original = requireJSONObject(value); - const literalBudget = typeof original === 'string' ? original.length : jsonLiteralBudget(original); - if (literalBudget <= minimumReferenceBudget) return; - const reference = await finalizer.finalizeField(original, state); + const literalCost = typeof original === 'string' ? original.length : estimateJSONLiteralCost(original); + if (literalCost <= minimumReferenceCost) return; + const reference = await referencer.referenceField(original, state); if (reference instanceof ContentReference) { - const savings = literalBudget - referenceBudget(reference); - if (savings > 0) choices.push({ field: state, reference, hashes: [...new Set(reference.parts.map(part => Array.isArray(part) ? part[0] : part))], savings }); + const savings = literalCost - estimateReferenceCost(reference); + if (savings > 0) candidates.push({ field: state, reference, hashes: [...new Set(reference.parts.map(part => Array.isArray(part) ? part[0] : part))], savings }); } return; } for (const [, childValue, childState] of pairedChildren(value, state)) await collect(childValue, childState); }; await collect(turn, fieldState); - if (choices.length === 0) return { targetTurn: turn }; + if (candidates.length === 0) return { targetTurn: turn }; - const savingsByHash = finalizer.referencedContentHashes.map(() => 0); - for (const choice of choices) for (const hash of choice.hashes) savingsByHash[hash] += choice.savings / choice.hashes.length; - const selected = new Map(); + const savingsByHash = referencer.referencedContentHashes.map(() => 0); + for (const candidate of candidates) for (const hash of candidate.hashes) savingsByHash[hash] += candidate.savings / candidate.hashes.length; + const selected = new Map(); const selectedHashes = new Set(); let savings = 0; - for (const choice of choices) { - if (choice.hashes.some(hash => savingsByHash[hash] <= HASH_BUDGET)) continue; - selected.set(choice.field, choice.reference); - savings += choice.savings; - for (const hash of choice.hashes) selectedHashes.add(hash); + for (const candidate of candidates) { + if (candidate.hashes.some(hash => savingsByHash[hash] <= HASH_COST_ESTIMATE)) continue; + selected.set(candidate.field, candidate.reference); + savings += candidate.savings; + for (const hash of candidate.hashes) selectedHashes.add(hash); } // Rejected atomic fields may have contributed to a surviving hash's estimate. // Check the selected set again so those contributions cannot fund its table. - if (savings <= HASH_TABLE_BUDGET + selectedHashes.size * HASH_BUDGET) return { targetTurn: turn }; + if (savings <= HASH_TABLE_COST_ESTIMATE + selectedHashes.size * HASH_COST_ESTIMATE) return { targetTurn: turn }; const referencedContentHashes: Uint8Array[] = []; const compactIndices: number[] = []; - for (const [index, hash] of finalizer.referencedContentHashes.entries()) { + for (const [index, hash] of referencer.referencedContentHashes.entries()) { if (!selectedHashes.has(index)) continue; compactIndices[index] = referencedContentHashes.length; referencedContentHashes.push(hash); @@ -100,7 +99,7 @@ export const finalizeAssistantTurnReferences = async { - if (isFieldState(state)) return selected.get(state) ?? value; + if (isReadFieldState(state)) return selected.get(state) ?? value; if (Array.isArray(state)) { if (!Array.isArray(value)) throw new TypeError('ReferenceBuilder state array does not match its assistant turn'); const result = value.slice(); @@ -116,7 +115,7 @@ export const finalizeAssistantTurnReferences = async ; + const targetTurn = visit(turn, fieldState) as ReferenceableInputAssistantTurnFor; return { targetTurn, referencedContentHashes, diff --git a/packages/translate/src/round-trip/reference/restore.ts b/packages/translate/src/round-trip/reference/restore.ts index 45152f37fb..72fb8a47f2 100644 --- a/packages/translate/src/round-trip/reference/restore.ts +++ b/packages/translate/src/round-trip/reference/restore.ts @@ -1,8 +1,9 @@ -import { ContentReference, type ReplayContent, type ContentReferenceSegment, type ReferenceableAssistantTurnFor } from './assistant-turn.ts'; +import { type ReferenceableInputAssistantTurnFor } from './assistant-turn-types.ts'; +import { ContentReference, type ReplayContent, type ContentReferenceSegment } from './types.ts'; import { parseJSONObject } from '../../shared/json/object.ts'; import { rawJSON } from '../../shared/json/raw-json.ts'; -import type { AssistantTurnByProtocol, AssistantTurnFor } from '../assistant-turn.ts'; -import { hashRoundTripContent } from '../turn-digest.ts'; +import { hashRoundTripContent } from '../hash.ts'; +import type { InputAssistantTurnByProtocol, InputAssistantTurnFor } from '../input-assistant-turn.ts'; import { encodeHex } from '@floway-dev/protocols/common'; export type RestoreResult = @@ -43,12 +44,12 @@ const restoreValue = (value: unknown, referencedContentHashes: readonly Uint8Arr }; /** Consumes the exclusively owned turn decoded by the sidecar codec. */ -export const restoreAssistantTurn = async ( +export const restoreAssistantTurn = async ( _protocol: Protocol, candidates: readonly ReplayContent[], - targetTurn: ReferenceableAssistantTurnFor, + targetTurn: ReferenceableInputAssistantTurnFor, referencedContentHashes?: readonly Uint8Array[], -): Promise>> => { +): Promise>> => { const textsByHash = new Map(); if (referencedContentHashes !== undefined) { for (const candidate of candidates) { @@ -59,6 +60,6 @@ export const restoreAssistantTurn = async } + ? { ok: true, turn: result.value as InputAssistantTurnFor } : result; }; diff --git a/packages/translate/src/round-trip/reference/assistant-turn.ts b/packages/translate/src/round-trip/reference/types.ts similarity index 57% rename from packages/translate/src/round-trip/reference/assistant-turn.ts rename to packages/translate/src/round-trip/reference/types.ts index 86cfa647e5..eba2f3258d 100644 --- a/packages/translate/src/round-trip/reference/assistant-turn.ts +++ b/packages/translate/src/round-trip/reference/types.ts @@ -1,8 +1,4 @@ import type { IRJSONObject } from '../../ir/types.ts'; -import type { AnthropicMessagesAssistantState, AnthropicMessagesReferenceableAssistantTurn } from '../../protocols/anthropic-messages/as-target/assistant-turn.ts'; -import type { GeminiGenerateContentAssistantState, GeminiGenerateContentReferenceableAssistantTurn } from '../../protocols/gemini-generate-content/as-target/assistant-turn.ts'; -import type { OpenAIChatCompletionsAssistantState, OpenAIChatCompletionsReferenceableAssistantTurn } from '../../protocols/openai-chat-completions/as-target/assistant-turn.ts'; -import type { OpenAIResponsesAssistantState, OpenAIResponsesReferenceableAssistantTurn } from '../../protocols/openai-responses/as-target/assistant-turn.ts'; export type ContentReferenceSegment = number | [hashIndex: number, start: number, endExclusive: number]; @@ -46,21 +42,4 @@ export type ReferenceState = T ext } extends infer State extends object ? keyof State extends never ? never : State : never : never : never; -interface ReferenceableAssistantTurnByProtocol { - openaiChatCompletions: OpenAIChatCompletionsReferenceableAssistantTurn; - openaiResponses: OpenAIResponsesReferenceableAssistantTurn; - anthropicMessages: AnthropicMessagesReferenceableAssistantTurn; - geminiGenerateContent: GeminiGenerateContentReferenceableAssistantTurn; -} - -interface AssistantTurnStateByProtocol { - openaiChatCompletions: OpenAIChatCompletionsAssistantState; - openaiResponses: OpenAIResponsesAssistantState; - anthropicMessages: AnthropicMessagesAssistantState; - geminiGenerateContent: GeminiGenerateContentAssistantState; -} - -export type ReferenceableAssistantTurnFor, Reference> = ReferenceableAssistantTurnByProtocol[Protocol]; -export type AssistantTurnStateFor, States extends ReferenceStateLeaves> = AssistantTurnStateByProtocol[Protocol]; - export type ReplayContent = string | IRJSONObject; diff --git a/packages/translate/src/round-trip/resolve.ts b/packages/translate/src/round-trip/resolve.ts index bc5b5d04ae..18d9fe73a7 100644 --- a/packages/translate/src/round-trip/resolve.ts +++ b/packages/translate/src/round-trip/resolve.ts @@ -1,34 +1,34 @@ -import type { AssistantTurnByProtocol, AssistantTurnFor } from './assistant-turn.ts'; +import type { SidecarCodec } from './codec.ts'; +import type { InputAssistantTurnByProtocol, InputAssistantTurnFor } from './input-assistant-turn.ts'; +import { restoreAssistantTurn } from './reference/restore.ts'; +import type { ReplayContent } from './reference/types.ts'; import { decodeRoundTripSidecar, hasRoundTripTarget, type AnyRoundTripSidecar } from './sidecar.ts'; import type { RoundTripTurnDigest } from './turn-digest.ts'; -import type { SidecarCodec } from '../types.ts'; -import type { ReplayContent } from './reference/assistant-turn.ts'; -import { restoreAssistantTurn } from './reference/restore.ts'; -export interface EncodedRoundTripSidecar { +export interface SidecarOccurrence { readonly data: string; readonly withoutSidecar: () => Turn; } -export interface RoundTripAssistantTurn { - turn: AssistantTurnFor; +export interface RoundTripAssistantTurn { + turn: InputAssistantTurnFor; } export const warnDroppedUnrecognizedAssistantReasoning = ( - source: keyof AssistantTurnByProtocol, - target: keyof AssistantTurnByProtocol, + source: keyof InputAssistantTurnByProtocol, + target: keyof InputAssistantTurnByProtocol, ): void => { console.warn('Dropping reasoning from an unidentifiable foreign assistant turn', { source, target }); }; -export const roundTripAssistantTurn = async ( +export const roundTripAssistantTurn = async ( source: Source, target: Target, turn: SourceTurn, codec: SidecarCodec, - inspect: (turn: SourceTurn) => EncodedRoundTripSidecar[], + inspect: (turn: SourceTurn) => SidecarOccurrence[], verify: (turn: SourceTurn, sourceTurnDigest: RoundTripTurnDigest, version: number) => Promise, collectCandidates: (turn: SourceTurn) => ReplayContent[], ): Promise | undefined> => { - let selected: { candidate: EncodedRoundTripSidecar; sidecar: AnyRoundTripSidecar } | undefined; + let selected: { candidate: SidecarOccurrence; sidecar: AnyRoundTripSidecar } | undefined; for (const candidate of inspect(turn)) { const sidecar = await decodeRoundTripSidecar(codec, source, candidate.data); if (sidecar === undefined) continue; diff --git a/packages/translate/src/round-trip/sidecar.ts b/packages/translate/src/round-trip/sidecar.ts index 05422949a9..2db0e3a4b4 100644 --- a/packages/translate/src/round-trip/sidecar.ts +++ b/packages/translate/src/round-trip/sidecar.ts @@ -1,10 +1,9 @@ -import type { AssistantTurnByProtocol } from './assistant-turn.ts'; +import type { SidecarCodec } from './codec.ts'; +import type { AssistantTurnProtocol } from './input-assistant-turn.ts'; +import type { ReferenceableInputAssistantTurnFor } from './reference/assistant-turn-types.ts'; +import { tagContentReferences, untagContentReferences } from './reference/content-reference-tag.ts'; +import type { ContentReference } from './reference/types.ts'; import type { RoundTripTurnDigest } from './turn-digest.ts'; -import type { SidecarCodec } from '../types.ts'; -import type { ReferenceableAssistantTurnFor, ContentReference } from './reference/assistant-turn.ts'; -import { encodeSidecarTaggedValues, restoreSidecarTaggedValues } from './reference/tagged-value.ts'; - -export type RoundTripProtocol = keyof AssistantTurnByProtocol; const ROUND_TRIP_SIDECAR_VERSION = 1; @@ -12,24 +11,24 @@ const ROUND_TRIP_SIDECAR_VERSION = 1; // change derives its own format version from this `v` (currently `v: 1` selects // the v1 turn-digest material) instead of carrying a second version member, so // advancing any subsystem advances this single `v`. -export type RoundTripSidecar = { +export type RoundTripSidecar = { v: 1; - source: RoundTripProtocol; + source: AssistantTurnProtocol; target: Target; - targetTurn: ReferenceableAssistantTurnFor; + targetTurn: ReferenceableInputAssistantTurnFor; referencedContentHashes?: Uint8Array[]; sourceTurnDigest: RoundTripTurnDigest; }; -type RoundTripSidecarByTarget = { [Target in RoundTripProtocol]: RoundTripSidecar }; -export type AnyRoundTripSidecar = RoundTripSidecarByTarget[RoundTripProtocol]; +type RoundTripSidecarByTarget = { [Target in AssistantTurnProtocol]: RoundTripSidecar }; +export type AnyRoundTripSidecar = RoundTripSidecarByTarget[AssistantTurnProtocol]; const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); -const isRoundTripProtocol = (value: unknown): value is RoundTripProtocol => +const isAssistantTurnProtocol = (value: unknown): value is AssistantTurnProtocol => value === 'openaiChatCompletions' || value === 'openaiResponses' || value === 'anthropicMessages' || value === 'geminiGenerateContent'; -const assertTargetTurn = (target: RoundTripProtocol, value: unknown): void => { +const assertTargetTurnContainer = (target: AssistantTurnProtocol, value: unknown): void => { if (target === 'openaiChatCompletions') { if (!isRecord(value) || value.role !== 'assistant') throw new TypeError('Chat Completions assistant turn is invalid'); return; @@ -41,11 +40,11 @@ const assertTargetTurn = (target: RoundTripProtocol, value: unknown): void => { if (!Array.isArray(value)) throw new TypeError(`${target} assistant turn is invalid`); }; -function assertRoundTripSidecar(value: Record): asserts value is AnyRoundTripSidecar { +function assertRoundTripEnvelope(value: Record): asserts value is AnyRoundTripSidecar { if (value.v !== ROUND_TRIP_SIDECAR_VERSION) throw new TypeError('Assistant-turn sidecar version is unsupported'); - if (!isRoundTripProtocol(value.source)) throw new TypeError('Assistant-turn sidecar source is invalid'); - if (!isRoundTripProtocol(value.target)) throw new TypeError('Assistant-turn sidecar target is invalid'); - assertTargetTurn(value.target, value.targetTurn); + if (!isAssistantTurnProtocol(value.source)) throw new TypeError('Assistant-turn sidecar source is invalid'); + if (!isAssistantTurnProtocol(value.target)) throw new TypeError('Assistant-turn sidecar target is invalid'); + assertTargetTurnContainer(value.target, value.targetTurn); if (value.referencedContentHashes !== undefined && (!Array.isArray(value.referencedContentHashes) || value.referencedContentHashes.some(hash => !(hash instanceof Uint8Array)))) { throw new TypeError('Assistant-turn sidecar referenced contents are invalid'); @@ -53,24 +52,24 @@ function assertRoundTripSidecar(value: Record): asserts value i if (!(value.sourceTurnDigest instanceof Uint8Array)) throw new TypeError('Assistant-turn sidecar turn digest is invalid'); } -export const decodeRoundTripSidecar = async (codec: SidecarCodec, source: RoundTripProtocol, data: string): Promise => { - const sidecar = restoreSidecarTaggedValues(await codec.decode(data)); +export const decodeRoundTripSidecar = async (codec: SidecarCodec, source: AssistantTurnProtocol, data: string): Promise => { + const sidecar = untagContentReferences(await codec.decode(data)); if (sidecar === undefined) return undefined; if (!isRecord(sidecar) || typeof sidecar.source !== 'string') throw new TypeError('Authenticated assistant-turn sidecar envelope is invalid'); if (sidecar.source !== source) return undefined; - assertRoundTripSidecar(sidecar); + assertRoundTripEnvelope(sidecar); return sidecar; }; -export const encodeRoundTripSidecar = async ( +export const encodeRoundTripSidecar = async ( codec: SidecarCodec, sidecar: RoundTripSidecar, ): Promise => await codec.encode(encodedRoundTripSidecar(sidecar)); -const encodedRoundTripSidecar = (sidecar: RoundTripSidecar): NonNullable => { - const encoded = encodeSidecarTaggedValues(sidecar); +const encodedRoundTripSidecar = (sidecar: RoundTripSidecar): NonNullable => { + const encoded = tagContentReferences(sidecar); if (typeof encoded !== 'object' || encoded === null) throw new TypeError('Round-trip sidecar must encode to an object'); return encoded; }; -export const hasRoundTripTarget = (sidecar: AnyRoundTripSidecar, target: Target): sidecar is RoundTripSidecarByTarget[Target] => sidecar.target === target; +export const hasRoundTripTarget = (sidecar: AnyRoundTripSidecar, target: Target): sidecar is RoundTripSidecarByTarget[Target] => sidecar.target === target; diff --git a/packages/translate/src/round-trip/stream.ts b/packages/translate/src/round-trip/stream.ts deleted file mode 100644 index 17d393f9c0..0000000000 --- a/packages/translate/src/round-trip/stream.ts +++ /dev/null @@ -1,65 +0,0 @@ -import type { AssistantTurnByProtocol, AssistantTurnFor } from './assistant-turn.ts'; -import type { SidecarCodec } from '../types.ts'; -import type { AssistantTurnStateFor } from './reference/assistant-turn.ts'; -import { collectContentViews, createReferenceBuilder, type ContentStates, type FieldStates, type ReferenceBuilder } from './reference/reference-builder.ts'; -import { finalizeAssistantTurnReferences } from './reference/reference-selection.ts'; -import type { RoundTripSidecar } from './sidecar.ts'; -import { encodeRoundTripSidecar } from './sidecar.ts'; -import type { TextPlacementIndex } from './text-groups.ts'; -import type { RoundTripTurnDigest } from './turn-digest.ts'; - -export interface RoundTripReader { - builder: ReferenceBuilder; - onAssistantTurn: ( - choice: number, - turn: AssistantTurnFor, - fieldState: AssistantTurnStateFor, - ) => void; -} - -export interface RoundTripWriter { - builder: ReferenceBuilder; - prepareSidecar: ( - choice: number, - sourceTurnDigest: RoundTripTurnDigest, - turn: AssistantTurnFor, - contentState: AssistantTurnStateFor, - textViews: TextPlacementIndex, - ) => Promise; -} - -export const createRoundTripStream = < - Source extends keyof AssistantTurnByProtocol, - Target extends keyof AssistantTurnByProtocol, ->(source: Source, target: Target, codec: SidecarCodec) => { - const builder = createReferenceBuilder(); - const turns = new Map; - fieldState: AssistantTurnStateFor; - }>(); - const reader: RoundTripReader = { - builder, - onAssistantTurn: (choice, turn, fieldState) => { turns.set(choice, { turn, fieldState }); }, - }; - const writer: RoundTripWriter = { - builder, - prepareSidecar: async (choice, sourceTurnDigest, turn, contentState, textViews) => { - const pending = turns.get(choice); - if (pending === undefined) throw new Error(`Missing native assistant turn for choice ${choice}`); - const finalized = await finalizeAssistantTurnReferences( - pending.turn, - pending.fieldState, - builder, - collectContentViews(turn, contentState, textViews), - ); - return await encodeRoundTripSidecar(codec, { v: 1, source, target, ...finalized, sourceTurnDigest } satisfies RoundTripSidecar); - }, - }; - return { reader, writer }; -}; - -export interface RoundTripOutputOptions { - id?: string; - created?: number; - roundTrip: RoundTripWriter; -} diff --git a/packages/translate/src/round-trip/text-groups.ts b/packages/translate/src/round-trip/text-groups.ts index fa58e726b6..5d2e7be386 100644 --- a/packages/translate/src/round-trip/text-groups.ts +++ b/packages/translate/src/round-trip/text-groups.ts @@ -6,11 +6,11 @@ export interface TextContribution { export interface TextGroup { text: string; - readonly segments: Array<{ readonly part: Part; readonly field: string; readonly offset: number }>; + readonly fields: Array<{ readonly part: Part; readonly field: string; readonly offset: number }>; } export interface TextPlacement { - readonly text: string; + readonly enclosingText: string; readonly offset: number; } @@ -24,8 +24,8 @@ export const collectTextGroups = ( for (const part of parts) { const projected = project(part); if (projected === undefined) continue; - const channel = channels.get(projected.channel) ?? { text: '', segments: [] }; - channel.segments.push({ part, field: projected.field, offset: channel.text.length }); + const channel = channels.get(projected.channel) ?? { text: '', fields: [] }; + channel.fields.push({ part, field: projected.field, offset: channel.text.length }); channel.text += projected.text; channels.set(projected.channel, channel); } @@ -37,9 +37,9 @@ export const collectTextPlacementIndex = ( views: TextPlacementIndex, ): void => { for (const channel of channels.values()) { - for (const { part, field, offset } of channel.segments) { + for (const { part, field, offset } of channel.fields) { const fields = views.get(part) ?? new Map(); - fields.set(field, { text: channel.text, offset }); + fields.set(field, { enclosingText: channel.text, offset }); views.set(part, fields); } } diff --git a/packages/translate/src/round-trip/turn-digest.ts b/packages/translate/src/round-trip/turn-digest.ts index 065d3b8982..de25250052 100644 --- a/packages/translate/src/round-trip/turn-digest.ts +++ b/packages/translate/src/round-trip/turn-digest.ts @@ -1,3 +1,4 @@ +import { hashRoundTripContent } from './hash.ts'; import type { IRJSONObject, IRJSONValue } from '../ir/types.ts'; export type RoundTripTurnDigest = Uint8Array; @@ -18,16 +19,6 @@ const stringifyReplayMaterial = (material: IRJSONValue): string => { return text; }; -const utf16Bytes = (text: string): Uint8Array => { - const bytes: Uint8Array = new Uint8Array(text.length * 2); - const view = new DataView(bytes.buffer); - for (let index = 0; index < text.length; index++) view.setUint16(index * 2, text.charCodeAt(index), true); - return bytes; -}; - -export const hashRoundTripContent = async (text: string): Promise => - new Uint8Array(await crypto.subtle.digest('SHA-256', utf16Bytes(text)), 0, 8); - export const computeRoundTripTurnDigest = async (material: IRJSONValue): Promise => await hashRoundTripContent(stringifyReplayMaterial(material)); diff --git a/packages/translate/src/round-trip/writer.ts b/packages/translate/src/round-trip/writer.ts new file mode 100644 index 0000000000..0bf5f90c1b --- /dev/null +++ b/packages/translate/src/round-trip/writer.ts @@ -0,0 +1,16 @@ +import type { InputAssistantTurnByProtocol, InputAssistantTurnFor } from './input-assistant-turn.ts'; +import type { InputAssistantTurnStateFor } from './reference/assistant-turn-types.ts'; +import type { EmittedFieldStates, ReferenceBuilder } from './reference/reference-builder.ts'; +import type { TextPlacementIndex } from './text-groups.ts'; +import type { RoundTripTurnDigest } from './turn-digest.ts'; + +export interface RoundTripWriter { + builder: ReferenceBuilder; + encodeSidecar: ( + choice: number, + sourceTurnDigest: RoundTripTurnDigest, + turn: InputAssistantTurnFor, + contentState: InputAssistantTurnStateFor, + textPlacements: TextPlacementIndex, + ) => Promise; +} diff --git a/packages/translate/src/shared/event-writer-options.ts b/packages/translate/src/shared/event-writer-options.ts new file mode 100644 index 0000000000..c79805c2b0 --- /dev/null +++ b/packages/translate/src/shared/event-writer-options.ts @@ -0,0 +1,8 @@ +import type { AssistantTurnProtocol } from '../round-trip/input-assistant-turn.ts'; +import type { RoundTripWriter } from '../round-trip/writer.ts'; + +export interface SourceEventWriterOptions { + id?: string; + created?: number; + roundTrip: RoundTripWriter; +} diff --git a/packages/translate/src/types.ts b/packages/translate/src/types.ts index 2c7735dbb1..39728d8814 100644 --- a/packages/translate/src/types.ts +++ b/packages/translate/src/types.ts @@ -1,11 +1,6 @@ +import type { SidecarCodec } from './round-trip/codec.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; -export interface SidecarCodec { - /** Encode and decode must preserve sidecar tagged values, byte arrays, and raw JSON values. */ - encode: (payload: NonNullable | null) => Promise; - decode: (encoded: string) => Promise; -} - export interface RemoteImageData { mediaType: string | null; data: Uint8Array; From e9d30980cf39119afd0ba8bfb7a144e78fbf9bd2 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 04:55:49 +0800 Subject: [PATCH 05/66] fix(translate): preserve cross-protocol error details Restate recognized upstream errors through source-owned converters before and during streams. Preserve independent error classifications, nullable code and param values, envelope metadata, and one merged provider extension container. Use the same recognized context-overflow decision for Gemini HTTP and body codes. Keep unknown response bytes untouched and propagate unexpected parser failures with their original error and cause. Validate 322 tests in 19 named files, changed-file ESLint, and the translate and gateway TypeScript projects. --- .../as-source/context-window-error_test.ts | 79 -------- .../as-source/error-body_test.ts | 121 ++++++++++++ .../protocols/cross-protocol-errors_test.ts | 175 ++++++++++++++++++ .../as-source/error-body_test.ts | 71 +++++++ .../via-openai-responses/events_test.ts | 3 +- .../__tests__/shared/api-error_test.ts | 86 +++++++++ packages/translate/src/ir/types.ts | 1 + .../as-source/context-window-error.ts | 24 --- .../as-source/error-body.ts | 10 + .../anthropic-messages/as-source/sse-to.ts | 26 ++- .../via-openai-chat-completions/translate.ts | 4 +- .../via-openai-responses/translate.ts | 4 +- .../as-source/error-body.ts | 10 + .../as-source/sse-to.ts | 38 +++- .../via-anthropic-messages/translate.ts | 3 +- .../via-openai-chat-completions/translate.ts | 3 +- .../via-openai-responses/translate.ts | 3 +- .../as-source/error-body.ts | 9 + .../as-source/sse-to.ts | 20 +- .../via-anthropic-messages/translate.ts | 3 +- .../via-openai-responses/translate.ts | 3 +- .../openai-responses/as-source/error-body.ts | 9 + .../openai-responses/as-source/sse-to.ts | 21 ++- .../openai-responses/as-target/sse-from.ts | 10 +- .../via-anthropic-messages/translate.ts | 3 +- .../via-openai-chat-completions/translate.ts | 3 +- packages/translate/src/shared/api-error.ts | 112 +++++++++++ 27 files changed, 710 insertions(+), 144 deletions(-) delete mode 100644 packages/translate/__tests__/protocols/anthropic-messages/as-source/context-window-error_test.ts create mode 100644 packages/translate/__tests__/protocols/anthropic-messages/as-source/error-body_test.ts create mode 100644 packages/translate/__tests__/protocols/cross-protocol-errors_test.ts create mode 100644 packages/translate/__tests__/protocols/gemini-generate-content/as-source/error-body_test.ts create mode 100644 packages/translate/__tests__/shared/api-error_test.ts delete mode 100644 packages/translate/src/protocols/anthropic-messages/as-source/context-window-error.ts create mode 100644 packages/translate/src/protocols/anthropic-messages/as-source/error-body.ts create mode 100644 packages/translate/src/protocols/gemini-generate-content/as-source/error-body.ts create mode 100644 packages/translate/src/protocols/openai-chat-completions/as-source/error-body.ts create mode 100644 packages/translate/src/protocols/openai-responses/as-source/error-body.ts create mode 100644 packages/translate/src/shared/api-error.ts diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-source/context-window-error_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/context-window-error_test.ts deleted file mode 100644 index e5e266c303..0000000000 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-source/context-window-error_test.ts +++ /dev/null @@ -1,79 +0,0 @@ -import { test } from 'vitest'; - -import { rewriteContextExceededToPromptTooLong } from '../../../../src/protocols/anthropic-messages/as-source/context-window-error.ts'; -import { buildPromptTooLongBody, PROMPT_TOO_LONG_MESSAGE } from '@floway-dev/protocols/anthropic-messages'; -import { assert, assertEquals } from '@floway-dev/test-utils'; - -test('buildPromptTooLongBody — Anthropic envelope with load-bearing prefix', () => { - const body = new TextDecoder().decode(buildPromptTooLongBody()); - const parsed = JSON.parse(body) as { type: string; error: { type: string; message: string } }; - assertEquals(parsed.type, 'error'); - assertEquals(parsed.error.type, 'invalid_request_error'); - assertEquals(parsed.error.message, PROMPT_TOO_LONG_MESSAGE); - assert(parsed.error.message.startsWith('prompt is too long:')); -}); - -test('rewriteContextExceededToPromptTooLong — rewrites Copilot OpenAI Responses shape', () => { - const upstream = { - status: 400, - headers: new Headers({ 'x-copilot-request-id': 'abc' }), - body: new TextEncoder().encode(JSON.stringify({ - error: { code: 'model_max_prompt_tokens_exceeded', message: 'prompt token count of 1000000 exceeds the limit of 128000' }, - })), - }; - - const result = rewriteContextExceededToPromptTooLong(upstream.status, upstream.headers, upstream.body); - assert(result !== undefined); - assertEquals(result!.status, 400); - assertEquals(result!.headers.get('content-type'), 'application/json'); - const parsed = result.body as { error: { message: string } }; - assert(parsed.error.message.startsWith('prompt is too long:')); -}); - -test('rewriteContextExceededToPromptTooLong — rewrites Codex OpenAI Responses shape', () => { - const upstream = { - status: 400, - headers: new Headers(), - body: new TextEncoder().encode(JSON.stringify({ - error: { code: 'context_length_exceeded', message: 'Your input exceeds the context window of this model. Please adjust your input and try again.' }, - })), - }; - const result = rewriteContextExceededToPromptTooLong(upstream.status, upstream.headers, upstream.body); - assert(result !== undefined); -}); - -test('rewriteContextExceededToPromptTooLong — recognizes bodies whose signal is in the message substring only', () => { - const upstream = { - status: 400, - headers: new Headers(), - body: new TextEncoder().encode(JSON.stringify({ - error: { message: "This model's maximum context length is 4097 tokens. However, your messages resulted in 4498 tokens." }, - })), - }; - assert(rewriteContextExceededToPromptTooLong(upstream.status, upstream.headers, upstream.body) !== undefined); -}); - -test('rewriteContextExceededToPromptTooLong — passes unrelated bodies through unchanged', () => { - const upstream = { - status: 401, - headers: new Headers(), - body: new TextEncoder().encode(JSON.stringify({ error: { code: 'invalid_api_key', message: 'bad token' } })), - }; - assertEquals(rewriteContextExceededToPromptTooLong(upstream.status, upstream.headers, upstream.body), undefined); -}); - -test('rewriteContextExceededToPromptTooLong — falls back to plain-text substring when the body is not JSON', () => { - const matching = { - status: 502, - headers: new Headers(), - body: new TextEncoder().encode('some upstream prose: exceeds the context window of this model'), - }; - assert(rewriteContextExceededToPromptTooLong(matching.status, matching.headers, matching.body) !== undefined); - - const unrelated = { - status: 502, - headers: new Headers(), - body: new TextEncoder().encode('Bad Gateway'), - }; - assertEquals(rewriteContextExceededToPromptTooLong(unrelated.status, unrelated.headers, unrelated.body), undefined); -}); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-source/error-body_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/error-body_test.ts new file mode 100644 index 0000000000..051b7f2b20 --- /dev/null +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-source/error-body_test.ts @@ -0,0 +1,121 @@ +import { test } from 'vitest'; + +import { restateAnthropicMessagesError } from '../../../../src/protocols/anthropic-messages/as-source/error-body.ts'; +import { anthropicMessagesErrorFromIR } from '../../../../src/protocols/anthropic-messages/as-source/sse-to.ts'; +import { errorPayloadFromWire } from '../../../../src/shared/api-error.ts'; +import { buildPromptTooLongBody, PROMPT_TOO_LONG_MESSAGE } from '@floway-dev/protocols/anthropic-messages'; +import { assert, assertEquals } from '@floway-dev/test-utils'; + +const decoded = (result: { body: unknown } | undefined) => result!.body as { + type: string; + error: { type: string; message: string }; +}; +const upstreamOf = (status: number, body: unknown, headers: Record = {}) => [ + status, + new Headers({ 'x-upstream': 'a', ...headers }), + new TextEncoder().encode(typeof body === 'string' ? body : JSON.stringify(body)), +] as const; + +test('buildPromptTooLongBody — Anthropic envelope with load-bearing prefix', () => { + const body = new TextDecoder().decode(buildPromptTooLongBody()); + const parsed = JSON.parse(body) as { type: string; error: { type: string; message: string } }; + assertEquals(parsed.type, 'error'); + assertEquals(parsed.error.type, 'invalid_request_error'); + assertEquals(parsed.error.message, PROMPT_TOO_LONG_MESSAGE); + assert(parsed.error.message.startsWith('prompt is too long:')); +}); + +test.each([ + ['Copilot Responses', { error: { code: 'model_max_prompt_tokens_exceeded', message: 'prompt token count of 1000000 exceeds the limit of 128000' } }], + ['Codex Responses', { error: { code: 'context_length_exceeded', message: 'Your input exceeds the context window of this model. Please adjust your input and try again.' } }], + ['message-only signal', { error: { message: "This model's maximum context length is 4097 tokens. However, your messages resulted in 4498 tokens." } }], +])('restates a context overflow from the %s as the prompt-too-long wording', (_name, body) => { + const result = restateAnthropicMessagesError(...upstreamOf(502, body)); + assertEquals(result!.status, 400); + assertEquals(result!.headers.get('content-type'), 'application/json'); + const parsed = decoded(result); + assertEquals(parsed.error.type, 'invalid_request_error'); + assert(parsed.error.message.startsWith('prompt is too long:')); +}); + +test('restates a recognized failure in the Messages envelope and keeps the upstream status', () => { + const result = restateAnthropicMessagesError(...upstreamOf(401, { error: { code: 'invalid_api_key', message: 'bad token' } })); + assertEquals(result!.status, 401); + assertEquals(decoded(result).error.message, 'bad token'); +}); + +test('keeps the upstream headers a client reads', () => { + const result = restateAnthropicMessagesError(...upstreamOf(429, { error: { message: 'slow down' } }, { 'retry-after': '30', 'request-id': 'req_1' })); + assertEquals(result!.headers.get('retry-after'), '30'); + assertEquals(result!.headers.get('request-id'), 'req_1'); + assertEquals(result!.headers.get('x-upstream'), 'a'); +}); + +test('passes an unrecognized body through untouched', () => { + assertEquals(restateAnthropicMessagesError(...upstreamOf(502, 'Bad Gateway')), undefined); + assertEquals(restateAnthropicMessagesError(...upstreamOf(502, 'exceeds the context window of this model')), undefined); + assertEquals(restateAnthropicMessagesError(...upstreamOf(500, { detail: 'something broke' })), undefined); + assertEquals(restateAnthropicMessagesError(...upstreamOf(500, {})), undefined); +}); + +test('reads a message-only envelope the same way its stream does', () => { + const result = restateAnthropicMessagesError(...upstreamOf(529, { error: { message: 'Overloaded' } })); + + assertEquals(result!.status, 529); + assertEquals(decoded(result).error.message, 'Overloaded'); +}); + +test('carries the fields the target protocol states that the IR does not name', () => { + const result = restateAnthropicMessagesError(...upstreamOf(400, { + error: { message: 'bad request', code: 'invalid', safety_violations: [{ category: 'hate' }], headers: { 'x-trace': 'abc' } }, + })); + + const error = decoded(result).error as Record; + assertEquals(error.provider_specific_fields, { + code: 'invalid', + safety_violations: [{ category: 'hate' }], + headers: { 'x-trace': 'abc' }, + }); +}); + +test('merges loose error fields with the upstream container and keeps its collision priority', () => { + const result = restateAnthropicMessagesError(...upstreamOf(400, { + error: { message: 'bad request', provider_specific_fields: { stack: 'theirs' }, stack: 'legacy', retry_after: 10, code: 'invalid_value', param: 'temperature' }, + })); + + assertEquals((decoded(result).error as Record).provider_specific_fields, { + stack: 'theirs', retry_after: 10, code: 'invalid_value', param: 'temperature', + }); +}); + +test('preserves the same failure fields before and during a stream', () => { + const error = { + message: 'bad field', type: 'invalid_request_error', code: 'invalid_value', param: 'temperature', + provider_specific_fields: { request_id: 'req_1' }, retry_after: 10, + }; + const result = restateAnthropicMessagesError(...upstreamOf(400, { error })); + const expected = { + type: 'invalid_request_error', message: 'bad field', + provider_specific_fields: { code: 'invalid_value', param: 'temperature', request_id: 'req_1', retry_after: 10 }, + }; + assertEquals(decoded(result).error, expected); + assertEquals(anthropicMessagesErrorFromIR(errorPayloadFromWire(error)), expected); +}); + +test('normalizes a wire msg alias before treating the object as an IR error', () => { + const details = { request_id: 'req_alias' }; + const wire = { msg: 'Alias message', code: 'invalid_value', param: 'temperature', provider_specific_fields: details }; + const error = errorPayloadFromWire({ error: wire }); + assertEquals(error.message, wire.msg); + assertEquals(error.code, wire.code); + assertEquals(error.param, wire.param); + assert(error.provider_specific_fields === details); + assertEquals(anthropicMessagesErrorFromIR(error).message, wire.msg); + const beforeStream = restateAnthropicMessagesError(...upstreamOf(400, { error: wire })); + assertEquals(decoded(beforeStream).error, anthropicMessagesErrorFromIR(error)); +}); + +test('a canonical wire error keeps its original object', () => { + const wire = { message: 'Canonical message', code: 'invalid_value' }; + assert(errorPayloadFromWire({ error: wire }) === wire); +}); diff --git a/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts b/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts new file mode 100644 index 0000000000..de0b7adf5a --- /dev/null +++ b/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts @@ -0,0 +1,175 @@ +import { expect, test } from 'vitest'; + +import { requireTrip } from './require-trip.ts'; +import type { IRErrorPayload } from '../../src/ir/types.ts'; +import { irFromAnthropicMessages } from '../../src/protocols/anthropic-messages/as-target/sse-from.ts'; +import { irFromOpenAIChatCompletions } from '../../src/protocols/openai-chat-completions/as-target/sse-from.ts'; +import { irFromOpenAIResponses } from '../../src/protocols/openai-responses/as-target/sse-from.ts'; +import { collect, iterate } from '../ir/helpers.ts'; +import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { createRoundTripReaderCapture } from '../round-trip/test-helpers.ts'; +import { PROMPT_TOO_LONG_MESSAGE } from '@floway-dev/protocols/anthropic-messages'; +import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; +import { + routeAnthropicMessagesViaOpenAIChatCompletions, + routeAnthropicMessagesViaOpenAIResponses, + routeGeminiGenerateContentViaAnthropicMessages, + routeGeminiGenerateContentViaOpenAIChatCompletions, + routeGeminiGenerateContentViaOpenAIResponses, + routeOpenAIChatCompletionsViaAnthropicMessages, + routeOpenAIChatCompletionsViaOpenAIResponses, + routeOpenAIResponsesViaAnthropicMessages, + routeOpenAIResponsesViaOpenAIChatCompletions, + type TranslateInputError, + type TranslateTrip, +} from '@floway-dev/translate'; + +const options = () => ({ model: 'm', sidecarCodec: createTestSidecarCodec('error-routes') }); +const chat = { model: 'm', messages: [{ role: 'user' as const, content: 'hello' }] }; +const responses = { model: 'm', input: 'hello' }; +const messages = { model: 'm', max_tokens: 16, messages: [{ role: 'user' as const, content: 'hello' }] }; +const gemini = { contents: [{ role: 'user' as const, parts: [{ text: 'hello' }] }] }; + +const failure = { + message: 'bad field', type: 'invalid_request_error', code: 'invalid_prompt', param: 'temperature', + status: 'UPSTREAM_STATUS', msg: 'secondary message', retry_after: 7, + provider_specific_fields: { request_id: 'req_1', retry_after: 9 }, +}; +const chatEvent = (error: IRErrorPayload) => ({ + id: 'error', object: 'chat.completion.chunk' as const, created: 0, model: 'm', choices: [], error, +}); +const nestedEvent = (error: typeof failure) => ({ type: 'error' as const, error }); + +const expectedChat = { + error: { + message: failure.message, type: failure.type, code: failure.code, param: failure.param, + provider_specific_fields: { status: failure.status, msg: failure.msg, request_id: 'req_1', retry_after: 9 }, + }, +}; +const expectedResponses = { + type: 'error', message: failure.message, code: failure.code, param: failure.param, + provider_specific_fields: { type: failure.type, status: failure.status, msg: failure.msg, request_id: 'req_1', retry_after: 9 }, +}; +const expectedMessages = { + type: 'error', error: { + type: failure.type, message: failure.message, + provider_specific_fields: { code: failure.code, param: failure.param, status: failure.status, msg: failure.msg, request_id: 'req_1', retry_after: 9 }, + }, +}; +const expectedGemini = { + error: { + code: 400, status: 'INVALID_ARGUMENT', message: failure.message, + details: [{ + '@type': 'type.googleapis.com/google.protobuf.Struct', value: { + type: failure.type, code: failure.code, param: failure.param, status: failure.status, + msg: failure.msg, request_id: 'req_1', retry_after: 9, + }, + }], + }, +}; + +const verifyPhases = async ( + pending: Promise | TranslateInputError>, + event: Target, + expected: unknown, +) => { + const trip = await requireTrip(pending); + const headers = new Headers({ + 'content-type': 'text/plain', 'content-length': '123', 'content-encoding': 'gzip', + 'retry-after': '5', 'x-request-id': 'req_1', + }); + const http = trip.error(400, headers, new TextEncoder().encode(JSON.stringify({ error: failure }))); + expect(http?.status).toBe(400); + expect(http?.body).toEqual(expected); + expect(http?.headers.get('retry-after')).toBe('5'); + expect(http?.headers.get('x-request-id')).toBe('req_1'); + expect(http?.headers.get('content-type')).toBe('application/json'); + expect(http?.headers.has('content-length')).toBe(false); + expect(http?.headers.has('content-encoding')).toBe(false); + expect(headers.get('content-type')).toBe('text/plain'); + const frames = await collect(trip.events(iterate([eventFrame(event)]))); + expect(frames).toHaveLength(1); + const [frame] = frames; + if (frame.type !== 'event') throw new Error('Expected the translated error event'); + const source = frame.event as Record; + const { sequence_number: _sequenceNumber, ...body } = source; + expect(body).toEqual(expected); +}; + +const cases = [ + ['Chat via Responses', () => verifyPhases(routeOpenAIChatCompletionsViaOpenAIResponses(chat, options()), nestedEvent(failure), expectedChat)], + ['Chat via Messages', () => verifyPhases(routeOpenAIChatCompletionsViaAnthropicMessages(chat, { ...options(), loadRemoteImage: async () => null, fallbackMaxOutputTokens: 16 }), nestedEvent(failure), expectedChat)], + ['Responses via Chat', () => verifyPhases(routeOpenAIResponsesViaOpenAIChatCompletions(responses, options()), chatEvent(failure), expectedResponses)], + ['Responses via Messages', () => verifyPhases(routeOpenAIResponsesViaAnthropicMessages(responses, { ...options(), loadRemoteImage: async () => null, fallbackMaxOutputTokens: 16 }), nestedEvent(failure), expectedResponses)], + ['Messages via Chat', () => verifyPhases(routeAnthropicMessagesViaOpenAIChatCompletions(messages, options()), chatEvent(failure), expectedMessages)], + ['Messages via Responses', () => verifyPhases(routeAnthropicMessagesViaOpenAIResponses(messages, options()), nestedEvent(failure), expectedMessages)], + ['Gemini via Chat', () => verifyPhases(routeGeminiGenerateContentViaOpenAIChatCompletions(gemini, options()), chatEvent(failure), expectedGemini)], + ['Gemini via Responses', () => verifyPhases(routeGeminiGenerateContentViaOpenAIResponses(gemini, options()), nestedEvent(failure), expectedGemini)], + ['Gemini via Messages', () => verifyPhases(routeGeminiGenerateContentViaAnthropicMessages(gemini, { ...options(), fallbackMaxOutputTokens: 16 }), nestedEvent(failure), expectedGemini)], +] as const; + +test.each(cases)('%s preserves the error fields in both phases', async (_name, run) => { await run(); }); + +test.each([ + { message: 'failed' }, + { message: 'failed', code: null, param: null }, + { message: 'failed', type: 'classification' }, + { message: 'failed', type: 'classification', code: null, param: null }, +])('OpenAI errors preserve absent and nullable fields independently: %j', async error => { + const chatTrip = await requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses(chat, options())); + const responsesTrip = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(responses, options())); + const encoded = new TextEncoder().encode(JSON.stringify({ error })); + expect(chatTrip.error(502, new Headers(), encoded)?.body).toEqual({ error }); + const expected = { + type: 'error', message: error.message, + ...('code' in error ? { code: error.code } : {}), + ...('param' in error ? { param: error.param } : {}), + ...('type' in error ? { provider_specific_fields: { type: error.type } } : {}), + }; + expect(responsesTrip.error(502, new Headers(), encoded)?.body).toEqual(expected); + expect(await collect(chatTrip.events(iterate([eventFrame({ type: 'error', error } as const)])))).toEqual([eventFrame({ error })]); + expect(await collect(responsesTrip.events(iterate([eventFrame(chatEvent(error))])))).toEqual([eventFrame({ ...expected, sequence_number: 0 })]); +}); + +test('Responses flat errors remove transport discriminants before restatement', async () => { + const trip = await requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses(chat, options())); + const error = { type: 'error' as const, sequence_number: 17, message: 'failed', code: null, param: null }; + const frames = await collect(trip.events(iterate([eventFrame(error)]))); + expect(trip.error(502, new Headers(), new TextEncoder().encode(JSON.stringify(error)))?.body).toEqual({ + error: { message: 'failed', code: null, param: null }, + }); + expect(frames).toEqual([eventFrame({ error: { message: 'failed', code: null, param: null } })]); +}); + +test('native target errors retain canonical object identity', async () => { + const chatRecords = await collect(irFromOpenAIChatCompletions(iterate([eventFrame(chatEvent(failure))]), { + roundTrip: createRoundTripReaderCapture<'openaiChatCompletions'>().roundTrip, + })); + const messagesRecords = await collect(irFromAnthropicMessages(iterate([eventFrame(nestedEvent(failure))]), { + roundTrip: createRoundTripReaderCapture<'anthropicMessages'>().roundTrip, + })); + const responsesRecords = await collect(irFromOpenAIResponses(iterate([eventFrame(nestedEvent(failure))]), { + roundTrip: createRoundTripReaderCapture<'openaiResponses'>().roundTrip, + })); + for (const records of [chatRecords, messagesRecords, responsesRecords]) { + const record = records.find(value => value.type === 'error'); + expect(record?.error).toBe(failure); + } +}); + +test.each([ + ['Messages', () => routeAnthropicMessagesViaOpenAIResponses(messages, options())], + ['Gemini', () => routeGeminiGenerateContentViaOpenAIResponses(gemini, options())], +] as const)('%s context overflow keeps the same recognized semantics in both phases', async (protocol, create) => { + const trip = await create(); + if (trip.type === 'input-error') throw new Error(trip.message); + const error = { message: 'too long', code: 'context_length_exceeded' }; + const http = trip.error(502, new Headers(), new TextEncoder().encode(JSON.stringify({ error }))); + expect(http?.status).toBe(400); + const frames: ProtocolFrame[] = []; + for await (const frame of trip.events(iterate([eventFrame({ type: 'error', error } as const)]))) frames.push(frame); + expect(frames).toEqual([eventFrame(http?.body)]); + expect(http?.body).toMatchObject(protocol === 'Messages' + ? { error: { type: 'invalid_request_error', message: PROMPT_TOO_LONG_MESSAGE } } + : { error: { code: 400, status: 'INVALID_ARGUMENT', message: 'too long' } }); +}); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/error-body_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/error-body_test.ts new file mode 100644 index 0000000000..2d2cf41214 --- /dev/null +++ b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/error-body_test.ts @@ -0,0 +1,71 @@ +import { test } from 'vitest'; + +import { restateGeminiGenerateContentError } from '../../../../src/protocols/gemini-generate-content/as-source/error-body.ts'; +import { geminiGenerateContentErrorFromIR } from '../../../../src/protocols/gemini-generate-content/as-source/sse-to.ts'; +import { errorPayloadFromWire } from '../../../../src/shared/api-error.ts'; +import { assertEquals } from '@floway-dev/test-utils'; + +const decoded = (result: { body: unknown } | undefined) => result!.body as { + error: { code: number; status: string; message: string }; +}; + +const upstreamOf = (status: number, body: unknown, headers: Record = {}) => [ + status, + new Headers({ 'x-upstream': 'a', ...headers }), + new TextEncoder().encode(typeof body === 'string' ? body : JSON.stringify(body)), +] as const; + +test('keeps the upstream status in both places GenerateContent states it', () => { + const result = restateGeminiGenerateContentError(...upstreamOf(503, { error: { message: 'upstream overloaded' } })); + assertEquals(result!.status, 503); + assertEquals(decoded(result).error.code, 503); + assertEquals(decoded(result).error.status, 'UNAVAILABLE'); + assertEquals(decoded(result).error.message, 'upstream overloaded'); +}); + +test('keeps a status the inferred table would have flattened', () => { + const result = restateGeminiGenerateContentError(...upstreamOf(402, { error: { message: 'payment required' } })); + + assertEquals(result!.status, 402); + assertEquals(decoded(result).error.code, 402); + assertEquals(decoded(result).error.status, 'UNKNOWN'); +}); + +test('keeps the upstream headers a client reads', () => { + const result = restateGeminiGenerateContentError(...upstreamOf(429, { error: { message: 'slow down' } }, { 'retry-after': '12', 'x-request-id': 'req_9' })); + + assertEquals(result!.headers.get('retry-after'), '12'); + assertEquals(result!.headers.get('x-request-id'), 'req_9'); + assertEquals(result!.headers.get('content-type'), 'application/json'); +}); + +test('passes an unrecognized body through untouched', () => { + assertEquals(restateGeminiGenerateContentError(...upstreamOf(500, 'internal blew up')), undefined); + assertEquals(restateGeminiGenerateContentError(...upstreamOf(500, { detail: 'nope' })), undefined); +}); + +test('preserves the upstream failure fields beside the native RPC code', () => { + const error = { + message: 'bad field', type: 'invalid_request_error', code: 'invalid_value', param: 'temperature', + provider_specific_fields: { request_id: 'req_1' }, retry_after: 10, + }; + const result = restateGeminiGenerateContentError(...upstreamOf(400, { error })); + const expected = { + code: 400, status: 'INVALID_ARGUMENT', message: 'bad field', + details: [{ + '@type': 'type.googleapis.com/google.protobuf.Struct', + value: { type: 'invalid_request_error', code: 'invalid_value', param: 'temperature', request_id: 'req_1', retry_after: 10 }, + }], + }; + assertEquals(decoded(result).error, expected); + assertEquals(geminiGenerateContentErrorFromIR(errorPayloadFromWire(error), 400), expected); +}); + +test('context overflow determines both the HTTP status and body code', () => { + const error = { message: 'too long', code: 'context_length_exceeded' }; + const result = restateGeminiGenerateContentError(...upstreamOf(502, { error })); + assertEquals(result!.status, 400); + assertEquals(decoded(result).error.code, 400); + assertEquals(decoded(result).error.status, 'INVALID_ARGUMENT'); + assertEquals(decoded(result).error, geminiGenerateContentErrorFromIR(error)); +}); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts index 811f2cec7d..6f2dfbac5c 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts @@ -376,7 +376,6 @@ test('translateToSourceEvents translates OpenAI Responses error events to OpenAI if (frames[0].type !== 'event') throw new Error('expected event frame'); assertEquals((frames[0].event as unknown as Record).error, { message: 'upstream overloaded', - type: 'overloaded_error', code: 'overloaded_error', }); }); @@ -432,7 +431,7 @@ test.each(['flat error', 'nested error', 'failed response'])('translateToSourceE const translated = (frames[0].event as unknown as { error: Record }).error; expect(translated).toEqual({ message: error.message, - type: path === 'flat error' ? error.code : error.type, + ...(path === 'flat error' ? {} : { type: error.type }), code: error.code, provider_specific_fields, }); diff --git a/packages/translate/__tests__/shared/api-error_test.ts b/packages/translate/__tests__/shared/api-error_test.ts new file mode 100644 index 0000000000..0d1d9c10db --- /dev/null +++ b/packages/translate/__tests__/shared/api-error_test.ts @@ -0,0 +1,86 @@ +import { expect, test, vi } from 'vitest'; + +import { routeOpenAIChatCompletionsViaOpenAIResponses } from '../../src/index.ts'; +import { errorPayloadFromWire, irErrorPayloadFromBody } from '../../src/shared/api-error.ts'; +import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; + +const createTrip = async () => { + const trip = await routeOpenAIChatCompletionsViaOpenAIResponses({ + model: 'm', stream: true, messages: [{ role: 'user', content: 'hello' }], + }, { model: 'm', sidecarCodec: createTestSidecarCodec() }); + if (trip.type !== 'trip') throw new Error(trip.message); + return trip; +}; + +test('error restatement retains nullable fields, unused aliases and outer metadata', async () => { + const trip = await createTrip(); + const result = trip.error(400, new Headers({ 'retry-after': '5' }), new TextEncoder().encode(JSON.stringify({ + error: { message: 'bad field', code: null, param: null, status: 'INVALID_ARGUMENT' }, request_id: 'req_1', + }))); + expect(result?.body).toEqual({ + error: { + message: 'bad field', code: null, param: null, + provider_specific_fields: { status: 'INVALID_ARGUMENT', request_id: 'req_1' }, + }, + }); + expect(result?.status).toBe(400); + expect(result?.headers.get('retry-after')).toBe('5'); +}); + +test('unrecognized encoding and nonobject JSON leave the upstream body untouched', async () => { + const trip = await createTrip(); + const invalid = new Uint8Array([ + ...new TextEncoder().encode('{"error":{"message":"'), 255, ...new TextEncoder().encode('"}}'), + ]); + expect(trip.error(500, new Headers(), invalid)).toBeUndefined(); + for (const body of ['null', '[]', '502 Bad Gateway', 'some upstream prose: exceeds the context window of this model']) { + expect(trip.error(500, new Headers(), new TextEncoder().encode(body))).toBeUndefined(); + } +}); + +test('an internal error from JSON parsing propagates with its original cause', async () => { + const trip = await createTrip(); + const failure = new Error('internal parser failure', { cause: new Error('underlying failure') }); + const json = JSON as typeof JSON & { rawJSON: (text: string) => unknown }; + const spy = vi.spyOn(json, 'rawJSON').mockImplementation(() => { throw failure; }); + try { + let caught: unknown; + try { + trip.error(500, new Headers(), new TextEncoder().encode('{"error":{"message":"bad","retry_after":1.0}}')); + } catch (error) { + caught = error; + } + expect(spy).toHaveBeenCalledOnce(); + expect(caught).toBe(failure); + expect(failure.cause).toBeInstanceOf(Error); + expect((caught as Error).cause).toBe(failure.cause); + } finally { + spy.mockRestore(); + } +}); + +test('consumes msg only when it supplies the canonical message', () => { + const encode = (value: unknown) => new TextEncoder().encode(JSON.stringify(value)); + expect(irErrorPayloadFromBody(encode({ error: { message: 'primary', msg: 'secondary' } }))).toEqual({ + message: 'primary', provider_specific_fields: { msg: 'secondary' }, + }); + expect(irErrorPayloadFromBody(encode({ error: { msg: 'alias', trace_id: 'trace' } }))).toEqual({ + message: 'alias', provider_specific_fields: { trace_id: 'trace' }, + }); + expect(errorPayloadFromWire({ msg: 'alias', trace_id: 'trace' })).toEqual({ message: 'alias', trace_id: 'trace' }); +}); + +test('canonical wire errors retain object identity', () => { + const error = { message: 'failed', type: 'upstream_type', code: 'upstream_code', param: null, request_id: 'req_1' }; + expect(errorPayloadFromWire(error)).toBe(error); + expect(errorPayloadFromWire({ error })).toBe(error); +}); + +test('envelope transport fields stay outside the error metadata', () => { + const body = new TextEncoder().encode(JSON.stringify({ + type: 'error', sequence_number: 9, request_id: 'req_1', error: { message: 'failed', code: null }, + })); + expect(irErrorPayloadFromBody(body)).toEqual({ + message: 'failed', code: null, provider_specific_fields: { request_id: 'req_1' }, + }); +}); diff --git a/packages/translate/src/ir/types.ts b/packages/translate/src/ir/types.ts index 9e55e6e097..264c0b87b9 100644 --- a/packages/translate/src/ir/types.ts +++ b/packages/translate/src/ir/types.ts @@ -9,6 +9,7 @@ export type IRJSONObject = { readonly [key: string]: IRJSONValue }; // The error fields upstream protocols agree on; each writer picks the ones its protocol defines. export interface IRErrorPayload { + readonly [field: string]: unknown; readonly message: string; readonly type?: string; readonly code?: string | null; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/context-window-error.ts b/packages/translate/src/protocols/anthropic-messages/as-source/context-window-error.ts deleted file mode 100644 index d4d4a50239..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/as-source/context-window-error.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { isContextExceededError } from '../../../shared/context-window-error.ts'; -import type { ApiErrorTranslator } from '../../../types.ts'; -import { PROMPT_TOO_LONG_MESSAGE } from '@floway-dev/protocols/anthropic-messages'; - -const isContextExceededErrorText = (text: string): boolean => { - try { - const parsed = JSON.parse(text) as { error?: unknown }; - return parsed !== null && typeof parsed === 'object' && isContextExceededError(parsed.error); - } catch { - return isContextExceededError({ message: text }); - } -}; - -// Shared `TranslateTrip.error` implementation for the `anthropic-messages-via-*` -// pairs. Rewrites an upstream context-exceeded body into an Anthropic -// prompt-too-long envelope; passes anything else through unchanged. -export const rewriteContextExceededToPromptTooLong: ApiErrorTranslator = (_status, _headers, body) => { - if (!isContextExceededErrorText(new TextDecoder().decode(body))) return undefined; - return { - status: 400, - headers: new Headers({ 'content-type': 'application/json' }), - body: { type: 'error', error: { type: 'invalid_request_error', message: PROMPT_TOO_LONG_MESSAGE } }, - }; -}; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/error-body.ts b/packages/translate/src/protocols/anthropic-messages/as-source/error-body.ts new file mode 100644 index 0000000000..270ead5694 --- /dev/null +++ b/packages/translate/src/protocols/anthropic-messages/as-source/error-body.ts @@ -0,0 +1,10 @@ +import { anthropicMessagesErrorEnvelope } from './sse-to.ts'; +import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; +import type { ApiErrorTranslator } from '../../../types.ts'; + +export const restateAnthropicMessagesError: ApiErrorTranslator = (upstreamStatus, headers, upstreamBody) => { + const error = irErrorPayloadFromBody(upstreamBody); + if (error === undefined) return undefined; + const { body, status } = anthropicMessagesErrorEnvelope(error); + return restateError(status ?? upstreamStatus, headers, body); +}; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts index d31c3cf762..08ac20ae08 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts @@ -9,6 +9,7 @@ import { createIRUnitQueue, createIRUnitRegistry } from '../../../ir/unit-queue. import { appendContent, createContentState, type ContentState, type ContentStates } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; +import { errorDetailsOf } from '../../../shared/api-error.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; import { parseJSONObject, parseJSONObjectAtCompletion, requireJSONObject } from '../../../shared/json/object.ts'; import type { AnthropicMessagesAssistantState, AnthropicMessagesInputAssistantTurn } from '../as-target/assistant-turn.ts'; @@ -25,6 +26,7 @@ import { } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, type EventFrame } from '@floway-dev/protocols/common'; +// https://platform.claude.com/docs/en/api/errors#http-errors const MESSAGES_ERROR_TYPES = [ 'invalid_request_error', 'authentication_error', @@ -35,9 +37,25 @@ const MESSAGES_ERROR_TYPES = [ 'overloaded_error', ]; -const irMessagesError = (error: IRErrorPayload): AnthropicMessagesErrorEventEx['error'] => isContextExceededError(error) - ? { type: 'invalid_request_error', message: PROMPT_TOO_LONG_MESSAGE } - : { type: error.type !== undefined && MESSAGES_ERROR_TYPES.includes(error.type) ? error.type : 'api_error', message: error.message }; +const messagesError = (error: IRErrorPayload, contextExceeded: boolean): AnthropicMessagesErrorEventEx['error'] => { + const type = contextExceeded + ? 'invalid_request_error' + : error.type !== undefined && MESSAGES_ERROR_TYPES.includes(error.type) ? error.type : 'api_error'; + const details = errorDetailsOf(error, type === error.type ? ['message', 'type'] : ['message']); + const extension = details === undefined ? {} : { provider_specific_fields: details }; + return { type, message: contextExceeded ? PROMPT_TOO_LONG_MESSAGE : error.message, ...extension }; +}; + +export const anthropicMessagesErrorFromIR = (error: IRErrorPayload): AnthropicMessagesErrorEventEx['error'] => + messagesError(error, isContextExceededError(error)); + +export const anthropicMessagesErrorEnvelope = (error: IRErrorPayload): { readonly body: unknown; readonly status: number | undefined } => { + const contextExceeded = isContextExceededError(error); + return { + body: { type: 'error', error: messagesError(error, contextExceeded) }, + status: contextExceeded ? 400 : undefined, + }; +}; interface MessagesUnit { readonly path: IRPath; @@ -220,7 +238,7 @@ export const anthropicMessagesFromIR = async function* ( for await (const { record, collector } of consumeIRRecords(records)) { const { state } = collector; - if (record.type === 'error') { yield emit({ type: 'error', error: irMessagesError(record.error) }); return; } + if (record.type === 'error') { yield emit({ type: 'error', error: anthropicMessagesErrorFromIR(record.error) }); return; } if (record.type === 'write' && record.path[0] === 'model' && typeof record.value === 'string') metadata.model = record.value; if (record.type === 'start') { if (state.model === undefined) throw new TypeError('Messages output requires an IR model before start'); diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/translate.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/translate.ts index e8c0ad9d71..70864f89d7 100644 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/translate.ts +++ b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/translate.ts @@ -2,7 +2,7 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import type { Translator } from '../../../types.ts'; -import { rewriteContextExceededToPromptTooLong } from '../as-source/context-window-error.ts'; +import { restateAnthropicMessagesError } from '../as-source/error-body.ts'; import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; @@ -15,7 +15,7 @@ export const translate: Translator< type: 'trip', target, events: frames => translateToSourceEvents(frames, ctx.sidecarCodec, target.n ?? 1), - error: rewriteContextExceededToPromptTooLong, + error: restateAnthropicMessagesError, }; } catch (error) { if (error instanceof TranslateInputException) return error.failure; diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/translate.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/translate.ts index bdec21ae2e..83581fc501 100644 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/translate.ts +++ b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/translate.ts @@ -2,7 +2,7 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import type { Translator } from '../../../types.ts'; -import { rewriteContextExceededToPromptTooLong } from '../as-source/context-window-error.ts'; +import { restateAnthropicMessagesError } from '../as-source/error-body.ts'; import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; @@ -14,7 +14,7 @@ export const translate: Translator< type: 'trip', target: await buildTargetRequest(src, ctx.sidecarCodec), events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), - error: rewriteContextExceededToPromptTooLong, + error: restateAnthropicMessagesError, }; } catch (error) { if (error instanceof TranslateInputException) return error.failure; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/error-body.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/error-body.ts new file mode 100644 index 0000000000..951d2ee794 --- /dev/null +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/error-body.ts @@ -0,0 +1,10 @@ +import { geminiGenerateContentErrorFromIR } from './sse-to.ts'; +import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; +import type { ApiErrorTranslator } from '../../../types.ts'; + +export const restateGeminiGenerateContentError: ApiErrorTranslator = (upstreamStatus, headers, upstreamBody) => { + const error = irErrorPayloadFromBody(upstreamBody); + if (error === undefined) return undefined; + const restated = geminiGenerateContentErrorFromIR(error, upstreamStatus); + return restateError(restated.code, headers, { error: restated }); +}; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts index 7e224bee09..0c187560f0 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts @@ -8,6 +8,7 @@ import { createIRUnitQueue, createIRUnitRegistry, type IRUnitQueue } from '../.. import { appendContent, createContentState, type ContentStates, type ContentState as ReferenceContentState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; +import { errorDetailsOf } from '../../../shared/api-error.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; import { isCompleteJSONObject, parseJSONObjectAtCompletion } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; @@ -23,12 +24,35 @@ import { createGeminiGenerateContentEventAccumulator, } from '@floway-dev/protocols/gemini-generate-content'; -const irGenerateContentError = (error: IRErrorPayload): GeminiGenerateContentErrorResponse['error'] => { - const code = error.code ?? error.type; - const [status, statusCode] = code === 'rate_limit_exceeded' || code === 'rate_limit_error' ? ['RESOURCE_EXHAUSTED', 429] - : code === 'invalid_prompt' || code === 'invalid_request_error' || isContextExceededError(error) ? ['INVALID_ARGUMENT', 400] - : code === 'overloaded_error' ? ['UNAVAILABLE', 503] : ['INTERNAL', 500]; - return { code: statusCode, status, message: error.message }; +// https://ai.google.dev/api/rest/v1beta/Status +const statusNameOf = (status: number): string => { + switch (status) { + case 400: return 'INVALID_ARGUMENT'; + case 401: return 'UNAUTHENTICATED'; + case 403: return 'PERMISSION_DENIED'; + case 404: return 'NOT_FOUND'; + case 408: return 'DEADLINE_EXCEEDED'; + case 409: return 'ABORTED'; + case 429: return 'RESOURCE_EXHAUSTED'; + case 500: return 'INTERNAL'; + case 501: return 'UNIMPLEMENTED'; + case 503: return 'UNAVAILABLE'; + case 504: return 'DEADLINE_EXCEEDED'; + default: return 'UNKNOWN'; + } +}; + +export const geminiGenerateContentErrorFromIR = (error: IRErrorPayload, httpStatus?: number): GeminiGenerateContentErrorResponse['error'] => { + const details = errorDetailsOf(error, ['message']); + // Arbitrary error fields use the source's Struct details container. + // https://protobuf.dev/reference/protobuf/google.protobuf/#struct + const extension = details === undefined ? {} : { details: [{ '@type': 'type.googleapis.com/google.protobuf.Struct', value: details }] }; + const kind = error.code ?? error.type; + const code = httpStatus !== undefined && !isContextExceededError(error) ? httpStatus + : kind === 'rate_limit_exceeded' || kind === 'rate_limit_error' ? 429 + : kind === 'invalid_prompt' || kind === 'invalid_request_error' || isContextExceededError(error) ? 400 + : kind === 'overloaded_error' ? 503 : 500; + return { code, status: statusNameOf(code), message: error.message, ...extension }; }; interface GenerateContentUnit { @@ -352,7 +376,7 @@ export const geminiGenerateContentFromIR = async function* ( for await (const { record, collector } of consumeIRRecords(records)) { const { state } = collector; - if (record.type === 'error') { yield eventFrame({ error: irGenerateContentError(record.error) }); return; } + if (record.type === 'error') { yield eventFrame({ error: geminiGenerateContentErrorFromIR(record.error) }); return; } if (record.type === 'write' && record.path[0] === 'model' && typeof record.value === 'string') metadata.model = record.value; if (record.type === 'start') { if (state.model === undefined) throw new TypeError('GenerateContent output requires an IR model before start'); diff --git a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/translate.ts b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/translate.ts index bbd30e7ffc..c2fd71fb32 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/translate.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/translate.ts @@ -2,6 +2,7 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import type { Translator } from '../../../types.ts'; +import { restateGeminiGenerateContentError } from '../as-source/error-body.ts'; import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; @@ -12,7 +13,7 @@ export const translate: Translator< try { return { type: 'trip', - error: () => undefined, + error: restateGeminiGenerateContentError, target: await buildTargetRequest(src, ctx.model, ctx.sidecarCodec, { fallbackMaxOutputTokens: ctx.fallbackMaxOutputTokens }), events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), }; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/translate.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/translate.ts index 0290871d73..648c6d9fa6 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/translate.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/translate.ts @@ -2,6 +2,7 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import type { Translator } from '../../../types.ts'; +import { restateGeminiGenerateContentError } from '../as-source/error-body.ts'; import type { GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; @@ -10,7 +11,7 @@ export const translate: Translator< > = async (src, ctx) => { try { const target = await buildTargetRequest(src, ctx.model, ctx.sidecarCodec); - return { type: 'trip', error: () => undefined, target, events: frames => translateToSourceEvents(frames, ctx.sidecarCodec, target.n ?? 1) }; + return { type: 'trip', error: restateGeminiGenerateContentError, target, events: frames => translateToSourceEvents(frames, ctx.sidecarCodec, target.n ?? 1) }; } catch (error) { if (error instanceof TranslateInputException) return error.failure; throw error; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/translate.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/translate.ts index 240d232135..f483efe36f 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/translate.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/translate.ts @@ -2,6 +2,7 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import type { Translator } from '../../../types.ts'; +import { restateGeminiGenerateContentError } from '../as-source/error-body.ts'; import type { GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; @@ -11,7 +12,7 @@ export const translate: Translator< try { return { type: 'trip', - error: () => undefined, + error: restateGeminiGenerateContentError, target: await buildTargetRequest(src, ctx.model, ctx.sidecarCodec), events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), }; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/error-body.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/error-body.ts new file mode 100644 index 0000000000..7968ecc167 --- /dev/null +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/error-body.ts @@ -0,0 +1,9 @@ +import { openaiChatCompletionsErrorFromIR } from './sse-to.ts'; +import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; +import type { ApiErrorTranslator } from '../../../types.ts'; + +export const restateOpenAIChatCompletionsError: ApiErrorTranslator = (upstreamStatus, headers, upstreamBody) => { + const error = irErrorPayloadFromBody(upstreamBody); + if (error === undefined) return undefined; + return restateError(upstreamStatus, headers, { error: openaiChatCompletionsErrorFromIR(error) }); +}; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts index 9cba106198..175311b94b 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts @@ -7,6 +7,7 @@ import { IRAbsent, type IRErrorPayload, type IRItem, type IRMessageItem, type IR import { appendContent, createContentState, type ContentStates, type ContentState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; +import { errorDetailsOf } from '../../../shared/api-error.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIChatCompletionsInputAssistantTurn, type OpenAIChatCompletionsOutputAssistantState } from '../as-target/assistant-turn.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; @@ -19,13 +20,16 @@ import { createOpenAIChatCompletionsEventAccumulator, } from '@floway-dev/protocols/openai-chat-completions'; -const irChatError = (error: IRErrorPayload): IRErrorPayload => ({ - message: error.message, - type: typeof error.type === 'string' && error.type !== 'error' ? error.type : error.code ?? 'server_error', - ...(error.code === undefined ? {} : { code: error.code }), - ...(error.param === undefined ? {} : { param: error.param }), - ...(error.provider_specific_fields === undefined ? {} : { provider_specific_fields: error.provider_specific_fields }), -}); +export const openaiChatCompletionsErrorFromIR = (error: IRErrorPayload) => { + const details = errorDetailsOf(error, ['message', 'type', 'code', 'param']); + return { + message: error.message, + ...(error.type === undefined ? {} : { type: error.type }), + ...(error.code === undefined ? {} : { code: error.code }), + ...(error.param === undefined ? {} : { param: error.param }), + ...(details === undefined ? {} : { provider_specific_fields: details }), + }; +}; interface OpenAIChatCompletionsSSEOutputOptions extends RoundTripOutputOptions<'openaiChatCompletions'> { // Replay identity belongs to the newly emitted target audio, not the source IR content. @@ -175,7 +179,7 @@ export const openaiChatCompletionsFromIR = async function* ( for await (const { record, collector } of consumeIRRecords(records)) { const { state } = collector; if (record.type === 'error') { - yield eventFrame({ error: irChatError(record.error) } as unknown as OpenAIChatCompletionsStreamEvent); + yield eventFrame({ error: openaiChatCompletionsErrorFromIR(record.error) } as unknown as OpenAIChatCompletionsStreamEvent); return; } if (record.type === 'write' && record.path[0] === 'model' && typeof record.value === 'string') metadata.model = record.value; diff --git a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/translate.ts b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/translate.ts index 588339a6bf..864f6867c6 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/translate.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/translate.ts @@ -2,6 +2,7 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import type { RemoteImageLoader, Translator } from '../../../types.ts'; +import { restateOpenAIChatCompletionsError } from '../as-source/error-body.ts'; import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; @@ -13,7 +14,7 @@ export const translate: Translator< const streamOptions = { ...src.stream_options }; return { type: 'trip', - error: () => undefined, + error: restateOpenAIChatCompletionsError, target: await buildTargetRequest(src, ctx.sidecarCodec, { fallbackMaxOutputTokens: ctx.fallbackMaxOutputTokens, loadRemoteImage: ctx.loadRemoteImage, diff --git a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/translate.ts b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/translate.ts index cbd5549db7..27fbb53a80 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/translate.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/translate.ts @@ -2,6 +2,7 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import type { Translator } from '../../../types.ts'; +import { restateOpenAIChatCompletionsError } from '../as-source/error-body.ts'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; @@ -12,7 +13,7 @@ export const translate: Translator< const streamOptions = { ...src.stream_options }; return { type: 'trip', - error: () => undefined, + error: restateOpenAIChatCompletionsError, target: await buildTargetRequest(src, ctx.sidecarCodec), events: frames => translateToSourceEvents(frames, streamOptions, ctx.sidecarCodec), }; diff --git a/packages/translate/src/protocols/openai-responses/as-source/error-body.ts b/packages/translate/src/protocols/openai-responses/as-source/error-body.ts new file mode 100644 index 0000000000..7a0c0f957b --- /dev/null +++ b/packages/translate/src/protocols/openai-responses/as-source/error-body.ts @@ -0,0 +1,9 @@ +import { openaiResponsesErrorEventOf } from './sse-to.ts'; +import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; +import type { ApiErrorTranslator } from '../../../types.ts'; + +export const restateOpenAIResponsesError: ApiErrorTranslator = (upstreamStatus, headers, upstreamBody) => { + const error = irErrorPayloadFromBody(upstreamBody); + if (error === undefined) return undefined; + return restateError(upstreamStatus, headers, openaiResponsesErrorEventOf(error)); +}; diff --git a/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts b/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts index b08ce80dfe..a2224ba2cc 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts @@ -6,11 +6,12 @@ import type { IRCollector } from '../../../ir/collector.ts'; import { irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/operation-paths.ts'; import { createIROrdinalMapper } from '../../../ir/ordinal-mapper.ts'; import { consumeIRRecords, type IRPath, type IRRecord } from '../../../ir/stream.ts'; -import { IRAbsent, type IR, type IRItem, type IRMessageItem, type IRSourceCitation, type IRTokenLogprob } from '../../../ir/types.ts'; +import { IRAbsent, type IR, type IRErrorPayload, type IRItem, type IRMessageItem, type IRSourceCitation, type IRTokenLogprob } from '../../../ir/types.ts'; import { createIRUnitQueue, createIRUnitRegistry } from '../../../ir/unit-queue.ts'; import { appendContent, createContentState, type ContentState, type ContentStates } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; +import { errorDetailsOf } from '../../../shared/api-error.ts'; import { isCompleteJSONObject, parseJSONObjectAtCompletion } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIResponsesInputAssistantTurn, type OpenAIResponsesItemState, type OpenAIResponsesOutputAssistantState } from '../as-target/assistant-turn.ts'; @@ -176,6 +177,17 @@ const textOrRefusalPart = (state: IR, path: IRPath): Extract { + const details = errorDetailsOf(error, ['message', 'code', 'param']); + return { + type: 'error', + message: error.message, + ...(error.code === undefined ? {} : { code: error.code }), + ...(error.param === undefined ? {} : { param: error.param }), + ...(details === undefined ? {} : { provider_specific_fields: details }), + }; +}; + export const openaiResponsesFromIR = async function* ( records: AsyncIterable, options: OpenAIResponsesSSEOutputOptions, @@ -614,12 +626,7 @@ export const openaiResponsesFromIR = async function* ( for await (const { record, collector } of consumeIRRecords(records)) { const { state } = collector; if (record.type === 'error') { - yield emit({ - type: 'error', - message: record.error.message, - code: record.error.code ?? record.error.type, - ...(record.error.param === undefined ? {} : { param: record.error.param }), - }); + yield emit(openaiResponsesErrorEventOf(record.error)); return; } if (record.type === 'write' && record.path[0] === 'model' && typeof record.value === 'string') metadata.model = record.value; diff --git a/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts b/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts index 8521bdc743..565426dc20 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts @@ -11,6 +11,7 @@ import { import type { IRSourceCitation } from '../../../ir/types.ts'; import { appendField, createFieldState, invalidateField, type FieldStates } from '../../../round-trip/reference/reference-builder.ts'; import type { RoundTripReader } from '../../../round-trip/stream.ts'; +import { errorPayloadFromWire } from '../../../shared/api-error.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { @@ -548,10 +549,15 @@ export const irFromOpenAIResponses = async function* ( for await (const frame of frames) { if (frame.type === 'done') throw new Error('Responses done arrived without a terminal response'); const e = frame.event; - if (e.type === 'error') { ir.event({ type: 'error', error: 'error' in e ? e.error ?? e : e }); yield* ir.drain(); return; } + if (e.type === 'error') { + const { sequence_number: _sequenceNumber, type: _type, ...failure } = e; + ir.event({ type: 'error', error: errorPayloadFromWire('error' in e ? e.error ?? e : failure) }); + yield* ir.drain(); + return; + } if (e.type === 'response.failed' && e.response.error?.code !== 'bio_policy' && e.response.error?.code !== 'cyber_policy') { // `error` is nullable on a Response, so a failure may carry no description. - ir.event({ type: 'error', error: e.response.error ?? { message: 'Response failed due to unknown error.' } }); + ir.event({ type: 'error', error: errorPayloadFromWire(e.response.error ?? { message: 'Response failed due to unknown error.' }) }); yield* ir.drain(); return; } diff --git a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/translate.ts b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/translate.ts index 7f71c1c4ed..4c8618bc35 100644 --- a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/translate.ts +++ b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/translate.ts @@ -2,6 +2,7 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import type { RemoteImageLoader, Translator } from '../../../types.ts'; +import { restateOpenAIResponsesError } from '../as-source/error-body.ts'; import { restoreNamespaceEvents } from '../as-source/namespace-tools.ts'; import { createRandomOpenAIResponsesResponseId } from '../as-source/response-id.ts'; import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; @@ -23,7 +24,7 @@ export const translate: Translator< return { type: 'trip', - error: () => undefined, + error: restateOpenAIResponsesError, target, events: frames => restoreNamespaceEvents(translateToSourceEvents(frames, responseId, customToolNames, ctx.sidecarCodec, namespaceToolNames), namespaceToolNames), }; diff --git a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/translate.ts b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/translate.ts index 7ad9108450..57da635c3d 100644 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/translate.ts +++ b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/translate.ts @@ -2,6 +2,7 @@ import { translateToSourceEvents } from './events.ts'; import { buildTargetRequest } from './request.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import type { Translator } from '../../../types.ts'; +import { restateOpenAIResponsesError } from '../as-source/error-body.ts'; import { restoreNamespaceEvents } from '../as-source/namespace-tools.ts'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; @@ -13,7 +14,7 @@ export const translate: Translator< const { target, customToolNames, namespaceToolNames } = await buildTargetRequest(src, ctx.sidecarCodec); return { type: 'trip', - error: () => undefined, + error: restateOpenAIResponsesError, target, events: frames => restoreNamespaceEvents( translateToSourceEvents(frames, customToolNames, ctx.sidecarCodec, namespaceToolNames, target.n ?? 1), diff --git a/packages/translate/src/shared/api-error.ts b/packages/translate/src/shared/api-error.ts new file mode 100644 index 0000000000..39659d1e22 --- /dev/null +++ b/packages/translate/src/shared/api-error.ts @@ -0,0 +1,112 @@ +import type { IRErrorPayload } from '../ir/types.ts'; +import { parseJSONWithRawNumbers } from '@floway-dev/protocols/common'; + +const recordOf = (value: unknown): Readonly> | undefined => + typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Readonly> : undefined; + +const stringOf = (value: unknown): string | undefined => typeof value === 'string' ? value : undefined; + +// Unknown bodies keep their original bytes; parser failures other than invalid syntax propagate. +export const irErrorPayloadFromBody = (body: Uint8Array): IRErrorPayload | undefined => { + let text: string; + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(body); + } catch (error) { + if (!(error instanceof TypeError)) throw error; + return undefined; + } + let parsed: unknown; + try { + parsed = parseJSONWithRawNumbers(text); + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + return undefined; + } + const envelopeRecord = recordOf(parsed); + if (envelopeRecord === undefined) return undefined; + const nested = recordOf(envelopeRecord.error); + if (nested === undefined) { + const { type: _type, sequence_number: _sequenceNumber, ...failure } = envelopeRecord; + return errorPayloadOf(envelopeRecord.type === 'error' ? failure : envelopeRecord); + } + const payload = errorPayloadOf(nested); + if (payload === undefined) return undefined; + const envelope = extensionOf(envelopeRecord, new Set(['error', ...(envelopeRecord.type === 'error' ? ['type', 'sequence_number'] : [])])); + return envelope === undefined ? payload : { + ...payload, + provider_specific_fields: { ...envelope, ...payload.provider_specific_fields }, + }; +}; + +export const errorPayloadOf = (error: Readonly>): IRErrorPayload | undefined => { + const message = stringOf(error.message) ?? stringOf(error.msg); + if (message === undefined) return undefined; + const type = stringOf(error.type); + const code = error.code === null ? null : stringOf(error.code) ?? stringOf(error.status); + const param = error.param === null ? null : stringOf(error.param); + const represented = new Set([ + typeof error.message === 'string' ? 'message' : 'msg', + ...(type === undefined ? [] : ['type']), + ...(code === undefined ? [] : [error.code === null || typeof error.code === 'string' ? 'code' : 'status']), + ...(param === undefined ? [] : ['param']), + ]); + const extra = extensionOf(error, represented); + return { + message, + ...(type === undefined ? {} : { type }), + ...(code === undefined ? {} : { code }), + ...(param === undefined ? {} : { param }), + ...(extra === undefined ? {} : { provider_specific_fields: extra }), + }; +}; + +// Canonical wire payloads retain identity for native errors carried through the IR. +export const errorPayloadFromWire = (wire: unknown): IRErrorPayload => { + const stated = recordOf(wire) ?? {}; + const nested = recordOf(stated.error); + const source = nested ?? stated; + if (hasErrorMessage(source)) return source; + const aliasMessage = stringOf(source.msg); + if (aliasMessage !== undefined) { + const { msg: _msg, ...fields } = source; + return { ...fields, message: aliasMessage }; + } + const extra = extensionOf(source, NAMED_ERROR_FIELDS); + return { message: '', ...(extra === undefined ? {} : { provider_specific_fields: extra }) }; +}; + +const hasErrorMessage = (value: Readonly>): value is Readonly> & { readonly message: string } => + stringOf(value.message) !== undefined; +const NAMED_ERROR_FIELDS: ReadonlySet = new Set(['message', 'msg', 'type', 'code', 'status', 'param']); + +// The upstream container wins collisions; loose fields share that same container. +const extensionOf = (error: Readonly>, representedFields: ReadonlySet): Readonly> | undefined => { + const own = recordOf(error.provider_specific_fields); + const rest: Record = {}; + for (const [key, value] of Object.entries(error)) { + if (representedFields.has(key) || key === 'provider_specific_fields' || own !== undefined && Object.hasOwn(own, key)) continue; + rest[key] = value; + } + return Object.keys(rest).length === 0 ? own : { ...rest, ...own }; +}; + +export const errorDetailsOf = (error: IRErrorPayload, representedFields: readonly string[]): Readonly> | undefined => + extensionOf(error, new Set(representedFields)); + +export const restateError = ( + status: number, + headers: Headers, + body: unknown, +) => ({ + status, + headers: restatedHeaders(headers), + body, +}); + +const restatedHeaders = (headers: Headers): Headers => { + const kept = new Headers(headers); + kept.set('content-type', 'application/json'); + kept.delete('content-length'); + kept.delete('content-encoding'); + return kept; +}; From c94b6c4a8cf0adc646b7cebe9f564cebb4ded83e Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 04:56:59 +0800 Subject: [PATCH 06/66] refactor(translate): give output IR modules explicit ownership Separate output construction, records, accumulation, lifecycle and path operations. Name output model types explicitly and share JSON and text-range shapes through their owners. Move protocol fixtures and assertions out of output IR tests. Let unit queues own their links and remove dead builder and path-query APIs. Preserve configured choice limits and native SSE, error and sidecar behavior. Validate with 612 focused tests, 67 baseline differential probes, 39 logical correspondences, scoped lint and the owning TypeScript project. --- packages/translate/AGENTS.md | 6 +- .../translate/__tests__/ir/collector_test.ts | 82 ------- .../translate/__tests__/ir/initialize_test.ts | 50 ---- .../__tests__/ir/output/collector_test.ts | 82 +++++++ .../ir/{ => output}/copy-ownership_test.ts | 39 ++- .../__tests__/ir/{ => output}/handles_test.ts | 13 +- .../translate/__tests__/ir/output/helpers.ts | 61 +++++ .../__tests__/ir/output/initialize_test.ts | 32 +++ .../__tests__/ir/{ => output}/part-id_test.ts | 2 +- .../__tests__/ir/{ => output}/schema_test.ts | 15 +- .../__tests__/ir/output/unit-queue_test.ts | 36 +++ .../unit-registry_test.ts} | 21 +- .../as-source/sse-to_test.ts | 6 +- .../as-target/sse-from_test.ts | 2 +- .../events_test.ts | 2 +- .../events-protocol_test.ts | 2 +- .../via-openai-responses/events_test.ts | 2 +- .../protocols/chat-legacy-round-trip_test.ts | 2 +- .../{ir => protocols}/edge-cases_test.ts | 10 +- .../fixtures.ts} | 2 +- .../as-source/sse-to_test.ts | 6 +- .../via-anthropic-messages/events_test.ts | 2 +- .../events_test.ts | 2 +- .../via-openai-responses/events_test.ts | 2 +- .../__tests__/{ir => protocols}/helpers.ts | 64 +---- .../initial-envelope_test.ts | 0 .../{ir => protocols}/lifecycle_test.ts | 10 +- .../as-source}/continuous-usage_test.ts | 8 +- .../as-source/sse-to_test.ts | 6 +- .../as-target/sse-from_test.ts | 2 +- .../via-anthropic-messages/events_test.ts | 2 +- .../via-openai-responses/events_test.ts | 4 +- .../as-source/initialize_test.ts | 22 ++ .../openai-responses/as-source/sse-to_test.ts | 6 +- .../as-target/snapshots_test.ts} | 6 +- .../openai-responses/fixtures.ts} | 0 .../events-protocol_test.ts | 2 +- .../via-anthropic-messages/events_test.ts | 2 +- .../events_test.ts | 2 +- .../{ir => protocols}/ownership_test.ts | 17 +- .../protocols/round-trip-receive_test.ts | 2 +- .../__tests__/protocols/routes_test.ts | 4 +- .../{ir => protocols}/streams_test.ts | 23 +- .../__tests__/{ir => protocols}/usage_test.ts | 9 +- .../reference/reference-builder_test.ts | 2 +- .../reference/reference-cost-json_test.ts | 2 +- .../translate/__tests__/shared/iterables.ts | 5 + .../shared/text-coordinate-index_test.ts | 12 +- packages/translate/src/ir/collector.ts | 223 ------------------ packages/translate/src/ir/common/json.ts | 6 + packages/translate/src/ir/ordinal-mapper.ts | 27 --- packages/translate/src/ir/output/absent.ts | 3 + .../src/ir/{stream.ts => output/builder.ts} | 72 +----- packages/translate/src/ir/output/collector.ts | 105 +++++++++ packages/translate/src/ir/output/error.ts | 8 + packages/translate/src/ir/output/lifecycle.ts | 48 ++++ .../src/ir/{ => output}/operation-paths.ts | 43 +--- packages/translate/src/ir/output/records.ts | 17 ++ .../translate/src/ir/{ => output}/schema.ts | 19 +- packages/translate/src/ir/output/stream.ts | 30 +++ .../translate/src/ir/{ => output}/types.ts | 153 +++++------- .../translate/src/ir/output/unit-index.ts | 27 +++ .../translate/src/ir/output/unit-queue.ts | 27 +++ .../translate/src/ir/output/unit-registry.ts | 21 ++ .../src/ir/output/value-validation.ts | 79 +++++++ packages/translate/src/ir/unit-queue.ts | 46 ---- .../as-source/round-trip.ts | 2 +- .../anthropic-messages/as-source/sse-to.ts | 32 +-- .../anthropic-messages/as-source/usage.ts | 6 +- .../as-target/assistant-turn.ts | 2 +- .../anthropic-messages/as-target/sse-from.ts | 11 +- .../anthropic-messages/as-target/usage.ts | 8 +- .../as-source/round-trip.ts | 2 +- .../as-source/sse-to.ts | 36 +-- .../as-source/usage.ts | 8 +- .../as-source/round-trip.ts | 2 +- .../as-source/sse-to.ts | 35 +-- .../as-source/usage.ts | 6 +- .../as-target/sse-from.ts | 18 +- .../as-target/usage.ts | 8 +- .../openai-responses/as-source/round-trip.ts | 2 +- .../openai-responses/as-source/sse-to.ts | 66 +++--- .../openai-responses/as-source/usage.ts | 6 +- .../openai-responses/as-target/sse-from.ts | 28 +-- .../openai-responses/as-target/usage.ts | 8 +- .../round-trip/reference/assistant-turn.ts | 2 +- .../round-trip/reference/reference-builder.ts | 2 +- .../reference/reference-selection.ts | 2 +- .../translate/src/round-trip/turn-digest.ts | 2 +- packages/translate/src/shared/json/object.ts | 2 +- .../src/shared/text-coordinate-index.ts | 10 +- packages/translate/src/shared/text-range.ts | 7 + 92 files changed, 984 insertions(+), 974 deletions(-) delete mode 100644 packages/translate/__tests__/ir/collector_test.ts delete mode 100644 packages/translate/__tests__/ir/initialize_test.ts create mode 100644 packages/translate/__tests__/ir/output/collector_test.ts rename packages/translate/__tests__/ir/{ => output}/copy-ownership_test.ts (62%) rename packages/translate/__tests__/ir/{ => output}/handles_test.ts (83%) create mode 100644 packages/translate/__tests__/ir/output/helpers.ts create mode 100644 packages/translate/__tests__/ir/output/initialize_test.ts rename packages/translate/__tests__/ir/{ => output}/part-id_test.ts (94%) rename packages/translate/__tests__/ir/{ => output}/schema_test.ts (78%) create mode 100644 packages/translate/__tests__/ir/output/unit-queue_test.ts rename packages/translate/__tests__/ir/{unit-queue_test.ts => output/unit-registry_test.ts} (61%) rename packages/translate/__tests__/{ir => protocols}/edge-cases_test.ts (97%) rename packages/translate/__tests__/{ir/translation-cases.ts => protocols/fixtures.ts} (99%) rename packages/translate/__tests__/{ir => protocols}/helpers.ts (54%) rename packages/translate/__tests__/{ir => protocols}/initial-envelope_test.ts (100%) rename packages/translate/__tests__/{ir => protocols}/lifecycle_test.ts (98%) rename packages/translate/__tests__/{ir => protocols/openai-chat-completions/as-source}/continuous-usage_test.ts (93%) create mode 100644 packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts rename packages/translate/__tests__/{ir/snapshot-policy_test.ts => protocols/openai-responses/as-target/snapshots_test.ts} (98%) rename packages/translate/__tests__/{ir/responses-fixture.ts => protocols/openai-responses/fixtures.ts} (100%) rename packages/translate/__tests__/{ir => protocols}/ownership_test.ts (88%) rename packages/translate/__tests__/{ir => protocols}/streams_test.ts (96%) rename packages/translate/__tests__/{ir => protocols}/usage_test.ts (95%) create mode 100644 packages/translate/__tests__/shared/iterables.ts delete mode 100644 packages/translate/src/ir/collector.ts create mode 100644 packages/translate/src/ir/common/json.ts delete mode 100644 packages/translate/src/ir/ordinal-mapper.ts create mode 100644 packages/translate/src/ir/output/absent.ts rename packages/translate/src/ir/{stream.ts => output/builder.ts} (66%) create mode 100644 packages/translate/src/ir/output/collector.ts create mode 100644 packages/translate/src/ir/output/error.ts create mode 100644 packages/translate/src/ir/output/lifecycle.ts rename packages/translate/src/ir/{ => output}/operation-paths.ts (80%) create mode 100644 packages/translate/src/ir/output/records.ts rename packages/translate/src/ir/{ => output}/schema.ts (89%) create mode 100644 packages/translate/src/ir/output/stream.ts rename packages/translate/src/ir/{ => output}/types.ts (54%) create mode 100644 packages/translate/src/ir/output/unit-index.ts create mode 100644 packages/translate/src/ir/output/unit-queue.ts create mode 100644 packages/translate/src/ir/output/unit-registry.ts create mode 100644 packages/translate/src/ir/output/value-validation.ts delete mode 100644 packages/translate/src/ir/unit-queue.ts create mode 100644 packages/translate/src/shared/text-range.ts diff --git a/packages/translate/AGENTS.md b/packages/translate/AGENTS.md index 8a7031c696..e2e20bbff5 100644 --- a/packages/translate/AGENTS.md +++ b/packages/translate/AGENTS.md @@ -18,8 +18,8 @@ import the source protocol's subpath and call protocol's wire mapping in the matching `via-*` route. Retained files use functional names without another protocol name. 5. **Output IR** — keep private types, its path schema, stream operations, - validation, accumulation, operation paths, and native ordinal mapping under - `ir/`. Native SSE adapters + validation, accumulation, operation paths, and native unit index allocation under + `ir/output/`. Native SSE adapters belong to their protocol: `as-target/sse-from.ts` consumes upstream SSE and `as-source/sse-to.ts` emits client SSE with its sidecar. The return path is upstream SSE → IR SSE → client SSE with sidecar. IR stays internal to translate. @@ -33,7 +33,7 @@ import the source protocol's subpath and call ## Current Production Subdirectories -- `ir/` — output model, its path schema, and protocol-independent streaming state. +- `ir/output/` — output model, its path schema, and protocol-independent streaming state. - `round-trip/` — authenticated assistant-turn sidecars, replay validation, and native stream coordination. - `protocols/anthropic-messages/as-source/` — interprets Messages requests and emits Messages SSE. - `protocols/anthropic-messages/as-target/` — maps requests to Messages and consumes Messages SSE. diff --git a/packages/translate/__tests__/ir/collector_test.ts b/packages/translate/__tests__/ir/collector_test.ts deleted file mode 100644 index 21c29b5b0b..0000000000 --- a/packages/translate/__tests__/ir/collector_test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, test } from 'vitest'; - -import { createIRCollector } from '../../src/ir/collector.ts'; -import { IRAbsent } from '../../src/ir/types.ts'; - -describe('IR accumulator', () => { - test('stores plain accumulated state without publication metadata', () => { - const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['usage'], value: {} }); - collector.apply({ type: 'write', kind: 'assign', path: ['usage', 'output_tokens_inclusive'], value: 0 }); - expect(collector.value(['usage'])).toEqual({ output_tokens_inclusive: 0 }); - }); - - test('enforces static append targets', () => { - const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: { type: 'text', text: '' } }); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'x' })).toThrow('append target cannot be assigned'); - expect(() => collector.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 1 } as any)).toThrow('append requires a string value'); - collector.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'x' }); - expect(collector.value(['choices', 0, 'items', 0, 'content', 0, 'text'])).toBe('x'); - - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1], value: { type: 'reasoning', content: [''] } }); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 1, 'content', 0], value: 1 })).toThrow('append target cannot be assigned'); - }); - - test('rejects undefined and only assigns IRAbsent to absent fields', () => { - const collector = createIRCollector(); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['model'], value: IRAbsent })).toThrow('IRAbsent is not allowed'); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call', name: 'f', arguments: '' } }); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'call_id'], value: undefined as any })).toThrow('undefined'); - collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'call_id'], value: IRAbsent }); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'call_id'], value: IRAbsent })).toThrow('absent target'); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'refusal'], value: { category: IRAbsent, explanation: null } as any })).toThrow('assigned directly'); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'refusal'], value: { category: undefined, explanation: null } as any })).toThrow('cannot contain undefined'); - expect(() => collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1], value: { type: 'reasoning', summary: [IRAbsent] } })).toThrow('append target must initialize to an empty string'); - }); - - test('enforces append ownership by item type', () => { - const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call', name: 'f', arguments: '' } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1], value: { type: 'custom_tool_call', name: 'c', input: '' } }); - expect(() => collector.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'input'], value: 'x' })).toThrow('append-target path'); - expect(() => collector.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 1, 'arguments'], value: 'x' })).toThrow('append-target path'); - }); - - test('rejects an unallocated choice end', () => { - const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - expect(() => collector.applyEvent({ type: 'choice_end', choice: 99, finish_reason: 'stop' })).toThrow('does not exist'); - }); - - test('rejects operations after part and item end', () => { - const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: { type: 'text', text: '' } }); - collector.applyEvent({ type: 'part_end', choice: 0, item: 0, part: 0 }); - expect(() => collector.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'late' })).toThrow('closed part'); - collector.applyEvent({ type: 'item_end', choice: 0, item: 0 }); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'type'], value: 'message' })).toThrow('closed item'); - }); - - test('rejects ended units that omit required schema fields', () => { - const incompleteItem = createIRCollector(); - incompleteItem.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - incompleteItem.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call' } }); - expect(() => incompleteItem.applyEvent({ type: 'item_end', choice: 0, item: 0 })).toThrow('requires field name'); - - const incompleteMessage = createIRCollector(); - incompleteMessage.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - incompleteMessage.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message' } }); - expect(() => incompleteMessage.applyEvent({ type: 'item_end', choice: 0, item: 0 })).toThrow('requires field content'); - - const incompleteChoice = createIRCollector(); - incompleteChoice.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: {} }); - expect(() => incompleteChoice.applyEvent({ type: 'choice_end', choice: 0, finish_reason: 'stop' })).toThrow('requires field items'); - }); -}); diff --git a/packages/translate/__tests__/ir/initialize_test.ts b/packages/translate/__tests__/ir/initialize_test.ts deleted file mode 100644 index eff27b313b..0000000000 --- a/packages/translate/__tests__/ir/initialize_test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { expect, test } from 'vitest'; - -import { collect, iterate, openaiResponsesFromIR } from './helpers.ts'; -import { createIRCollector } from '../../src/ir/collector.ts'; -import { createIRBuilder, type IRRecord } from '../../src/ir/stream.ts'; - -test('initialize carries real required fields while append targets start empty', async () => { - const records: IRRecord[] = [ - { type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }, - { type: 'write', kind: 'assign', path: ['model'], value: 'model' }, - { type: 'start', id: 'source' }, - { type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call', call_id: 'call', name: 'real-name', arguments: '' } }, - { type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'arguments'], value: '{}' }, - { type: 'item_end', choice: 0, item: 0 }, - { type: 'choice_end', choice: 0, finish_reason: 'tool_calls' }, - { type: 'finish', status: 'completed' }, - ]; - const output = await collect(openaiResponsesFromIR(iterate(records))); - const serialized = JSON.stringify(output); - expect(serialized).toContain('real-name'); - expect(serialized).toContain('{}'); -}); - -test('initialize arrays require the exact next index and never replace entries', () => { - const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - expect(() => collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } })).toThrow('absent target'); - for (const index of [-1, 0.5, 2]) expect(() => collector.apply({ type: 'write', kind: 'initialize', path: ['choices', index], value: { items: [] } })).toThrow(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 1], value: { items: [] } }); - expect(collector.state.choices).toHaveLength(2); -}); - -test('append accepts only statically declared string fields', () => { - const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: { type: 'text', text: '' } }); - expect(() => collector.apply({ type: 'write', kind: 'append', path: ['model'], value: 'x' })).toThrow('append-target path'); - collector.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'x' }); - expect(collector.value(['choices', 0, 'items', 0, 'content', 0, 'text'])).toBe('x'); -}); - -test('builder initializes items without separate start events', () => { - const builder = createIRBuilder(); - builder.item(0, { type: 'message', content: [] }); - expect(builder.drain()).toEqual([ - { type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }, - { type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }, - ]); -}); diff --git a/packages/translate/__tests__/ir/output/collector_test.ts b/packages/translate/__tests__/ir/output/collector_test.ts new file mode 100644 index 0000000000..0ad6b217e4 --- /dev/null +++ b/packages/translate/__tests__/ir/output/collector_test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'vitest'; + +import { IRAbsent } from '../../../src/ir/output/absent.ts'; +import { createIRCollector } from '../../../src/ir/output/collector.ts'; + +describe('IR accumulator', () => { + test('stores plain accumulated state without publication metadata', () => { + const collector = createIRCollector(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['usage'], value: {} }); + collector.applyWrite({ type: 'write', kind: 'assign', path: ['usage', 'output_tokens_inclusive'], value: 0 }); + expect(collector.value(['usage'])).toEqual({ output_tokens_inclusive: 0 }); + }); + + test('enforces static append targets', () => { + const collector = createIRCollector(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: { type: 'text', text: '' } }); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'x' })).toThrow('append target cannot be assigned'); + expect(() => collector.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 1 } as any)).toThrow('append requires a string value'); + collector.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'x' }); + expect(collector.value(['choices', 0, 'items', 0, 'content', 0, 'text'])).toBe('x'); + + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1], value: { type: 'reasoning', content: [''] } }); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 1, 'content', 0], value: 1 })).toThrow('append target cannot be assigned'); + }); + + test('rejects undefined and only assigns IRAbsent to absent fields', () => { + const collector = createIRCollector(); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['model'], value: IRAbsent })).toThrow('IRAbsent is not allowed'); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call', name: 'f', arguments: '' } }); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'call_id'], value: undefined as any })).toThrow('undefined'); + collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'call_id'], value: IRAbsent }); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'call_id'], value: IRAbsent })).toThrow('absent target'); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'refusal_details'], value: { category: IRAbsent, explanation: null } as any })).toThrow('assigned directly'); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'refusal_details'], value: { category: undefined, explanation: null } as any })).toThrow('cannot contain undefined'); + expect(() => collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1], value: { type: 'reasoning', summary: [IRAbsent] } })).toThrow('append target must initialize to an empty string'); + }); + + test('enforces append ownership by item type', () => { + const collector = createIRCollector(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call', name: 'f', arguments: '' } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1], value: { type: 'custom_tool_call', name: 'c', input: '' } }); + expect(() => collector.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'input'], value: 'x' })).toThrow('append-target path'); + expect(() => collector.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 1, 'arguments'], value: 'x' })).toThrow('append-target path'); + }); + + test('rejects an unallocated choice end', () => { + const collector = createIRCollector(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + expect(() => collector.applyEvent({ type: 'choice_end', choice: 99, finish_reason: 'stop' })).toThrow('does not exist'); + }); + + test('rejects operations after part and item end', () => { + const collector = createIRCollector(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: { type: 'text', text: '' } }); + collector.applyEvent({ type: 'part_end', choice: 0, item: 0, part: 0 }); + expect(() => collector.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'late' })).toThrow('closed part'); + collector.applyEvent({ type: 'item_end', choice: 0, item: 0 }); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'type'], value: 'message' })).toThrow('closed item'); + }); + + test('rejects ended units that omit required schema fields', () => { + const incompleteItem = createIRCollector(); + incompleteItem.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + incompleteItem.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call' } }); + expect(() => incompleteItem.applyEvent({ type: 'item_end', choice: 0, item: 0 })).toThrow('requires field name'); + + const incompleteMessage = createIRCollector(); + incompleteMessage.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + incompleteMessage.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message' } }); + expect(() => incompleteMessage.applyEvent({ type: 'item_end', choice: 0, item: 0 })).toThrow('requires field content'); + + const incompleteChoice = createIRCollector(); + incompleteChoice.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: {} }); + expect(() => incompleteChoice.applyEvent({ type: 'choice_end', choice: 0, finish_reason: 'stop' })).toThrow('requires field items'); + }); +}); diff --git a/packages/translate/__tests__/ir/copy-ownership_test.ts b/packages/translate/__tests__/ir/output/copy-ownership_test.ts similarity index 62% rename from packages/translate/__tests__/ir/copy-ownership_test.ts rename to packages/translate/__tests__/ir/output/copy-ownership_test.ts index e5d88f05e6..741ef8a64b 100644 --- a/packages/translate/__tests__/ir/copy-ownership_test.ts +++ b/packages/translate/__tests__/ir/output/copy-ownership_test.ts @@ -1,14 +1,14 @@ import { expect, test } from 'vitest'; -import { collect, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, iterate } from './helpers.ts'; -import { createIRCollector } from '../../src/ir/collector.ts'; -import { consumeIRRecords, createIRBuilder } from '../../src/ir/stream.ts'; -import { eventFrame } from '@floway-dev/protocols/common'; +import { createIRBuilder } from '../../../src/ir/output/builder.ts'; +import { createIRCollector } from '../../../src/ir/output/collector.ts'; +import { consumeIRRecords } from '../../../src/ir/output/stream.ts'; +import { collect, iterate } from '../../shared/iterables.ts'; const initializeTextPart = (collector: ReturnType, part: object): void => { - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: part as any }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: part as any }); }; test('collectors borrow record trees and copy only containers first written by that collector', () => { @@ -21,13 +21,13 @@ test('collectors borrow record trees and copy only containers first written by t expect(first.state.choices[0].items[0].type === 'message' && first.state.choices[0].items[0].content[0]).toBe(part); expect(second.state.choices[0].items[0].type === 'message' && second.state.choices[0].items[0].content[0]).toBe(part); - first.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'one' }); + first.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'one' }); const firstPart = first.state.choices[0].items[0].type === 'message' ? first.state.choices[0].items[0].content[0] : undefined; expect(firstPart).not.toBe(part); expect(firstPart?.type === 'text' && firstPart.annotations).toBe(annotations); expect(second.state.choices[0].items[0].type === 'message' && second.state.choices[0].items[0].content[0]).toBe(part); - second.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'other' }); + second.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'other' }); expect(first.value(['choices', 0, 'items', 0, 'content', 0, 'text'])).toBe('one'); expect(second.value(['choices', 0, 'items', 0, 'content', 0, 'text'])).toBe('other'); expect(part.text).toBe(''); @@ -62,32 +62,21 @@ test('builder transfers payloads without copying and later operations never modi test('array writes copy the array while retaining immutable sibling values', () => { const sibling = Object.freeze({ items: Object.freeze([]) }); const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: sibling }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: sibling }); const preceding = collector.state.choices; - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 1], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 1], value: { items: [] } }); expect(collector.state.choices).toBe(preceding); expect(collector.state.choices[0]).toBe(sibling); const owned = collector.state.choices; - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 2], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 2], value: { items: [] } }); expect(collector.state.choices).toBe(owned); }); test('value exposes the accumulator live readonly view', () => { const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['usage'], value: {} }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['usage'], value: {} }); const value = collector.value>(['usage'])!; - collector.apply({ type: 'write', kind: 'assign', path: ['usage', 'output_tokens_inclusive'], value: 1 }); + collector.applyWrite({ type: 'write', kind: 'assign', path: ['usage', 'output_tokens_inclusive'], value: 1 }); expect(value).toEqual({}); expect(collector.value(['usage'])).toEqual({ output_tokens_inclusive: 1 }); }); - -test.each([ - ['ChatCompletions', irFromOpenAIChatCompletions, (error: object) => ({ id: 'chat', model: 'm', choices: [], error })], - ['Responses', irFromOpenAIResponses, (error: object) => ({ type: 'error', error })], - ['Messages', irFromAnthropicMessages, (error: object) => ({ type: 'error', error })], -] as const)('%s reader transfers immutable error objects without copying', async (_name, reader, event) => { - const error = Object.freeze({ type: 'api_error', message: 'original', details: Object.freeze([Object.freeze({ text: 'original' })]) }); - const frames = await collect(reader(iterate([eventFrame(event(error) as any)]))); - const record = frames.find(record => record.type === 'error'); - expect(record?.error).toBe(error); -}); diff --git a/packages/translate/__tests__/ir/handles_test.ts b/packages/translate/__tests__/ir/output/handles_test.ts similarity index 83% rename from packages/translate/__tests__/ir/handles_test.ts rename to packages/translate/__tests__/ir/output/handles_test.ts index a7d9055259..a474d2db90 100644 --- a/packages/translate/__tests__/ir/handles_test.ts +++ b/packages/translate/__tests__/ir/output/handles_test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; -import { createIRBuilder } from '../../src/ir/stream.ts'; +import { createIRBuilder } from '../../../src/ir/output/builder.ts'; test('write handles build unit paths and ensure fields once', () => { const builder = createIRBuilder(); @@ -15,7 +15,7 @@ test('write handles build unit paths and ensure fields once', () => { expect(choice.path).toEqual(['choices', 0]); expect(item.path).toEqual(['choices', 0, 'items', 0]); - expect(item.resolve(['summary', 0])).toEqual(['choices', 0, 'items', 0, 'summary', 0]); + expect(item.absolutePath(['summary', 0])).toEqual(['choices', 0, 'items', 0, 'summary', 0]); expect(builder.drain()).toEqual([ { type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }, { type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'reasoning' } }, @@ -47,9 +47,9 @@ test('ending an item closes only its open parts and is idempotent', () => { ]); }); -test('handles expose accumulated values needed for snapshot reconciliation and annotation coordinates', () => { +test('a handle reads back the value accumulated from earlier upstream events', () => { const builder = createIRBuilder(); - const choice = builder.choice(0); + builder.choice(0); const first = builder.item(0, { type: 'message', content: [] }); const firstText = first.part({ type: 'text', text: '' }); firstText.append('text', 'first'); @@ -58,8 +58,5 @@ test('handles expose accumulated values needed for snapshot reconciliation and a secondText.append('text', 'second'); expect(firstText.current('text')).toBe('first'); - expect(choice.textParts()).toEqual([ - { item: first, part: firstText, text: 'first' }, - { item: second, part: secondText, text: 'second' }, - ]); + expect(secondText.current('text')).toBe('second'); }); diff --git a/packages/translate/__tests__/ir/output/helpers.ts b/packages/translate/__tests__/ir/output/helpers.ts new file mode 100644 index 0000000000..80bfc12a59 --- /dev/null +++ b/packages/translate/__tests__/ir/output/helpers.ts @@ -0,0 +1,61 @@ +import { createIRBuilder } from '../../../src/ir/output/builder.ts'; +import type { IRRecord } from '../../../src/ir/output/records.ts'; +import { consumeIRRecords } from '../../../src/ir/output/stream.ts'; +import type { IROutput, IROutputItem } from '../../../src/ir/output/types.ts'; + +export const completeIR = (value: readonly IROutputItem[]): IRRecord[] => { + const b = createIRBuilder(); + b.choice(0); + b.assign(['model'], 'model'); + b.event({ type: 'start', id: 'source' }); + for (const item of value) { + const index = b.state.choices[0].items.length; + const path = ['choices', 0, 'items', index] as const; + if (item.type === 'message') { + b.initialize(path, { type: 'message', content: [] }); + item.content.forEach((part, partIndex) => { + const partPath = [...path, 'content', partIndex] as const; + if (part.type === 'text') { + b.initialize(partPath, { type: 'text', text: '', ...(part.annotations === undefined ? {} : { annotations: part.annotations }) }); + b.append([...partPath, 'text'], part.text); + } else if (part.type === 'refusal') { + b.initialize(partPath, { type: 'refusal', refusal: '' }); + b.append([...partPath, 'refusal'], part.refusal); + } else if (part.type === 'image') b.initialize(partPath, part); + else { + b.initialize(partPath, { type: 'audio', audio: { ...(part.audio.media_type === undefined ? {} : { media_type: part.audio.media_type }) } }); + for (const field of ['data', 'transcript'] as const) if (part.audio[field] !== undefined) { + const value = part.audio[field]; + if (value === undefined) continue; + b.initialize([...partPath, 'audio', field], ''); + b.append([...partPath, 'audio', field], value); + } + } + b.event({ type: 'part_end', choice: 0, item: index, part: partIndex }); + }); + } else if (item.type === 'reasoning') { + b.initialize(path, { type: 'reasoning', ...(item.summary === undefined ? {} : { summary: item.summary.map(() => '') }), ...(item.content === undefined ? {} : { content: item.content.map(() => '') }) }); + item.summary?.forEach((text, part) => b.append([...path, 'summary', part], text)); + item.content?.forEach((text, part) => b.append([...path, 'content', part], text)); + } else if (item.type === 'function_call') { + b.initialize(path, { type: 'function_call', ...(item.call_id === undefined ? {} : { call_id: item.call_id }), name: item.name, arguments: '' }); + b.append([...path, 'arguments'], item.arguments); + } else { + b.initialize(path, { type: 'custom_tool_call', ...(item.call_id === undefined ? {} : { call_id: item.call_id }), name: item.name, input: '' }); + b.append([...path, 'input'], item.input); + } + b.event({ type: 'item_end', choice: 0, item: index }); + } + b.event({ type: 'choice_end', choice: 0, finish_reason: 'stop' }); + b.event({ type: 'finish', status: 'completed' }); + return [...b.drain()]; +}; + +export const collectIR = async (records: AsyncIterable): Promise => { + let state: IROutput = { choices: [] }; + for await (const value of consumeIRRecords(records)) { + state = value.collector.state; + if (value.record.type === 'error') throw new Error('IR generation failed', { cause: value.record.error }); + } + return state; +}; diff --git a/packages/translate/__tests__/ir/output/initialize_test.ts b/packages/translate/__tests__/ir/output/initialize_test.ts new file mode 100644 index 0000000000..94900f974a --- /dev/null +++ b/packages/translate/__tests__/ir/output/initialize_test.ts @@ -0,0 +1,32 @@ +import { expect, test } from 'vitest'; + +import { createIRBuilder } from '../../../src/ir/output/builder.ts'; +import { createIRCollector } from '../../../src/ir/output/collector.ts'; + +test('initialize arrays require the exact next index and never replace entries', () => { + const collector = createIRCollector(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + expect(() => collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } })).toThrow('absent target'); + for (const index of [-1, 0.5, 2]) expect(() => collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', index], value: { items: [] } })).toThrow(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 1], value: { items: [] } }); + expect(collector.state.choices).toHaveLength(2); +}); + +test('append accepts only statically declared string fields', () => { + const collector = createIRCollector(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: { type: 'text', text: '' } }); + expect(() => collector.applyWrite({ type: 'write', kind: 'append', path: ['model'], value: 'x' })).toThrow('append-target path'); + collector.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'x' }); + expect(collector.value(['choices', 0, 'items', 0, 'content', 0, 'text'])).toBe('x'); +}); + +test('builder initializes items without separate start events', () => { + const builder = createIRBuilder(); + builder.item(0, { type: 'message', content: [] }); + expect(builder.drain()).toEqual([ + { type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }, + { type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }, + ]); +}); diff --git a/packages/translate/__tests__/ir/part-id_test.ts b/packages/translate/__tests__/ir/output/part-id_test.ts similarity index 94% rename from packages/translate/__tests__/ir/part-id_test.ts rename to packages/translate/__tests__/ir/output/part-id_test.ts index f6450cc581..17d81054c0 100644 --- a/packages/translate/__tests__/ir/part-id_test.ts +++ b/packages/translate/__tests__/ir/output/part-id_test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; -import { createIRBuilder } from '../../src/ir/stream.ts'; +import { createIRBuilder } from '../../../src/ir/output/builder.ts'; test('carries optional source part IDs on append operations', () => { const builder = createIRBuilder(); diff --git a/packages/translate/__tests__/ir/schema_test.ts b/packages/translate/__tests__/ir/output/schema_test.ts similarity index 78% rename from packages/translate/__tests__/ir/schema_test.ts rename to packages/translate/__tests__/ir/output/schema_test.ts index 143f6774c1..901e737119 100644 --- a/packages/translate/__tests__/ir/schema_test.ts +++ b/packages/translate/__tests__/ir/output/schema_test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; -import { irEndedUnit, irItemPath, irPartPath, irRecordOwnerPaths, irUnitChain, irUnitPath, isIRAppendTarget, isIRItemPath, isIRAbsentTarget, isIRPartPath } from '../../src/ir/operation-paths.ts'; +import { irEndedUnit, irItemPath, irPartPath, irRecordOwnerPaths, irUnitChain, irUnitPath, isIRItemPath, isIRPartPath, resolveIRPath } from '../../../src/ir/output/operation-paths.ts'; const root = { choices: [{ @@ -19,7 +19,7 @@ describe('IR schema queries', () => { [item(0, 'content', 0, 'text'), true], [item(0, 'content', 1, 'audio', 'data'), true], [item(0, 'content', 1, 'audio', 'transcript'), true], - [item(0, 'content', 1, 'audio', 'mime_type'), false], + [item(0, 'content', 1, 'audio', 'media_type'), false], [item(1, 'summary', 0), true], [item(1, 'content', 0), true], [item(2, 'arguments'), true], @@ -28,14 +28,15 @@ describe('IR schema queries', () => { [item(2, 'input'), false], [['model'], false], ])('append target at %j is %s', (path, expected) => { - expect(isIRAppendTarget(path, root)).toBe(expected); + const node = resolveIRPath(path, root)?.node; + expect(node?.kind === 'string' && node.append === true).toBe(expected); }); test('only tool call IDs may be IRAbsent', () => { - expect(isIRAbsentTarget(item(2, 'call_id'), root)).toBe(true); - expect(isIRAbsentTarget(item(3, 'call_id'), root)).toBe(true); - expect(isIRAbsentTarget(item(2, 'name'), root)).toBe(false); - expect(isIRAbsentTarget(item(0, 'call_id'), root)).toBe(false); + expect(resolveIRPath(item(2, 'call_id'), root)?.node).toMatchObject({ kind: 'string', absent: true }); + expect(resolveIRPath(item(3, 'call_id'), root)?.node).toMatchObject({ kind: 'string', absent: true }); + expect(resolveIRPath(item(2, 'name'), root)?.node).not.toMatchObject({ kind: 'string', absent: true }); + expect(resolveIRPath(item(0, 'call_id'), root)?.node).not.toMatchObject({ kind: 'string', absent: true }); }); test('items and parts are the lifecycle units of a choice', () => { diff --git a/packages/translate/__tests__/ir/output/unit-queue_test.ts b/packages/translate/__tests__/ir/output/unit-queue_test.ts new file mode 100644 index 0000000000..a81542bcb3 --- /dev/null +++ b/packages/translate/__tests__/ir/output/unit-queue_test.ts @@ -0,0 +1,36 @@ +import { expect, test } from 'vitest'; + +import { createIRUnitQueue } from '../../../src/ir/output/unit-queue.ts'; + +interface Unit { readonly name: string } + +test('unit queue dequeues units in the order they were enqueued', () => { + const queue = createIRUnitQueue(); + const [a, b] = [{ name: 'a' }, { name: 'b' }]; + queue.enqueue(a); + queue.enqueue(b); + expect(queue.head).toBe(a); + queue.dropHead(); + expect(queue.head).toBe(b); + queue.dropHead(); + expect(queue.head).toBeUndefined(); + const c = { name: 'c' }; + queue.enqueue(c); + expect(queue.head).toBe(c); +}); + +test('unit queue owns its links without changing payloads or coupling independent queues', () => { + const unit = Object.freeze({ name: 'shared' }); + const first = createIRUnitQueue(); + const second = createIRUnitQueue(); + first.enqueue(unit); + first.enqueue(unit); + second.enqueue(unit); + first.dropHead(); + expect(first.head).toBe(unit); + expect(second.head).toBe(unit); + first.dropHead(); + expect(first.head).toBeUndefined(); + expect(second.head).toBe(unit); + expect(Object.keys(unit)).toEqual(['name']); +}); diff --git a/packages/translate/__tests__/ir/unit-queue_test.ts b/packages/translate/__tests__/ir/output/unit-registry_test.ts similarity index 61% rename from packages/translate/__tests__/ir/unit-queue_test.ts rename to packages/translate/__tests__/ir/output/unit-registry_test.ts index 500bb6ef1d..e68845953a 100644 --- a/packages/translate/__tests__/ir/unit-queue_test.ts +++ b/packages/translate/__tests__/ir/output/unit-registry_test.ts @@ -1,24 +1,7 @@ import { expect, test } from 'vitest'; -import type { IRRecord } from '../../src/ir/stream.ts'; -import { createIRUnitQueue, createIRUnitRegistry, type IRQueuedUnit } from '../../src/ir/unit-queue.ts'; - -interface Unit extends IRQueuedUnit { readonly name: string } - -test('unit queue dequeues units in the order they were enqueued', () => { - const queue = createIRUnitQueue(); - const [a, b] = [{ name: 'a' }, { name: 'b' }]; - queue.enqueue(a); - queue.enqueue(b); - expect(queue.head).toBe(a); - queue.dequeue(); - expect(queue.head).toBe(b); - queue.dequeue(); - expect(queue.head).toBeUndefined(); - const c = { name: 'c' }; - queue.enqueue(c); - expect(queue.head).toBe(c); -}); +import type { IRRecord } from '../../../src/ir/output/records.ts'; +import { createIRUnitRegistry } from '../../../src/ir/output/unit-registry.ts'; test('unit registry finds the innermost registered owner of a record', () => { const registry = createIRUnitRegistry(); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/sse-to_test.ts index 20ab1c350c..40fe708d69 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-source/sse-to_test.ts @@ -1,11 +1,11 @@ import { expect, test } from 'vitest'; -import { createIRBuilder } from '../../../../src/ir/stream.ts'; +import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; import { anthropicMessagesFromIR } from '../../../../src/protocols/anthropic-messages/as-source/sse-to.ts'; import { appendField, collectContentViews, createReferenceBuilder, createFieldState, createReferenceFinalizer } from '../../../../src/round-trip/reference/reference-builder.ts'; -import { type RoundTripWriter } from '../../../../src/round-trip/stream.ts'; +import type { RoundTripWriter } from '../../../../src/round-trip/stream.ts'; import { hashRoundTripContent } from '../../../../src/round-trip/turn-digest.ts'; -import { type AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; +import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; test.each(['text', 'refusal', 'audio'] as const)('resolves blocked %s against the emitted Messages text field', async kind => { const references = createReferenceBuilder(); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-target/sse-from_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-target/sse-from_test.ts index db364566b8..cff11a8ab1 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-target/sse-from_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-target/sse-from_test.ts @@ -4,7 +4,7 @@ import type { AnthropicMessagesAssistantState, AnthropicMessagesInputAssistantTu import { irFromAnthropicMessages } from '../../../../src/protocols/anthropic-messages/as-target/sse-from.ts'; import { appendContent, collectContentViews, createContentState, createReferenceBuilder, type FieldStates } from '../../../../src/round-trip/reference/reference-builder.ts'; import { finalizeAssistantTurnReferences } from '../../../../src/round-trip/reference/reference-selection.ts'; -import { fixtureFrames } from '../../../ir/translation-cases.ts'; +import { fixtureFrames } from '../../fixtures.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events_test.ts index e65ea405a7..a03fca1f03 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events_test.ts @@ -1,4 +1,4 @@ import { translateToSourceEvents } from '../../../../src/protocols/anthropic-messages/via-openai-chat-completions/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; testOutputTranslation('anthropic-messages', 'openai-chat-completions', (frames, codec) => translateToSourceEvents(frames, codec)); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts index 079e83e658..f3d783b4e0 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts @@ -1,8 +1,8 @@ import { test } from 'vitest'; import { translateToSourceEvents } from '../../../../src/protocols/anthropic-messages/via-openai-responses/events.ts'; -import { structuredResponsesFixture } from '../../../ir/responses-fixture.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { structuredResponsesFixture } from '../../openai-responses/fixtures.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { openaiResponsesResultToEvents, type OpenAIResponsesResultEx, type OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events_test.ts index 0f05aec1df..3234ee801f 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events_test.ts @@ -1,8 +1,8 @@ import { expect, test } from 'vitest'; import { translateToSourceEvents } from '../../../../src/protocols/anthropic-messages/via-openai-responses/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; import { parseAnthropicMessagesStream, collectAnthropicMessagesProtocolEventsToResult, anthropicMessagesProtocolFrameToSSEFrame } from '@floway-dev/protocols/anthropic-messages'; import { parseOpenAIResponsesStream } from '@floway-dev/protocols/openai-responses'; diff --git a/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts b/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts index 0e54d49260..7e786f5648 100644 --- a/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts +++ b/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts @@ -1,8 +1,8 @@ import { expect, test } from 'vitest'; import { toOpenAIResponsesInputAssistantTurn } from '../../src/protocols/openai-responses/as-target/assistant-turn.ts'; -import { iterate } from '../ir/helpers.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { iterate } from '../shared/iterables.ts'; import { collectAnthropicMessagesProtocolEventsToResult, type AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { collectGeminiGenerateContentProtocolEventsToResult, type GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; diff --git a/packages/translate/__tests__/ir/edge-cases_test.ts b/packages/translate/__tests__/protocols/edge-cases_test.ts similarity index 97% rename from packages/translate/__tests__/ir/edge-cases_test.ts rename to packages/translate/__tests__/protocols/edge-cases_test.ts index 652013558f..28ffbb2025 100644 --- a/packages/translate/__tests__/ir/edge-cases_test.ts +++ b/packages/translate/__tests__/protocols/edge-cases_test.ts @@ -1,8 +1,8 @@ import { expect, test, vi } from 'vitest'; -import { anthropicMessagesFromIR, collectIR, collect, completeIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, iterate, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; -import { fixtureFrames, nativeResult } from './translation-cases.ts'; -import { createIRBuilder } from '../../src/ir/stream.ts'; +import { fixtureFrames, nativeResult } from './fixtures.ts'; +import { anthropicMessagesFromIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; +import { createIRBuilder } from '../../src/ir/output/builder.ts'; import { translateToSourceEvents as messagesViaChat } from '../../src/protocols/anthropic-messages/via-openai-chat-completions/events.ts'; import { buildTargetRequest as messagesRequestViaChat } from '../../src/protocols/anthropic-messages/via-openai-chat-completions/request.ts'; import { translateToSourceEvents as messagesViaResponses } from '../../src/protocols/anthropic-messages/via-openai-responses/events.ts'; @@ -11,8 +11,10 @@ import { translateToSourceEvents as generateContentViaResponses } from '../../sr import { irFromOpenAIChatCompletions as irFromOpenAIChatCompletionsProduction } from '../../src/protocols/openai-chat-completions/as-target/sse-from.ts'; import { translateToSourceEvents as responsesViaMessages } from '../../src/protocols/openai-responses/via-anthropic-messages/events.ts'; import { translateToSourceEvents as responsesViaChat } from '../../src/protocols/openai-responses/via-openai-chat-completions/events.ts'; +import { collectIR, completeIR } from '../ir/output/helpers.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { createRoundTripReaderCapture, emptyNamespaceToolNames } from '../round-trip/test-helpers.ts'; +import { collect, iterate } from '../shared/iterables.ts'; import { eventFrame } from '@floway-dev/protocols/common'; for (const [code, category] of [['bio_policy', 'bio'], ['cyber_policy', 'cyber']] as const) { @@ -135,7 +137,7 @@ test('Messages and GenerateContent reject custom tool calls they cannot represen }); test('writers omit output they cannot represent', async () => { - const image = completeIR([{ type: 'message', content: [{ type: 'text', text: 'before' }, { type: 'image', image: { data: 'aW1n', mime_type: 'image/png' } }] }]); + const image = completeIR([{ type: 'message', content: [{ type: 'text', text: 'before' }, { type: 'image', image: { data: 'aW1n', media_type: 'image/png' } }] }]); expect(JSON.stringify(await collect(anthropicMessagesFromIR(iterate(image))))).toContain('before'); expect(JSON.stringify(await collect(openaiChatCompletionsFromIR(iterate(image))))).toContain('before'); diff --git a/packages/translate/__tests__/ir/translation-cases.ts b/packages/translate/__tests__/protocols/fixtures.ts similarity index 99% rename from packages/translate/__tests__/ir/translation-cases.ts rename to packages/translate/__tests__/protocols/fixtures.ts index eacb60b174..6146d453d9 100644 --- a/packages/translate/__tests__/ir/translation-cases.ts +++ b/packages/translate/__tests__/protocols/fixtures.ts @@ -1,9 +1,9 @@ import { expect, test } from 'vitest'; -import { collect, iterate } from './helpers.ts'; import { decodeRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; import type { RoundTripProtocol } from '../../src/round-trip/sidecar.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { collect, iterate } from '../shared/iterables.ts'; import { collectAnthropicMessagesProtocolEventsToResult } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, doneFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { collectGeminiGenerateContentProtocolEventsToResult } from '@floway-dev/protocols/gemini-generate-content'; diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/sse-to_test.ts index a354be3ec3..38fab0a504 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/sse-to_test.ts @@ -1,9 +1,9 @@ import { expect, test } from 'vitest'; -import { createIRBuilder } from '../../../../src/ir/stream.ts'; +import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; import { geminiGenerateContentFromIR } from '../../../../src/protocols/gemini-generate-content/as-source/sse-to.ts'; import { appendField, collectContentViews, createReferenceBuilder, createFieldState, createReferenceFinalizer } from '../../../../src/round-trip/reference/reference-builder.ts'; -import { type RoundTripWriter } from '../../../../src/round-trip/stream.ts'; +import type { RoundTripWriter } from '../../../../src/round-trip/stream.ts'; test('resolves delayed Gemini audio atoms within the complete emitted data field', async () => { const references = createReferenceBuilder(); @@ -14,7 +14,7 @@ test('resolves delayed Gemini audio atoms within the complete emitted data field source.assign(['model'], 'model'); source.event({ type: 'start', id: 'response' }); const message = source.item(0, { type: 'message', content: [] }); - const audio = message.part({ type: 'audio', audio: { data: '', mime_type: 'audio/wav' } }); + const audio = message.part({ type: 'audio', audio: { data: '', media_type: 'audio/wav' } }); audio.append(['audio', 'data'], 'Y'); audio.append(['audio', 'data'], 'Q==', [partId]); audio.end(); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/events_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/events_test.ts index 4c350535f3..9576b13585 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/events_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/events_test.ts @@ -1,4 +1,4 @@ import { translateToSourceEvents } from '../../../../src/protocols/gemini-generate-content/via-anthropic-messages/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; testOutputTranslation('gemini-generate-content', 'anthropic-messages', (frames, codec) => translateToSourceEvents(frames, codec)); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/events_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/events_test.ts index 4fd9041eff..803a949a56 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/events_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/events_test.ts @@ -1,4 +1,4 @@ import { translateToSourceEvents } from '../../../../src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; testOutputTranslation('gemini-generate-content', 'openai-chat-completions', (frames, codec) => translateToSourceEvents(frames, codec)); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/events_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/events_test.ts index b9f72afc5d..da065b0d58 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/events_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/events_test.ts @@ -1,4 +1,4 @@ import { translateToSourceEvents } from '../../../../src/protocols/gemini-generate-content/via-openai-responses/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; testOutputTranslation('gemini-generate-content', 'openai-responses', (frames, codec) => translateToSourceEvents(frames, codec)); diff --git a/packages/translate/__tests__/ir/helpers.ts b/packages/translate/__tests__/protocols/helpers.ts similarity index 54% rename from packages/translate/__tests__/ir/helpers.ts rename to packages/translate/__tests__/protocols/helpers.ts index 396b11e24d..864df006d6 100644 --- a/packages/translate/__tests__/ir/helpers.ts +++ b/packages/translate/__tests__/protocols/helpers.ts @@ -1,5 +1,4 @@ -import { consumeIRRecords, createIRBuilder, type IRRecord } from '../../src/ir/stream.ts'; -import type { IR, IRItem } from '../../src/ir/types.ts'; +import type { IRRecord } from '../../src/ir/output/records.ts'; import { anthropicMessagesFromIR as anthropicMessagesFromIRProduction } from '../../src/protocols/anthropic-messages/as-source/sse-to.ts'; import { irFromAnthropicMessages as irFromAnthropicMessagesProduction } from '../../src/protocols/anthropic-messages/as-target/sse-from.ts'; import { geminiGenerateContentFromIR as geminiGenerateContentFromIRProduction } from '../../src/protocols/gemini-generate-content/as-source/sse-to.ts'; @@ -10,11 +9,6 @@ import { openaiResponsesFromIR as openaiResponsesFromIRProduction } from '../../ import { irFromOpenAIResponses as irFromOpenAIResponsesProduction } from '../../src/protocols/openai-responses/as-target/sse-from.ts'; import { createReferenceBuilder } from '../../src/round-trip/reference/reference-builder.ts'; import type { RoundTripReader, RoundTripWriter } from '../../src/round-trip/stream.ts'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; - -export const iterate = async function* (values: readonly T[]): AsyncGenerator { yield* values; }; -export const collect = async (values: AsyncIterable): Promise => { const result: T[] = []; for await (const value of values) result.push(value); return result; }; -export const events = async function* (frames: AsyncIterable>): AsyncGenerator { for await (const frame of frames) if (frame.type === 'event') yield frame.event; }; const reader = (): RoundTripReader => ({ builder: createReferenceBuilder(), @@ -53,59 +47,3 @@ export const openaiResponsesFromIR = (records: Parameters(), namespaceToolNames }); export const geminiGenerateContentFromIR = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => geminiGenerateContentFromIRProduction(records, { ...options, roundTrip: writer<'geminiGenerateContent'>() }); -export const completeIR = (value: readonly IRItem[]): IRRecord[] => { - const b = createIRBuilder(); - b.choice(0); - b.assign(['model'], 'model'); - b.event({ type: 'start', id: 'source' }); - for (const item of value) { - const index = b.state.choices[0].items.length; - const path = ['choices', 0, 'items', index] as const; - if (item.type === 'message') { - b.initialize(path, { type: 'message', content: [] }); - item.content.forEach((part, partIndex) => { - const partPath = [...path, 'content', partIndex] as const; - if (part.type === 'text') { - b.initialize(partPath, { type: 'text', text: '', ...(part.annotations === undefined ? {} : { annotations: part.annotations }) }); - b.append([...partPath, 'text'], part.text); - } else if (part.type === 'refusal') { - b.initialize(partPath, { type: 'refusal', refusal: '' }); - b.append([...partPath, 'refusal'], part.refusal); - } else if (part.type === 'image') b.initialize(partPath, part); - else { - b.initialize(partPath, { type: 'audio', audio: { ...(part.audio.mime_type === undefined ? {} : { mime_type: part.audio.mime_type }) } }); - for (const field of ['data', 'transcript'] as const) if (part.audio[field] !== undefined) { - const value = part.audio[field]; - if (value === undefined) continue; - b.initialize([...partPath, 'audio', field], ''); - b.append([...partPath, 'audio', field], value); - } - } - b.event({ type: 'part_end', choice: 0, item: index, part: partIndex }); - }); - } else if (item.type === 'reasoning') { - b.initialize(path, { type: 'reasoning', ...(item.summary === undefined ? {} : { summary: item.summary.map(() => '') }), ...(item.content === undefined ? {} : { content: item.content.map(() => '') }) }); - item.summary?.forEach((text, part) => b.append([...path, 'summary', part], text)); - item.content?.forEach((text, part) => b.append([...path, 'content', part], text)); - } else if (item.type === 'function_call') { - b.initialize(path, { type: 'function_call', ...(item.call_id === undefined ? {} : { call_id: item.call_id }), name: item.name, arguments: '' }); - b.append([...path, 'arguments'], item.arguments); - } else { - b.initialize(path, { type: 'custom_tool_call', ...(item.call_id === undefined ? {} : { call_id: item.call_id }), name: item.name, input: '' }); - b.append([...path, 'input'], item.input); - } - b.event({ type: 'item_end', choice: 0, item: index }); - } - b.event({ type: 'choice_end', choice: 0, finish_reason: 'stop' }); - b.event({ type: 'finish', status: 'completed' }); - return [...b.drain()]; -}; - -export const collectIR = async (records: AsyncIterable): Promise => { - let state: IR = { choices: [] }; - for await (const value of consumeIRRecords(records)) { - state = value.collector.state; - if (value.record.type === 'error') throw new Error('IR generation failed', { cause: value.record.error }); - } - return state; -}; diff --git a/packages/translate/__tests__/ir/initial-envelope_test.ts b/packages/translate/__tests__/protocols/initial-envelope_test.ts similarity index 100% rename from packages/translate/__tests__/ir/initial-envelope_test.ts rename to packages/translate/__tests__/protocols/initial-envelope_test.ts diff --git a/packages/translate/__tests__/ir/lifecycle_test.ts b/packages/translate/__tests__/protocols/lifecycle_test.ts similarity index 98% rename from packages/translate/__tests__/ir/lifecycle_test.ts rename to packages/translate/__tests__/protocols/lifecycle_test.ts index 18e5df52ea..7614c6f6ca 100644 --- a/packages/translate/__tests__/ir/lifecycle_test.ts +++ b/packages/translate/__tests__/protocols/lifecycle_test.ts @@ -1,9 +1,11 @@ import { expect, test, vi } from 'vitest'; -import { anthropicMessagesFromIR, collect, collectIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, iterate, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; -import { nativeResult } from './translation-cases.ts'; -import type { IRRecord } from '../../src/ir/stream.ts'; -import { IRAbsent } from '../../src/ir/types.ts'; +import { nativeResult } from './fixtures.ts'; +import { anthropicMessagesFromIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; +import { IRAbsent } from '../../src/ir/output/absent.ts'; +import type { IRRecord } from '../../src/ir/output/records.ts'; +import { collectIR } from '../ir/output/helpers.ts'; +import { collect, iterate } from '../shared/iterables.ts'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; const chat = (delta: any, finish_reason: string | null = null) => eventFrame({ id: 'chatcmpl_lifecycle', model: 'served', created: 1, choices: [{ index: 0, delta, finish_reason }] } as any); diff --git a/packages/translate/__tests__/ir/continuous-usage_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/continuous-usage_test.ts similarity index 93% rename from packages/translate/__tests__/ir/continuous-usage_test.ts rename to packages/translate/__tests__/protocols/openai-chat-completions/as-source/continuous-usage_test.ts index a64739675a..20a2995bf6 100644 --- a/packages/translate/__tests__/ir/continuous-usage_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/continuous-usage_test.ts @@ -1,9 +1,9 @@ import { expect, test } from 'vitest'; -import { collect, iterate } from './helpers.ts'; -import { fixtureFrames } from './translation-cases.ts'; -import { routeOpenAIChatCompletions } from '../../src/protocols/openai-chat-completions/index.ts'; -import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +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 type { OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; const protocols = ['anthropic-messages', 'openai-responses'] as const; diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts index 7fe2a02421..2ca92d6702 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts @@ -1,9 +1,9 @@ import { expect, test } from 'vitest'; -import { createIRBuilder } from '../../../../src/ir/stream.ts'; +import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; import { openaiChatCompletionsFromIR } from '../../../../src/protocols/openai-chat-completions/as-source/sse-to.ts'; import { createReferenceBuilder, collectContentViews, createFieldState, createReferenceFinalizer, appendField } from '../../../../src/round-trip/reference/reference-builder.ts'; -import { type RoundTripWriter } from '../../../../src/round-trip/stream.ts'; +import type { RoundTripWriter } from '../../../../src/round-trip/stream.ts'; test('prepares each Chat choice independently while another choice has partial audio', async () => { const source = createIRBuilder(2); @@ -13,7 +13,7 @@ test('prepares each Chat choice independently while another choice has partial a source.event({ type: 'start', id: 'response' }); source.initialize(['choices', 1, 'items', 0], { type: 'message', content: [] }); - source.initialize(['choices', 1, 'items', 0, 'content', 0], { type: 'audio', audio: { mime_type: 'audio/wav' } }); + source.initialize(['choices', 1, 'items', 0, 'content', 0], { type: 'audio', audio: { media_type: 'audio/wav' } }); source.initialize(['choices', 1, 'items', 0, 'content', 0, 'audio', 'data'], ''); source.append(['choices', 1, 'items', 0, 'content', 0, 'audio', 'data'], 'YQ=='); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts index 134b73403f..f73b26fe4a 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; -import type { IRRecord } from '../../../../src/ir/stream.ts'; +import type { IRRecord } from '../../../../src/ir/output/records.ts'; import type { OpenAIChatCompletionsInputAssistantTurn } from '../../../../src/protocols/openai-chat-completions/as-target/assistant-turn.ts'; import { irFromOpenAIChatCompletions } from '../../../../src/protocols/openai-chat-completions/as-target/sse-from.ts'; import { appendContent, collectContentViews, createContentState, createReferenceBuilder } from '../../../../src/round-trip/reference/reference-builder.ts'; diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events_test.ts index 7a6cdd62a2..3fdcfbc082 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events_test.ts @@ -1,4 +1,4 @@ import { translateToSourceEvents } from '../../../../src/protocols/openai-chat-completions/via-anthropic-messages/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; testOutputTranslation('openai-chat-completions', 'anthropic-messages', (frames, codec) => translateToSourceEvents(frames, {}, codec)); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts index beafe30ffa..a7d7f70afe 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 @@ -2,9 +2,9 @@ import { expect, test } from 'vitest'; import { translateToSourceEvents } from '../../../../src/protocols/openai-chat-completions/via-openai-responses/events.ts'; import { buildTargetRequest } from '../../../../src/protocols/openai-chat-completions/via-openai-responses/request.ts'; -import { structuredResponsesFixture } from '../../../ir/responses-fixture.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; +import { structuredResponsesFixture } from '../../openai-responses/fixtures.ts'; import { eventFrame, type ProtocolFrame, type SseFrame, sseFrame } from '@floway-dev/protocols/common'; import { collectOpenAIChatCompletionsProtocolEventsToResult, parseOpenAIChatCompletionsStream, openaiChatCompletionsProtocolFrameToSSEFrame as writeChatFrame, type OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; import { openaiResponsesResultToEvents, parseOpenAIResponsesStream, collectOpenAIResponsesProtocolEventsToResult, type OpenAIResponsesResultEx, type OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts new file mode 100644 index 0000000000..f023e6d8bc --- /dev/null +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts @@ -0,0 +1,22 @@ +import { expect, test } from 'vitest'; + +import type { IRRecord } from '../../../../src/ir/output/records.ts'; +import { collect, iterate } from '../../../shared/iterables.ts'; +import { openaiResponsesFromIR } from '../../helpers.ts'; + +test('initialize carries real required fields while append targets start empty', async () => { + const records: IRRecord[] = [ + { type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }, + { type: 'write', kind: 'assign', path: ['model'], value: 'model' }, + { type: 'start', id: 'source' }, + { type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call', call_id: 'call', name: 'real-name', arguments: '' } }, + { type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'arguments'], value: '{}' }, + { type: 'item_end', choice: 0, item: 0 }, + { type: 'choice_end', choice: 0, finish_reason: 'tool_calls' }, + { type: 'finish', status: 'completed' }, + ]; + const output = await collect(openaiResponsesFromIR(iterate(records))); + const serialized = JSON.stringify(output); + expect(serialized).toContain('real-name'); + expect(serialized).toContain('{}'); +}); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/sse-to_test.ts index 70cc9ccb60..728f7046f7 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/sse-to_test.ts @@ -1,10 +1,10 @@ import { expect, test } from 'vitest'; -import { createIRBuilder } from '../../../../src/ir/stream.ts'; +import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; import { flattenNamespaceTools } from '../../../../src/protocols/openai-responses/as-source/namespace-tools.ts'; import { openaiResponsesFromIR } from '../../../../src/protocols/openai-responses/as-source/sse-to.ts'; import { appendField, collectContentViews, createReferenceBuilder, createFieldState, createReferenceFinalizer } from '../../../../src/round-trip/reference/reference-builder.ts'; -import { type RoundTripWriter } from '../../../../src/round-trip/stream.ts'; +import type { RoundTripWriter } from '../../../../src/round-trip/stream.ts'; test('collects sparse namespace-restored Responses state from emitted IR atoms', async () => { const namespace = flattenNamespaceTools({ @@ -72,7 +72,7 @@ test('collects sparse namespace-restored Responses state from emitted IR atoms', builder.event({ type: 'item_end', choice: 0, item: 3 }); builder.initialize(['choices', 0, 'items', 4], { type: 'message', content: [] }); - builder.initialize(['choices', 0, 'items', 4, 'content', 0], { type: 'image', image: { data: 'literal', mime_type: 'image/png' } }); + builder.initialize(['choices', 0, 'items', 4, 'content', 0], { type: 'image', image: { data: 'literal', media_type: 'image/png' } }); builder.event({ type: 'part_end', choice: 0, item: 4, part: 0 }); builder.event({ type: 'item_end', choice: 0, item: 4 }); diff --git a/packages/translate/__tests__/ir/snapshot-policy_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts similarity index 98% rename from packages/translate/__tests__/ir/snapshot-policy_test.ts rename to packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts index c93e4ec4ec..73980cb9f8 100644 --- a/packages/translate/__tests__/ir/snapshot-policy_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts @@ -1,7 +1,9 @@ import { expect, test } from 'vitest'; -import { collect, collectIR, irFromOpenAIResponses, iterate } from './helpers.ts'; -import { createRoundTripReaderCapture } from '../round-trip/test-helpers.ts'; +import { collectIR } from '../../../ir/output/helpers.ts'; +import { createRoundTripReaderCapture } from '../../../round-trip/test-helpers.ts'; +import { collect, iterate } from '../../../shared/iterables.ts'; +import { irFromOpenAIResponses } from '../../helpers.ts'; import { eventFrame } from '@floway-dev/protocols/common'; const response = (output: any[] = []) => ({ id: 'resp', model: 'served', created_at: 1, status: 'completed', output, usage: null, error: null, incomplete_details: null }); diff --git a/packages/translate/__tests__/ir/responses-fixture.ts b/packages/translate/__tests__/protocols/openai-responses/fixtures.ts similarity index 100% rename from packages/translate/__tests__/ir/responses-fixture.ts rename to packages/translate/__tests__/protocols/openai-responses/fixtures.ts diff --git a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts b/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts index d956a51402..c47c4cd04e 100644 --- a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts @@ -1,9 +1,9 @@ import { test } from 'vitest'; import { translateToSourceEvents } from '../../../../src/protocols/openai-responses/via-anthropic-messages/events.ts'; -import { fixtureFrames } from '../../../ir/translation-cases.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; import { emptyNamespaceToolNames } from '../../../round-trip/test-helpers.ts'; +import { fixtureFrames } from '../../fixtures.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; diff --git a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events_test.ts b/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events_test.ts index 9793ccb088..d8873d7dfa 100644 --- a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events_test.ts @@ -1,5 +1,5 @@ import { translateToSourceEvents } from '../../../../src/protocols/openai-responses/via-anthropic-messages/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; import { emptyNamespaceToolNames } from '../../../round-trip/test-helpers.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; testOutputTranslation('openai-responses', 'anthropic-messages', (frames, codec) => translateToSourceEvents(frames, 'resp_test', new Set(), codec, emptyNamespaceToolNames())); diff --git a/packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/events_test.ts b/packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/events_test.ts index 71e8cf633c..c1ffc06400 100644 --- a/packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/events_test.ts @@ -1,5 +1,5 @@ import { translateToSourceEvents } from '../../../../src/protocols/openai-responses/via-openai-chat-completions/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; import { emptyNamespaceToolNames } from '../../../round-trip/test-helpers.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; testOutputTranslation('openai-responses', 'openai-chat-completions', (frames, codec) => translateToSourceEvents(frames, new Set(), codec, emptyNamespaceToolNames())); diff --git a/packages/translate/__tests__/ir/ownership_test.ts b/packages/translate/__tests__/protocols/ownership_test.ts similarity index 88% rename from packages/translate/__tests__/ir/ownership_test.ts rename to packages/translate/__tests__/protocols/ownership_test.ts index 3e98ca9cc0..96b0dc75af 100644 --- a/packages/translate/__tests__/ir/ownership_test.ts +++ b/packages/translate/__tests__/protocols/ownership_test.ts @@ -1,7 +1,9 @@ import { expect, test, vi } from 'vitest'; -import { collect, collectIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, iterate, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; -import { nativeResult } from './translation-cases.ts'; +import { nativeResult } from './fixtures.ts'; +import { geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; +import { collectIR } from '../ir/output/helpers.ts'; +import { collect, iterate } from '../shared/iterables.ts'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; const chunk = (delta: any, extra: any = {}) => eventFrame({ id: 'chat_owner', model: 'served', created: 1, choices: [{ index: 0, delta, finish_reason: null, ...extra }] } as any); @@ -107,3 +109,14 @@ test('range-less Messages citations do not expand to whole-part ChatCompletions const generateContentFrames = await collect(geminiGenerateContentFromIR(irFromAnthropicMessages(iterate(messagesCitationFrames('source quote'))))); expect(generateContentFrames.some(frame => frame.type === 'event' && 'candidates' in frame.event && frame.event.candidates?.some(candidate => candidate.groundingMetadata !== undefined))).toBe(false); }); + +test.each([ + ['ChatCompletions', irFromOpenAIChatCompletions, (error: object) => ({ id: 'chat', model: 'm', choices: [], error })], + ['Responses', irFromOpenAIResponses, (error: object) => ({ type: 'error', error })], + ['Messages', irFromAnthropicMessages, (error: object) => ({ type: 'error', error })], +] as const)('%s reader transfers immutable error objects without copying', async (_name, reader, event) => { + const error = Object.freeze({ type: 'api_error', message: 'original', details: Object.freeze([Object.freeze({ text: 'original' })]) }); + const frames = await collect(reader(iterate([eventFrame(event(error) as any)]))); + const record = frames.find(record => record.type === 'error'); + expect(record?.error).toBe(error); +}); diff --git a/packages/translate/__tests__/protocols/round-trip-receive_test.ts b/packages/translate/__tests__/protocols/round-trip-receive_test.ts index ef74461ed8..deacc16be5 100644 --- a/packages/translate/__tests__/protocols/round-trip-receive_test.ts +++ b/packages/translate/__tests__/protocols/round-trip-receive_test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; -import type { IRJSONObject } from '../../src/ir/types.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'; import { computeOpenAIChatCompletionsTurnDigest } from '../../src/protocols/openai-chat-completions/as-source/round-trip.ts'; diff --git a/packages/translate/__tests__/protocols/routes_test.ts b/packages/translate/__tests__/protocols/routes_test.ts index 129b7a61c5..9c462a803d 100644 --- a/packages/translate/__tests__/protocols/routes_test.ts +++ b/packages/translate/__tests__/protocols/routes_test.ts @@ -1,5 +1,6 @@ import { test } from 'vitest'; +import { fixtureFrames, type nativeResult } from './fixtures.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'; @@ -11,9 +12,8 @@ import { findOpenAIResponsesSidecarCarriers } from '../../src/protocols/openai-r import { toOpenAIResponsesInputAssistantTurn } from '../../src/protocols/openai-responses/as-target/assistant-turn.ts'; import { decodeRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; import { requireJSONObject } from '../../src/shared/json/object.ts'; -import { collect, iterate } from '../ir/helpers.ts'; -import { fixtureFrames, type nativeResult } from '../ir/translation-cases.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { collect, iterate } from '../shared/iterables.ts'; import { collectAnthropicMessagesProtocolEventsToResult, type AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; import { collectGeminiGenerateContentProtocolEventsToResult, type GeminiGenerateContentContent, type GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; import { collectOpenAIChatCompletionsProtocolEventsToResult, type OpenAIChatCompletionsAssistantMessageEx, type OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; diff --git a/packages/translate/__tests__/ir/streams_test.ts b/packages/translate/__tests__/protocols/streams_test.ts similarity index 96% rename from packages/translate/__tests__/ir/streams_test.ts rename to packages/translate/__tests__/protocols/streams_test.ts index 7040a5fdb2..98fafde2e7 100644 --- a/packages/translate/__tests__/ir/streams_test.ts +++ b/packages/translate/__tests__/protocols/streams_test.ts @@ -1,13 +1,16 @@ import { describe, expect, it } from 'vitest'; -import { anthropicMessagesFromIR, collectIR, collect, completeIR, events, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, iterate, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; -import { createIRBuilder } from '../../src/ir/stream.ts'; -import type { IRItem, IRJSONObject } from '../../src/ir/types.ts'; +import { anthropicMessagesFromIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; +import type { IRJSONObject } from '../../src/ir/common/json.ts'; +import { createIRBuilder } from '../../src/ir/output/builder.ts'; +import type { IROutputItem } from '../../src/ir/output/types.ts'; import { usageFromIR as messagesUsageFromIR } from '../../src/protocols/anthropic-messages/as-source/usage.ts'; import { usageToIR as messagesUsageToIR } from '../../src/protocols/anthropic-messages/as-target/usage.ts'; import { usageFromIR as responsesUsageFromIR } from '../../src/protocols/openai-responses/as-source/usage.ts'; import { usageToIR as responsesUsageToIR } from '../../src/protocols/openai-responses/as-target/usage.ts'; import { TextCoordinateIndex } from '../../src/shared/text-coordinate-index.ts'; +import { collectIR, completeIR } from '../ir/output/helpers.ts'; +import { collect, events, iterate } from '../shared/iterables.ts'; import { reassembleAnthropicMessagesEvents, type AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; import { reassembleGeminiGenerateContentEvents } from '@floway-dev/protocols/gemini-generate-content'; @@ -190,10 +193,10 @@ describe('IR streaming', () => { const value = await collectIR(chat()); const item = value.choices[0].items.find(i => i.type === 'message'); expect(item?.type === 'message' && item.content[0].type === 'text' && item.content[0].annotations?.[0].output_text_range).toEqual({ start: 1, end_exclusive: 4 }); - expect(TextCoordinateIndex.codePointsToUTF16('A😀中B').codePointRangeToIR(1, 3)).toEqual({ start: 1, end_exclusive: 4 }); + expect(TextCoordinateIndex.codePointsToUTF16('A😀中B').codePointRangeToUTF16(1, 3)).toEqual({ start: 1, end_exclusive: 4 }); const index = TextCoordinateIndex.utf16ToCodePoints('A😀中B'); - expect(index.irRangeToCodePoints({ start: 1, end_exclusive: 4 })).toEqual({ start: 1, end: 3 }); - expect(() => index.irRangeToCodePoints({ start: 2, end_exclusive: 4 })).toThrow(); + expect(index.utf16RangeToCodePoints({ start: 1, end_exclusive: 4 })).toEqual({ start: 1, end: 3 }); + expect(() => index.utf16RangeToCodePoints({ start: 2, end_exclusive: 4 })).toThrow(); }); it('delivers text before pulling the upstream completion', async () => { @@ -324,7 +327,7 @@ describe('IR streaming', () => { b.event({ type: 'part_end', choice: 0, item: 1, part: 0 }); b.event({ type: 'item_end', choice: 0, item: 1 }); b.initialize(['choices', 0, 'items', 2], { type: 'message', content: [] }); - b.initialize(['choices', 0, 'items', 2, 'content', 0], { type: 'audio', audio: { mime_type: 'audio/wav' } }); + b.initialize(['choices', 0, 'items', 2, 'content', 0], { type: 'audio', audio: { media_type: 'audio/wav' } }); b.initialize(['choices', 0, 'items', 2, 'content', 0, 'audio', 'data'], ''); b.append(['choices', 0, 'items', 2, 'content', 0, 'audio', 'data'], 'YQ=='); b.event({ type: 'part_end', choice: 0, item: 2, part: 0 }); @@ -354,7 +357,7 @@ describe('IR streaming', () => { }); it('retains text-image-text order in Responses output', async () => { - const frames = openaiResponsesFromIR(iterate(completeIR([{ type: 'message', content: [{ type: 'text', text: 'before' }, { type: 'image', image: { data: 'YQ==', mime_type: 'image/png' } }, { type: 'text', text: 'after' }] }])), options); + const frames = openaiResponsesFromIR(iterate(completeIR([{ type: 'message', content: [{ type: 'text', text: 'before' }, { type: 'image', image: { data: 'YQ==', media_type: 'image/png' } }, { type: 'text', text: 'after' }] }])), options); const result = await reassembleOpenAIResponsesEvents(events(frames)); expect(result.output.map(item => item.type)).toEqual(['message', 'image_generation_call', 'message', 'reasoning']); }); @@ -419,7 +422,7 @@ describe('IR streaming', () => { expect(result.output[0]).toMatchObject({ content: [{ logprobs: tokens }] }); }); - it.each<{ items: readonly IRItem[] }>([{ items: [] }, { items: [{ type: 'reasoning', content: ['thought'] }] }])('exposes invalid logprob item ownership %j', async ({ items }) => { + it.each<{ items: readonly IROutputItem[] }>([{ items: [] }, { items: [{ type: 'reasoning', content: ['thought'] }] }])('exposes invalid logprob item ownership %j', async ({ items }) => { const frames = completeIR(items); const end = frames.findIndex(record => record.type === 'choice_end'); const logprobs = [ @@ -438,7 +441,7 @@ describe('IR streaming', () => { }); it('preserves Responses generated image format', async () => { - const result = await reassembleOpenAIResponsesEvents(events(openaiResponsesFromIR(iterate(completeIR([{ type: 'message', content: [{ type: 'image', image: { data: 'YQ==', mime_type: 'image/jpeg' } }] }])), options))); + const result = await reassembleOpenAIResponsesEvents(events(openaiResponsesFromIR(iterate(completeIR([{ type: 'message', content: [{ type: 'image', image: { data: 'YQ==', media_type: 'image/jpeg' } }] }])), options))); expect(result.output[0]).toMatchObject({ type: 'image_generation_call', output_format: 'jpeg' }); }); diff --git a/packages/translate/__tests__/ir/usage_test.ts b/packages/translate/__tests__/protocols/usage_test.ts similarity index 95% rename from packages/translate/__tests__/ir/usage_test.ts rename to packages/translate/__tests__/protocols/usage_test.ts index 3067b168c4..b6348755b9 100644 --- a/packages/translate/__tests__/ir/usage_test.ts +++ b/packages/translate/__tests__/protocols/usage_test.ts @@ -1,14 +1,17 @@ import { expect, test } from 'vitest'; -import { collectIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, iterate, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; -import { fixtureFrames } from './translation-cases.ts'; +import { fixtureFrames } from './fixtures.ts'; +import { geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; import { usageFromIR as messagesUsageFromIR } from '../../src/protocols/anthropic-messages/as-source/usage.ts'; import { usageToIR as messagesUsageToIR } from '../../src/protocols/anthropic-messages/as-target/usage.ts'; import { usageFromIR } from '../../src/protocols/gemini-generate-content/as-source/usage.ts'; import { usageFromIR as chatUsageFromIR } from '../../src/protocols/openai-chat-completions/as-source/usage.ts'; import { usageToIR as chatUsageToIR } from '../../src/protocols/openai-chat-completions/as-target/usage.ts'; import { usageFromIR as responsesUsageFromIR } from '../../src/protocols/openai-responses/as-source/usage.ts'; -import { usageToIR as responsesUsageToIR } from '../../src/protocols/openai-responses/as-target/usage.ts';import { eventFrame } from '@floway-dev/protocols/common'; +import { usageToIR as responsesUsageToIR } from '../../src/protocols/openai-responses/as-target/usage.ts'; +import { collectIR } from '../ir/output/helpers.ts'; +import { iterate } from '../shared/iterables.ts'; +import { eventFrame } from '@floway-dev/protocols/common'; const initialUsage = { input_tokens: 17, output_tokens: 2, total_tokens: 19, input_tokens_details: { cached_tokens: 3, cache_write_tokens: 4 } }; const sources = [ diff --git a/packages/translate/__tests__/round-trip/reference/reference-builder_test.ts b/packages/translate/__tests__/round-trip/reference/reference-builder_test.ts index d57e84ffb2..e7eec86f9d 100644 --- a/packages/translate/__tests__/round-trip/reference/reference-builder_test.ts +++ b/packages/translate/__tests__/round-trip/reference/reference-builder_test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import type { IRJSONObject } from '../../../src/ir/types.ts'; +import type { IRJSONObject } from '../../../src/ir/common/json.ts'; import { collectAnthropicMessagesTextViews } from '../../../src/protocols/anthropic-messages/as-source/round-trip.ts'; import type { AnthropicMessagesReferenceableAssistantTurn } from '../../../src/protocols/anthropic-messages/as-target/assistant-turn.ts'; import { collectGeminiGenerateContentTextViews } from '../../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; diff --git a/packages/translate/__tests__/round-trip/reference/reference-cost-json_test.ts b/packages/translate/__tests__/round-trip/reference/reference-cost-json_test.ts index d6f73aa6de..3dd3f55936 100644 --- a/packages/translate/__tests__/round-trip/reference/reference-cost-json_test.ts +++ b/packages/translate/__tests__/round-trip/reference/reference-cost-json_test.ts @@ -1,6 +1,6 @@ import { expect, it } from 'vitest'; -import type { IRJSONObject } from '../../../src/ir/types.ts'; +import type { IRJSONObject } from '../../../src/ir/common/json.ts'; import type { AnthropicMessagesInputAssistantTurn } from '../../../src/protocols/anthropic-messages/as-target/assistant-turn.ts'; import { ContentReference } from '../../../src/round-trip/reference/assistant-turn.ts'; import { appendContent, appendField, collectContentViews, createContentState, createFieldState, createReferenceBuilder } from '../../../src/round-trip/reference/reference-builder.ts'; diff --git a/packages/translate/__tests__/shared/iterables.ts b/packages/translate/__tests__/shared/iterables.ts new file mode 100644 index 0000000000..b56dd306ad --- /dev/null +++ b/packages/translate/__tests__/shared/iterables.ts @@ -0,0 +1,5 @@ +import type { ProtocolFrame } from '@floway-dev/protocols/common'; + +export const iterate = async function* (values: readonly T[]): AsyncGenerator { yield* values; }; +export const collect = async (values: AsyncIterable): Promise => { const result: T[] = []; for await (const value of values) result.push(value); return result; }; +export const events = async function* (frames: AsyncIterable>): AsyncGenerator { for await (const frame of frames) if (frame.type === 'event') yield frame.event; }; diff --git a/packages/translate/__tests__/shared/text-coordinate-index_test.ts b/packages/translate/__tests__/shared/text-coordinate-index_test.ts index 01aa76a666..c978a18a73 100644 --- a/packages/translate/__tests__/shared/text-coordinate-index_test.ts +++ b/packages/translate/__tests__/shared/text-coordinate-index_test.ts @@ -6,22 +6,22 @@ test('maps code-point ranges to UTF-16 and follows later appends', () => { const index = TextCoordinateIndex.codePointsToUTF16('A\ud83d'); index.append('\ude00中B'); index.assertUTF16Length('A😀中B'.length); - expect(index.codePointRangeToIR(1, 3)).toEqual({ start: 1, end_exclusive: 4 }); - expect(() => index.codePointRangeToIR(0, 5)).toThrow('exceeds text'); + expect(index.codePointRangeToUTF16(1, 3)).toEqual({ start: 1, end_exclusive: 4 }); + expect(() => index.codePointRangeToUTF16(0, 5)).toThrow('exceeds text'); }); test('maps UTF-16 ranges to code points and rejects surrogate interiors', () => { const index = TextCoordinateIndex.utf16ToCodePoints('A\ud83d'); index.append('\ude00中B'); - expect(index.irRangeToCodePoints({ start: 1, end_exclusive: 4 })).toEqual({ start: 1, end: 3 }); - expect(() => index.irRangeToCodePoints({ start: 2, end_exclusive: 4 })).toThrow('splits a surrogate pair'); + expect(index.utf16RangeToCodePoints({ start: 1, end_exclusive: 4 })).toEqual({ start: 1, end: 3 }); + expect(() => index.utf16RangeToCodePoints({ start: 2, end_exclusive: 4 })).toThrow('splits a surrogate pair'); }); test('maps UTF-16 ranges to UTF-8 bytes and requires well-formed text', () => { const index = TextCoordinateIndex.utf16ToUTF8('A\ud83d'); - expect(() => index.irRangeToUTF8({ start: 0, end_exclusive: 2 })).toThrow('well-formed Unicode'); + expect(() => index.utf16RangeToUTF8({ start: 0, end_exclusive: 2 })).toThrow('well-formed Unicode'); index.append('\ude00中B'); - expect(index.irRangeToUTF8({ start: 1, end_exclusive: 4 })).toEqual({ start: 1, end: 8 }); + expect(index.utf16RangeToUTF8({ start: 1, end_exclusive: 4 })).toEqual({ start: 1, end: 8 }); }); test('detects a missed append at the protocol owner', () => { diff --git a/packages/translate/src/ir/collector.ts b/packages/translate/src/ir/collector.ts deleted file mode 100644 index 5d7bd28b85..0000000000 --- a/packages/translate/src/ir/collector.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { irEndedUnit, irPathKey, resolveIRPath, unionVariantNode, valueAt, type IRUnit } from './operation-paths.ts'; -import type { IRSchemaNode } from './schema.ts'; -import type { IREvent, IRWrite, IRPath } from './stream.ts'; -import { IRAbsent, type IR, type IRValue } from './types.ts'; - -type IRValueVisitor = (path: IRPath, node: IRSchemaNode, value: IRValue) => void; - -const isPlainObject = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); - -// Opaque values are replaced as a whole, so only what no IR value may contain is rejected. -const assertPlainValue = (path: IRPath, value: unknown): void => { - if (value === undefined) throw new TypeError(`IR values cannot contain undefined: ${irPathKey(path)}`); - if (value === IRAbsent) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); - if (typeof value !== 'object' || value === null) return; - for (const [key, child] of Object.entries(value)) assertPlainValue([...path, Array.isArray(value) ? Number(key) : key], child); -}; - -// Walks a value that is about to be written together with the schema node describing it. -// `IRAbsent` is accepted where the schema allows it unless `allowNone` is off. -const walkValue = (path: IRPath, node: IRSchemaNode, value: IRValue, visitor: IRValueVisitor, allowNone: boolean): void => { - if (value === undefined) throw new TypeError(`IR values cannot contain undefined: ${irPathKey(path)}`); - if (node.kind === 'nullable') { - if (value === null) return visitor(path, node, value); - node = node.of; - } - visitor(path, node, value); - switch (node.kind) { - case 'string': - if (value === IRAbsent) { - if (!allowNone) throw new TypeError('IRAbsent must be assigned directly to call_id'); - if (node.absent !== true) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); - } else if (typeof value !== 'string') throw new TypeError(`IR value must be a string: ${irPathKey(path)}`); - return; - case 'number': - if (typeof value !== 'number') throw new TypeError(`IR value must be a number: ${irPathKey(path)}`); - return; - case 'array': - if (!Array.isArray(value)) throw new TypeError(`IR value must be an array: ${irPathKey(path)}`); - value.forEach((child, index) => walkValue([...path, index], node.of, child, visitor, allowNone)); - return; - case 'object': - case 'union': { - if (!isPlainObject(value)) throw new TypeError(`IR value must be an object: ${irPathKey(path)}`); - const variant = unionVariantNode(node, value); - if (variant?.kind !== 'object') throw new TypeError(`IR value has an unknown variant: ${irPathKey(path)}`); - for (const [key, child] of Object.entries(value)) { - const field = node.kind === 'union' && key === node.discriminant ? { kind: 'string' } as const : Object.hasOwn(variant.fields, key) ? variant.fields[key] : undefined; - if (field === undefined) throw new TypeError(`IR value has no field ${key}: ${irPathKey(path)}`); - walkValue([...path, key], field, child, visitor, allowNone); - } - return; - } - case 'opaque': - assertPlainValue(path, value); - return; - default: - } -}; - -// Fails if a unit that has ended lacks any field required by its schema. -const assertComplete = (path: IRPath, node: IRSchemaNode, value: unknown): void => { - const actual = unionVariantNode(node, value); - if (actual === undefined || typeof value !== 'object' || value === null) return; - if (actual.kind === 'array') (value as unknown[]).forEach((child, index) => assertComplete([...path, index], actual.of, child)); - else if (actual.kind === 'object') { - const present = value as Record; - if (actual.requiresAnyOf !== undefined && !actual.requiresAnyOf.some(key => present[key] !== undefined)) { - throw new TypeError(`IR value requires one of ${actual.requiresAnyOf.join(', ')}: ${irPathKey(path)}`); - } - for (const [key, field] of Object.entries(actual.fields)) { - if (!Object.hasOwn(present, key)) { - if (!actual.optional.includes(key)) throw new TypeError(`IR value requires field ${key}: ${irPathKey(path)}`); - continue; - } - assertComplete([...path, key], field, present[key]); - } - } -}; - -const createCounter = () => { - const counts = new Map(); - return { - get: (key: K): number => counts.get(key) ?? 0, - increment: (key: K): void => { counts.set(key, (counts.get(key) ?? 0) + 1); }, - decrement: (key: K): void => { - const count = counts.get(key); - if (count === undefined) throw new TypeError(`IR lifecycle counter underflow: ${String(key)}`); - if (count === 1) counts.delete(key); - else counts.set(key, count - 1); - }, - }; -}; - -// Tracks which units (choices, items and parts) are open or closed. -const createLifecycle = () => { - const open = new Set(); - const closed = new Set(); - const openChildren = createCounter(); - - const assertWritable = (units: readonly IRUnit[], path: IRPath): void => { - for (const unit of units) if (closed.has(irPathKey(unit.path))) throw new TypeError(`IR write targets a closed ${unit.kind}: ${irPathKey(path)}`); - }; - - // `units` ends with the unit that was initialized. - const begin = (units: readonly IRUnit[]): void => { - open.add(irPathKey(units[units.length - 1].path)); - if (units.length > 1) openChildren.increment(irPathKey(units[units.length - 2].path)); - }; - - const end = (units: readonly IRUnit[]): void => { - const unit = units[units.length - 1]; - const key = irPathKey(unit.path); - if (!open.delete(key)) throw new TypeError(`IR ${unit.kind} is not open: ${key}`); - if (openChildren.get(key) !== 0) throw new TypeError(`IR ${unit.kind} ended with an open unit inside it: ${key}`); - closed.add(key); - if (units.length > 1) openChildren.decrement(irPathKey(units[units.length - 2].path)); - }; - - const assertFinishable = (): void => { - if (open.size !== 0) throw new TypeError('IR finish arrived with open units'); - }; - - return { assertWritable, begin, end, assertFinishable, isOpen: (path: IRPath): boolean => open.has(irPathKey(path)), isClosed: (path: IRPath): boolean => closed.has(irPathKey(path)) }; -}; - -export const createIRCollector = () => { - const state: IR = { choices: [] }; - // Containers created by this collector. Everything else is borrowed from a record and - // is copied before its first write. - const owned = new WeakSet([state, state.choices]); - const lifecycle = createLifecycle(); - - const writableContainer = (path: IRPath): Record => { - let target = state as unknown as Record; - for (const segment of path) { - const child = target[segment] as object; - if (!owned.has(child)) { - const copy = Array.isArray(child) ? [...child] : { ...child }; - owned.add(copy); - Object.defineProperty(target, segment, { value: copy, writable: true, enumerable: true, configurable: true }); - } - target = target[segment] as Record; - } - return target; - }; - - // Returns whether the write changed the state. - const apply = (write: IRWrite): boolean => { - if (write.value === undefined) throw new TypeError('IR writes cannot carry undefined'); - const { path } = write; - const resolution = resolveIRPath(path, state); - if (resolution !== undefined) lifecycle.assertWritable(resolution.units, path); - const key = path.at(-1); - if (key === undefined) throw new TypeError('IR writes require a property path'); - const parentPath = path.slice(0, -1); - const parent = valueAt(state, parentPath); - if (parent === undefined) throw new TypeError(`Missing IR path: ${irPathKey(parentPath)}`); - if (typeof parent !== 'object' || parent === null) throw new TypeError('IR write parent must be an object or array'); - const present = Object.hasOwn(parent, key); - if (write.kind === 'initialize' && present) throw new TypeError('IR initialize requires an absent target'); - if (Array.isArray(parent)) { - if (typeof key !== 'number' || !Number.isInteger(key) || key < 0 || key > parent.length) throw new TypeError('IR array write requires a contiguous nonnegative index'); - if (!present && key !== parent.length) throw new TypeError('IR array insertion must use the next index'); - if (write.kind === 'initialize' && key !== parent.length) throw new TypeError('IR array initialization must use the next index'); - } - const current = (parent as Record)[key]; - const node = resolution?.node; - - if (write.kind === 'append') { - if (node?.kind !== 'string' || node.append !== true) throw new TypeError(`IR append requires an append-target path: ${irPathKey(path)}`); - if (typeof current !== 'string') throw new TypeError('IR append requires a string target'); - if (typeof write.value !== 'string') throw new TypeError('IR append requires a string value'); - if (write.value === '') return false; - } else { - if (node === undefined) throw new TypeError(`IR path is not part of the IR schema: ${irPathKey(path)}`); - if (write.kind === 'initialize') { - walkValue(path, node, write.value, (childPath, childNode, child) => { - if (childNode.kind === 'string' && childNode.append === true && child !== '') throw new TypeError(`IR append target must initialize to an empty string: ${irPathKey(childPath)}`); - }, true); - } else if (write.value === IRAbsent) { - if (node.kind !== 'string' || node.absent !== true) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); - if (present) throw new TypeError('IRAbsent may only be assigned to an absent target'); - } else { - walkValue(path, node, write.value, (childPath, childNode) => { - if (childNode.kind === 'string' && childNode.append === true) throw new TypeError(`IR append target cannot be assigned: ${irPathKey(childPath)}`); - }, false); - if (present && Object.is(current, write.value)) return false; - } - } - - const target = writableContainer(parentPath); - if (write.kind === 'append') target[key] = (target[key] as string) + write.value; - else Object.defineProperty(target, key, { value: write.value, writable: true, enumerable: true, configurable: true }); - if (write.kind === 'initialize' && resolution !== undefined) { - const unit = resolution.units.at(-1); - if (unit !== undefined && unit.path.length === path.length) lifecycle.begin(resolution.units); - } - return true; - }; - - const applyEvent = (event: IREvent): void => { - const ended = irEndedUnit(event); - if (ended !== undefined) { - const resolution = resolveIRPath(ended.path, state); - if (resolution === undefined || valueAt(state, ended.path) === undefined) throw new TypeError(`IR ${ended.kind} does not exist: ${irPathKey(ended.path)}`); - lifecycle.end(resolution.units); - assertComplete(ended.path, resolution.node, valueAt(state, ended.path)); - } else if (event.type === 'finish') lifecycle.assertFinishable(); - }; - - const value = (path: IRPath): T | undefined => valueAt(state, path) as T | undefined; - // A tool call that closed without a call ID has none to wait for. - const optional = (path: IRPath): T | typeof IRAbsent | undefined => { - const actual = value(path); - if (actual !== undefined) return actual; - const resolution = resolveIRPath(path, state); - const owner = resolution?.units.at(-1); - return resolution?.node.kind === 'string' && resolution.node.absent === true && owner !== undefined && lifecycle.isClosed(owner.path) ? IRAbsent : undefined; - }; - return { state, apply, applyEvent, value, isOpen: lifecycle.isOpen, optional }; -}; - -export type IRCollector = ReturnType; diff --git a/packages/translate/src/ir/common/json.ts b/packages/translate/src/ir/common/json.ts new file mode 100644 index 0000000000..5a8c1cd4d7 --- /dev/null +++ b/packages/translate/src/ir/common/json.ts @@ -0,0 +1,6 @@ +// The JSON value shape, shared by complete JSON and output values that admit IRAbsent. +export type JSONValueOf = Extra | null | boolean | number | string | readonly JSONValueOf[] | { readonly [key: string]: JSONValueOf }; + +export type IRJSONValue = JSONValueOf; + +export type IRJSONObject = { readonly [key: string]: IRJSONValue }; diff --git a/packages/translate/src/ir/ordinal-mapper.ts b/packages/translate/src/ir/ordinal-mapper.ts deleted file mode 100644 index afd2cf8424..0000000000 --- a/packages/translate/src/ir/ordinal-mapper.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { irPathKey } from './operation-paths.ts'; -import type { IRPath } from './stream.ts'; - -// Numbers the native units (blocks, output items, tool calls) of a stream in the order -// they open, and forgets a unit when it closes. -export const createIROrdinalMapper = () => { - const ordinals = new Map(); - let next = 0; - const open = (path: IRPath): number => { - const key = irPathKey(path); - if (ordinals.has(key)) throw new TypeError(`IR unit is already open: ${key}`); - const ordinal = next++; - ordinals.set(key, ordinal); - return ordinal; - }; - const get = (path: IRPath): number => { - const key = irPathKey(path); - const ordinal = ordinals.get(key); - if (ordinal === undefined) throw new TypeError(`Unknown open IR unit: ${key}`); - return ordinal; - }; - const close = (path: IRPath): void => { - get(path); - ordinals.delete(irPathKey(path)); - }; - return { open, get, close }; -}; diff --git a/packages/translate/src/ir/output/absent.ts b/packages/translate/src/ir/output/absent.ts new file mode 100644 index 0000000000..0db8f19542 --- /dev/null +++ b/packages/translate/src/ir/output/absent.ts @@ -0,0 +1,3 @@ +export const IRAbsent: unique symbol = Symbol('IRAbsent'); + +export type IRAbsent = typeof IRAbsent; diff --git a/packages/translate/src/ir/stream.ts b/packages/translate/src/ir/output/builder.ts similarity index 66% rename from packages/translate/src/ir/stream.ts rename to packages/translate/src/ir/output/builder.ts index fec713ba24..f0c8653261 100644 --- a/packages/translate/src/ir/stream.ts +++ b/packages/translate/src/ir/output/builder.ts @@ -1,21 +1,7 @@ -import { createIRCollector, type IRCollector } from './collector.ts'; +import { createIRCollector } from './collector.ts'; import { irItemPath, irPartPath, irPathKey, irUnitPath } from './operation-paths.ts'; -import type { IRErrorPayload, IRUsage, IRValue } from './types.ts'; - -export type IRPath = readonly (string | number)[]; -export type IRWrite = Readonly< - | { type: 'write'; kind: 'initialize'; path: IRPath; value: IRValue } - | { type: 'write'; kind: 'assign'; path: IRPath; value: IRValue } - | { type: 'write'; kind: 'append'; path: IRPath; value: string; referencePieceIds?: readonly number[] }>; -export type IREvent = Readonly< - | { type: 'start'; id?: string; created?: number } - | { type: 'item_end'; choice: number; item: number; status?: 'completed' | 'incomplete' } - | { type: 'part_end'; choice: number; item: number; part: number } - | { type: 'choice_end'; choice: number; finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter' } - | { type: 'finish'; status: 'completed' | 'incomplete' } - | { type: 'error'; error: IRErrorPayload } - | { type: 'ping' }>; -export type IRRecord = IRWrite | IREvent; +import type { IREvent, IRPath, IRRecord, IRWrite } from './records.ts'; +import type { IROutputUsage, IROutputValue } from './types.ts'; export type IRRelativePath = string | number | IRPath; @@ -26,7 +12,7 @@ export interface IRWriteHandle { assign: (path: IRRelativePath, value: unknown) => void; initialize: (path: IRRelativePath, value: unknown) => void; ensure: (path: IRRelativePath, initialValue: unknown) => void; - resolve: (path: IRRelativePath) => IRPath; + absolutePath: (path: IRRelativePath) => IRPath; // Snapshot reconciliation needs the value accumulated from earlier upstream events. current: (path: IRRelativePath) => T | undefined; } @@ -45,8 +31,6 @@ export interface IRItemHandle extends IRWriteHandle { export interface IRChoiceHandle extends IRWriteHandle { readonly isOpen: boolean; end: (finishReason: 'stop' | 'length' | 'tool_calls' | 'content_filter') => void; - // ChatCompletions annotations use coordinates across all text emitted for a choice. - textParts: () => readonly { item: IRItemHandle; part: IRPartHandle; text: string }[]; } export const createIRBuilder = (maxChoices = 1) => { @@ -56,8 +40,6 @@ export const createIRBuilder = (maxChoices = 1) => { let records: IRRecord[] = []; const initialized = new Set(); const choices = new Map(); - const items = new Map(); - const parts = new Map(); const itemCounts = new Map(); const partCounts = new Map(); @@ -66,13 +48,13 @@ export const createIRBuilder = (maxChoices = 1) => { if (typeof value !== 'object' || value === null) return; for (const [key, child] of Object.entries(value)) remember([...path, Array.isArray(value) ? Number(key) : key], child); }; - const write = (value: IRWrite): void => { if (collector.apply(value)) records.push(value); }; + const write = (value: IRWrite): void => { if (collector.applyWrite(value)) records.push(value); }; const assign = (path: IRPath, value: unknown): void => { - write({ type: 'write', kind: 'assign', path, value: value as IRValue }); + write({ type: 'write', kind: 'assign', path, value: value as IROutputValue }); remember(path, value); }; const initialize = (path: IRPath, value: unknown): void => { - write({ type: 'write', kind: 'initialize', path, value: value as IRValue }); + write({ type: 'write', kind: 'initialize', path, value: value as IROutputValue }); remember(path, value); }; const append = (path: IRPath, value: string, referencePieceIds?: readonly number[]): void => write({ @@ -96,11 +78,11 @@ export const createIRBuilder = (maxChoices = 1) => { if (initialized.has(irPathKey(target))) return; initialize(target, initialValue); }, - resolve: subpath => relativePath(path, subpath), + absolutePath: subpath => relativePath(path, subpath), current: subpath => collector.value(relativePath(path, subpath)), }); // Usage arrives as cumulative snapshots, so each reported field replaces the previous one. - const usage = (fields: IRUsage): void => { + const usage = (fields: IROutputUsage): void => { if (state.usage === undefined) initialize(['usage'], {}); for (const [key, value] of Object.entries(fields)) assign(['usage', key], value); }; @@ -119,12 +101,6 @@ export const createIRBuilder = (maxChoices = 1) => { event({ type: 'choice_end', choice: choiceIndex, finish_reason: finishReason }); open = false; }, - textParts: () => [...parts.values()].flatMap(part => { - const itemHandle = items.get(irPathKey(part.path.slice(0, -2))); - if (itemHandle?.path[1] !== choiceIndex) return []; - const text = part.current('text'); - return text === undefined ? [] : [{ item: itemHandle, part, text }]; - }), }; choices.set(choiceIndex, handle); itemCounts.set(choiceIndex, 0); @@ -159,7 +135,6 @@ export const createIRBuilder = (maxChoices = 1) => { }, }; openParts.push(partHandle); - parts.set(irPathKey(partPath), partHandle); return partHandle; }, end: status => { @@ -169,36 +144,7 @@ export const createIRBuilder = (maxChoices = 1) => { open = false; }, }; - items.set(irPathKey(path), handle); return handle; }; return { collector, state, initialize, assign, append, event, drain, usage, choice, item }; }; - -// Applies each record to a fresh collector and yields it together with the collector, so a -// writer sees the state as of that record. A well-formed stream is exactly one `start`, then -// records, then `finish` or `error`. -export const consumeIRRecords = async function* (records: AsyncIterable): AsyncGenerator<{ record: IRRecord; collector: IRCollector }> { - const collector = createIRCollector(); - let started = false; - let finished = false; - for await (const record of records) { - if (finished) throw new Error('IR record arrived after finish'); - if (record.type === 'write') { - collector.apply(record); - } else { - if (record.type === 'start') { - if (started) throw new Error('Duplicate IR start'); - started = true; - } else if (record.type === 'finish') { - if (!started) throw new Error('IR finish arrived before start'); - finished = true; - } else if (record.type === 'error') { - finished = true; - } - collector.applyEvent(record); - } - yield { record, collector }; - } - if (!finished) throw new Error('IR stream ended without finish'); -}; diff --git a/packages/translate/src/ir/output/collector.ts b/packages/translate/src/ir/output/collector.ts new file mode 100644 index 0000000000..94944ee6c5 --- /dev/null +++ b/packages/translate/src/ir/output/collector.ts @@ -0,0 +1,105 @@ +import { IRAbsent } from './absent.ts'; +import { createLifecycle } from './lifecycle.ts'; +import { irEndedUnit, irPathKey, resolveIRPath, valueAt } from './operation-paths.ts'; +import type { IREvent, IRWrite, IRPath } from './records.ts'; +import type { IROutput } from './types.ts'; +import { assertComplete, walkValue } from './value-validation.ts'; + +export const createIRCollector = () => { + const state: IROutput = { choices: [] }; + // Containers created by this collector. Everything else is borrowed from a record and + // is copied before its first write. + const owned = new WeakSet([state, state.choices]); + const lifecycle = createLifecycle(); + + const writableContainer = (path: IRPath): Record => { + let target: Record = state; + for (const segment of path) { + const child = target[segment] as object; + if (!owned.has(child)) { + const copy = Array.isArray(child) ? [...child] : { ...child }; + owned.add(copy); + Object.defineProperty(target, segment, { value: copy, writable: true, enumerable: true, configurable: true }); + } + target = target[segment] as Record; + } + return target; + }; + + // Returns whether the write changed the state. + const applyWrite = (write: IRWrite): boolean => { + if (write.value === undefined) throw new TypeError('IR writes cannot carry undefined'); + const { path } = write; + const resolution = resolveIRPath(path, state); + if (resolution !== undefined) lifecycle.assertWritable(resolution.units, path); + const key = path.at(-1); + if (key === undefined) throw new TypeError('IR writes require a property path'); + const parentPath = path.slice(0, -1); + const parent = valueAt(state, parentPath); + if (parent === undefined) throw new TypeError(`Missing IR path: ${irPathKey(parentPath)}`); + if (typeof parent !== 'object' || parent === null) throw new TypeError('IR write parent must be an object or array'); + const present = Object.hasOwn(parent, key); + if (write.kind === 'initialize' && present) throw new TypeError('IR initialize requires an absent target'); + if (Array.isArray(parent)) { + if (typeof key !== 'number' || !Number.isInteger(key) || key < 0 || key > parent.length) throw new TypeError('IR array write requires a contiguous nonnegative index'); + if (!present && key !== parent.length) throw new TypeError('IR array insertion must use the next index'); + if (write.kind === 'initialize' && key !== parent.length) throw new TypeError('IR array initialization must use the next index'); + } + const current = (parent as Record)[key]; + const node = resolution?.node; + + if (write.kind === 'append') { + if (node?.kind !== 'string' || node.append !== true) throw new TypeError(`IR append requires an append-target path: ${irPathKey(path)}`); + if (typeof current !== 'string') throw new TypeError('IR append requires a string target'); + if (typeof write.value !== 'string') throw new TypeError('IR append requires a string value'); + if (write.value === '') return false; + } else { + if (node === undefined) throw new TypeError(`IR path is not part of the IR schema: ${irPathKey(path)}`); + if (write.kind === 'initialize') { + walkValue(path, node, write.value, (childPath, childNode, child) => { + if (childNode.kind === 'string' && childNode.append === true && child !== '') throw new TypeError(`IR append target must initialize to an empty string: ${irPathKey(childPath)}`); + }, { allowAbsent: true }); + } else if (write.value === IRAbsent) { + if (node.kind !== 'string' || node.absent !== true) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); + if (present) throw new TypeError('IRAbsent may only be assigned to an absent target'); + } else { + walkValue(path, node, write.value, (childPath, childNode) => { + if (childNode.kind === 'string' && childNode.append === true) throw new TypeError(`IR append target cannot be assigned: ${irPathKey(childPath)}`); + }, { allowAbsent: false }); + if (present && Object.is(current, write.value)) return false; + } + } + + const target = writableContainer(parentPath); + if (write.kind === 'append') target[key] = (target[key] as string) + write.value; + else Object.defineProperty(target, key, { value: write.value, writable: true, enumerable: true, configurable: true }); + if (write.kind === 'initialize' && resolution !== undefined) { + const unit = resolution.units.at(-1); + if (unit !== undefined && unit.path.length === path.length) lifecycle.openUnit(resolution.units); + } + return true; + }; + + const applyEvent = (event: IREvent): void => { + const ended = irEndedUnit(event); + if (ended !== undefined) { + const resolution = resolveIRPath(ended.path, state); + if (resolution === undefined || valueAt(state, ended.path) === undefined) throw new TypeError(`IR ${ended.kind} does not exist: ${irPathKey(ended.path)}`); + lifecycle.closeUnit(resolution.units); + assertComplete(ended.path, resolution.node, valueAt(state, ended.path)); + } else if (event.type === 'finish') lifecycle.assertFinishable(); + }; + + const value = (path: IRPath): T | undefined => valueAt(state, path) as T | undefined; + // A tool call that closed without a call ID has none to wait for. + const valueOrAbsent = (path: IRPath): T | typeof IRAbsent | undefined => { + const actual = value(path); + if (actual !== undefined) return actual; + const resolution = resolveIRPath(path, state); + const owner = resolution?.units.at(-1); + return resolution?.node.kind === 'string' && resolution.node.absent === true && owner !== undefined && lifecycle.isClosed(owner.path) ? IRAbsent : undefined; + }; + return { state, applyWrite, applyEvent, value, isOpen: lifecycle.isOpen, valueOrAbsent }; +}; + +export type IRCollector = ReturnType; diff --git a/packages/translate/src/ir/output/error.ts b/packages/translate/src/ir/output/error.ts new file mode 100644 index 0000000000..edd52c87e6 --- /dev/null +++ b/packages/translate/src/ir/output/error.ts @@ -0,0 +1,8 @@ +// The error fields upstream protocols agree on; each writer picks the ones its protocol defines. +export interface IRErrorPayload { + readonly message: string; + readonly type?: string; + readonly code?: string | null; + readonly param?: string | null; + readonly provider_specific_fields?: Readonly>; +} diff --git a/packages/translate/src/ir/output/lifecycle.ts b/packages/translate/src/ir/output/lifecycle.ts new file mode 100644 index 0000000000..25754c73b4 --- /dev/null +++ b/packages/translate/src/ir/output/lifecycle.ts @@ -0,0 +1,48 @@ +import { irPathKey, type IRUnit } from './operation-paths.ts'; +import type { IRPath } from './records.ts'; + +const createCounter = () => { + const counts = new Map(); + return { + get: (key: K): number => counts.get(key) ?? 0, + increment: (key: K): void => { counts.set(key, (counts.get(key) ?? 0) + 1); }, + decrement: (key: K): void => { + const count = counts.get(key); + if (count === undefined) throw new TypeError(`IR lifecycle counter underflow: ${String(key)}`); + if (count === 1) counts.delete(key); + else counts.set(key, count - 1); + }, + }; +}; + +// Tracks which units (choices, items and parts) are open or closed. +export const createLifecycle = () => { + const open = new Set(); + const closed = new Set(); + const openChildren = createCounter(); + + const assertWritable = (units: readonly IRUnit[], path: IRPath): void => { + for (const unit of units) if (closed.has(irPathKey(unit.path))) throw new TypeError(`IR write targets a closed ${unit.kind}: ${irPathKey(path)}`); + }; + + // `units` ends with the unit that was initialized. + const openUnit = (units: readonly IRUnit[]): void => { + open.add(irPathKey(units[units.length - 1].path)); + if (units.length > 1) openChildren.increment(irPathKey(units[units.length - 2].path)); + }; + + const closeUnit = (units: readonly IRUnit[]): void => { + const unit = units[units.length - 1]; + const key = irPathKey(unit.path); + if (!open.delete(key)) throw new TypeError(`IR ${unit.kind} is not open: ${key}`); + if (openChildren.get(key) !== 0) throw new TypeError(`IR ${unit.kind} ended with an open unit inside it: ${key}`); + closed.add(key); + if (units.length > 1) openChildren.decrement(irPathKey(units[units.length - 2].path)); + }; + + const assertFinishable = (): void => { + if (open.size !== 0) throw new TypeError('IR finish arrived with open units'); + }; + + return { assertWritable, openUnit, closeUnit, assertFinishable, isOpen: (path: IRPath): boolean => open.has(irPathKey(path)), isClosed: (path: IRPath): boolean => closed.has(irPathKey(path)) }; +}; diff --git a/packages/translate/src/ir/operation-paths.ts b/packages/translate/src/ir/output/operation-paths.ts similarity index 80% rename from packages/translate/src/ir/operation-paths.ts rename to packages/translate/src/ir/output/operation-paths.ts index 23a652b93f..07be9869b2 100644 --- a/packages/translate/src/ir/operation-paths.ts +++ b/packages/translate/src/ir/output/operation-paths.ts @@ -1,5 +1,5 @@ +import type { IREvent, IRPath, IRRecord } from './records.ts'; import { irSchema, type IRObjectNode, type IRSchemaNode, type IRUnionNode, type IRUnitKind } from './schema.ts'; -import type { IREvent, IRPath, IRRecord } from './stream.ts'; // Everything here is derived from the schema in schema.ts. A path is resolved against the // schema, and the answers come from the traits of the node it lands on. Where the node @@ -89,16 +89,6 @@ export const isIRItemPath = (path: IRPath, root: unknown): boolean => unitAt(pat export const isIRPartPath = (path: IRPath, root: unknown): boolean => unitAt(path, root) === 'part'; -export const isIRAppendTarget = (path: IRPath, root: unknown): boolean => { - const node = resolveIRPath(path, root)?.node; - return node?.kind === 'string' && node.append === true; -}; - -export const isIRAbsentTarget = (path: IRPath, root: unknown): boolean => { - const node = resolveIRPath(path, root)?.node; - return node?.kind === 'string' && node.absent === true; -}; - const WILDCARD = Symbol('index'); type TemplateSegment = string | typeof WILDCARD; @@ -125,28 +115,6 @@ const findUnitTemplate = (node: IRSchemaNode, kind: IRUnitKind, prefix: readonly const unitTemplates = new Map(); -// The units enclosing a unit, outermost first, followed by the unit itself. -const unitKindsAlong = (kind: IRUnitKind): readonly IRUnitKind[] => { - const kinds: IRUnitKind[] = []; - const visit = (node: IRSchemaNode): boolean => { - const own = unitKindOf(node); - if (own !== undefined) kinds.push(own); - if (own === kind) return true; - let found = false; - switch (node.kind) { - case 'nullable': found = visit(node.of); break; - case 'array': found = visit(node.of); break; - case 'object': found = Object.values(node.fields).some(visit); break; - case 'union': found = Object.values(node.cases).some(visit); break; - default: - } - if (!found && own !== undefined) kinds.pop(); - return found; - }; - visit(irSchema); - return kinds; -}; - // The path of a unit from the indices of it and its enclosing units, outermost first. export const irUnitPath = (kind: IRUnitKind, ...indices: readonly number[]): IRPath => { let template = unitTemplates.get(kind); @@ -167,11 +135,10 @@ export const irPartPath = (choice: number, item: number, part: number): IRPath = // An `_end` event names the unit it ends by the indices of that unit and the units around it. export const irEndedUnit = (event: IREvent): IRUnit | undefined => { - if (!event.type.endsWith('_end')) return undefined; - const kind = event.type.slice(0, -'_end'.length) as IRUnitKind; - const kinds = unitKindsAlong(kind); - if (kinds.at(-1) !== kind) return undefined; - return { kind, path: irUnitPath(kind, ...kinds.map(unit => (event as unknown as Record)[unit])) }; + if (event.type === 'choice_end') return { kind: 'choice', path: irUnitPath('choice', event.choice) }; + if (event.type === 'item_end') return { kind: 'item', path: irUnitPath('item', event.choice, event.item) }; + if (event.type === 'part_end') return { kind: 'part', path: irUnitPath('part', event.choice, event.item, event.part) }; + return undefined; }; // The units a record belongs to, innermost first. diff --git a/packages/translate/src/ir/output/records.ts b/packages/translate/src/ir/output/records.ts new file mode 100644 index 0000000000..67a7617e08 --- /dev/null +++ b/packages/translate/src/ir/output/records.ts @@ -0,0 +1,17 @@ +import type { IRErrorPayload } from './error.ts'; +import type { IROutputValue } from './types.ts'; + +export type IRPath = readonly (string | number)[]; +export type IRWrite = Readonly< + | { type: 'write'; kind: 'initialize'; path: IRPath; value: IROutputValue } + | { type: 'write'; kind: 'assign'; path: IRPath; value: IROutputValue } + | { type: 'write'; kind: 'append'; path: IRPath; value: string; referencePieceIds?: readonly number[] }>; +export type IREvent = Readonly< + | { type: 'start'; id?: string; created?: number } + | { type: 'item_end'; choice: number; item: number; status?: 'completed' | 'incomplete' } + | { type: 'part_end'; choice: number; item: number; part: number } + | { type: 'choice_end'; choice: number; finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter' } + | { type: 'finish'; status: 'completed' | 'incomplete' } + | { type: 'error'; error: IRErrorPayload } + | { type: 'ping' }>; +export type IRRecord = IRWrite | IREvent; diff --git a/packages/translate/src/ir/schema.ts b/packages/translate/src/ir/output/schema.ts similarity index 89% rename from packages/translate/src/ir/schema.ts rename to packages/translate/src/ir/output/schema.ts index dfafbd0bbb..1ab93b5261 100644 --- a/packages/translate/src/ir/schema.ts +++ b/packages/translate/src/ir/output/schema.ts @@ -1,4 +1,5 @@ -import type { IR, IRAbsent, IRSourceCitation, IRTokenLogprob } from './types.ts'; +import type { IRAbsent } from './absent.ts'; +import type { IROutput, IROutputSourceCitation, IROutputTokenLogprob } from './types.ts'; // The structure of the IR, declared once. Every question about a path (is it an append // target, may it hold `IRAbsent`, which lifecycle unit does it belong to) is answered by @@ -54,11 +55,11 @@ const union = ({ kind: 'union', discriminant, cases, ...unit === undefined ? {} : { unit } }); const contentPart = union('type', { - text: object({ text: appendString, annotations: nullable(array(opaque())) }, { optional: ['annotations'] }), + text: object({ text: appendString, annotations: nullable(array(opaque())) }, { optional: ['annotations'] }), refusal: object({ refusal: appendString }), - image: object({ image: object({ data: string, mime_type: string }, { optional: ['mime_type'] }) }), + image: object({ image: object({ data: string, media_type: string }, { optional: ['media_type'] }) }), audio: object({ - audio: object({ data: appendString, transcript: appendString, mime_type: string }, { optional: ['data', 'transcript', 'mime_type'], requiresAnyOf: ['data', 'transcript'] }), + audio: object({ data: appendString, transcript: appendString, media_type: string }, { optional: ['data', 'transcript', 'media_type'], requiresAnyOf: ['data', 'transcript'] }), }), }, 'part'); @@ -73,16 +74,16 @@ const item = union('type', { }, 'item'); const logprobs = nullable(array(union('scope', { - choice: object({ tokens: array(opaque()) }), - text_part: object({ item_index: number, content_index: number, tokens: array(opaque()) }), + choice: object({ tokens: array(opaque()) }), + text_part: object({ item_index: number, content_index: number, tokens: array(opaque()) }), }))); export const irSchema = object({ choices: array(object({ items: array(item), - refusal: object({ category: nullable(string), explanation: nullable(string) }), + refusal_details: object({ category: nullable(string), explanation: nullable(string) }), logprobs, - }, { optional: ['refusal', 'logprobs'], unit: 'choice' })), + }, { optional: ['refusal_details', 'logprobs'], unit: 'choice' })), model: string, service_tier: string, usage: allOptional({ @@ -120,4 +121,4 @@ type Mutual = [A] extends [B] ? ([B] extends [A] ? true : false) : false; // Compile-time proof that the schema and the IR types describe the same values: a field the // schema declares optional, nullable, appendable or `IRAbsent`-capable differs from the IR type // exactly when this stops being `true`. -export const irSchemaMatchesIRTypes: Mutual, IR> = true; +export const irSchemaMatchesIRTypes: Mutual, IROutput> = true; diff --git a/packages/translate/src/ir/output/stream.ts b/packages/translate/src/ir/output/stream.ts new file mode 100644 index 0000000000..e5dce93ac3 --- /dev/null +++ b/packages/translate/src/ir/output/stream.ts @@ -0,0 +1,30 @@ +import { createIRCollector, type IRCollector } from './collector.ts'; +import type { IRRecord } from './records.ts'; + +// Applies each record to a fresh collector and yields it together with the collector, so a +// writer sees the state as of that record. A well-formed stream is exactly one `start`, then +// records, then `finish` or `error`. +export const consumeIRRecords = async function* (records: AsyncIterable): AsyncGenerator<{ record: IRRecord; collector: IRCollector }> { + const collector = createIRCollector(); + let started = false; + let finished = false; + for await (const record of records) { + if (finished) throw new Error('IR record arrived after finish'); + if (record.type === 'write') { + collector.applyWrite(record); + } else { + if (record.type === 'start') { + if (started) throw new Error('Duplicate IR start'); + started = true; + } else if (record.type === 'finish') { + if (!started) throw new Error('IR finish arrived before start'); + finished = true; + } else if (record.type === 'error') { + finished = true; + } + collector.applyEvent(record); + } + yield { record, collector }; + } + if (!finished) throw new Error('IR stream ended without finish'); +}; diff --git a/packages/translate/src/ir/types.ts b/packages/translate/src/ir/output/types.ts similarity index 54% rename from packages/translate/src/ir/types.ts rename to packages/translate/src/ir/output/types.ts index 9e55e6e097..e1991f9c86 100644 --- a/packages/translate/src/ir/types.ts +++ b/packages/translate/src/ir/output/types.ts @@ -1,132 +1,112 @@ -// The JSON value shape, declared once; IRValue adds the IRAbsent marker wherever a leaf may hold it. -type IRValueOf = Extra | null | boolean | number | string | readonly IRValueOf[] | { readonly [key: string]: IRValueOf }; +import type { IRAbsent } from './absent.ts'; +import type { TextRange } from '../../shared/text-range.ts'; +import type { JSONValueOf } from '../common/json.ts'; // All -export type IRJSONValue = IRValueOf; +export type IROutputValue = JSONValueOf; // All -export type IRJSONObject = { readonly [key: string]: IRJSONValue }; - -// The error fields upstream protocols agree on; each writer picks the ones its protocol defines. -export interface IRErrorPayload { - readonly message: string; - readonly type?: string; - readonly code?: string | null; - readonly param?: string | null; - readonly provider_specific_fields?: Readonly>; -} - -// All -export const IRAbsent: unique symbol = Symbol('IRAbsent'); - -// All -export type IRAbsent = typeof IRAbsent; - -// All -export type IRValue = IRValueOf; - -// All -export interface IR { - readonly choices: readonly IRChoice[]; // All +export type IROutput = { + readonly choices: readonly IROutputChoice[]; // All readonly model?: string; // All readonly service_tier?: string; // All - readonly usage?: IRUsage; // All -} + readonly usage?: IROutputUsage; // All +}; // All -interface IRChoice { - readonly items: readonly IRItem[]; // All - readonly refusal?: IRClassifierRefusal; // All - readonly logprobs?: readonly IRLogprobGroup[] | null; // ChatCompletions, Responses, GenerateContent -} +type IROutputChoice = { + readonly items: readonly IROutputItem[]; // All + readonly refusal_details?: IROutputRefusalDetails; // All + readonly logprobs?: readonly IROutputLogprobGroup[] | null; // ChatCompletions, Responses, GenerateContent +}; // All -interface IRClassifierRefusal { +type IROutputRefusalDetails = { readonly category: string | null; // Messages, Responses readonly explanation: string | null; // All -} +}; // All -export type IRItem = IRMessageItem | IRReasoningItem | IRFunctionCallItem | IRCustomToolCallItem; +export type IROutputItem = IROutputMessageItem | IROutputReasoningItem | IROutputFunctionCallItem | IROutputCustomToolCallItem; // All -export interface IRMessageItem { +export type IROutputMessageItem = { readonly type: 'message'; // All - readonly content: readonly IRContentPart[]; // All -} + readonly content: readonly IROutputContentPart[]; // All +}; // All -type IRContentPart = IRTextPart | IRRefusalPart | IRImagePart | IRAudioPart; +type IROutputContentPart = IROutputTextPart | IROutputRefusalPart | IROutputImagePart | IROutputAudioPart; // All -interface IRTextPart { +type IROutputTextPart = { readonly type: 'text'; // All readonly text: string; // All - readonly annotations?: readonly IRSourceCitation[] | null; // All -} + readonly annotations?: readonly IROutputSourceCitation[] | null; // All +}; // All -interface IRRefusalPart { +type IROutputRefusalPart = { readonly type: 'refusal'; // All readonly refusal: string; // All -} +}; // All -export interface IRReasoningItem { +export type IROutputReasoningItem = { readonly type: 'reasoning'; // All readonly summary?: readonly string[]; // All readonly content?: readonly string[]; // All -} +}; // All -interface IRFunctionCallItem { +type IROutputFunctionCallItem = { readonly type: 'function_call'; // All readonly call_id?: string | IRAbsent; // All readonly name: string; // All readonly arguments: string; // All -} +}; // ChatCompletions, Responses, Messages -interface IRCustomToolCallItem { +type IROutputCustomToolCallItem = { readonly type: 'custom_tool_call'; // ChatCompletions, Responses, Messages readonly call_id?: string | IRAbsent; // ChatCompletions, Responses, Messages readonly name: string; // ChatCompletions, Responses, Messages readonly input: string; // ChatCompletions, Responses, Messages -} +}; // Responses, GenerateContent -interface IRImagePart { +type IROutputImagePart = { readonly type: 'image'; // Responses, GenerateContent - readonly image: IRImage; // Responses, GenerateContent -} + readonly image: IROutputImage; // Responses, GenerateContent +}; // Responses, GenerateContent -interface IRImage { +type IROutputImage = { readonly data: string; // Responses, GenerateContent - readonly mime_type?: string; // Responses, GenerateContent -} + readonly media_type?: string; // Responses, GenerateContent +}; // ChatCompletions, Responses, GenerateContent -export interface IRAudioPart { +export type IROutputAudioPart = { readonly type: 'audio'; // ChatCompletions, Responses, GenerateContent - readonly audio: IRAudio; // ChatCompletions, Responses, GenerateContent -} + readonly audio: IROutputAudio; // ChatCompletions, Responses, GenerateContent +}; // ChatCompletions, Responses, GenerateContent -interface IRAudio { +type IROutputAudio = { readonly data?: string; // ChatCompletions, Responses, GenerateContent readonly transcript?: string; // ChatCompletions, Responses, GenerateContent // No current upstream reader produces it; reserved for a GenerateContent upstream. - readonly mime_type?: string; // GenerateContent -} + readonly media_type?: string; // GenerateContent +}; // All -export type IRSourceCitation = { +export type IROutputSourceCitation = { readonly type: 'source_citation'; // All readonly source_label?: string | null; // All readonly source_text?: string; // Messages, GenerateContent - readonly source_page_range?: IRSourcePageRange; // Messages, GenerateContent - readonly output_text_range?: IRUTF16TextRange; // ChatCompletions, Responses, GenerateContent + readonly source_page_range?: IROutputSourcePageRange; // Messages, GenerateContent + readonly output_text_range?: TextRange; // ChatCompletions, Responses, GenerateContent } & ( | { readonly source_kind: 'url' | 'document' | 'search_result'; // All @@ -140,52 +120,43 @@ export type IRSourceCitation = { ); // Messages, GenerateContent -interface IRSourcePageRange { +type IROutputSourcePageRange = { readonly start_one_based: number; // Messages, GenerateContent readonly end_one_based_exclusive: number; // Messages -} - -// ChatCompletions, Responses, GenerateContent -export interface IRUTF16TextRange { - readonly start: number; // ChatCompletions, Responses, GenerateContent - readonly end_exclusive: number; // ChatCompletions, Responses, GenerateContent -} +}; // ChatCompletions, Responses, GenerateContent -type IRLogprobGroup = IRChoiceLogprobs | IRTextPartLogprobs; +type IROutputLogprobGroup = IROutputChoiceLogprobs | IROutputTextPartLogprobs; // Reserved for a GenerateContent upstream: the current upstream readers only produce text-part logprobs. // ChatCompletions, GenerateContent -interface IRChoiceLogprobs { +type IROutputChoiceLogprobs = { readonly scope: 'choice'; // ChatCompletions, GenerateContent - readonly tokens: readonly IRTokenLogprob[]; // ChatCompletions, GenerateContent -} + readonly tokens: readonly IROutputTokenLogprob[]; // ChatCompletions, GenerateContent +}; // ChatCompletions, Responses -interface IRTextPartLogprobs { +type IROutputTextPartLogprobs = { readonly scope: 'text_part'; // ChatCompletions, Responses readonly item_index: number; // ChatCompletions, Responses readonly content_index: number; // ChatCompletions, Responses - readonly tokens: readonly IRTokenLogprob[]; // ChatCompletions, Responses -} + readonly tokens: readonly IROutputTokenLogprob[]; // ChatCompletions, Responses +}; // ChatCompletions, Responses, GenerateContent -export interface IRTokenLogprob { - readonly token: string; // ChatCompletions, Responses, GenerateContent - readonly logprob: number; // ChatCompletions, Responses, GenerateContent - readonly bytes?: readonly number[] | null; // ChatCompletions, Responses - readonly top_logprobs?: readonly IRTokenLogprobAlternative[]; // ChatCompletions, Responses, GenerateContent -} +export type IROutputTokenLogprob = IROutputTokenLogprobAlternative & { + readonly top_logprobs?: readonly IROutputTokenLogprobAlternative[]; // ChatCompletions, Responses, GenerateContent +}; // ChatCompletions, Responses, GenerateContent -interface IRTokenLogprobAlternative { +type IROutputTokenLogprobAlternative = { readonly token: string; // ChatCompletions, Responses, GenerateContent readonly logprob: number; // ChatCompletions, Responses, GenerateContent readonly bytes?: readonly number[] | null; // ChatCompletions, Responses -} +}; // All -export interface IRUsage { +export type IROutputUsage = { readonly input_tokens_inclusive?: number; // All readonly output_tokens_inclusive?: number; // All readonly total_tokens?: number; // ChatCompletions, Responses, GenerateContent @@ -197,4 +168,4 @@ export interface IRUsage { readonly input_text_tokens?: number; // ChatCompletions, Responses, GenerateContent readonly output_audio_tokens?: number; // ChatCompletions, Responses, GenerateContent readonly output_text_tokens?: number; // ChatCompletions, Responses, GenerateContent -} +}; diff --git a/packages/translate/src/ir/output/unit-index.ts b/packages/translate/src/ir/output/unit-index.ts new file mode 100644 index 0000000000..6590fdd066 --- /dev/null +++ b/packages/translate/src/ir/output/unit-index.ts @@ -0,0 +1,27 @@ +import { irPathKey } from './operation-paths.ts'; +import type { IRPath } from './records.ts'; + +// Numbers the native units (blocks, output items, tool calls) of a stream in the order +// they open, and forgets a unit when it closes. +export const createIRUnitIndexAllocator = () => { + const indices = new Map(); + let next = 0; + const open = (path: IRPath): number => { + const key = irPathKey(path); + if (indices.has(key)) throw new TypeError(`IR unit is already open: ${key}`); + const index = next++; + indices.set(key, index); + return index; + }; + const get = (path: IRPath): number => { + const key = irPathKey(path); + const index = indices.get(key); + if (index === undefined) throw new TypeError(`Unknown open IR unit: ${key}`); + return index; + }; + const close = (path: IRPath): void => { + get(path); + indices.delete(irPathKey(path)); + }; + return { open, get, close }; +}; diff --git a/packages/translate/src/ir/output/unit-queue.ts b/packages/translate/src/ir/output/unit-queue.ts new file mode 100644 index 0000000000..ae997416de --- /dev/null +++ b/packages/translate/src/ir/output/unit-queue.ts @@ -0,0 +1,27 @@ +// The queue's own link, so the units it carries stay plain values. +interface QueuedNode { + readonly value: T; + next?: QueuedNode; +} + +// Units wait here until the writer can open them, and are opened in output IR order. +export const createIRUnitQueue = () => { + let head: QueuedNode | undefined; + let tail: QueuedNode | undefined; + return { + get head(): T | undefined { return head?.value; }, + enqueue: (unit: T): void => { + const node: QueuedNode = { value: unit }; + if (tail === undefined) head = node; + else tail.next = node; + tail = node; + }, + // Removes the head unit, which the caller has just opened or discarded. + dropHead: (): void => { + head = head!.next; + if (head === undefined) tail = undefined; + }, + }; +}; + +export type IRUnitQueue = ReturnType>; diff --git a/packages/translate/src/ir/output/unit-registry.ts b/packages/translate/src/ir/output/unit-registry.ts new file mode 100644 index 0000000000..228e553441 --- /dev/null +++ b/packages/translate/src/ir/output/unit-registry.ts @@ -0,0 +1,21 @@ +import { irPathKey, irRecordOwnerPaths } from './operation-paths.ts'; +import type { IRPath, IRRecord } from './records.ts'; + +// Finds the unit that a record belongs to. A writer registers each unit at the path +// where its IR structure was initialized. +export const createIRUnitRegistry = () => { + const units = new Map(); + return { + set: (path: IRPath, unit: T): void => { units.set(irPathKey(path), unit); }, + get: (path: IRPath): T | undefined => units.get(irPathKey(path)), + has: (path: IRPath): boolean => units.has(irPathKey(path)), + delete: (path: IRPath): void => { units.delete(irPathKey(path)); }, + forRecord: (record: IRRecord, root: unknown): T | undefined => { + for (const path of irRecordOwnerPaths(record, root)) { + const unit = units.get(irPathKey(path)); + if (unit !== undefined) return unit; + } + return undefined; + }, + }; +}; diff --git a/packages/translate/src/ir/output/value-validation.ts b/packages/translate/src/ir/output/value-validation.ts new file mode 100644 index 0000000000..5cb73c903f --- /dev/null +++ b/packages/translate/src/ir/output/value-validation.ts @@ -0,0 +1,79 @@ +import { IRAbsent } from './absent.ts'; +import { irPathKey, unionVariantNode } from './operation-paths.ts'; +import type { IRPath } from './records.ts'; +import type { IRSchemaNode } from './schema.ts'; +import type { IROutputValue } from './types.ts'; + +type IROutputValueVisitor = (path: IRPath, node: IRSchemaNode, value: IROutputValue) => void; + +const isPlainObject = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); + +// Opaque values are replaced as a whole, so only what no IR value may contain is rejected. +const assertPlainValue = (path: IRPath, value: unknown): void => { + if (value === undefined) throw new TypeError(`IR values cannot contain undefined: ${irPathKey(path)}`); + if (value === IRAbsent) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); + if (typeof value !== 'object' || value === null) return; + for (const [key, child] of Object.entries(value)) assertPlainValue([...path, Array.isArray(value) ? Number(key) : key], child); +}; + +// Walks a value that is about to be written together with the schema node describing it. +// `IRAbsent` is accepted where the schema allows it unless `allowAbsent` is off. +export const walkValue = (path: IRPath, node: IRSchemaNode, value: IROutputValue, visitor: IROutputValueVisitor, { allowAbsent }: { allowAbsent: boolean }): void => { + if (value === undefined) throw new TypeError(`IR values cannot contain undefined: ${irPathKey(path)}`); + if (node.kind === 'nullable') { + if (value === null) return visitor(path, node, value); + node = node.of; + } + visitor(path, node, value); + switch (node.kind) { + case 'string': + if (value === IRAbsent) { + if (!allowAbsent) throw new TypeError('IRAbsent must be assigned directly to call_id'); + if (node.absent !== true) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); + } else if (typeof value !== 'string') throw new TypeError(`IR value must be a string: ${irPathKey(path)}`); + return; + case 'number': + if (typeof value !== 'number') throw new TypeError(`IR value must be a number: ${irPathKey(path)}`); + return; + case 'array': + if (!Array.isArray(value)) throw new TypeError(`IR value must be an array: ${irPathKey(path)}`); + value.forEach((child, index) => walkValue([...path, index], node.of, child, visitor, { allowAbsent })); + return; + case 'object': + case 'union': { + if (!isPlainObject(value)) throw new TypeError(`IR value must be an object: ${irPathKey(path)}`); + const variant = unionVariantNode(node, value); + if (variant?.kind !== 'object') throw new TypeError(`IR value has an unknown variant: ${irPathKey(path)}`); + for (const [key, child] of Object.entries(value)) { + const field = node.kind === 'union' && key === node.discriminant ? { kind: 'string' } as const : Object.hasOwn(variant.fields, key) ? variant.fields[key] : undefined; + if (field === undefined) throw new TypeError(`IR value has no field ${key}: ${irPathKey(path)}`); + walkValue([...path, key], field, child, visitor, { allowAbsent }); + } + return; + } + case 'opaque': + assertPlainValue(path, value); + return; + default: + } +}; + +// Fails if a unit that has ended lacks any field required by its schema. +export const assertComplete = (path: IRPath, node: IRSchemaNode, value: unknown): void => { + const actual = unionVariantNode(node, value); + if (actual === undefined || typeof value !== 'object' || value === null) return; + if (actual.kind === 'array') (value as unknown[]).forEach((child, index) => assertComplete([...path, index], actual.of, child)); + else if (actual.kind === 'object') { + const present = value as Record; + if (actual.requiresAnyOf !== undefined && !actual.requiresAnyOf.some(key => present[key] !== undefined)) { + throw new TypeError(`IR value requires one of ${actual.requiresAnyOf.join(', ')}: ${irPathKey(path)}`); + } + for (const [key, field] of Object.entries(actual.fields)) { + if (!Object.hasOwn(present, key)) { + if (!actual.optional.includes(key)) throw new TypeError(`IR value requires field ${key}: ${irPathKey(path)}`); + continue; + } + assertComplete([...path, key], field, present[key]); + } + } +}; diff --git a/packages/translate/src/ir/unit-queue.ts b/packages/translate/src/ir/unit-queue.ts deleted file mode 100644 index 4b73fad318..0000000000 --- a/packages/translate/src/ir/unit-queue.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { irPathKey, irRecordOwnerPaths } from './operation-paths.ts'; -import type { IRPath, IRRecord } from './stream.ts'; - -// Finds the unit that a record belongs to. A writer registers each unit at the path -// where its IR structure was initialized. -export const createIRUnitRegistry = () => { - const units = new Map(); - return { - set: (path: IRPath, unit: T): void => { units.set(irPathKey(path), unit); }, - get: (path: IRPath): T | undefined => units.get(irPathKey(path)), - has: (path: IRPath): boolean => units.has(irPathKey(path)), - delete: (path: IRPath): void => { units.delete(irPathKey(path)); }, - forRecord: (record: IRRecord, root: unknown): T | undefined => { - for (const path of irRecordOwnerPaths(record, root)) { - const unit = units.get(irPathKey(path)); - if (unit !== undefined) return unit; - } - return undefined; - }, - }; -}; - -// Units wait here until the writer can open them, and are opened in IR order. -export interface IRQueuedUnit { nextPending?: T } - -export const createIRUnitQueue = >() => { - let head: T | undefined; - let tail: T | undefined; - return { - get head(): T | undefined { return head; }, - enqueue: (unit: T): void => { - if (tail === undefined) head = unit; - else tail.nextPending = unit; - tail = unit; - }, - // Removes the head unit, which the caller has just opened or discarded. - dequeue: (): void => { - const unit = head!; - head = unit.nextPending; - unit.nextPending = undefined; - if (head === undefined) tail = undefined; - }, - }; -}; - -export type IRUnitQueue> = ReturnType>; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts b/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts index 8eb74e8204..93eb4db66f 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts @@ -1,4 +1,4 @@ -import type { IRJSONObject, IRJSONValue } from '../../../ir/types.ts'; +import type { IRJSONObject, IRJSONValue } from '../../../ir/common/json.ts'; import type { ReplayContent } from '../../../round-trip/reference/assistant-turn.ts'; import type { EncodedRoundTripSidecar } from '../../../round-trip/resolve.ts'; import { collectTextGroups, collectTextPlacementIndex, type TextContribution, type TextPlacementIndex } from '../../../round-trip/text-groups.ts'; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts index d31c3cf762..456e5d9a5e 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts @@ -1,11 +1,16 @@ import { collectAnthropicMessagesTextViews, computeAnthropicMessagesTurnDigest, createAnthropicMessagesSidecarCarrier } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; -import type { IRCollector } from '../../../ir/collector.ts'; -import { isIRItemPath, isIRPartPath, irPathKey } from '../../../ir/operation-paths.ts'; -import { createIROrdinalMapper } from '../../../ir/ordinal-mapper.ts'; -import { consumeIRRecords, type IRPath, type IRRecord } from '../../../ir/stream.ts'; -import { IRAbsent, type IR, type IRErrorPayload, type IRJSONObject } from '../../../ir/types.ts'; -import { createIRUnitQueue, createIRUnitRegistry } from '../../../ir/unit-queue.ts'; +import type { IRJSONObject } from '../../../ir/common/json.ts'; +import { IRAbsent } from '../../../ir/output/absent.ts'; +import type { IRCollector } from '../../../ir/output/collector.ts'; +import type { IRErrorPayload } from '../../../ir/output/error.ts'; +import { isIRItemPath, isIRPartPath, irPathKey } from '../../../ir/output/operation-paths.ts'; +import type { IRPath, IRRecord } from '../../../ir/output/records.ts'; +import { consumeIRRecords } from '../../../ir/output/stream.ts'; +import type { IROutput } from '../../../ir/output/types.ts'; +import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; +import { createIRUnitQueue } from '../../../ir/output/unit-queue.ts'; +import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; import { appendContent, createContentState, type ContentState, type ContentStates } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; @@ -46,7 +51,6 @@ interface MessagesUnit { textContentState?: ContentState<'text'>; // Keep source intervals until their native field is emitted, including terminal JSON normalization. pendingText?: Map; - nextPending?: MessagesUnit; } const isRecord = (value: unknown): value is Readonly> => @@ -96,7 +100,7 @@ export const anthropicMessagesFromIR = async function* ( let finishReason: NonNullable = 'end_turn'; const units = createIRUnitRegistry(); const pending = createIRUnitQueue(); - const ordinals = createIROrdinalMapper(); + const ordinals = createIRUnitIndexAllocator(); const nativeAccumulator = createAnthropicMessagesEventAccumulator(); const contentState: NonNullable['content']> = []; let nextBlockIndex = 0; @@ -111,7 +115,7 @@ export const anthropicMessagesFromIR = async function* ( const blockDelta = (index: number, delta: AnthropicMessagesContentBlockDeltaEvent['delta']): EventFrame => emit({ type: 'content_block_delta', index, delta }); - const usage = (state: IR): AnthropicMessagesUsage => { + const usage = (state: IROutput): AnthropicMessagesUsage => { const value = usageFromIR(state.usage ?? {}); if (state.service_tier === 'fast') value.speed = 'fast'; else if (typeof state.service_tier === 'string') value.service_tier = state.service_tier; @@ -119,7 +123,7 @@ export const anthropicMessagesFromIR = async function* ( }; // Queues the content blocks that the IR structures initialized by a record will become. - const registerUnits = (record: IRRecord, state: IR): void => { + const registerUnits = (record: IRRecord, state: IROutput): void => { if (record.type === 'write' && record.kind === 'initialize') { let path: IRPath | undefined; let kind: MessagesUnit['kind'] | undefined; @@ -155,7 +159,7 @@ export const anthropicMessagesFromIR = async function* ( }; // Opens the waiting units in IR order, until one still lacks what its start event needs. - const openPendingUnits = (state: IR, collector: IRCollector): AnthropicMessagesStreamEventEx[] => { + const openPendingUnits = (state: IROutput, collector: IRCollector): AnthropicMessagesStreamEventEx[] => { const pendingEvents: AnthropicMessagesStreamEventEx[] = []; while (pending.head !== undefined) { const unit = pending.head; @@ -179,12 +183,12 @@ export const anthropicMessagesFromIR = async function* ( else wait = !closed; } else if (item.type === 'function_call') { if (item.name === '') throw new TypeError('Completed tool calls require a name'); - const callId = collector.optional([...unit.path, 'call_id']); + const callId = collector.valueOrAbsent([...unit.path, 'call_id']); if (callId === undefined) wait = true; else block = { type: 'tool_use', id: callId === IRAbsent ? `${metadata.id}_${itemIndex}` : callId, name: item.name, input: {} }; } if (wait) break; - pending.dequeue(); + pending.dropHead(); if (block === undefined) { units.delete(unit.path); continue; } unit.opened = true; const index = ordinals.open(unit.path); @@ -361,7 +365,7 @@ export const anthropicMessagesFromIR = async function* ( yield pendingBlockStop; pendingBlockStop = undefined; } - const refusal = state.choices[0]?.refusal; + const refusal = state.choices[0]?.refusal_details; yield emit({ type: 'message_delta', delta: { diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/usage.ts b/packages/translate/src/protocols/anthropic-messages/as-source/usage.ts index 10b65072dd..20061d6d57 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/usage.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/usage.ts @@ -1,9 +1,9 @@ -import type { IRUsage } from '../../../ir/types.ts'; +import type { IROutputUsage } from '../../../ir/output/types.ts'; import { requireTokenCount } from '../../../shared/token-count.ts'; import { type AnthropicMessagesUsage, createAnthropicMessagesUsage } from '@floway-dev/protocols/anthropic-messages'; import { splitInclusiveInputTokens } from '@floway-dev/protocols/common'; -export const usageFromIR = (usage: IRUsage): AnthropicMessagesUsage => { - const count = (key: keyof IRUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); +export const usageFromIR = (usage: IROutputUsage): AnthropicMessagesUsage => { + const count = (key: keyof IROutputUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); const input = count('input_tokens_inclusive') ?? 0; const output = count('output_tokens_inclusive') ?? 0; const cached = count('cached_input_tokens') ?? 0; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/assistant-turn.ts b/packages/translate/src/protocols/anthropic-messages/as-target/assistant-turn.ts index 0358b85b89..4604e380af 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/assistant-turn.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/assistant-turn.ts @@ -1,4 +1,4 @@ -import type { IRJSONObject } from '../../../ir/types.ts'; +import type { IRJSONObject } from '../../../ir/common/json.ts'; import type { ReferenceState, ReferenceStateLeaves, ReferenceableValue, TextRule, JSONRule } from '../../../round-trip/reference/assistant-turn.ts'; import type { AnthropicMessagesAssistantContentBlock } from '@floway-dev/protocols/anthropic-messages'; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/sse-from.ts b/packages/translate/src/protocols/anthropic-messages/as-target/sse-from.ts index bc83751297..4225141cee 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/sse-from.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/sse-from.ts @@ -1,7 +1,8 @@ import type { AnthropicMessagesAssistantState } from './assistant-turn.ts'; import { type AnthropicMessagesInputCounts, usageToIR } from './usage.ts'; -import { createIRBuilder, type IRItemHandle, type IRPartHandle, type IRRecord } from '../../../ir/stream.ts'; -import type { IRSourceCitation } from '../../../ir/types.ts'; +import { createIRBuilder, type IRItemHandle, type IRPartHandle } from '../../../ir/output/builder.ts'; +import type { IRRecord } from '../../../ir/output/records.ts'; +import type { IROutputSourceCitation } from '../../../ir/output/types.ts'; import { appendField, createFieldState, type FieldState, type FieldStates } from '../../../round-trip/reference/reference-builder.ts'; import type { RoundTripReader } from '../../../round-trip/stream.ts'; import { requireJSONObject } from '../../../shared/json/object.ts'; @@ -15,7 +16,7 @@ import { } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; -const messagesCitationToIR = (citation: AnthropicMessagesTextCitation): IRSourceCitation | undefined => { +const messagesCitationToIR = (citation: AnthropicMessagesTextCitation): IROutputSourceCitation | undefined => { const isSearchLocation = citation.type === 'web_search_result_location' || citation.type === 'search_result_location'; const source = citation.type === 'web_search_result_location' ? citation.url : citation.type === 'search_result_location' ? citation.source : citation.file_id ?? undefined; @@ -31,7 +32,7 @@ const messagesCitationToIR = (citation: AnthropicMessagesTextCitation): IRSource ...(citation.type === 'page_location' ? { source_page_range: { start_one_based: citation.start_page_number, end_one_based_exclusive: citation.end_page_number } } : {}), - } as IRSourceCitation; + } as IROutputSourceCitation; }; // Blocks that open no IR item; their content is not translated. @@ -214,7 +215,7 @@ export const irFromAnthropicMessages = async function* ( assignUsage(e.usage); if (e.delta.stop_reason === 'compaction') throw new TypeError('Cannot translate Messages compaction stop'); if (e.delta.stop_reason === 'refusal') { - ir.choice(0).assign('refusal', { + ir.choice(0).assign('refusal_details', { category: e.delta.stop_details?.category ?? null, explanation: e.delta.stop_details?.explanation ?? null, }); diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/usage.ts b/packages/translate/src/protocols/anthropic-messages/as-target/usage.ts index 68ecb081b4..cffe87e8cc 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/usage.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/usage.ts @@ -1,4 +1,4 @@ -import type { IRUsage } from '../../../ir/types.ts'; +import type { IROutputUsage } from '../../../ir/output/types.ts'; import { requireTokenCount } from '../../../shared/token-count.ts'; import { type AnthropicMessagesUsageDeltaEx, splitAnthropicMessagesCacheCreationTokens } from '@floway-dev/protocols/anthropic-messages'; import { splitInclusiveInputTokens } from '@floway-dev/protocols/common'; @@ -11,10 +11,10 @@ export interface AnthropicMessagesInputCounts { cacheWrite?: number; } -export const usageToIR = (usage: Partial | null | undefined, counts: AnthropicMessagesInputCounts): IRUsage => { - const result: Partial> = {}; +export const usageToIR = (usage: Partial | null | undefined, counts: AnthropicMessagesInputCounts): IROutputUsage => { + const result: Partial> = {}; if (usage == null) return result; - const put = (key: keyof IRUsage, value: number | undefined | null): void => { if (value != null) result[key] = requireTokenCount(key, value); }; + const put = (key: keyof IROutputUsage, value: number | undefined | null): void => { if (value != null) result[key] = requireTokenCount(key, value); }; if (usage.input_tokens != null) counts.input = requireTokenCount('input_tokens', usage.input_tokens); if (usage.cache_read_input_tokens != null) counts.cacheRead = requireTokenCount('cache_read_input_tokens', usage.cache_read_input_tokens); const hasCacheWrite = usage.cache_creation_input_tokens != null || usage.cache_creation != null; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts index c7a6839460..f2f24c54c2 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts @@ -1,4 +1,4 @@ -import type { IRJSONObject } from '../../../ir/types.ts'; +import type { IRJSONObject } from '../../../ir/common/json.ts'; import type { ReplayContent } from '../../../round-trip/reference/assistant-turn.ts'; import type { EncodedRoundTripSidecar } from '../../../round-trip/resolve.ts'; import { collectTextGroups, collectTextPlacementIndex, type TextContribution, type TextPlacementIndex } from '../../../round-trip/text-groups.ts'; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts index 7e224bee09..545f856c1a 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts @@ -1,10 +1,15 @@ import { collectGeminiGenerateContentTextViews, computeGeminiGenerateContentTurnDigest, createGeminiGenerateContentSidecarCarrier } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; -import type { IRCollector } from '../../../ir/collector.ts'; -import { irItemPath, irPartPath, irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/operation-paths.ts'; -import { consumeIRRecords, type IRPath, type IRRecord } from '../../../ir/stream.ts'; -import { IRAbsent, type IR, type IRErrorPayload, type IRJSONObject, type IRSourceCitation, type IRTokenLogprob } from '../../../ir/types.ts'; -import { createIRUnitQueue, createIRUnitRegistry, type IRUnitQueue } from '../../../ir/unit-queue.ts'; +import type { IRJSONObject } from '../../../ir/common/json.ts'; +import { IRAbsent } from '../../../ir/output/absent.ts'; +import type { IRCollector } from '../../../ir/output/collector.ts'; +import type { IRErrorPayload } from '../../../ir/output/error.ts'; +import { irItemPath, irPartPath, irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/output/operation-paths.ts'; +import type { IRPath, IRRecord } from '../../../ir/output/records.ts'; +import { consumeIRRecords } from '../../../ir/output/stream.ts'; +import type { IROutput, IROutputSourceCitation, IROutputTokenLogprob } from '../../../ir/output/types.ts'; +import { createIRUnitQueue, type IRUnitQueue } from '../../../ir/output/unit-queue.ts'; +import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; import { appendContent, createContentState, type ContentStates, type ContentState as ReferenceContentState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; @@ -37,7 +42,6 @@ interface GenerateContentUnit { opened: boolean; pendingData?: TextProvenanceSpan[]; pendingArguments?: TextProvenanceSpan[]; - nextPending?: GenerateContentUnit; } interface PendingTextQueue { @@ -186,7 +190,7 @@ export const geminiGenerateContentFromIR = async function* ( }; // Queues the units that the IR structures initialized by a record will become. - const registerUnits = (record: IRRecord, state: IR): void => { + const registerUnits = (record: IRRecord, state: IROutput): void => { if (record.type === 'write' && record.kind === 'initialize') { const choice = record.path[1]; if (typeof choice !== 'number') return; @@ -206,7 +210,7 @@ export const geminiGenerateContentFromIR = async function* ( }; // Opens each choice's waiting units in IR order, until one still lacks what its part needs. - const openPendingUnits = function* (record: IRRecord, state: IR, collector: IRCollector): Generator> { + const openPendingUnits = function* (record: IRRecord, state: IROutput, collector: IRCollector): Generator> { const endingChoice = record.type === 'choice_end' ? record : undefined; for (const [choice, queue] of queues) { const choiceEnding = endingChoice?.choice === choice ? endingChoice : undefined; @@ -220,9 +224,9 @@ export const geminiGenerateContentFromIR = async function* ( if (item.type !== 'message') throw new TypeError('GenerateContent message unit requires a message item'); const content = item.content[unit.path[5] as number]; const inlineMedia = content.type === 'image' - ? { mimeType: content.image.mime_type, data: content.image.data } + ? { mimeType: content.image.media_type, data: content.image.data } : content.type === 'audio' && content.audio.data !== undefined - ? { mimeType: content.audio.mime_type, data: content.audio.data } + ? { mimeType: content.audio.media_type, data: content.audio.data } : undefined; if (content.type === 'text' || content.type === 'refusal') { part = undefined; @@ -287,7 +291,7 @@ export const geminiGenerateContentFromIR = async function* ( } } if (wait) break; - queue.dequeue(); + queue.dropHead(); unit.opened = true; if (part !== undefined) yield emitPart(choice, part); for (const pendingPart of drainPendingText(choice, collector)) yield emitPart(choice, pendingPart); @@ -298,7 +302,7 @@ export const geminiGenerateContentFromIR = async function* ( }; // The terminal candidate of a choice: finish reason, grounding built from its citations, and logprobs. - const terminalCandidate = (record: Extract, state: IR, collector: IRCollector): GeminiGenerateContentCandidate => { + const terminalCandidate = (record: Extract, state: IROutput, collector: IRCollector): GeminiGenerateContentCandidate => { const choice = state.choices[record.choice]; const partTexts = new Map(); for (const [partIndex, segments] of segmentsByChoice.get(record.choice) ?? []) { @@ -331,7 +335,7 @@ export const geminiGenerateContentFromIR = async function* ( const range = { start: segment.targetStart + start - segment.sourceStart, end_exclusive: segment.targetStart + end - segment.sourceStart }; partText.coordinateIndex ??= TextCoordinateIndex.utf16ToUTF8(partText.text); partText.coordinateIndex.assertUTF16Length(partText.text.length); - const bytes = partText.coordinateIndex.irRangeToUTF8(range); + const bytes = partText.coordinateIndex.utf16RangeToUTF8(range); supports.push({ groundingChunkIndices: [chunkIndex], segment: { partIndex: segment.partIndex, startIndex: bytes.start, endIndex: bytes.end, text: partText.text.slice(range.start, range.end_exclusive) }, @@ -342,7 +346,7 @@ export const geminiGenerateContentFromIR = async function* ( }); const tokens = choice.logprobs?.flatMap(group => group.tokens); return { - ...(choice.refusal?.explanation == null ? {} : { finishMessage: choice.refusal.explanation }), + ...(choice.refusal_details?.explanation == null ? {} : { finishMessage: choice.refusal_details.explanation }), index: record.choice, finishReason: record.finish_reason === 'length' ? 'MAX_TOKENS' : record.finish_reason === 'content_filter' ? 'SAFETY' : 'STOP', ...(chunks.length === 0 ? {} : { groundingMetadata: { groundingChunks: chunks, groundingSupports: supports } }), @@ -430,14 +434,14 @@ export const geminiGenerateContentFromIR = async function* ( } }; -const logprobsResult = (tokens: readonly IRTokenLogprob[]): NonNullable => ({ +const logprobsResult = (tokens: readonly IROutputTokenLogprob[]): NonNullable => ({ chosenCandidates: tokens.map(token => ({ token: token.token, logProbability: token.logprob })), topCandidates: tokens.map(token => ({ candidates: (token.top_logprobs ?? []).map(alternative => ({ token: alternative.token, logProbability: alternative.logprob })), })), }); -const groundingChunk = (citation: IRSourceCitation): GeminiGenerateContentGroundingChunk => { +const groundingChunk = (citation: IROutputSourceCitation): GeminiGenerateContentGroundingChunk => { if (citation.source_kind === 'url') return { web: { uri: citation.source, title: citation.source_label ?? undefined } }; return { retrievedContext: { diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/usage.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/usage.ts index febb58e32f..bb5a802f2c 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/usage.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/usage.ts @@ -1,15 +1,15 @@ -import type { IRUsage } from '../../../ir/types.ts'; +import type { IROutputUsage } from '../../../ir/output/types.ts'; import { requireTokenCount } from '../../../shared/token-count.ts'; import { splitInclusiveOutputTokens } from '@floway-dev/protocols/common'; import type { GeminiGenerateContentModalityTokenCount, GeminiGenerateContentUsageMetadata } from '@floway-dev/protocols/gemini-generate-content'; -export const usageFromIR = (usage: IRUsage): GeminiGenerateContentUsageMetadata => { - const count = (key: keyof IRUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); +export const usageFromIR = (usage: IROutputUsage): GeminiGenerateContentUsageMetadata => { + const count = (key: keyof IROutputUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); const input = count('input_tokens_inclusive') ?? 0; const output = count('output_tokens_inclusive') ?? 0; const reasoning = count('reasoning_tokens'); const split = splitInclusiveOutputTokens(output, reasoning); - const modalityTokens = (keys: readonly (readonly [modality: string, key: keyof IRUsage])[]): GeminiGenerateContentModalityTokenCount[] => + const modalityTokens = (keys: readonly (readonly [modality: string, key: keyof IROutputUsage])[]): GeminiGenerateContentModalityTokenCount[] => keys.flatMap(([modality, key]) => usage[key] === undefined ? [] : [{ modality, tokenCount: requireTokenCount(key, usage[key]) }]); const promptDetails = modalityTokens([['AUDIO', 'input_audio_tokens'], ['IMAGE', 'input_image_tokens'], ['TEXT', 'input_text_tokens']]); const candidatesDetails = modalityTokens([['AUDIO', 'output_audio_tokens'], ['TEXT', 'output_text_tokens']]); diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts index c9577cba28..0ef3b5905a 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts @@ -1,4 +1,4 @@ -import type { IRJSONObject, IRJSONValue } from '../../../ir/types.ts'; +import type { IRJSONObject, IRJSONValue } from '../../../ir/common/json.ts'; import type { ReplayContent } from '../../../round-trip/reference/assistant-turn.ts'; import type { EncodedRoundTripSidecar } from '../../../round-trip/resolve.ts'; import { collectTextGroups, collectTextPlacementIndex, type TextContribution, type TextPlacementIndex } from '../../../round-trip/text-groups.ts'; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts index 9cba106198..74dfdade36 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts @@ -1,9 +1,12 @@ import { collectOpenAIChatCompletionsTextViews, computeOpenAIChatCompletionsTurnDigest, createOpenAIChatCompletionsSidecarCarrier } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; -import { irItemPath, irPartPath, irPathKey } from '../../../ir/operation-paths.ts'; -import { createIROrdinalMapper } from '../../../ir/ordinal-mapper.ts'; -import { consumeIRRecords, type IRRecord } from '../../../ir/stream.ts'; -import { IRAbsent, type IRErrorPayload, type IRItem, type IRMessageItem, type IRTokenLogprob } from '../../../ir/types.ts'; +import { IRAbsent } from '../../../ir/output/absent.ts'; +import type { IRErrorPayload } from '../../../ir/output/error.ts'; +import { irItemPath, irPartPath, irPathKey } from '../../../ir/output/operation-paths.ts'; +import type { IRRecord } from '../../../ir/output/records.ts'; +import { consumeIRRecords } from '../../../ir/output/stream.ts'; +import type { IROutputItem, IROutputMessageItem, IROutputTokenLogprob } from '../../../ir/output/types.ts'; +import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; import { appendContent, createContentState, type ContentStates, type ContentState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; @@ -35,12 +38,12 @@ interface OpenAIChatCompletionsSSEOutputOptions extends RoundTripOutputOptions<' const tierFields = (tier?: string): { service_tier?: string } => tier === undefined ? {} : { service_tier: tier }; -type IRToolCallItem = Extract; +type IRToolCallItem = Extract; const isRecord = (value: unknown): value is Readonly> => typeof value === 'object' && value !== null && !Array.isArray(value); -const isIRItem = (value: unknown): value is IRItem => { +const isIRItem = (value: unknown): value is IROutputItem => { if (!isRecord(value)) return false; switch (value.type) { case 'message': return Array.isArray(value.content); @@ -51,7 +54,7 @@ const isIRItem = (value: unknown): value is IRItem => { } }; -const partType = (value: unknown): IRMessageItem['content'][number]['type'] | undefined => { +const partType = (value: unknown): IROutputMessageItem['content'][number]['type'] | undefined => { if (!isRecord(value)) return undefined; return value.type === 'text' || value.type === 'refusal' || value.type === 'image' || value.type === 'audio' ? value.type : undefined; }; @@ -71,7 +74,7 @@ const logprobMetadata = (value: unknown): LogprobMetadata | undefined => { return undefined; }; -const isIRTokenLogprob = (value: unknown): value is IRTokenLogprob => +const isIRTokenLogprob = (value: unknown): value is IROutputTokenLogprob => isRecord(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; // Emitted deltas carry the assistant extension fields (reasoning, annotations) beside the standard ones. @@ -86,10 +89,10 @@ export const openaiChatCompletionsFromIR = async function* ( let metadata = { id: '', model: '', created: 0 }; let started = false; const startedChoices = new Set(); - const tools = new Map>(); + const tools = new Map>(); const startedAudio = new Set(); - const itemTypes = new Map(); - const partTypes = new Map(); + const itemTypes = new Map(); + const partTypes = new Map(); const logprobRefusal = new Map(); const nativeAccumulators = new Map>(); const nativeAccumulator = (choice: number): ReturnType => { @@ -153,7 +156,7 @@ export const openaiChatCompletionsFromIR = async function* ( const ensureChoice = (choice: number, tier?: string): ProtocolFrame | undefined => { if (startedChoices.has(choice)) return undefined; startedChoices.add(choice); - tools.set(choice, createIROrdinalMapper()); + tools.set(choice, createIRUnitIndexAllocator()); return deltaFrame(choice, { role: 'assistant', content: '' }, tier); }; @@ -377,7 +380,7 @@ export const openaiChatCompletionsFromIR = async function* ( const range = annotation.output_text_range; coordinateIndex ??= TextCoordinateIndex.utf16ToCodePoints(flattened); coordinateIndex.assertUTF16Length(flattened.length); - const offsets = coordinateIndex.irRangeToCodePoints({ start: base + range.start, end_exclusive: base + range.end_exclusive }); + const offsets = coordinateIndex.utf16RangeToCodePoints({ start: base + range.start, end_exclusive: base + range.end_exclusive }); annotations.push({ type: 'url_citation', url_citation: { start_index: offsets.start, end_index: offsets.end, title: annotation.source_label ?? annotation.source, url: annotation.source }, @@ -386,9 +389,9 @@ export const openaiChatCompletionsFromIR = async function* ( }); }); if (annotations.length > 0) yield deltaFrame(record.choice, { annotations }, tier); - if (choice.refusal?.explanation != null) { - appendContent(options.roundTrip.builder, textState(record.choice, 'refusal'), choice.refusal.explanation); - yield deltaFrame(record.choice, { refusal: choice.refusal.explanation }, tier); + if (choice.refusal_details?.explanation != null) { + appendContent(options.roundTrip.builder, textState(record.choice, 'refusal'), choice.refusal_details.explanation); + yield deltaFrame(record.choice, { refusal: choice.refusal_details.explanation }, tier); } const summary = nativeAccumulator(record.choice).result().choices.find(choice => choice.index === record.choice)?.message.reasoning_text; if (typeof summary === 'string' && summary !== '') { diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/usage.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/usage.ts index c6ad0844aa..029893fee6 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/usage.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/usage.ts @@ -1,9 +1,9 @@ -import type { IRUsage } from '../../../ir/types.ts'; +import type { IROutputUsage } from '../../../ir/output/types.ts'; import { requireTokenCount } from '../../../shared/token-count.ts'; import type { OpenAIChatCompletionsUsageEx } from '@floway-dev/protocols/openai-chat-completions'; -export const usageFromIR = (usage: IRUsage): OpenAIChatCompletionsUsageEx => { - const count = (key: keyof IRUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); +export const usageFromIR = (usage: IROutputUsage): OpenAIChatCompletionsUsageEx => { + const count = (key: keyof IROutputUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); const input = count('input_tokens_inclusive') ?? 0; const output = count('output_tokens_inclusive') ?? 0; const inputDetails: NonNullable = {}; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts index 26e65ac120..534a901812 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts @@ -1,14 +1,10 @@ import { toOpenAIChatCompletionsInputAssistantTurn, type OpenAIChatCompletionsOutputAssistantState } from './assistant-turn.ts'; import { usageToIR } from './usage.ts'; -import { - createIRBuilder, - type IRRecord, - type IRItemHandle, - type IRPartHandle, - type IRWriteHandle, - type IRRelativePath, -} from '../../../ir/stream.ts'; -import { IRAbsent, type IRErrorPayload, type IRSourceCitation } from '../../../ir/types.ts'; +import { IRAbsent } from '../../../ir/output/absent.ts'; +import { createIRBuilder, type IRItemHandle, type IRPartHandle, type IRWriteHandle, type IRRelativePath } from '../../../ir/output/builder.ts'; +import type { IRErrorPayload } from '../../../ir/output/error.ts'; +import type { IRRecord } from '../../../ir/output/records.ts'; +import type { IROutputSourceCitation } from '../../../ir/output/types.ts'; import { appendField, createFieldState, type FieldStates, type FieldState, type ReferencePieceId } from '../../../round-trip/reference/reference-builder.ts'; import type { RoundTripReader } from '../../../round-trip/stream.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; @@ -226,12 +222,12 @@ export const irFromOpenAIChatCompletions = async function* ( } const start = Math.min(native.start_index, availableCodePoints); const end = Math.min(native.end_index, availableCodePoints); - return { annotation, range: state.coordinateIndex!.codePointRangeToIR(start, end), exceedsAvailableText: native.end_index > availableCodePoints }; + return { annotation, range: state.coordinateIndex!.codePointRangeToUTF16(start, end), exceedsAvailableText: native.end_index > availableCodePoints }; }); // Only the message that is still open can receive citations; earlier parts already ended. const openSegments = state.message?.isOpen === true ? state.textSegments.filter(segment => segment.item === state.message) : []; for (const segment of openSegments) { - const converted: IRSourceCitation[] = citations.flatMap(({ annotation, range }) => { + const converted: IROutputSourceCitation[] = citations.flatMap(({ annotation, range }) => { const start = Math.max(range.start, segment.start); const end = Math.min(range.end_exclusive, segment.end); if (start >= end) return []; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/usage.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/usage.ts index d140be125c..b0efbed8d8 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/usage.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/usage.ts @@ -1,11 +1,11 @@ -import type { IRUsage } from '../../../ir/types.ts'; +import type { IROutputUsage } from '../../../ir/output/types.ts'; import { requireTokenCount } from '../../../shared/token-count.ts'; import { splitInclusiveInputTokens } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsUsageEx } from '@floway-dev/protocols/openai-chat-completions'; -export const usageToIR = (usage: Partial): IRUsage => { - const result: Partial> = {}; - const put = (key: keyof IRUsage, value: number | undefined | null): void => { if (value != null) result[key] = requireTokenCount(key, value); }; +export const usageToIR = (usage: Partial): IROutputUsage => { + const result: Partial> = {}; + const put = (key: keyof IROutputUsage, value: number | undefined | null): void => { if (value != null) result[key] = requireTokenCount(key, value); }; put('input_tokens_inclusive', usage.prompt_tokens); put('output_tokens_inclusive', usage.completion_tokens); put('total_tokens', usage.total_tokens); diff --git a/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts b/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts index d58668c45b..623e1fa547 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts @@ -1,4 +1,4 @@ -import type { IRJSONObject, IRJSONValue } from '../../../ir/types.ts'; +import type { IRJSONObject, IRJSONValue } from '../../../ir/common/json.ts'; import type { ReplayContent } from '../../../round-trip/reference/assistant-turn.ts'; import type { EncodedRoundTripSidecar } from '../../../round-trip/resolve.ts'; import { collectTextGroups, collectTextPlacementIndex, type TextContribution, type TextPlacementIndex } from '../../../round-trip/text-groups.ts'; diff --git a/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts b/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts index b08ce80dfe..78b2c93993 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts @@ -2,12 +2,15 @@ import { restoreNamespaceOutputItem, type NamespaceToolNames } from './namespace import { createRandomOpenAIResponsesResponseId } from './response-id.ts'; import { collectOpenAIResponsesTextViews, computeOpenAIResponsesTurnDigest, createOpenAIResponsesSidecarCarrier } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; -import type { IRCollector } from '../../../ir/collector.ts'; -import { irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/operation-paths.ts'; -import { createIROrdinalMapper } from '../../../ir/ordinal-mapper.ts'; -import { consumeIRRecords, type IRPath, type IRRecord } from '../../../ir/stream.ts'; -import { IRAbsent, type IR, type IRItem, type IRMessageItem, type IRSourceCitation, type IRTokenLogprob } from '../../../ir/types.ts'; -import { createIRUnitQueue, createIRUnitRegistry } from '../../../ir/unit-queue.ts'; +import { IRAbsent } from '../../../ir/output/absent.ts'; +import type { IRCollector } from '../../../ir/output/collector.ts'; +import { irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/output/operation-paths.ts'; +import type { IRPath, IRRecord } from '../../../ir/output/records.ts'; +import { consumeIRRecords } from '../../../ir/output/stream.ts'; +import type { IROutput, IROutputItem, IROutputMessageItem, IROutputSourceCitation, IROutputTokenLogprob } from '../../../ir/output/types.ts'; +import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; +import { createIRUnitQueue } from '../../../ir/output/unit-queue.ts'; +import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; import { appendContent, createContentState, type ContentState, type ContentStates } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; @@ -29,7 +32,7 @@ import { interface ResponsesOutputUnitBase { readonly path: IRPath; readonly parts: IRPath[]; - readonly contentOrdinals: ReturnType; + readonly contentOrdinals: ReturnType; readonly annotationProgress: Map; readonly coordinateIndexes: Map; pendingText?: Map; @@ -38,7 +41,6 @@ interface ResponsesOutputUnitBase { id?: string; callId?: string; status?: 'completed' | 'incomplete'; - nextPending?: ResponsesOutputUnit; } type ResponsesOutputUnit = ResponsesOutputUnitBase & ( @@ -53,7 +55,7 @@ type OpenAIResponsesSSEOutputOptions = RoundTripOutputOptions<'openaiResponses'> const IMAGE_OUTPUT_FORMATS = { 'image/png': 'png', 'image/jpeg': 'jpeg', 'image/webp': 'webp' } as const; type IRWriteRecord = Extract; -type IRToolCallItem = Extract; +type IRToolCallItem = Extract; // The item an opened unit announces, and the events that bring it up to the content IR already holds. interface OpenedUnit { readonly added: OpenAIResponsesOutputItemEx; @@ -61,7 +63,7 @@ interface OpenedUnit { } type ReasoningField = 'summary' | 'content'; -type MessagePart = IRMessageItem['content'][number]; +type MessagePart = IROutputMessageItem['content'][number]; type ResponsesMessageState = OpenAIResponsesItemState, ContentStates>; type ResponsesReasoningState = OpenAIResponsesItemState, ContentStates>; @@ -69,7 +71,7 @@ type ResponsesReasoningState = OpenAIResponsesItemState> => typeof value === 'object' && value !== null && !Array.isArray(value); -const initializedType = (value: unknown): IRItem['type'] | MessagePart['type'] | undefined => { +const initializedType = (value: unknown): IROutputItem['type'] | MessagePart['type'] | undefined => { if (!isRecord(value)) return undefined; return value.type === 'message' || value.type === 'reasoning' @@ -92,7 +94,7 @@ const textPartLogprobMetadata = (value: unknown): TextPartLogprobMetadata | unde : undefined; }; -const isIRTokenLogprob = (value: unknown): value is IRTokenLogprob => +const isIRTokenLogprob = (value: unknown): value is IROutputTokenLogprob => isRecord(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; const reasoningPartAdded = (field: ReasoningField, itemId: string, outputIndex: number, index: number): OpenAIResponsesStreamEventEx => @@ -168,7 +170,7 @@ const emptyContentPart = (type: 'text' | 'refusal'): OpenAIResponsesOutputConten : { type: 'refusal', refusal: '' }; // The text or refusal part a message unit covers; other part kinds never join a message unit. -const textOrRefusalPart = (state: IR, path: IRPath): Extract => { +const textOrRefusalPart = (state: IROutput, path: IRPath): Extract => { const item = state.choices[0].items[path[3] as number]; if (item.type !== 'message') throw new TypeError('Responses content unit requires a message item'); const part = item.content[path[5] as number]; @@ -249,10 +251,10 @@ export const openaiResponsesFromIR = async function* ( return index; }; - const convertAnnotation = (coordinateIndex: () => TextCoordinateIndex, annotation: IRSourceCitation): OpenAIResponsesAnnotation | undefined => { + const convertAnnotation = (coordinateIndex: () => TextCoordinateIndex, annotation: IROutputSourceCitation): OpenAIResponsesAnnotation | undefined => { if ((annotation.source_kind === 'url' || annotation.source_kind === 'search_result') && annotation.source != null) { if (annotation.output_text_range === undefined) return undefined; - const range = coordinateIndex().irRangeToCodePoints(annotation.output_text_range); + const range = coordinateIndex().utf16RangeToCodePoints(annotation.output_text_range); return { type: 'url_citation', url: annotation.source, @@ -284,11 +286,11 @@ export const openaiResponsesFromIR = async function* ( return unit.outputIndex; }; - const contentPart = (state: IR, unit: ResponsesOutputUnit, path: IRPath): OpenAIResponsesOutputRefusal | Required => { + const contentPart = (state: IROutput, unit: ResponsesOutputUnit, path: IRPath): OpenAIResponsesOutputRefusal | Required => { const part = textOrRefusalPart(state, path); if (part.type === 'refusal') return { type: 'refusal', refusal: part.refusal }; const logprobGroup = logprobGroups.get(irPathKey(path)); - let logprobs: readonly IRTokenLogprob[] = []; + let logprobs: readonly IROutputTokenLogprob[] = []; if (logprobGroup !== undefined) { const group = state.choices[0].logprobs?.[logprobGroup]; if (group === undefined) throw new TypeError(`Responses logprob group ${logprobGroup} is missing`); @@ -297,7 +299,7 @@ export const openaiResponsesFromIR = async function* ( return { type: 'output_text', text: part.text, annotations: convertAnnotations(unit, path, part), logprobs: [...logprobs] }; }; - const outputItem = (state: IR, unit: ResponsesOutputUnit): OpenAIResponsesOutputItemEx => { + const outputItem = (state: IROutput, unit: ResponsesOutputUnit): OpenAIResponsesOutputItemEx => { const itemIndex = unit.path[3]; if (typeof itemIndex !== 'number') throw new TypeError('Responses output unit has no item index'); const item = state.choices[0].items[itemIndex]; @@ -310,7 +312,7 @@ export const openaiResponsesFromIR = async function* ( const part = item.content[unit.path[5] as number]; if (part.type !== 'image') throw new TypeError('Responses image unit requires an image part'); if (status !== 'completed') throw new TypeError('Responses image unit can only complete'); - const outputFormat = part.image.mime_type === undefined ? undefined : IMAGE_OUTPUT_FORMATS[part.image.mime_type as keyof typeof IMAGE_OUTPUT_FORMATS]; + const outputFormat = part.image.media_type === undefined ? undefined : IMAGE_OUTPUT_FORMATS[part.image.media_type as keyof typeof IMAGE_OUTPUT_FORMATS]; return { type: 'image_generation_call', id, status, result: part.image.data, ...(outputFormat === undefined ? {} : { output_format: outputFormat }) }; } if (unit.kind === 'reasoning') { @@ -330,7 +332,7 @@ export const openaiResponsesFromIR = async function* ( return { type: 'function_call', id, call_id: unit.callId, name: item.name, arguments: item.arguments, status }; }; - const response = (state: IR, status: OpenAIResponsesResultEx['status'], usage: OpenAIResponsesResultEx['usage']): OpenAIResponsesResultEx => ({ + const response = (state: IROutput, status: OpenAIResponsesResultEx['status'], usage: OpenAIResponsesResultEx['usage']): OpenAIResponsesResultEx => ({ id: metadata.id, object: 'response', created_at: metadata.created, @@ -344,7 +346,7 @@ export const openaiResponsesFromIR = async function* ( }); // Creates the output units that the IR structures initialized by a record will become. - const registerUnits = (record: IRRecord, state: IR): void => { + const registerUnits = (record: IRRecord, state: IROutput): void => { if (record.type === 'write' && record.kind === 'initialize') { if (record.path[0] === 'choices' && record.path[1] !== 0) throw new Error('Responses cannot represent multiple choices'); if (record.path.length === 4 && record.path[0] === 'choices' && record.path[2] === 'logprobs') { @@ -361,7 +363,7 @@ export const openaiResponsesFromIR = async function* ( const unit: ResponsesOutputUnitBase = { path: record.path, parts: [], - contentOrdinals: createIROrdinalMapper(), + contentOrdinals: createIRUnitIndexAllocator(), annotationProgress: new Map(), coordinateIndexes: new Map(), opened: false, @@ -387,7 +389,7 @@ export const openaiResponsesFromIR = async function* ( path: record.path, kind: 'message', parts: [], - contentOrdinals: createIROrdinalMapper(), + contentOrdinals: createIRUnitIndexAllocator(), annotationProgress: new Map(), coordinateIndexes: new Map(), contentState: {}, @@ -403,7 +405,7 @@ export const openaiResponsesFromIR = async function* ( path: record.path, kind: 'image', parts: [record.path], - contentOrdinals: createIROrdinalMapper(), + contentOrdinals: createIRUnitIndexAllocator(), annotationProgress: new Map(), coordinateIndexes: new Map(), opened: false, @@ -413,7 +415,7 @@ export const openaiResponsesFromIR = async function* ( } }; - const openMessageUnit = (unit: ResponsesOutputUnit, state: IR, outputIndex: number): OpenedUnit => { + const openMessageUnit = (unit: ResponsesOutputUnit, state: IROutput, outputIndex: number): OpenedUnit => { const id = createRandomOpenAIResponsesItemId('message'); unit.id = id; const followUps: OpenAIResponsesStreamEventEx[] = []; @@ -464,7 +466,7 @@ export const openaiResponsesFromIR = async function* ( return { added: { type: 'image_generation_call', id, status: 'in_progress', result: null }, followUps: [] }; }; - const openReasoningUnit = (unit: ResponsesOutputUnit, item: IRItem, outputIndex: number): OpenedUnit => { + const openReasoningUnit = (unit: ResponsesOutputUnit, item: IROutputItem, outputIndex: number): OpenedUnit => { const reasoning = item.type === 'reasoning' ? item : undefined; const id = createRandomOpenAIResponsesItemId('reasoning'); unit.id = id; @@ -502,7 +504,7 @@ export const openaiResponsesFromIR = async function* ( }; // Opens the waiting units in IR order, until one still lacks what its opening event needs. - const openPendingUnits = (state: IR, collector: IRCollector): { events: OpenAIResponsesStreamEventEx[]; justOpened: Set } => { + const openPendingUnits = (state: IROutput, collector: IRCollector): { events: OpenAIResponsesStreamEventEx[]; justOpened: Set } => { const justOpened = new Set(); const pendingEvents: OpenAIResponsesStreamEventEx[] = []; while (pending.head !== undefined) { @@ -511,11 +513,11 @@ export const openaiResponsesFromIR = async function* ( let tool: { item: IRToolCallItem; callId: string | typeof IRAbsent } | undefined; if (unit.kind === 'function_call' || unit.kind === 'custom_tool_call') { if ((item.type !== 'function_call' && item.type !== 'custom_tool_call') || item.name === '') throw new TypeError('Completed tool calls require a name'); - const callId = collector.optional([...unit.path, 'call_id']); + const callId = collector.valueOrAbsent([...unit.path, 'call_id']); if (callId === undefined) break; tool = { item, callId }; } - pending.dequeue(); + pending.dropHead(); const outputIndex = outputUnits.length; unit.outputIndex = outputIndex; if (unit.kind === 'message' || unit.kind === 'reasoning') nativeState[outputIndex] = unit.contentState; @@ -531,7 +533,7 @@ export const openaiResponsesFromIR = async function* ( return { events: pendingEvents, justOpened }; }; - const messageUnitEvents = (unit: ResponsesOutputUnit, outputIndex: number, record: IRWriteRecord, state: IR): OpenAIResponsesStreamEventEx[] => { + const messageUnitEvents = (unit: ResponsesOutputUnit, outputIndex: number, record: IRWriteRecord, state: IROutput): OpenAIResponsesStreamEventEx[] => { const itemId = requireId(unit); if (record.kind === 'initialize' && record.path.length === 6) { const type = initializedType(record.value); @@ -600,7 +602,7 @@ export const openaiResponsesFromIR = async function* ( }; // The events for an operation on a unit that is already open. - const openedUnitEvents = (unit: ResponsesOutputUnit, record: IRWriteRecord, state: IR): OpenAIResponsesStreamEventEx[] => { + const openedUnitEvents = (unit: ResponsesOutputUnit, record: IRWriteRecord, state: IROutput): OpenAIResponsesStreamEventEx[] => { const outputIndex = requireOutputIndex(unit); switch (unit.kind) { case 'message': return messageUnitEvents(unit, outputIndex, record, state); @@ -799,7 +801,7 @@ export const openaiResponsesFromIR = async function* ( } if (record.type === 'finish') { - const refusal = state.choices[0].refusal; + const refusal = state.choices[0].refusal_details; const finalUsage = state.usage === undefined ? null : usageFromIR(state.usage); if (refusal?.category === 'cyber' || refusal?.category === 'bio') { const declined = refusal.explanation == null || refusal.explanation.trim() === ''; diff --git a/packages/translate/src/protocols/openai-responses/as-source/usage.ts b/packages/translate/src/protocols/openai-responses/as-source/usage.ts index b985f13c5d..d8a3f7c195 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/usage.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/usage.ts @@ -1,12 +1,12 @@ -import type { IRUsage } from '../../../ir/types.ts'; +import type { IROutputUsage } from '../../../ir/output/types.ts'; import { requireTokenCount } from '../../../shared/token-count.ts'; import type { OpenAIResponsesResultEx } from '@floway-dev/protocols/openai-responses'; type OpenAIResponsesUsage = NonNullable; type OpenAIResponsesInputTokensDetails = NonNullable; -export const usageFromIR = (usage: IRUsage): OpenAIResponsesUsage => { - const count = (key: keyof IRUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); +export const usageFromIR = (usage: IROutputUsage): OpenAIResponsesUsage => { + const count = (key: keyof IROutputUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); const input = count('input_tokens_inclusive') ?? 0; const output = count('output_tokens_inclusive') ?? 0; const inputDetails: Partial = {}; diff --git a/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts b/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts index 8521bdc743..3eba5a9b29 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts @@ -1,14 +1,8 @@ import { toOpenAIResponsesInputAssistantTurn, type OpenAIResponsesItemState, type OpenAIResponsesOutputAssistantState } from './assistant-turn.ts'; import { usageToIR } from './usage.ts'; -import { - createIRBuilder, - type IRRecord, - type IRItemHandle, - type IRPartHandle, - type IRRelativePath, - type IRWriteHandle, -} from '../../../ir/stream.ts'; -import type { IRSourceCitation } from '../../../ir/types.ts'; +import { createIRBuilder, type IRItemHandle, type IRPartHandle, type IRRelativePath, type IRWriteHandle } from '../../../ir/output/builder.ts'; +import type { IRRecord } from '../../../ir/output/records.ts'; +import type { IROutputSourceCitation } from '../../../ir/output/types.ts'; import { appendField, createFieldState, invalidateField, type FieldStates } from '../../../round-trip/reference/reference-builder.ts'; import type { RoundTripReader } from '../../../round-trip/stream.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; @@ -98,14 +92,14 @@ const closedSnapshotEqual = (left: OpenAIResponsesOutputItemEx, right: OpenAIRes return deepEqual(leftSnapshot, rightSnapshot); }; -const annotationToIR = (coordinateIndex: () => TextCoordinateIndex, annotation: OpenAIResponsesAnnotation): IRSourceCitation | undefined => { +const annotationToIR = (coordinateIndex: () => TextCoordinateIndex, annotation: OpenAIResponsesAnnotation): IROutputSourceCitation | undefined => { if (annotation.type === 'url_citation') { return { type: 'source_citation', source_kind: 'url', source: annotation.url, source_label: annotation.title, - output_text_range: coordinateIndex().codePointRangeToIR(annotation.start_index, annotation.end_index), + output_text_range: coordinateIndex().codePointRangeToUTF16(annotation.start_index, annotation.end_index), }; } if (annotation.type === 'file_citation') { @@ -117,7 +111,7 @@ const annotationToIR = (coordinateIndex: () => TextCoordinateIndex, annotation: source_kind: 'document', source: annotation.file_id, source_label: annotation.filename, - output_text_range: coordinateIndex().codePointRangeToIR(annotation.start_index, annotation.end_index), + output_text_range: coordinateIndex().codePointRangeToUTF16(annotation.start_index, annotation.end_index), }; } if (annotation.type === 'file_path') return { type: 'source_citation', source_kind: 'document', source: annotation.file_id }; @@ -246,7 +240,7 @@ export const irFromOpenAIResponses = async function* ( }; const reconcileAppend = (target: ResponsesTextTarget, snapshot: unknown): string => { const { handle, path } = target; - const absolutePath = handle.resolve(path); + const absolutePath = handle.absolutePath(path); if (typeof snapshot !== 'string') throw new TypeError(`Responses text snapshot must be a string at ${JSON.stringify(absolutePath)}`); const current = handle.current(path); // Responses done events are final snapshots in the official SDK. Downstream @@ -285,7 +279,7 @@ export const irFromOpenAIResponses = async function* ( const current = handle.current(path); if (current === undefined) handle.assign(path, snapshot); else if (!deepEqual(current, snapshot)) { - throw new TypeError(`Responses snapshot conflicts with streamed value at ${JSON.stringify(handle.resolve(path))}`); + throw new TypeError(`Responses snapshot conflicts with streamed value at ${JSON.stringify(handle.absolutePath(path))}`); } }; @@ -515,7 +509,7 @@ export const irFromOpenAIResponses = async function* ( if (state.parts.size === 0) { const handle = state.item.part({ type: 'image', - image: { data: item.result, ...(item.output_format == null ? {} : { mime_type: `image/${item.output_format}` }) }, + image: { data: item.result, ...(item.output_format == null ? {} : { media_type: `image/${item.output_format}` }) }, }); state.parts.set(0, { type: 'image', handle }); } else { @@ -524,7 +518,7 @@ export const irFromOpenAIResponses = async function* ( const { handle } = part; reconcileAssign({ handle, path: ['image', 'data'] }, item.result); if (item.output_format != null) { - reconcileAssign({ handle, path: ['image', 'mime_type'] }, `image/${item.output_format}`); + reconcileAssign({ handle, path: ['image', 'media_type'] }, `image/${item.output_format}`); } } } @@ -601,7 +595,7 @@ export const irFromOpenAIResponses = async function* ( audioItem.end(); } const policy = response.error?.code === 'cyber_policy' ? 'cyber' : response.error?.code === 'bio_policy' ? 'bio' : undefined; - if (policy !== undefined) ir.choice(0).assign('refusal', { category: policy, explanation: response.error?.message }); + if (policy !== undefined) ir.choice(0).assign('refusal_details', { category: policy, explanation: response.error?.message }); outputState.length = terminalOutput.length; const input = toOpenAIResponsesInputAssistantTurn(terminalOutput, outputState); options.roundTrip.onAssistantTurn(0, input.turn, input.state); diff --git a/packages/translate/src/protocols/openai-responses/as-target/usage.ts b/packages/translate/src/protocols/openai-responses/as-target/usage.ts index 0e00c3a745..fd90b723f0 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/usage.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/usage.ts @@ -1,4 +1,4 @@ -import type { IRUsage } from '../../../ir/types.ts'; +import type { IROutputUsage } from '../../../ir/output/types.ts'; import { requireTokenCount } from '../../../shared/token-count.ts'; import { splitInclusiveInputTokens } from '@floway-dev/protocols/common'; @@ -18,9 +18,9 @@ interface OpenAIResponsesUsageFields { output_tokens_details?: { reasoning_tokens?: number; audio_tokens?: number; text_tokens?: number }; } -export const usageToIR = (usage: OpenAIResponsesUsageFields): IRUsage => { - const result: Partial> = {}; - const put = (key: keyof IRUsage, value: number | undefined | null): void => { if (value != null) result[key] = requireTokenCount(key, value); }; +export const usageToIR = (usage: OpenAIResponsesUsageFields): IROutputUsage => { + const result: Partial> = {}; + const put = (key: keyof IROutputUsage, value: number | undefined | null): void => { if (value != null) result[key] = requireTokenCount(key, value); }; put('input_tokens_inclusive', usage.input_tokens); put('output_tokens_inclusive', usage.output_tokens); put('total_tokens', usage.total_tokens); diff --git a/packages/translate/src/round-trip/reference/assistant-turn.ts b/packages/translate/src/round-trip/reference/assistant-turn.ts index 86cfa647e5..fe11524e10 100644 --- a/packages/translate/src/round-trip/reference/assistant-turn.ts +++ b/packages/translate/src/round-trip/reference/assistant-turn.ts @@ -1,4 +1,4 @@ -import type { IRJSONObject } from '../../ir/types.ts'; +import type { IRJSONObject } from '../../ir/common/json.ts'; import type { AnthropicMessagesAssistantState, AnthropicMessagesReferenceableAssistantTurn } from '../../protocols/anthropic-messages/as-target/assistant-turn.ts'; import type { GeminiGenerateContentAssistantState, GeminiGenerateContentReferenceableAssistantTurn } from '../../protocols/gemini-generate-content/as-target/assistant-turn.ts'; import type { OpenAIChatCompletionsAssistantState, OpenAIChatCompletionsReferenceableAssistantTurn } from '../../protocols/openai-chat-completions/as-target/assistant-turn.ts'; diff --git a/packages/translate/src/round-trip/reference/reference-builder.ts b/packages/translate/src/round-trip/reference/reference-builder.ts index f4a584c816..4bebe685a7 100644 --- a/packages/translate/src/round-trip/reference/reference-builder.ts +++ b/packages/translate/src/round-trip/reference/reference-builder.ts @@ -1,5 +1,5 @@ import { ContentReference, type AssistantTurnStateFor, type ContentReferenceSegment } from './assistant-turn.ts'; -import type { IRJSONObject } from '../../ir/types.ts'; +import type { IRJSONObject } from '../../ir/common/json.ts'; import { parseJSONObject } from '../../shared/json/object.ts'; import type { AssistantTurnByProtocol, AssistantTurnFor } from '../assistant-turn.ts'; import type { TextPlacement, TextPlacementIndex } from '../text-groups.ts'; diff --git a/packages/translate/src/round-trip/reference/reference-selection.ts b/packages/translate/src/round-trip/reference/reference-selection.ts index 87114abb1d..3d8f10818e 100644 --- a/packages/translate/src/round-trip/reference/reference-selection.ts +++ b/packages/translate/src/round-trip/reference/reference-selection.ts @@ -1,6 +1,6 @@ import { ContentReference, type AssistantTurnStateFor, type ContentReferenceSegment, type ReferenceableAssistantTurnFor } from './assistant-turn.ts'; import { createReferenceFinalizer, isFieldState, pairedChildren, type FieldState, type FieldStates, type ReferenceBuilder } from './reference-builder.ts'; -import type { IRJSONObject } from '../../ir/types.ts'; +import type { IRJSONObject } from '../../ir/common/json.ts'; import { requireJSONObject } from '../../shared/json/object.ts'; import { rawJSON } from '../../shared/json/raw-json.ts'; import type { AssistantTurnByProtocol, AssistantTurnFor } from '../assistant-turn.ts'; diff --git a/packages/translate/src/round-trip/turn-digest.ts b/packages/translate/src/round-trip/turn-digest.ts index 065d3b8982..a6688cbfb6 100644 --- a/packages/translate/src/round-trip/turn-digest.ts +++ b/packages/translate/src/round-trip/turn-digest.ts @@ -1,4 +1,4 @@ -import type { IRJSONObject, IRJSONValue } from '../ir/types.ts'; +import type { IRJSONObject, IRJSONValue } from '../ir/common/json.ts'; export type RoundTripTurnDigest = Uint8Array; diff --git a/packages/translate/src/shared/json/object.ts b/packages/translate/src/shared/json/object.ts index 4032f2a5e5..76f05749c7 100644 --- a/packages/translate/src/shared/json/object.ts +++ b/packages/translate/src/shared/json/object.ts @@ -1,5 +1,5 @@ import { rawJSON } from './raw-json.ts'; -import type { IRJSONObject } from '../../ir/types.ts'; +import type { IRJSONObject } from '../../ir/common/json.ts'; import { isJsonObject, parseJSONWithRawNumbers } from '@floway-dev/protocols/common'; export const isPlainJSONObject = (value: unknown): value is IRJSONObject => isJsonObject(value) && !rawJSON.isRawJSON(value); diff --git a/packages/translate/src/shared/text-coordinate-index.ts b/packages/translate/src/shared/text-coordinate-index.ts index 563a955c04..74cc96296c 100644 --- a/packages/translate/src/shared/text-coordinate-index.ts +++ b/packages/translate/src/shared/text-coordinate-index.ts @@ -1,4 +1,4 @@ -import type { IRUTF16TextRange } from '../ir/types.ts'; +import type { TextRange } from './text-range.ts'; type TextCoordinateMode = 'code-point-to-utf16' | 'utf16-to-code-point' | 'utf16-to-utf8'; @@ -118,27 +118,27 @@ export class TextCoordinateIndex { this.#trailingHighSurrogate = isHighSurrogate(delta.charCodeAt(delta.length - 1)); } - codePointRangeToIR(start: number, end: number): IRUTF16TextRange { + codePointRangeToUTF16(start: number, end: number): TextRange { if (this.#mode !== 'code-point-to-utf16') throw new TypeError('Text coordinate index does not map code points to UTF-16'); if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start) throw new RangeError('Invalid code-point range'); if (end >= this.#boundaries.length) throw new RangeError('Code-point range exceeds text'); return { start: this.#boundaries.get(start), end_exclusive: this.#boundaries.get(end) }; } - irRangeToCodePoints(range: IRUTF16TextRange): { start: number; end: number } { + utf16RangeToCodePoints(range: TextRange): { start: number; end: number } { if (this.#mode !== 'utf16-to-code-point') throw new TypeError('Text coordinate index does not map UTF-16 to code points'); const [start, end] = this.#utf16Range(range); return { start, end }; } - irRangeToUTF8(range: IRUTF16TextRange): { start: number; end: number } { + utf16RangeToUTF8(range: TextRange): { start: number; end: number } { if (this.#mode !== 'utf16-to-utf8') throw new TypeError('Text coordinate index does not map UTF-16 to UTF-8'); if (this.#unpairedSurrogates !== 0) throw new TypeError('GenerateContent byte coordinates require well-formed Unicode'); const [start, end] = this.#utf16Range(range); return { start, end }; } - #utf16Range(range: IRUTF16TextRange): readonly [number, number] { + #utf16Range(range: TextRange): readonly [number, number] { if (!Number.isInteger(range.start) || !Number.isInteger(range.end_exclusive) || range.start < 0 || range.end_exclusive < range.start || range.end_exclusive > this.#utf16Length) { throw new RangeError('IR range is outside text or splits a surrogate pair'); diff --git a/packages/translate/src/shared/text-range.ts b/packages/translate/src/shared/text-range.ts new file mode 100644 index 0000000000..add60b4335 --- /dev/null +++ b/packages/translate/src/shared/text-range.ts @@ -0,0 +1,7 @@ +// A range over a JavaScript string, counted in UTF-16 code units so it slices +// the stored value exactly, including isolated surrogates. Native protocols may +// count code points or UTF-8 bytes; their readers convert. +export interface TextRange { + readonly start: number; + readonly end_exclusive: number; +} From 2c93c64fed98425aa6517fa08e9569c96b3fea29 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 05:30:09 +0800 Subject: [PATCH 07/66] fix(translate): retain streaming error envelope metadata Normalize HTTP and streaming error envelopes through the same metadata merge. Preserve outer request identifiers and trace fields in the existing extension container while retaining error-level collision priority. Distinguish event transport fields from native error payloads so canonical payloads retain their original reference and classification when no envelope data remains. Validate 324 tests in 19 named files, ESLint on all four changed files, and the translate and gateway TypeScript projects. --- .../protocols/cross-protocol-errors_test.ts | 32 ++++++++++++ .../__tests__/shared/api-error_test.ts | 3 ++ .../openai-responses/as-target/sse-from.ts | 5 +- packages/translate/src/shared/api-error.ts | 52 ++++++++++++------- 4 files changed, 70 insertions(+), 22 deletions(-) diff --git a/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts b/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts index de0b7adf5a..0a2cea6c25 100644 --- a/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts +++ b/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts @@ -141,6 +141,38 @@ test('Responses flat errors remove transport discriminants before restatement', expect(frames).toEqual([eventFrame({ error: { message: 'failed', code: null, param: null } })]); }); +test('Responses nested error envelopes preserve request_id in both phases', async () => { + const trip = await requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses(chat, options())); + const envelope = { + type: 'error' as const, sequence_number: 17, request_id: 'req_1', error: { message: 'failed', code: null }, + }; + const expected = { error: { message: 'failed', code: null, provider_specific_fields: { request_id: 'req_1' } } }; + const http = trip.error(502, new Headers(), new TextEncoder().encode(JSON.stringify(envelope))); + expect(http?.body).toEqual(expected); + expect(await collect(trip.events(iterate([eventFrame(envelope)])))).toEqual([eventFrame(expected)]); +}); + +test('Responses nested error envelopes preserve outer metadata in both phases', async () => { + const trip = await requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses(chat, options())); + const error = { + message: 'failed', type: 'upstream_type', code: null, param: null, + request_id: 'inner_loose', provider_specific_fields: { request_id: 'inner_bag', retry_after: 7 }, + }; + const envelope = { + type: 'error' as const, sequence_number: 17, error, + request_id: 'outer', trace: { region: 'us' }, provider_specific_fields: { outer_detail: true }, + }; + const expected = { + error: { + message: 'failed', type: 'upstream_type', code: null, param: null, + provider_specific_fields: { request_id: 'inner_bag', retry_after: 7, trace: { region: 'us' }, outer_detail: true }, + }, + }; + const http = trip.error(502, new Headers(), new TextEncoder().encode(JSON.stringify(envelope))); + expect(http?.body).toEqual(expected); + expect(await collect(trip.events(iterate([eventFrame(envelope)])))).toEqual([eventFrame(expected)]); +}); + test('native target errors retain canonical object identity', async () => { const chatRecords = await collect(irFromOpenAIChatCompletions(iterate([eventFrame(chatEvent(failure))]), { roundTrip: createRoundTripReaderCapture<'openaiChatCompletions'>().roundTrip, diff --git a/packages/translate/__tests__/shared/api-error_test.ts b/packages/translate/__tests__/shared/api-error_test.ts index 0d1d9c10db..e3e90446b5 100644 --- a/packages/translate/__tests__/shared/api-error_test.ts +++ b/packages/translate/__tests__/shared/api-error_test.ts @@ -74,6 +74,9 @@ test('canonical wire errors retain object identity', () => { const error = { message: 'failed', type: 'upstream_type', code: 'upstream_code', param: null, request_id: 'req_1' }; expect(errorPayloadFromWire(error)).toBe(error); expect(errorPayloadFromWire({ error })).toBe(error); + expect(errorPayloadFromWire({ type: 'error', sequence_number: 1, error })).toBe(error); + const nativeCategory = { ...error, type: 'error' }; + expect(errorPayloadFromWire(nativeCategory)).toBe(nativeCategory); }); test('envelope transport fields stay outside the error metadata', () => { diff --git a/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts b/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts index 565426dc20..1e48c0e3d2 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts @@ -11,7 +11,7 @@ import { import type { IRSourceCitation } from '../../../ir/types.ts'; import { appendField, createFieldState, invalidateField, type FieldStates } from '../../../round-trip/reference/reference-builder.ts'; import type { RoundTripReader } from '../../../round-trip/stream.ts'; -import { errorPayloadFromWire } from '../../../shared/api-error.ts'; +import { errorPayloadFromEvent, errorPayloadFromWire } from '../../../shared/api-error.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { @@ -550,8 +550,7 @@ export const irFromOpenAIResponses = async function* ( if (frame.type === 'done') throw new Error('Responses done arrived without a terminal response'); const e = frame.event; if (e.type === 'error') { - const { sequence_number: _sequenceNumber, type: _type, ...failure } = e; - ir.event({ type: 'error', error: errorPayloadFromWire('error' in e ? e.error ?? e : failure) }); + ir.event({ type: 'error', error: errorPayloadFromEvent(e) }); yield* ir.drain(); return; } diff --git a/packages/translate/src/shared/api-error.ts b/packages/translate/src/shared/api-error.ts index 39659d1e22..2a95afa549 100644 --- a/packages/translate/src/shared/api-error.ts +++ b/packages/translate/src/shared/api-error.ts @@ -24,18 +24,9 @@ export const irErrorPayloadFromBody = (body: Uint8Array): IRErrorPayload | undef } const envelopeRecord = recordOf(parsed); if (envelopeRecord === undefined) return undefined; - const nested = recordOf(envelopeRecord.error); - if (nested === undefined) { - const { type: _type, sequence_number: _sequenceNumber, ...failure } = envelopeRecord; - return errorPayloadOf(envelopeRecord.type === 'error' ? failure : envelopeRecord); - } - const payload = errorPayloadOf(nested); - if (payload === undefined) return undefined; - const envelope = extensionOf(envelopeRecord, new Set(['error', ...(envelopeRecord.type === 'error' ? ['type', 'sequence_number'] : [])])); - return envelope === undefined ? payload : { - ...payload, - provider_specific_fields: { ...envelope, ...payload.provider_specific_fields }, - }; + const { source, details } = errorEnvelopeOf(envelopeRecord.type === 'error' ? errorEventPayloadOf(envelopeRecord) : envelopeRecord); + const payload = errorPayloadOf(source); + return payload === undefined ? undefined : withEnvelopeDetails(payload, details); }; export const errorPayloadOf = (error: Readonly>): IRErrorPayload | undefined => { @@ -60,19 +51,42 @@ export const errorPayloadOf = (error: Readonly>): IRErro }; }; -// Canonical wire payloads retain identity for native errors carried through the IR. +const errorEnvelopeOf = (envelope: Readonly>) => { + const nested = recordOf(envelope.error); + const transportFields = envelope.type === 'error' ? ['type', 'sequence_number'] : []; + if (nested !== undefined) return { + source: nested, + details: extensionOf(envelope, new Set(['error', ...transportFields])), + }; + return { source: envelope, details: undefined }; +}; + +// Error-level extension fields take precedence over envelope metadata in their shared container. +const withEnvelopeDetails = (payload: IRErrorPayload, details: Readonly> | undefined): IRErrorPayload => + details === undefined ? payload : { + ...payload, + provider_specific_fields: { ...details, ...errorDetailsOf(payload, ['message', 'type', 'code', 'param']) }, + }; + +const errorEventPayloadOf = (event: Readonly>) => { + const { type: _type, sequence_number: _sequenceNumber, ...payload } = event; + return payload; +}; + +export const errorPayloadFromEvent = (event: Readonly>): IRErrorPayload => + errorPayloadFromWire(errorEventPayloadOf(event)); + +// Canonical wire payloads retain identity when their envelope adds no metadata. export const errorPayloadFromWire = (wire: unknown): IRErrorPayload => { - const stated = recordOf(wire) ?? {}; - const nested = recordOf(stated.error); - const source = nested ?? stated; - if (hasErrorMessage(source)) return source; + const { source, details } = errorEnvelopeOf(recordOf(wire) ?? {}); + if (hasErrorMessage(source)) return withEnvelopeDetails(source, details); const aliasMessage = stringOf(source.msg); if (aliasMessage !== undefined) { const { msg: _msg, ...fields } = source; - return { ...fields, message: aliasMessage }; + return withEnvelopeDetails({ ...fields, message: aliasMessage }, details); } const extra = extensionOf(source, NAMED_ERROR_FIELDS); - return { message: '', ...(extra === undefined ? {} : { provider_specific_fields: extra }) }; + return withEnvelopeDetails({ message: '', ...(extra === undefined ? {} : { provider_specific_fields: extra }) }, details); }; const hasErrorMessage = (value: Readonly>): value is Readonly> & { readonly message: string } => From cdb444b2e6d69e3e215cca375d03caa6655d285a Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 05:52:48 +0800 Subject: [PATCH 08/66] refactor(translate): finalize output event ownership Use directional events modules and read/write entrypoints for the seven native output adapters, and mirror those owners in their tests. Move the unchanged source writer options shape to its shared owner. Finish schema phantom, absent-string and type-equality names so the input work has no remaining output-only naming migration. Preserve native behavior with 622 named tests, 67 controlled main/final wire and replay probes, 66 runtime/declaration/test-body correspondences, 98 scoped lint targets and the translate TypeScript project. --- packages/translate/AGENTS.md | 6 +- .../{sse-to_test.ts => events_test.ts} | 8 +- .../{sse-from_test.ts => events_test.ts} | 4 +- .../__tests__/protocols/edge-cases_test.ts | 54 ++++++------ .../{sse-to_test.ts => events_test.ts} | 6 +- .../translate/__tests__/protocols/helpers.ts | 44 +++++----- .../__tests__/protocols/lifecycle_test.ts | 60 ++++++------- .../{sse-to_test.ts => events_test.ts} | 6 +- .../{sse-from_test.ts => events_test.ts} | 6 +- .../{sse-to_test.ts => events_test.ts} | 4 +- .../as-source/initialize_test.ts | 4 +- .../{sse-from_test.ts => events_test.ts} | 4 +- .../as-target/snapshots_test.ts | 8 +- .../__tests__/protocols/ownership_test.ts | 34 ++++---- .../__tests__/protocols/streams_test.ts | 86 +++++++++---------- .../__tests__/protocols/usage_test.ts | 20 ++--- packages/translate/src/ir/output/schema.ts | 12 +-- .../as-source/{sse-to.ts => events.ts} | 8 +- .../as-target/{sse-from.ts => events.ts} | 2 +- .../via-openai-chat-completions/events.ts | 6 +- .../via-openai-responses/events.ts | 6 +- .../as-source/{sse-to.ts => events.ts} | 8 +- .../via-anthropic-messages/events.ts | 6 +- .../via-openai-chat-completions/events.ts | 6 +- .../via-openai-responses/events.ts | 6 +- .../as-source/{sse-to.ts => events.ts} | 8 +- .../as-target/{sse-from.ts => events.ts} | 2 +- .../via-anthropic-messages/events.ts | 6 +- .../via-openai-responses/events.ts | 6 +- .../as-source/{sse-to.ts => events.ts} | 8 +- .../as-target/{sse-from.ts => events.ts} | 2 +- .../via-anthropic-messages/events.ts | 6 +- .../via-openai-chat-completions/events.ts | 6 +- packages/translate/src/round-trip/stream.ts | 6 -- .../src/shared/event-writer-options.ts | 8 ++ 35 files changed, 237 insertions(+), 235 deletions(-) rename packages/translate/__tests__/protocols/anthropic-messages/as-source/{sse-to_test.ts => events_test.ts} (90%) rename packages/translate/__tests__/protocols/anthropic-messages/as-target/{sse-from_test.ts => events_test.ts} (93%) rename packages/translate/__tests__/protocols/gemini-generate-content/as-source/{sse-to_test.ts => events_test.ts} (86%) rename packages/translate/__tests__/protocols/openai-chat-completions/as-source/{sse-to_test.ts => events_test.ts} (92%) rename packages/translate/__tests__/protocols/openai-chat-completions/as-target/{sse-from_test.ts => events_test.ts} (95%) rename packages/translate/__tests__/protocols/openai-responses/as-source/{sse-to_test.ts => events_test.ts} (95%) rename packages/translate/__tests__/protocols/openai-responses/as-target/{sse-from_test.ts => events_test.ts} (98%) rename packages/translate/src/protocols/anthropic-messages/as-source/{sse-to.ts => events.ts} (98%) rename packages/translate/src/protocols/anthropic-messages/as-target/{sse-from.ts => events.ts} (99%) rename packages/translate/src/protocols/gemini-generate-content/as-source/{sse-to.ts => events.ts} (98%) rename packages/translate/src/protocols/openai-chat-completions/as-source/{sse-to.ts => events.ts} (98%) rename packages/translate/src/protocols/openai-chat-completions/as-target/{sse-from.ts => events.ts} (99%) rename packages/translate/src/protocols/openai-responses/as-source/{sse-to.ts => events.ts} (99%) rename packages/translate/src/protocols/openai-responses/as-target/{sse-from.ts => events.ts} (99%) create mode 100644 packages/translate/src/shared/event-writer-options.ts diff --git a/packages/translate/AGENTS.md b/packages/translate/AGENTS.md index e2e20bbff5..78a20dd6ce 100644 --- a/packages/translate/AGENTS.md +++ b/packages/translate/AGENTS.md @@ -20,9 +20,9 @@ import the source protocol's subpath and call 5. **Output IR** — keep private types, its path schema, stream operations, validation, accumulation, operation paths, and native unit index allocation under `ir/output/`. Native SSE adapters - belong to their protocol: `as-target/sse-from.ts` consumes upstream SSE and - `as-source/sse-to.ts` emits client SSE with its sidecar. The return path is - upstream SSE → IR SSE → client SSE with sidecar. IR stays internal to translate. + belong to their protocol: `as-target/events.ts` reads upstream protocol events and + `as-source/events.ts` writes client protocol events with its sidecar. The return path is + upstream events → IR records → client events with sidecar. IR stays internal to translate. 6. **Assistant-turn round trips** — keep owner-specific sidecar validation, replay validation, and native stream coordination under `round-trip/`. Source replay inspection and carriers belong in `as-source/round-trip.ts`; native assistant-turn diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/events_test.ts similarity index 90% rename from packages/translate/__tests__/protocols/anthropic-messages/as-source/sse-to_test.ts rename to packages/translate/__tests__/protocols/anthropic-messages/as-source/events_test.ts index 40fe708d69..2f7ea48519 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-source/events_test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; -import { anthropicMessagesFromIR } from '../../../../src/protocols/anthropic-messages/as-source/sse-to.ts'; +import { writeAnthropicMessagesEvents } from '../../../../src/protocols/anthropic-messages/as-source/events.ts'; import { appendField, collectContentViews, createReferenceBuilder, createFieldState, createReferenceFinalizer } from '../../../../src/round-trip/reference/reference-builder.ts'; import type { RoundTripWriter } from '../../../../src/round-trip/stream.ts'; import { hashRoundTripContent } from '../../../../src/round-trip/turn-digest.ts'; @@ -42,7 +42,7 @@ test.each(['text', 'refusal', 'audio'] as const)('resolves blocked %s against th }, }; const events: AnthropicMessagesStreamEventEx[] = []; - for await (const frame of anthropicMessagesFromIR((async function* () { yield* source.drain(); })(), { roundTrip })) events.push(frame.event); + for await (const frame of writeAnthropicMessagesEvents((async function* () { yield* source.drain(); })(), { roundTrip })) events.push(frame.event); expect(events.flatMap(event => event.type === 'content_block_delta' && event.delta.type === 'text_delta' ? [event.delta.text] : [])).toEqual(['Hi']); expect(prepared).toBe(true); }); @@ -78,7 +78,7 @@ test('resolves blocked reasoning in emitted summary/content order before live ap prepareSidecar: async (...args) => { prepared = args; return 'sidecar'; }, }; const events: AnthropicMessagesStreamEventEx[] = []; - for await (const frame of anthropicMessagesFromIR((async function* () { yield* source.drain(); })(), { roundTrip })) events.push(frame.event); + for await (const frame of writeAnthropicMessagesEvents((async function* () { yield* source.drain(); })(), { roundTrip })) events.push(frame.event); expect(events.flatMap(event => event.type === 'content_block_delta' && event.delta.type === 'thinking_delta' ? [event.delta.thinking] : [])).toEqual(['ABX', 'C']); if (prepared === undefined) throw new Error('Messages sidecar was not prepared'); const [, , turn, state] = prepared; @@ -114,6 +114,6 @@ test.each(['{"x":1,}', '{"x": 1}'])('tracks the emitted canonical JSON view with return 'sidecar'; }, }; - for await (const _frame of anthropicMessagesFromIR((async function* () { yield* source.drain(); })(), { roundTrip, parseToolArguments: original.endsWith(',}') ? () => ({ x: 1 }) : undefined })) {} + for await (const _frame of writeAnthropicMessagesEvents((async function* () { yield* source.drain(); })(), { roundTrip, parseToolArguments: original.endsWith(',}') ? () => ({ x: 1 }) : undefined })) {} expect(prepared).toBe(true); }); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-target/sse-from_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-target/events_test.ts similarity index 93% rename from packages/translate/__tests__/protocols/anthropic-messages/as-target/sse-from_test.ts rename to packages/translate/__tests__/protocols/anthropic-messages/as-target/events_test.ts index cff11a8ab1..eab9b2ffb8 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-target/sse-from_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-target/events_test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; import type { AnthropicMessagesAssistantState, AnthropicMessagesInputAssistantTurn } from '../../../../src/protocols/anthropic-messages/as-target/assistant-turn.ts'; -import { irFromAnthropicMessages } from '../../../../src/protocols/anthropic-messages/as-target/sse-from.ts'; +import { readAnthropicMessagesEvents } from '../../../../src/protocols/anthropic-messages/as-target/events.ts'; import { appendContent, collectContentViews, createContentState, createReferenceBuilder, type FieldStates } from '../../../../src/round-trip/reference/reference-builder.ts'; import { finalizeAssistantTurnReferences } from '../../../../src/round-trip/reference/reference-selection.ts'; import { fixtureFrames } from '../../fixtures.ts'; @@ -26,7 +26,7 @@ test('keeps native-shaped field state aligned through sparse block ordering and const builder = createReferenceBuilder(); const content = createContentState(builder, 'text'); let captured: { turn: AnthropicMessagesInputAssistantTurn; state: AnthropicMessagesAssistantState } | undefined; - for await (const record of irFromAnthropicMessages((async function* () { yield* frames; })(), { + for await (const record of readAnthropicMessagesEvents((async function* () { yield* frames; })(), { roundTrip: { builder, onAssistantTurn: (_choice, turn, state) => { captured = { turn, state }; } }, })) { if (record.type === 'write' && record.kind === 'append' && record.path.at(-1) === 'text') appendContent(builder, content, record.value, record.referencePieceIds); diff --git a/packages/translate/__tests__/protocols/edge-cases_test.ts b/packages/translate/__tests__/protocols/edge-cases_test.ts index 28ffbb2025..41e83be185 100644 --- a/packages/translate/__tests__/protocols/edge-cases_test.ts +++ b/packages/translate/__tests__/protocols/edge-cases_test.ts @@ -1,14 +1,14 @@ import { expect, test, vi } from 'vitest'; import { fixtureFrames, nativeResult } from './fixtures.ts'; -import { anthropicMessagesFromIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; +import { writeAnthropicMessagesEvents, writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; import { createIRBuilder } from '../../src/ir/output/builder.ts'; import { translateToSourceEvents as messagesViaChat } from '../../src/protocols/anthropic-messages/via-openai-chat-completions/events.ts'; import { buildTargetRequest as messagesRequestViaChat } from '../../src/protocols/anthropic-messages/via-openai-chat-completions/request.ts'; import { translateToSourceEvents as messagesViaResponses } from '../../src/protocols/anthropic-messages/via-openai-responses/events.ts'; import { translateToSourceEvents as generateContentViaChat } from '../../src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts'; import { translateToSourceEvents as generateContentViaResponses } from '../../src/protocols/gemini-generate-content/via-openai-responses/events.ts'; -import { irFromOpenAIChatCompletions as irFromOpenAIChatCompletionsProduction } from '../../src/protocols/openai-chat-completions/as-target/sse-from.ts'; +import { readOpenAIChatCompletionsEvents as readOpenAIChatCompletionsEventsProduction } from '../../src/protocols/openai-chat-completions/as-target/events.ts'; import { translateToSourceEvents as responsesViaMessages } from '../../src/protocols/openai-responses/via-anthropic-messages/events.ts'; import { translateToSourceEvents as responsesViaChat } from '../../src/protocols/openai-responses/via-openai-chat-completions/events.ts'; import { collectIR, completeIR } from '../ir/output/helpers.ts'; @@ -48,7 +48,7 @@ test('rate limiting becomes a native GenerateContent error', async () => { }); test('ChatCompletions ignores empty identity frames and starts when a model appears', async () => { - const frames = await collect(irFromOpenAIChatCompletions(iterate([ + const frames = await collect(readOpenAIChatCompletionsEvents(iterate([ eventFrame({ id: '', object: '', created: 0, model: '', choices: [], prompt_filter_results: [{}], usage: null } as any), eventFrame({ id: 'chat-real', object: 'chat.completion.chunk', created: 10, model: 'served', choices: [] } as any), eventFrame({ id: 'chat-real', object: 'chat.completion.chunk', created: 10, model: 'served', choices: [{ index: 0, delta: { content: 'answer' }, finish_reason: null }] } as any), @@ -59,7 +59,7 @@ test('ChatCompletions ignores empty identity frames and starts when a model appe }); test('ChatCompletions rejects generated content before any non-empty model', async () => { - await expect(collectIR(irFromOpenAIChatCompletions(iterate([ + await expect(collectIR(readOpenAIChatCompletionsEvents(iterate([ eventFrame({ id: '', object: '', created: 0, model: '', choices: [] } as any), eventFrame({ id: '', object: '', created: 0, model: '', choices: [{ index: 0, delta: { content: 'answer' }, finish_reason: null }] } as any), ])))).rejects.toThrow('before a non-empty model'); @@ -67,7 +67,7 @@ test('ChatCompletions rejects generated content before any non-empty model', asy test('ChatCompletions role-only choices still require finish_reason without creating IR records', async () => { const role = eventFrame({ id: 'chat_role', model: 'served', choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] } as any); - await expect(collectIR(irFromOpenAIChatCompletions(iterate([role])))).rejects.toThrow('ended without finish_reason'); + await expect(collectIR(readOpenAIChatCompletionsEvents(iterate([role])))).rejects.toThrow('ended without finish_reason'); let reached!: () => void; let release!: () => void; @@ -80,7 +80,7 @@ test('ChatCompletions role-only choices still require finish_reason without crea yield eventFrame({ id: 'chat_role', model: 'served', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] } as any); }; const output: any[] = []; - const run = (async () => { for await (const record of irFromOpenAIChatCompletions(source())) output.push(record); })(); + const run = (async () => { for await (const record of readOpenAIChatCompletionsEvents(source())) output.push(record); })(); await atGate; expect(output.some(record => record.type === 'write' && record.path[0] === 'choices')).toBe(false); release(); @@ -89,13 +89,13 @@ test('ChatCompletions role-only choices still require finish_reason without crea test.each([['id', 'an id'], ['model', 'a model']] as const)('Messages message_start requires a string %s', async (field, expected) => { const message = { id: 'msg', model: 'claude-test', content: [], usage: { input_tokens: 0, output_tokens: 0 }, [field]: 42 }; - await expect(collectIR(irFromAnthropicMessages(iterate([ + await expect(collectIR(readAnthropicMessagesEvents(iterate([ eventFrame({ type: 'message_start', message } as any), ])))).rejects.toThrow(`requires ${expected}`); }); test('Messages takes tool input only from input_json_delta', async () => { - const ir = await collectIR(irFromAnthropicMessages(iterate([ + const ir = await collectIR(readAnthropicMessagesEvents(iterate([ eventFrame({ type: 'message_start', message: { id: 'msg', model: 'claude-test', content: [], usage: { input_tokens: 0, output_tokens: 0 } } } as any), eventFrame({ type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'call', name: 'lookup', input: { ignored: true } } } as any), eventFrame({ type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '{"q":1}' } } as any), @@ -112,46 +112,46 @@ test('ChatCompletions ignores late identity changes and accepts later model upda eventFrame({ id: 'chat-late', object: 'other', created: 20, model: 'fallback', choices: [{ index: 0, delta: { content: 'B' }, finish_reason: null }] } as any), eventFrame({ id: '', object: '', created: 0, model: '', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] } as any), ]; - const frames = await collect(irFromOpenAIChatCompletions(iterate(input))); + const frames = await collect(readOpenAIChatCompletionsEvents(iterate(input))); const start = frames.find(record => record.type === 'start'); expect(start).toEqual({ type: 'start', id: 'chat-first', created: 10 }); - expect((await collectIR(irFromOpenAIChatCompletions(iterate(input)))).model).toBe('fallback'); + expect((await collectIR(readOpenAIChatCompletionsEvents(iterate(input)))).model).toBe('fallback'); }); test('ChatCompletions choice indexes must fit the request choice count', async () => { - await expect(collectIR(irFromOpenAIChatCompletions(iterate([ + await expect(collectIR(readOpenAIChatCompletionsEvents(iterate([ eventFrame({ id: 'chat_choices', model: 'served', choices: [{ index: 1, delta: {}, finish_reason: 'stop' }] } as any), ]), { maxChoices: 1 }))).rejects.toThrow('configured choice count'); }); test('Responses rejects output events before the initial envelope', async () => { - await expect(collectIR(irFromOpenAIResponses(iterate([ + await expect(collectIR(readOpenAIResponsesEvents(iterate([ eventFrame({ type: 'response.output_item.added', output_index: 0, item: { type: 'message', id: 'msg', role: 'assistant', content: [] } } as any), ])))).rejects.toThrow('before the initial response envelope'); }); test('Messages and GenerateContent reject custom tool calls they cannot represent', async () => { const frames = completeIR([{ type: 'custom_tool_call', call_id: 'call_custom', name: 'run_code', input: 'input' }]); - await expect(collect(anthropicMessagesFromIR(iterate(frames)))).rejects.toThrow('cannot represent custom tool calls'); - await expect(collect(geminiGenerateContentFromIR(iterate(frames)))).rejects.toThrow('cannot represent custom tool calls'); + await expect(collect(writeAnthropicMessagesEvents(iterate(frames)))).rejects.toThrow('cannot represent custom tool calls'); + await expect(collect(writeGeminiGenerateContentEvents(iterate(frames)))).rejects.toThrow('cannot represent custom tool calls'); }); test('writers omit output they cannot represent', async () => { const image = completeIR([{ type: 'message', content: [{ type: 'text', text: 'before' }, { type: 'image', image: { data: 'aW1n', media_type: 'image/png' } }] }]); - expect(JSON.stringify(await collect(anthropicMessagesFromIR(iterate(image))))).toContain('before'); - expect(JSON.stringify(await collect(openaiChatCompletionsFromIR(iterate(image))))).toContain('before'); + expect(JSON.stringify(await collect(writeAnthropicMessagesEvents(iterate(image))))).toContain('before'); + expect(JSON.stringify(await collect(writeOpenAIChatCompletionsEvents(iterate(image))))).toContain('before'); const documentCitation = completeIR([{ type: 'message', content: [{ type: 'text', text: 'source', annotations: [{ type: 'source_citation', source_kind: 'document', source: 'file_1', source_label: 'Document', output_text_range: { start: 0, end_exclusive: 6 } }] }] }]); - expect(JSON.stringify(await collect(openaiChatCompletionsFromIR(iterate(documentCitation))))).not.toContain('url_citation'); + expect(JSON.stringify(await collect(writeOpenAIChatCompletionsEvents(iterate(documentCitation))))).not.toContain('url_citation'); const ranged = completeIR([{ type: 'message', content: [{ type: 'text', text: 'source', annotations: [{ type: 'source_citation', source_kind: 'url', source: 'https://example.com', output_text_range: { start: 0, end_exclusive: 6 } }] }] }]); - expect(JSON.stringify(await collect(anthropicMessagesFromIR(iterate(ranged))))).toContain('source'); + expect(JSON.stringify(await collect(writeAnthropicMessagesEvents(iterate(ranged))))).toContain('source'); const documentForResponses = completeIR([{ type: 'message', content: [{ type: 'text', text: 'source', annotations: [{ type: 'source_citation', source_kind: 'document', source: 'file_1', source_label: 'Document', output_text_range: { start: 0, end_exclusive: 6 } }] }] }]); - expect(JSON.stringify(await collect(openaiResponsesFromIR(iterate(documentForResponses))))).not.toContain('citation'); + expect(JSON.stringify(await collect(writeOpenAIResponsesEvents(iterate(documentForResponses))))).not.toContain('citation'); const sourceLess = completeIR([{ type: 'message', content: [{ type: 'text', text: 'source', annotations: [{ type: 'source_citation', source_kind: 'document', source: null, source_text: 'quote' }] }] }]); - expect(JSON.stringify(await collect(openaiResponsesFromIR(iterate(sourceLess))))).not.toContain('citation'); + expect(JSON.stringify(await collect(writeOpenAIResponsesEvents(iterate(sourceLess))))).not.toContain('citation'); }); test('single-choice writers reject a second choice when it is initialized', async () => { @@ -160,12 +160,12 @@ test('single-choice writers reject a second choice when it is initialized', asyn b.event({ type: 'start', id: 'source' }); b.choice(1); const frames = [...b.drain()]; - await expect(collect(anthropicMessagesFromIR(iterate(frames)))).rejects.toThrow('cannot represent multiple choices'); - await expect(collect(openaiResponsesFromIR(iterate(frames)))).rejects.toThrow('cannot represent multiple choices'); + await expect(collect(writeAnthropicMessagesEvents(iterate(frames)))).rejects.toThrow('cannot represent multiple choices'); + await expect(collect(writeOpenAIResponsesEvents(iterate(frames)))).rejects.toThrow('cannot represent multiple choices'); }); test('Responses initial envelope requires a model', async () => { - await expect(collectIR(irFromOpenAIResponses(iterate([ + await expect(collectIR(readOpenAIResponsesEvents(iterate([ eventFrame({ type: 'response.created', response: { id: 'resp_missing_model', output: [], usage: null } } as any), ])))).rejects.toThrow('requires a model'); }); @@ -192,8 +192,8 @@ for (const source of ['openai-chat-completions', 'anthropic-messages'] as const) test('ChatCompletions EOF succeeds after explicit choice completion and fails before it', async () => { const frames = fixtureFrames('openai-chat-completions', { text: ['answer'] }); - expect((await collectIR(irFromOpenAIChatCompletionsProduction(iterate(frames.slice(0, -1)), { roundTrip: createRoundTripReaderCapture().roundTrip }))).choices[0].items).toMatchObject([{ content: [{ text: 'answer' }] }]); - await expect(collectIR(irFromOpenAIChatCompletionsProduction(iterate(frames.slice(0, -2)), { roundTrip: createRoundTripReaderCapture().roundTrip }))).rejects.toThrow('finish_reason'); + expect((await collectIR(readOpenAIChatCompletionsEventsProduction(iterate(frames.slice(0, -1)), { roundTrip: createRoundTripReaderCapture().roundTrip }))).choices[0].items).toMatchObject([{ content: [{ text: 'answer' }] }]); + await expect(collectIR(readOpenAIChatCompletionsEventsProduction(iterate(frames.slice(0, -2)), { roundTrip: createRoundTripReaderCapture().roundTrip }))).rejects.toThrow('finish_reason'); }); test.each([messagesViaChat, generateContentViaChat])('completed empty function JSON is rejected by object-requiring destinations', async translate => { @@ -226,7 +226,7 @@ test('ignores vendor object arrays and retains scalar reasoning items', async () const frames = fixtureFrames('openai-chat-completions', { thinking: ['ab'], text: ['answer'] }); const records = [{ type: 'vendor_record', id: 'record_a', segments: ['a'] }, { type: 'vendor_record', id: 'record_b', segments: ['b'] }]; frames.splice(-1, 0, eventFrame({ id: 'chatcmpl_test', model: 'served-model', created: 100, choices: [{ index: 0, delta: { vendor_records: records }, finish_reason: null }] })); - const ir = await collectIR(irFromOpenAIChatCompletionsProduction(iterate(frames), { roundTrip: createRoundTripReaderCapture().roundTrip })); + const ir = await collectIR(readOpenAIChatCompletionsEventsProduction(iterate(frames), { roundTrip: createRoundTripReaderCapture().roundTrip })); expect(ir.choices[0].items.filter(item => item.type === 'reasoning')).toEqual([{ type: 'reasoning', summary: ['ab'] }]); const result = await nativeResult('openai-responses', await collect(responsesViaChat(iterate(frames), new Set(), createTestSidecarCodec(), emptyNamespaceToolNames()))); expect(result.output.filter((item: any) => item.type === 'reasoning' && item.encrypted_content === undefined)).toMatchObject([{ summary: [{ text: 'ab' }] }]); @@ -237,7 +237,7 @@ test('ignores vendor object arrays and retains scalar reasoning items', async () test('late vendor extensions preserve messages on either side of reasoning', async () => { const chunk = (delta: any, finish_reason: string | null = null) => eventFrame({ id: 'chatcmpl_order', model: 'served-model', created: 100, choices: [{ index: 0, delta, finish_reason }] }); const frames = [chunk({ content: 'before' }), chunk({ reasoning_text: 'think' }), chunk({ content: 'after' }), chunk({}, 'stop'), chunk({ vendor_records: [{ type: 'vendor_record', id: 'record_order', segments: ['think'] }] }), { type: 'done' as const }]; - const ir = await collectIR(irFromOpenAIChatCompletionsProduction(iterate(frames as any), { roundTrip: createRoundTripReaderCapture().roundTrip })); + const ir = await collectIR(readOpenAIChatCompletionsEventsProduction(iterate(frames as any), { roundTrip: createRoundTripReaderCapture().roundTrip })); expect(ir.choices[0].items).toEqual([{ type: 'message', content: [{ type: 'text', text: 'before' }] }, { type: 'reasoning', summary: ['think'] }, { type: 'message', content: [{ type: 'text', text: 'after' }] }]); }); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/events_test.ts similarity index 86% rename from packages/translate/__tests__/protocols/gemini-generate-content/as-source/sse-to_test.ts rename to packages/translate/__tests__/protocols/gemini-generate-content/as-source/events_test.ts index 38fab0a504..5025f19e9b 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/events_test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; -import { geminiGenerateContentFromIR } from '../../../../src/protocols/gemini-generate-content/as-source/sse-to.ts'; +import { writeGeminiGenerateContentEvents } from '../../../../src/protocols/gemini-generate-content/as-source/events.ts'; import { appendField, collectContentViews, createReferenceBuilder, createFieldState, createReferenceFinalizer } from '../../../../src/round-trip/reference/reference-builder.ts'; import type { RoundTripWriter } from '../../../../src/round-trip/stream.ts'; @@ -33,7 +33,7 @@ test('resolves delayed Gemini audio atoms within the complete emitted data field return 'sidecar'; }, }; - for await (const _frame of geminiGenerateContentFromIR((async function* () { yield* source.drain(); })(), { roundTrip })) {} + for await (const _frame of writeGeminiGenerateContentEvents((async function* () { yield* source.drain(); })(), { roundTrip })) {} expect(prepared).toBe(true); }); @@ -62,6 +62,6 @@ test.each(['{"x":1,}', '{"x": 1}'])('tracks the emitted canonical JSON view with return 'sidecar'; }, }; - for await (const _frame of geminiGenerateContentFromIR((async function* () { yield* source.drain(); })(), { roundTrip, parseToolArguments: original.endsWith(',}') ? () => ({ x: 1 }) : undefined })) {} + for await (const _frame of writeGeminiGenerateContentEvents((async function* () { yield* source.drain(); })(), { roundTrip, parseToolArguments: original.endsWith(',}') ? () => ({ x: 1 }) : undefined })) {} expect(prepared).toBe(true); }); diff --git a/packages/translate/__tests__/protocols/helpers.ts b/packages/translate/__tests__/protocols/helpers.ts index 864df006d6..dbb08188b1 100644 --- a/packages/translate/__tests__/protocols/helpers.ts +++ b/packages/translate/__tests__/protocols/helpers.ts @@ -1,12 +1,12 @@ import type { IRRecord } from '../../src/ir/output/records.ts'; -import { anthropicMessagesFromIR as anthropicMessagesFromIRProduction } from '../../src/protocols/anthropic-messages/as-source/sse-to.ts'; -import { irFromAnthropicMessages as irFromAnthropicMessagesProduction } from '../../src/protocols/anthropic-messages/as-target/sse-from.ts'; -import { geminiGenerateContentFromIR as geminiGenerateContentFromIRProduction } from '../../src/protocols/gemini-generate-content/as-source/sse-to.ts'; -import { openaiChatCompletionsFromIR as openaiChatCompletionsFromIRProduction } from '../../src/protocols/openai-chat-completions/as-source/sse-to.ts'; -import { irFromOpenAIChatCompletions as irFromOpenAIChatCompletionsProduction } from '../../src/protocols/openai-chat-completions/as-target/sse-from.ts'; +import { writeAnthropicMessagesEvents as writeAnthropicMessagesEventsProduction } from '../../src/protocols/anthropic-messages/as-source/events.ts'; +import { readAnthropicMessagesEvents as readAnthropicMessagesEventsProduction } from '../../src/protocols/anthropic-messages/as-target/events.ts'; +import { writeGeminiGenerateContentEvents as writeGeminiGenerateContentEventsProduction } from '../../src/protocols/gemini-generate-content/as-source/events.ts'; +import { writeOpenAIChatCompletionsEvents as writeOpenAIChatCompletionsEventsProduction } from '../../src/protocols/openai-chat-completions/as-source/events.ts'; +import { readOpenAIChatCompletionsEvents as readOpenAIChatCompletionsEventsProduction } from '../../src/protocols/openai-chat-completions/as-target/events.ts'; +import { writeOpenAIResponsesEvents as writeOpenAIResponsesEventsProduction } from '../../src/protocols/openai-responses/as-source/events.ts'; import { flattenNamespaceTools } from '../../src/protocols/openai-responses/as-source/namespace-tools.ts'; -import { openaiResponsesFromIR as openaiResponsesFromIRProduction } from '../../src/protocols/openai-responses/as-source/sse-to.ts'; -import { irFromOpenAIResponses as irFromOpenAIResponsesProduction } from '../../src/protocols/openai-responses/as-target/sse-from.ts'; +import { readOpenAIResponsesEvents as readOpenAIResponsesEventsProduction } from '../../src/protocols/openai-responses/as-target/events.ts'; import { createReferenceBuilder } from '../../src/round-trip/reference/reference-builder.ts'; import type { RoundTripReader, RoundTripWriter } from '../../src/round-trip/stream.ts'; @@ -31,19 +31,19 @@ const withoutProvenance = async function* (records: AsyncIterable): As } }; -export const irFromAnthropicMessages = (frames: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => - withoutProvenance(irFromAnthropicMessagesProduction(frames, { ...options, roundTrip: reader<'anthropicMessages'>() })); -export const irFromOpenAIChatCompletions = (frames: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => - withoutProvenance(irFromOpenAIChatCompletionsProduction(frames, { ...options, roundTrip: reader<'openaiChatCompletions'>() })); -export const irFromOpenAIResponses = ( - frames: Parameters[0], +export const readAnthropicMessagesEvents = (frames: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => + withoutProvenance(readAnthropicMessagesEventsProduction(frames, { ...options, roundTrip: reader<'anthropicMessages'>() })); +export const readOpenAIChatCompletionsEvents = (frames: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => + withoutProvenance(readOpenAIChatCompletionsEventsProduction(frames, { ...options, roundTrip: reader<'openaiChatCompletions'>() })); +export const readOpenAIResponsesEvents = ( + frames: Parameters[0], roundTrip: RoundTripReader<'openaiResponses'> = reader<'openaiResponses'>(), -) => withoutProvenance(irFromOpenAIResponsesProduction(frames, { roundTrip })); -export const anthropicMessagesFromIR = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => - anthropicMessagesFromIRProduction(records, { ...options, roundTrip: writer<'anthropicMessages'>() }); -export const openaiChatCompletionsFromIR = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => - openaiChatCompletionsFromIRProduction(records, { ...options, roundTrip: writer<'openaiChatCompletions'>() }); -export const openaiResponsesFromIR = (records: Parameters[0], options: Omit[1], 'roundTrip' | 'namespaceToolNames'> = {}) => - openaiResponsesFromIRProduction(records, { ...options, roundTrip: writer<'openaiResponses'>(), namespaceToolNames }); -export const geminiGenerateContentFromIR = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => - geminiGenerateContentFromIRProduction(records, { ...options, roundTrip: writer<'geminiGenerateContent'>() }); +) => withoutProvenance(readOpenAIResponsesEventsProduction(frames, { roundTrip })); +export const writeAnthropicMessagesEvents = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => + writeAnthropicMessagesEventsProduction(records, { ...options, roundTrip: writer<'anthropicMessages'>() }); +export const writeOpenAIChatCompletionsEvents = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => + writeOpenAIChatCompletionsEventsProduction(records, { ...options, roundTrip: writer<'openaiChatCompletions'>() }); +export const writeOpenAIResponsesEvents = (records: Parameters[0], options: Omit[1], 'roundTrip' | 'namespaceToolNames'> = {}) => + writeOpenAIResponsesEventsProduction(records, { ...options, roundTrip: writer<'openaiResponses'>(), namespaceToolNames }); +export const writeGeminiGenerateContentEvents = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => + writeGeminiGenerateContentEventsProduction(records, { ...options, roundTrip: writer<'geminiGenerateContent'>() }); diff --git a/packages/translate/__tests__/protocols/lifecycle_test.ts b/packages/translate/__tests__/protocols/lifecycle_test.ts index 7614c6f6ca..d4d0bf46b9 100644 --- a/packages/translate/__tests__/protocols/lifecycle_test.ts +++ b/packages/translate/__tests__/protocols/lifecycle_test.ts @@ -1,7 +1,7 @@ import { expect, test, vi } from 'vitest'; import { nativeResult } from './fixtures.ts'; -import { anthropicMessagesFromIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; +import { writeAnthropicMessagesEvents, writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; import { IRAbsent } from '../../src/ir/output/absent.ts'; import type { IRRecord } from '../../src/ir/output/records.ts'; import { collectIR } from '../ir/output/helpers.ts'; @@ -28,7 +28,7 @@ const gated = async (prefix: T[], suffix: T[], convert: (source: AsyncIterabl }; test('ChatCompletions emits scalar reasoning before the next upstream frame', async () => { - const frames = await gated([chat({ reasoning_text: 'live' })], [chat({ content: 'body' }), chat({}, 'stop'), doneFrame()], irFromOpenAIChatCompletions, output => { + const frames = await gated([chat({ reasoning_text: 'live' })], [chat({ content: 'body' }), chat({}, 'stop'), doneFrame()], readOpenAIChatCompletionsEvents, output => { expect(output).toContainEqual(expect.objectContaining({ type: 'write', kind: 'append', value: 'live' })); }); const ir = await collectIR(iterate(frames)); @@ -37,7 +37,7 @@ test('ChatCompletions emits scalar reasoning before the next upstream frame', as test('ChatCompletions locks the first reasoning field independently for each choice', async () => { const chunk = (choices: any[]) => eventFrame({ id: 'chat', model: 'm', created: 1, choices } as any); - const ir = await collectIR(irFromOpenAIChatCompletions(iterate([ + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate([ chunk([{ index: 0, delta: { reasoning: 'A' } }, { index: 1, delta: { reasoning_content: 'X' } }]), chunk([{ index: 0, delta: { reasoning_text: 'B', reasoning: 'C' } }, { index: 1, delta: { reasoning: 'Y', reasoning_content: 'Z' } }]), chunk([{ index: 0, delta: {}, finish_reason: 'stop' }, { index: 1, delta: {}, finish_reason: 'stop' }]), doneFrame(), @@ -49,7 +49,7 @@ test('ChatCompletions locks the first reasoning field independently for each cho }); test('an empty first reasoning string locks the field without interrupting text', async () => { - const ir = await collectIR(irFromOpenAIChatCompletions(iterate([ + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate([ chat({ content: 'before' }), chat({ reasoning: '' }), chat({ reasoning_text: 'ignored', content: 'after' }), chat({ reasoning: 'selected' }), chat({}, 'stop'), doneFrame(), ]))); @@ -60,17 +60,17 @@ test('an empty first reasoning string locks the field without interrupting text' }); test('ChatCompletions creates separate reasoning runs at every text interruption', async () => { - const ir = await collectIR(irFromOpenAIChatCompletions(iterate([chat({ reasoning_text: 'r1' }), chat({ content: 'body' }), chat({ reasoning_text: 'r2' }), chat({ content: 'tail' }), chat({}, 'stop'), doneFrame()]))); + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate([chat({ reasoning_text: 'r1' }), chat({ content: 'body' }), chat({ reasoning_text: 'r2' }), chat({ content: 'tail' }), chat({}, 'stop'), doneFrame()]))); expect(ir.choices[0].items).toEqual([{ type: 'reasoning', summary: ['r1'] }, { type: 'message', content: [{ type: 'text', text: 'body' }] }, { type: 'reasoning', summary: ['r2'] }, { type: 'message', content: [{ type: 'text', text: 'tail' }] }]); }); test('an old open tool update leaves the current ChatCompletions text run open', async () => { - const ir = await collectIR(irFromOpenAIChatCompletions(iterate([chat({ content: 'before' }), tool(0, '{"x":', 'call', 'lookup'), chat({ content: 'middle' }), tool(0, '1}'), chat({ content: 'after' }), chat({}, 'tool_calls'), doneFrame()]))); + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate([chat({ content: 'before' }), tool(0, '{"x":', 'call', 'lookup'), chat({ content: 'middle' }), tool(0, '1}'), chat({ content: 'after' }), chat({}, 'tool_calls'), doneFrame()]))); expect(ir.choices[0].items).toEqual([{ type: 'message', content: [{ type: 'text', text: 'before' }] }, { type: 'function_call', name: 'lookup', call_id: 'call', arguments: '{"x":1}' }, { type: 'message', content: [{ type: 'text', text: 'middleafter' }] }]); }); test('a complete JSON prefix stays open and preserves following argument text', async () => { - const frames = await gated([tool(0, '{"x":1}', 'call', 'lookup')], [tool(0, ',{"x":2}'), chat({}, 'tool_calls'), doneFrame()], irFromOpenAIChatCompletions, output => { + const frames = await gated([tool(0, '{"x":1}', 'call', 'lookup')], [tool(0, ',{"x":2}'), chat({}, 'tool_calls'), doneFrame()], readOpenAIChatCompletionsEvents, output => { expect(output.filter((record: any) => record.type === 'item_end')).toEqual([]); }); const ir = await collectIR(iterate(frames)); @@ -80,7 +80,7 @@ test('a complete JSON prefix stays open and preserves following argument text', test('a new tool closes the preceding function and later updates warn without reopening it', async () => { const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { - const frames = await gated([tool(0, '{"x":1}', 'call0', 'lookup'), tool(1, '{"y":', 'call1', 'lookup')], [tool(0, 'BAD'), tool(1, '2}'), chat({}, 'tool_calls'), doneFrame()], irFromOpenAIChatCompletions, output => { + const frames = await gated([tool(0, '{"x":1}', 'call0', 'lookup'), tool(1, '{"y":', 'call1', 'lookup')], [tool(0, 'BAD'), tool(1, '2}'), chat({}, 'tool_calls'), doneFrame()], readOpenAIChatCompletionsEvents, output => { expect(output.filter((record: any) => record.type === 'item_end')).toEqual([{ type: 'item_end', choice: 0, item: 0, status: 'completed' }]); }); expect(warning).toHaveBeenCalledTimes(1); @@ -91,7 +91,7 @@ test('a new tool closes the preceding function and later updates warn without re test('a native custom call stays open across subsequent tool starts', async () => { const custom = (input: string, first = false) => chat({ tool_calls: [{ index: 0, ...(first ? { id: 'custom0', type: 'custom' } : {}), custom: { ...(first ? { name: 'script' } : {}), input } }] }); - const frames = await gated([custom('first', true), tool(1, '{}', 'call1', 'lookup')], [custom('second'), chat({}, 'tool_calls'), doneFrame()], irFromOpenAIChatCompletions, output => { + const frames = await gated([custom('first', true), tool(1, '{}', 'call1', 'lookup')], [custom('second'), chat({}, 'tool_calls'), doneFrame()], readOpenAIChatCompletionsEvents, output => { expect(output.filter((record: any) => record.type === 'item_end')).toEqual([]); }); const ir = await collectIR(iterate(frames)); @@ -99,7 +99,7 @@ test('a native custom call stays open across subsequent tool starts', async () = }); test('a wrapped custom input is published when its native function boundary closes', async () => { - const frames = await gated([tool(0, '{"input":"text"}', 'call0', 'script'), tool(1, '{}', 'call1', 'lookup')], [chat({}, 'tool_calls'), doneFrame()], source => irFromOpenAIChatCompletions(source, { customToolNames: new Set(['script']) }), output => { + const frames = await gated([tool(0, '{"input":"text"}', 'call0', 'script'), tool(1, '{}', 'call1', 'lookup')], [chat({}, 'tool_calls'), doneFrame()], source => readOpenAIChatCompletionsEvents(source, { customToolNames: new Set(['script']) }), output => { expect(output).toContainEqual({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'input'], value: 'text' }); expect(output).toContainEqual({ type: 'item_end', choice: 0, item: 0, status: 'completed' }); }); @@ -107,14 +107,14 @@ test('a wrapped custom input is published when its native function boundary clos }); test('a custom call start completes the preceding function', async () => { - const frames = await gated([tool(0, '{}', 'call0', 'lookup'), chat({ tool_calls: [{ index: 1, id: 'custom1', type: 'custom', custom: { name: 'script', input: 'text' } }] })], [chat({}, 'tool_calls'), doneFrame()], irFromOpenAIChatCompletions, output => { + const frames = await gated([tool(0, '{}', 'call0', 'lookup'), chat({ tool_calls: [{ index: 1, id: 'custom1', type: 'custom', custom: { name: 'script', input: 'text' } }] })], [chat({}, 'tool_calls'), doneFrame()], readOpenAIChatCompletionsEvents, output => { expect(output.filter((record: any) => record.type === 'item_end')).toEqual([{ type: 'item_end', choice: 0, item: 0, status: 'completed' }]); }); expect((await collectIR(iterate(frames))).choices[0].items).toHaveLength(2); }); -test.each([['Responses', openaiResponsesFromIR], ['Messages', anthropicMessagesFromIR]] as const)('%s waits for the actual tool identity before publishing its start', async (name, writer) => { - const frames = await gated([tool(0, '{"x":', undefined, 'lookup')], [tool(0, '1}', 'call_real'), chat({}, 'tool_calls'), doneFrame()], source => writer(irFromOpenAIChatCompletions(source)), output => { +test.each([['Responses', writeOpenAIResponsesEvents], ['Messages', writeAnthropicMessagesEvents]] as const)('%s waits for the actual tool identity before publishing its start', async (name, writer) => { + const frames = await gated([tool(0, '{"x":', undefined, 'lookup')], [tool(0, '1}', 'call_real'), chat({}, 'tool_calls'), doneFrame()], source => writer(readOpenAIChatCompletionsEvents(source)), output => { expect(output.some((frame: any) => frame.event?.type === 'response.output_item.added' || frame.event?.type === 'content_block_start')).toBe(false); }); const result: any = await nativeResult(name === 'Responses' ? 'openai-responses' : 'anthropic-messages', frames); @@ -135,7 +135,7 @@ const irSuffix = (): IRRecord[] => [ { type: 'finish', status: 'completed' }, ]; -test.each([['Responses', openaiResponsesFromIR], ['Messages', anthropicMessagesFromIR]] as const)('%s opens a pending call on the late call_id record', async (name, writer) => { +test.each([['Responses', writeOpenAIResponsesEvents], ['Messages', writeAnthropicMessagesEvents]] as const)('%s opens a pending call on the late call_id record', async (name, writer) => { const prefix: IRRecord[] = [...irPrefix(), { type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1], value: { type: 'message', content: [] } }, { type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1, 'content', 0], value: { type: 'text', text: '' } }, @@ -156,13 +156,13 @@ test.each([['Responses', openaiResponsesFromIR], ['Messages', anthropicMessagesF expect(name === 'Responses' ? result.output[0].call_id : result.content[0].id).toBe('call_real'); }); -test.each([['Responses', openaiResponsesFromIR], ['Messages', anthropicMessagesFromIR], ['ChatCompletions', openaiChatCompletionsFromIR]] as const)('%s opens call_id IRAbsent without waiting', async (_name, writer) => { +test.each([['Responses', writeOpenAIResponsesEvents], ['Messages', writeAnthropicMessagesEvents], ['ChatCompletions', writeOpenAIChatCompletionsEvents]] as const)('%s opens call_id IRAbsent without waiting', async (_name, writer) => { await gated(irPrefix(IRAbsent), irSuffix(), writer, prefix => { expect(prefix.some((frame: any) => frame.event?.type === 'response.output_item.added' || frame.event?.type === 'content_block_start' || frame.event?.choices?.[0]?.delta.tool_calls !== undefined)).toBe(true); }); }); -test.each([['Responses', openaiResponsesFromIR], ['Messages', anthropicMessagesFromIR]] as const)('%s synthesizes a call ID when the item closes with it absent', async (_name, writer) => { +test.each([['Responses', writeOpenAIResponsesEvents], ['Messages', writeAnthropicMessagesEvents]] as const)('%s synthesizes a call ID when the item closes with it absent', async (_name, writer) => { const frames = await gated(irPrefix(), irSuffix(), writer, prefix => { expect(prefix.some((frame: any) => frame.event?.type === 'response.output_item.added' || frame.event?.type === 'content_block_start')).toBe(false); }); @@ -170,7 +170,7 @@ test.each([['Responses', openaiResponsesFromIR], ['Messages', anthropicMessagesF }); test('GenerateContent emits a completed call without waiting for a call ID', async () => { - const result: any = await nativeResult('gemini-generate-content', await collect(geminiGenerateContentFromIR(iterate([...irPrefix(), ...irSuffix()])))); + const result: any = await nativeResult('gemini-generate-content', await collect(writeGeminiGenerateContentEvents(iterate([...irPrefix(), ...irSuffix()])))); expect(result.candidates[0].content.parts[0].functionCall).toEqual({ name: 'tool', args: { x: 1 } }); }); @@ -181,8 +181,8 @@ const call = (index: number, arguments_ = '', status = 'in_progress') => ({ type const args = (index: number, delta: string) => eventFrame({ type: 'response.function_call_arguments.delta', output_index: index, delta } as any); const endCall = (index: number, arguments_: string, status = 'completed') => eventFrame({ type: 'response.output_item.done', output_index: index, item: call(index, arguments_, status) } as any); -test.each([['ChatCompletions', openaiChatCompletionsFromIR], ['Messages', anthropicMessagesFromIR]] as const)('%s streams the second tool while the first remains open', async (name, writer) => { - await gated([responseStart, add(0, call(0)), args(0, '{"x":'), add(1, call(1)), args(1, '{"y":2}'), endCall(1, '{"y":2}')], [args(0, '1}'), endCall(0, '{"x":1}'), eventFrame({ type: 'response.completed', response: response([call(0, '{"x":1}', 'completed'), call(1, '{"y":2}', 'completed')]) } as any)], source => writer(irFromOpenAIResponses(source)), output => { +test.each([['ChatCompletions', writeOpenAIChatCompletionsEvents], ['Messages', writeAnthropicMessagesEvents]] as const)('%s streams the second tool while the first remains open', async (name, writer) => { + await gated([responseStart, add(0, call(0)), args(0, '{"x":'), add(1, call(1)), args(1, '{"y":2}'), endCall(1, '{"y":2}')], [args(0, '1}'), endCall(0, '{"x":1}'), eventFrame({ type: 'response.completed', response: response([call(0, '{"x":1}', 'completed'), call(1, '{"y":2}', 'completed')]) } as any)], source => writer(readOpenAIResponsesEvents(source)), output => { expect(output.some((frame: any) => name === 'Messages' ? frame.event?.type === 'content_block_delta' && frame.event.index === 1 && frame.event.delta?.partial_json === '{"y":2}' : frame.event?.choices?.some((choice: any) => choice.delta.tool_calls?.some((tool: any) => tool.index === 1 && tool.function.arguments === '{"y":2}')))).toBe(true); @@ -191,7 +191,7 @@ test.each([['ChatCompletions', openaiChatCompletionsFromIR], ['Messages', anthro test('Messages closes an earlier concurrent block immediately and the frontier block after its sidecar', async () => { const source = [responseStart, add(0, call(0)), args(0, '{"x":'), add(1, call(1)), args(1, '{"y":2}'), endCall(1, '{"y":2}'), args(0, '1}'), endCall(0, '{"x":1}'), eventFrame({ type: 'response.completed', response: response([call(0, '{"x":1}', 'completed'), call(1, '{"y":2}', 'completed')]) } as any)]; - const frames: any[] = await collect(anthropicMessagesFromIR(irFromOpenAIResponses(iterate(source)))); + const frames: any[] = await collect(writeAnthropicMessagesEvents(readOpenAIResponsesEvents(iterate(source)))); const lifecycle = frames.flatMap(frame => frame.event?.type === 'content_block_start' ? [`start:${frame.event.index}:${frame.event.content_block.type}`] : frame.event?.type === 'content_block_stop' ? [`stop:${frame.event.index}`] : []); @@ -205,24 +205,24 @@ test('Messages closes an earlier concurrent block immediately and the frontier b ]); }); -test.each([['ChatCompletions', openaiChatCompletionsFromIR], ['Messages', anthropicMessagesFromIR], ['GenerateContent', geminiGenerateContentFromIR]] as const)('%s flattens reasoning content and summary in arrival order', async (name, writer) => { +test.each([['ChatCompletions', writeOpenAIChatCompletionsEvents], ['Messages', writeAnthropicMessagesEvents], ['GenerateContent', writeGeminiGenerateContentEvents]] as const)('%s flattens reasoning content and summary in arrival order', async (name, writer) => { const item = { type: 'reasoning', id: 'r', summary: [] }; const source = [responseStart, add(0, item), eventFrame({ type: 'response.content_part.added', output_index: 0, content_index: 0, part: { type: 'reasoning_text', text: '' } } as any), eventFrame({ type: 'response.reasoning_text.delta', output_index: 0, content_index: 0, delta: 'C' } as any), eventFrame({ type: 'response.reasoning_summary_part.added', output_index: 0, summary_index: 0, part: { type: 'summary_text', text: '' } } as any), eventFrame({ type: 'response.reasoning_summary_text.delta', output_index: 0, summary_index: 0, delta: 'S' } as any), eventFrame({ type: 'response.completed', response: response([{ ...item, content: [{ type: 'reasoning_text', text: 'C' }], summary: [{ type: 'summary_text', text: 'S' }] }]) } as any)]; - const frames: any[] = await collect(writer(irFromOpenAIResponses(iterate(source)))); + const frames: any[] = await collect(writer(readOpenAIResponsesEvents(iterate(source)))); const text = frames.map(frame => name === 'ChatCompletions' ? frame.event?.choices?.[0]?.delta.reasoning_text ?? '' : name === 'Messages' ? frame.event.delta?.thinking ?? '' : frame.event.candidates?.[0]?.content?.parts?.filter((part: any) => part.thought).map((part: any) => part.text).join('') ?? '').join(''); expect(text).toBe('CS'); }); test('GenerateContent preserves completed tool order when the second finishes first', async () => { - const frames = await gated([responseStart, add(0, call(0)), args(0, '{"x":'), add(1, call(1)), args(1, '{}'), endCall(1, '{}')], [args(0, '1}'), endCall(0, '{"x":1}'), eventFrame({ type: 'response.completed', response: response([call(0, '{"x":1}', 'completed'), call(1, '{}', 'completed')]) } as any)], source => geminiGenerateContentFromIR(irFromOpenAIResponses(source)), output => { + const frames = await gated([responseStart, add(0, call(0)), args(0, '{"x":'), add(1, call(1)), args(1, '{}'), endCall(1, '{}')], [args(0, '1}'), endCall(0, '{"x":1}'), eventFrame({ type: 'response.completed', response: response([call(0, '{"x":1}', 'completed'), call(1, '{}', 'completed')]) } as any)], source => writeGeminiGenerateContentEvents(readOpenAIResponsesEvents(source)), output => { expect(output).toEqual([]); }); expect(frames.flatMap((frame: any) => frame.event.candidates?.flatMap((candidate: any) => candidate.content?.parts ?? []) ?? []).filter((part: any) => part.functionCall).map((part: any) => part.functionCall.id)).toEqual(['call_0', 'call_1']); }); -test.each([['ChatCompletions', openaiChatCompletionsFromIR], ['Responses', openaiResponsesFromIR], ['GenerateContent', geminiGenerateContentFromIR]] as const)('%s preserves Messages token cutoff without parsing incomplete input', async (name, writer) => { +test.each([['ChatCompletions', writeOpenAIChatCompletionsEvents], ['Responses', writeOpenAIResponsesEvents], ['GenerateContent', writeGeminiGenerateContentEvents]] as const)('%s preserves Messages token cutoff without parsing incomplete input', async (name, writer) => { const source = [messageStart, eventFrame({ type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'call', name: 'lookup', input: {} } } as any), eventFrame({ type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '{"x":' } } as any), eventFrame({ type: 'content_block_stop', index: 0 } as any), ...messageEnd('max_tokens')]; - const frames: any[] = await collect(writer(irFromAnthropicMessages(iterate(source)))); + const frames: any[] = await collect(writer(readAnthropicMessagesEvents(iterate(source)))); if (name === 'ChatCompletions') { expect(frames.some(frame => frame.event?.choices?.[0]?.finish_reason === 'length')).toBe(true); expect(frames.flatMap(frame => frame.event?.choices?.[0]?.delta.tool_calls ?? []).map(tool => tool.function.arguments ?? '').join('')).toBe('{"x":'); @@ -236,7 +236,7 @@ test.each([['ChatCompletions', openaiChatCompletionsFromIR], ['Responses', opena }); test('empty ChatCompletions scalar deltas do not interrupt reasoning', async () => { - const ir = await collectIR(irFromOpenAIChatCompletions(iterate([chat({ role: 'assistant', content: '' }), chat({ reasoning_text: 'r1' }), chat({ content: '' }), chat({ reasoning_text: 'r2' }), chat({}, 'stop'), doneFrame()]))); + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate([chat({ role: 'assistant', content: '' }), chat({ reasoning_text: 'r1' }), chat({ content: '' }), chat({ reasoning_text: 'r2' }), chat({}, 'stop'), doneFrame()]))); expect(ir.choices[0].items).toEqual([{ type: 'reasoning', summary: ['r1r2'] }]); }); @@ -253,7 +253,7 @@ test('Responses closes only the addressed text part while a refusal remains open { type: 'part_end', choice: 0, item: 0, part: 0 }, ]; const suffix: IRRecord[] = [{ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 1, 'refusal'], value: 'C' }, { type: 'part_end', choice: 0, item: 0, part: 1 }, { type: 'item_end', choice: 0, item: 0 }, { type: 'choice_end', choice: 0, finish_reason: 'stop' }, { type: 'finish', status: 'completed' }]; - const frames = await gated(prefix, suffix, openaiResponsesFromIR, output => { + const frames = await gated(prefix, suffix, writeOpenAIResponsesEvents, output => { expect(output.filter(frame => frame.event.type === 'response.content_part.done').map(frame => frame.event.content_index)).toEqual([0]); }); const result: any = await nativeResult('openai-responses', frames); @@ -263,7 +263,7 @@ test('Responses closes only the addressed text part while a refusal remains open test('GenerateContent retains reasoning arrival order while it waits for a prior tool', async () => { const reasoning = { type: 'reasoning', id: 'r', content: [{ type: 'reasoning_text', text: 'C' }], summary: [] }; const source = [responseStart, add(0, call(0)), args(0, '{"x":'), add(1, reasoning), eventFrame({ type: 'response.reasoning_summary_part.added', output_index: 1, summary_index: 0, part: { type: 'summary_text', text: 'S' } } as any), eventFrame({ type: 'response.output_item.done', output_index: 1, item: { ...reasoning, summary: [{ type: 'summary_text', text: 'S' }] } } as any), args(0, '1}'), endCall(0, '{"x":1}'), eventFrame({ type: 'response.completed', response: response([call(0, '{"x":1}', 'completed'), { ...reasoning, summary: [{ type: 'summary_text', text: 'S' }] }]) } as any)]; - const frames = await collect(geminiGenerateContentFromIR(irFromOpenAIResponses(iterate(source)))); + const frames = await collect(writeGeminiGenerateContentEvents(readOpenAIResponsesEvents(iterate(source)))); const parts = frames.flatMap(frame => (frame.event as any).candidates?.flatMap((candidate: any) => candidate.content?.parts ?? []) ?? []); expect(parts[0].functionCall.id).toBe('call_0'); expect(parts.filter((part: any) => part.thought).map((part: any) => part.text).join('')).toBe('CS'); @@ -276,7 +276,7 @@ test.each([false, true])('GenerateContent keeps parallel text arrival order with const offset = buffered ? 1 : 0; const prefix = [messageStart, ...(buffered ? [eventFrame({ type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'call', name: 'lookup', input: {} } } as any), eventFrame({ type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '{"x":' } } as any)] : []), block(offset), text(offset, 'a'), block(offset + 1), text(offset + 1, 'b'), text(offset, 'c')]; const suffix = [...(buffered ? [eventFrame({ type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '1}' } } as any), stop(0)] : []), stop(offset + 1), stop(offset), ...messageEnd(buffered ? 'tool_use' : 'end_turn')]; - const frames = await gated(prefix, suffix, source => geminiGenerateContentFromIR(irFromAnthropicMessages(source)), output => { + const frames = await gated(prefix, suffix, source => writeGeminiGenerateContentEvents(readAnthropicMessagesEvents(source)), output => { const text = output.flatMap(frame => frame.event.candidates?.flatMap((candidate: any) => candidate.content?.parts ?? []) ?? []).map((part: any) => part.text ?? '').join(''); expect(text).toBe(buffered ? '' : 'abc'); }); @@ -292,7 +292,7 @@ test('Messages publishes cumulative usage progress before pulling the rest of th first.event.usage = { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 }; const last = chat({ content: 'B' }, 'stop'); last.event.usage = { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 }; - const output = await gated([initial, first], [last, doneFrame()], frames => anthropicMessagesFromIR(irFromOpenAIChatCompletions(frames)), prefix => { + const output = await gated([initial, first], [last, doneFrame()], frames => writeAnthropicMessagesEvents(readOpenAIChatCompletionsEvents(frames)), prefix => { expect(prefix).toContainEqual(expect.objectContaining({ event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'A' } } })); expect(prefix.some(frame => frame.event?.type === 'message_delta' && frame.event.delta.stop_reason === null && frame.event.usage.output_tokens === 1)).toBe(true); expect(prefix.some(frame => frame.event?.delta?.stop_reason === 'end_turn')).toBe(false); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/events_test.ts similarity index 92% rename from packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts rename to packages/translate/__tests__/protocols/openai-chat-completions/as-source/events_test.ts index 2ca92d6702..e57603e70f 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/events_test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; -import { openaiChatCompletionsFromIR } from '../../../../src/protocols/openai-chat-completions/as-source/sse-to.ts'; +import { writeOpenAIChatCompletionsEvents } from '../../../../src/protocols/openai-chat-completions/as-source/events.ts'; import { createReferenceBuilder, collectContentViews, createFieldState, createReferenceFinalizer, appendField } from '../../../../src/round-trip/reference/reference-builder.ts'; import type { RoundTripWriter } from '../../../../src/round-trip/stream.ts'; @@ -37,7 +37,7 @@ test('prepares each Chat choice independently while another choice has partial a prepareSidecar: async choice => { prepared.push(choice); return `sidecar-${choice}`; }, }; const frames = []; - for await (const frame of openaiChatCompletionsFromIR((async function* () { yield* source.drain(); })(), { + for await (const frame of writeOpenAIChatCompletionsEvents((async function* () { yield* source.drain(); })(), { roundTrip, audioMetadata: choice => ({ id: `audio-${choice}`, expires_at: 10 }), })) frames.push(frame); @@ -79,7 +79,7 @@ test.each([false, true])('resolves delayed transcript provenance at actual proje builder: references, prepareSidecar: async (...args) => { prepared = args; return 'sidecar'; }, }; - for await (const _frame of openaiChatCompletionsFromIR((async function* () { yield* source.drain(); })(), { + for await (const _frame of writeOpenAIChatCompletionsEvents((async function* () { yield* source.drain(); })(), { roundTrip, audioMetadata: () => ({ id: 'audio', expires_at: 10 }), })) {} if (prepared === undefined) throw new Error('ChatCompletions sidecar was not prepared'); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts similarity index 95% rename from packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts rename to packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts index f73b26fe4a..decf5625ac 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts @@ -2,7 +2,7 @@ import { expect, test } from 'vitest'; import type { IRRecord } from '../../../../src/ir/output/records.ts'; import type { OpenAIChatCompletionsInputAssistantTurn } from '../../../../src/protocols/openai-chat-completions/as-target/assistant-turn.ts'; -import { irFromOpenAIChatCompletions } from '../../../../src/protocols/openai-chat-completions/as-target/sse-from.ts'; +import { readOpenAIChatCompletionsEvents } from '../../../../src/protocols/openai-chat-completions/as-target/events.ts'; import { appendContent, collectContentViews, createContentState, createReferenceBuilder } from '../../../../src/round-trip/reference/reference-builder.ts'; import { finalizeAssistantTurnReferences } from '../../../../src/round-trip/reference/reference-selection.ts'; import { restoreAssistantTurn } from '../../../../src/round-trip/reference/restore.ts'; @@ -20,7 +20,7 @@ const drain = async (frames: ReturnType[]): Promise { turns.set(choice, turn); }, }; - for await (const _record of irFromOpenAIChatCompletions((async function* () { yield* frames; yield doneFrame(); })(), { maxChoices: 2, roundTrip })) {} + for await (const _record of readOpenAIChatCompletionsEvents((async function* () { yield* frames; yield doneFrame(); })(), { maxChoices: 2, roundTrip })) {} return turns; }; @@ -82,7 +82,7 @@ const traceTools = async (frames: ReturnType[], options: { prefix? builder, onAssistantTurn: (...args) => { captured = args; }, }; - for await (const record of irFromOpenAIChatCompletions((async function* () { yield* frames; yield doneFrame(); })(), { roundTrip, customToolNames: options.customToolNames })) { + for await (const record of readOpenAIChatCompletionsEvents((async function* () { yield* frames; yield doneFrame(); })(), { roundTrip, customToolNames: options.customToolNames })) { if (record.type === 'write' && record.kind === 'assign') assigns.push(record); if (record.type !== 'write' || record.kind !== 'append') continue; const field = record.path.at(-1); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts similarity index 95% rename from packages/translate/__tests__/protocols/openai-responses/as-source/sse-to_test.ts rename to packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts index 728f7046f7..453b5b2b6e 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts @@ -1,8 +1,8 @@ import { expect, test } from 'vitest'; import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; +import { writeOpenAIResponsesEvents } from '../../../../src/protocols/openai-responses/as-source/events.ts'; import { flattenNamespaceTools } from '../../../../src/protocols/openai-responses/as-source/namespace-tools.ts'; -import { openaiResponsesFromIR } from '../../../../src/protocols/openai-responses/as-source/sse-to.ts'; import { appendField, collectContentViews, createReferenceBuilder, createFieldState, createReferenceFinalizer } from '../../../../src/round-trip/reference/reference-builder.ts'; import type { RoundTripWriter } from '../../../../src/round-trip/stream.ts'; @@ -79,7 +79,7 @@ test('collects sparse namespace-restored Responses state from emitted IR atoms', builder.event({ type: 'choice_end', choice: 0, finish_reason: 'tool_calls' }); builder.event({ type: 'finish', status: 'completed' }); - for await (const _frame of openaiResponsesFromIR((async function* () { yield* builder.drain(); })(), { roundTrip, namespaceToolNames: namespace.names })) {} + for await (const _frame of writeOpenAIResponsesEvents((async function* () { yield* builder.drain(); })(), { roundTrip, namespaceToolNames: namespace.names })) {} if (prepared === undefined) throw new Error('Responses sidecar was not prepared'); const [, , turn, state] = prepared; diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts index f023e6d8bc..42fa12acb2 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts @@ -2,7 +2,7 @@ import { expect, test } from 'vitest'; import type { IRRecord } from '../../../../src/ir/output/records.ts'; import { collect, iterate } from '../../../shared/iterables.ts'; -import { openaiResponsesFromIR } from '../../helpers.ts'; +import { writeOpenAIResponsesEvents } from '../../helpers.ts'; test('initialize carries real required fields while append targets start empty', async () => { const records: IRRecord[] = [ @@ -15,7 +15,7 @@ test('initialize carries real required fields while append targets start empty', { type: 'choice_end', choice: 0, finish_reason: 'tool_calls' }, { type: 'finish', status: 'completed' }, ]; - const output = await collect(openaiResponsesFromIR(iterate(records))); + const output = await collect(writeOpenAIResponsesEvents(iterate(records))); const serialized = JSON.stringify(output); expect(serialized).toContain('real-name'); expect(serialized).toContain('{}'); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-target/sse-from_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts similarity index 98% rename from packages/translate/__tests__/protocols/openai-responses/as-target/sse-from_test.ts rename to packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts index b37e944ccb..c9d61a3a24 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-target/sse-from_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; -import { irFromOpenAIResponses } from '../../../../src/protocols/openai-responses/as-target/sse-from.ts'; +import { readOpenAIResponsesEvents } from '../../../../src/protocols/openai-responses/as-target/events.ts'; import { appendContent, collectContentViews, createContentState } from '../../../../src/round-trip/reference/reference-builder.ts'; import { finalizeAssistantTurnReferences } from '../../../../src/round-trip/reference/reference-selection.ts'; import { restoreAssistantTurn } from '../../../../src/round-trip/reference/restore.ts'; @@ -46,7 +46,7 @@ const collectProvenance = async (events: readonly OpenAIResponsesStreamEventEx[] for (const event of events) yield eventFrame(event); })(); - for await (const record of irFromOpenAIResponses(frames, { roundTrip: capture.roundTrip })) { + for await (const record of readOpenAIResponsesEvents(frames, { roundTrip: capture.roundTrip })) { if (record.type !== 'write' || record.kind !== 'append' || record.path.at(-1) !== field) continue; appends.push({ text: record.value, ...(record.referencePieceIds === undefined ? {} : { referencePieceIds: record.referencePieceIds }) }); appendContent(capture.builder, content, record.value, record.referencePieceIds); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts index 73980cb9f8..8a57aa52e4 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts @@ -3,11 +3,11 @@ import { expect, test } from 'vitest'; import { collectIR } from '../../../ir/output/helpers.ts'; import { createRoundTripReaderCapture } from '../../../round-trip/test-helpers.ts'; import { collect, iterate } from '../../../shared/iterables.ts'; -import { irFromOpenAIResponses } from '../../helpers.ts'; +import { readOpenAIResponsesEvents } from '../../helpers.ts'; import { eventFrame } from '@floway-dev/protocols/common'; const response = (output: any[] = []) => ({ id: 'resp', model: 'served', created_at: 1, status: 'completed', output, usage: null, error: null, incomplete_details: null }); -const read = async (events: any[]) => await collectIR(irFromOpenAIResponses(iterate(events.map(event => eventFrame(event as any))))); +const read = async (events: any[]) => await collectIR(readOpenAIResponsesEvents(iterate(events.map(event => eventFrame(event as any))))); test.each([-1, 1.5, Number.MAX_SAFE_INTEGER + 1])('output indexes must be nonnegative safe integers: %s', async outputIndex => { await expect(read([ @@ -238,7 +238,7 @@ test('output item snapshots require text in every declared part', async () => { }); test('Responses retains the initial response id when later envelopes rotate it', async () => { - const records = await collect(irFromOpenAIResponses(iterate([ + const records = await collect(readOpenAIResponsesEvents(iterate([ eventFrame({ type: 'response.created', response: response([]) } as any), eventFrame({ type: 'response.in_progress', response: { ...response([]), id: 'resp_rotated' } } as any), eventFrame({ type: 'response.completed', response: { ...response([]), id: 'resp_terminal' } } as any), @@ -286,7 +286,7 @@ test('an empty terminal Responses output captures closed snapshots for round tri const added = { type: 'message', id: 'msg', role: 'assistant', status: 'in_progress', content: [] }; const done = { ...added, status: 'completed', content: [{ type: 'output_text', text: 'answer', annotations: [], logprobs: [] }] }; const capture = createRoundTripReaderCapture<'openaiResponses'>(); - await collectIR(irFromOpenAIResponses(iterate([ + await collectIR(readOpenAIResponsesEvents(iterate([ eventFrame({ type: 'response.created', response: response([]) } as any), eventFrame({ type: 'response.output_item.added', output_index: 0, item: added } as any), eventFrame({ type: 'response.output_item.done', output_index: 0, item: done } as any), diff --git a/packages/translate/__tests__/protocols/ownership_test.ts b/packages/translate/__tests__/protocols/ownership_test.ts index 96b0dc75af..05d7a849e8 100644 --- a/packages/translate/__tests__/protocols/ownership_test.ts +++ b/packages/translate/__tests__/protocols/ownership_test.ts @@ -1,7 +1,7 @@ import { expect, test, vi } from 'vitest'; import { nativeResult } from './fixtures.ts'; -import { geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; +import { writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; import { collectIR } from '../ir/output/helpers.ts'; import { collect, iterate } from '../shared/iterables.ts'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; @@ -22,10 +22,10 @@ const messagesCitationFrames = (citedText?: string) => [ test('ChatCompletions logprobs retain their text owner when a tool appears in the same chunk', async () => { const token = { token: 'hello', logprob: -0.1, bytes: [104, 101, 108, 108, 111], top_logprobs: [] }; const source = [chunk({ content: 'hello', annotations: [citation(0, 5)], tool_calls: [{ index: 0, id: 'call', type: 'function', function: { name: 'lookup', arguments: '{}' } }] }, { logprobs: { content: [token], refusal: null }, finish_reason: 'tool_calls' }), doneFrame()]; - const ir = await collectIR(irFromOpenAIChatCompletions(iterate(source))); + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate(source))); expect(ir.choices[0].items).toHaveLength(2); expect(ir.choices[0].logprobs).toEqual([{ scope: 'text_part', item_index: 0, content_index: 0, tokens: [token] }]); - const frames = await collect(openaiResponsesFromIR(irFromOpenAIChatCompletions(iterate(source)))); + const frames = await collect(writeOpenAIResponsesEvents(readOpenAIChatCompletionsEvents(iterate(source)))); expect(frames.find(frame => frame.type === 'event' && frame.event.type === 'response.output_text.done')).toMatchObject({ event: { logprobs: [token] } }); const annotation = frames.findIndex(frame => frame.type === 'event' && frame.event.type === 'response.output_text.annotation.added'); const end = frames.findIndex(frame => frame.type === 'event' && frame.event.type === 'response.output_item.done'); @@ -39,10 +39,10 @@ test('ChatCompletions logprobs retain their text owner when a tool appears in th test('ChatCompletions citations rebase cumulative Unicode coordinates across interrupted items', async () => { const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const source = [chunk({ content: 'A😀' }), chunk({ reasoning_text: 'R' }), chunk({ content: '中B' }), chunk({ annotations: [citation(1, 3)] }, { finish_reason: 'stop' }), doneFrame()]; - const ir = await collectIR(irFromOpenAIChatCompletions(iterate(source))); + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate(source))); expect(ir.choices[0].items[0]).toMatchObject({ content: [{ text: 'A😀' }] }); expect(ir.choices[0].items[2]).toMatchObject({ content: [{ text: '中B', annotations: [{ output_text_range: { start: 0, end_exclusive: 1 } }] }] }); - const result = await nativeResult('openai-responses', await collect(openaiResponsesFromIR(irFromOpenAIChatCompletions(iterate(source))))); + const result = await nativeResult('openai-responses', await collect(writeOpenAIResponsesEvents(readOpenAIChatCompletionsEvents(iterate(source))))); expect(result.output[2].content[0].annotations).toEqual([{ type: 'url_citation', url: 'https://example.com', title: 'Source', start_index: 0, end_index: 1 }]); expect(warning).toHaveBeenCalledWith('Clamping ChatCompletions citation to open text', expect.objectContaining({ choice: 0 })); warning.mockRestore(); @@ -58,7 +58,7 @@ test('Responses converts only new citations after indexed text keeps streaming', chunk({ annotations: [first, second] }, { finish_reason: 'stop' }), doneFrame(), ]; - const frames = await collect(openaiResponsesFromIR(irFromOpenAIChatCompletions(iterate(source)))); + const frames = await collect(writeOpenAIResponsesEvents(readOpenAIChatCompletionsEvents(iterate(source)))); const annotations = frames.flatMap(frame => frame.type === 'event' && frame.event.type === 'response.output_text.annotation.added' ? [frame.event] : []); expect(annotations.map(event => event.annotation_index)).toEqual([0, 1]); expect(annotations.map(event => event.annotation)).toEqual([ @@ -70,7 +70,7 @@ test('Responses converts only new citations after indexed text keeps streaming', test('late ChatCompletions citations on closed text are dropped', async () => { const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const source = [chunk({ content: 'A😀' }), chunk({ reasoning_text: 'R' }), chunk({ content: '中B' }), chunk({ reasoning_text: 'tail' }), chunk({ annotations: [citation(1, 3)] }, { finish_reason: 'stop' }), doneFrame()]; - const ir = await collectIR(irFromOpenAIChatCompletions(iterate(source))); + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate(source))); expect(ir.choices[0].items.filter(item => item.type === 'message').every(item => item.content.every(part => part.type !== 'text' || part.annotations === undefined))).toBe(true); expect(warning).toHaveBeenCalledWith('Dropping ChatCompletions citation outside open text', expect.objectContaining({ choice: 0 })); warning.mockRestore(); @@ -78,42 +78,42 @@ test('late ChatCompletions citations on closed text are dropped', async () => { test('ChatCompletions rejects a reversed citation before clamping it to available text', async () => { const source = [chunk({ content: 'abc' }), chunk({ annotations: [citation(5, 3)] }, { finish_reason: 'stop' }), doneFrame()]; - await expect(collectIR(irFromOpenAIChatCompletions(iterate(source)))).rejects.toThrow('Invalid code-point range'); + await expect(collectIR(readOpenAIChatCompletionsEvents(iterate(source)))).rejects.toThrow('Invalid code-point range'); }); test('Messages cited text remains source text without inventing output coordinates', async () => { - const ir = await collectIR(irFromAnthropicMessages(iterate(messagesCitationFrames('source quote')))); + const ir = await collectIR(readAnthropicMessagesEvents(iterate(messagesCitationFrames('source quote')))); const item = ir.choices[0].items[0]; if (item.type !== 'message' || item.content[0].type !== 'text') throw new Error('expected cited text part'); expect(item.content[0].annotations?.[0]).toMatchObject({ source_text: 'source quote' }); expect(item.content[0].annotations?.[0]).not.toHaveProperty('output_text_range'); - const result = await nativeResult('openai-responses', await collect(openaiResponsesFromIR(irFromAnthropicMessages(iterate(messagesCitationFrames('source quote')))))); + const result = await nativeResult('openai-responses', await collect(writeOpenAIResponsesEvents(readAnthropicMessagesEvents(iterate(messagesCitationFrames('source quote')))))); expect(result.output[0].content[0].annotations).toEqual([]); }); test('Messages URL citations without cited text do not expand to the entire Responses text', async () => { - const ir = await collectIR(irFromAnthropicMessages(iterate(messagesCitationFrames()))); + const ir = await collectIR(readAnthropicMessagesEvents(iterate(messagesCitationFrames()))); const item = ir.choices[0].items[0]; expect(item).toMatchObject({ content: [{ annotations: [{ source: 'https://docs.example.com/page-1' }] }] }); if (item.type !== 'message' || item.content[0].type !== 'text') throw new Error('expected cited text part'); expect(item.content[0].annotations?.[0]).not.toHaveProperty('output_text_range'); - const result = await nativeResult('openai-responses', await collect(openaiResponsesFromIR(irFromAnthropicMessages(iterate(messagesCitationFrames()))))); + const result = await nativeResult('openai-responses', await collect(writeOpenAIResponsesEvents(readAnthropicMessagesEvents(iterate(messagesCitationFrames()))))); expect(result.output[0].content[0].annotations).toEqual([]); }); test('range-less Messages citations do not expand to whole-part ChatCompletions or GenerateContent citations', async () => { - const chatFrames = await collect(openaiChatCompletionsFromIR(irFromAnthropicMessages(iterate(messagesCitationFrames('source quote'))), { id: 'chat_target', created: 1 })); + const chatFrames = await collect(writeOpenAIChatCompletionsEvents(readAnthropicMessagesEvents(iterate(messagesCitationFrames('source quote'))), { id: 'chat_target', created: 1 })); expect(chatFrames.some(frame => frame.type === 'event' && !('error' in frame.event) && frame.event.choices.some(choice => 'annotations' in choice.delta))).toBe(false); - const generateContentFrames = await collect(geminiGenerateContentFromIR(irFromAnthropicMessages(iterate(messagesCitationFrames('source quote'))))); + const generateContentFrames = await collect(writeGeminiGenerateContentEvents(readAnthropicMessagesEvents(iterate(messagesCitationFrames('source quote'))))); expect(generateContentFrames.some(frame => frame.type === 'event' && 'candidates' in frame.event && frame.event.candidates?.some(candidate => candidate.groundingMetadata !== undefined))).toBe(false); }); test.each([ - ['ChatCompletions', irFromOpenAIChatCompletions, (error: object) => ({ id: 'chat', model: 'm', choices: [], error })], - ['Responses', irFromOpenAIResponses, (error: object) => ({ type: 'error', error })], - ['Messages', irFromAnthropicMessages, (error: object) => ({ type: 'error', error })], + ['ChatCompletions', readOpenAIChatCompletionsEvents, (error: object) => ({ id: 'chat', model: 'm', choices: [], error })], + ['Responses', readOpenAIResponsesEvents, (error: object) => ({ type: 'error', error })], + ['Messages', readAnthropicMessagesEvents, (error: object) => ({ type: 'error', error })], ] as const)('%s reader transfers immutable error objects without copying', async (_name, reader, event) => { const error = Object.freeze({ type: 'api_error', message: 'original', details: Object.freeze([Object.freeze({ text: 'original' })]) }); const frames = await collect(reader(iterate([eventFrame(event(error) as any)]))); diff --git a/packages/translate/__tests__/protocols/streams_test.ts b/packages/translate/__tests__/protocols/streams_test.ts index 98fafde2e7..28128aae4b 100644 --- a/packages/translate/__tests__/protocols/streams_test.ts +++ b/packages/translate/__tests__/protocols/streams_test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { anthropicMessagesFromIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; +import { writeAnthropicMessagesEvents, writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; import type { IRJSONObject } from '../../src/ir/common/json.ts'; import { createIRBuilder } from '../../src/ir/output/builder.ts'; import type { IROutputItem } from '../../src/ir/output/types.ts'; @@ -19,7 +19,7 @@ import { openaiResponsesResultToEvents, reassembleOpenAIResponsesEvents, type Op const options = { id: 'target', created: 1 }; const chatFrame = (choices: any[], usage?: any) => eventFrame({ id: 'source', object: 'chat.completion.chunk', model: 'model', created: 1, choices, ...(usage === undefined ? {} : { usage }) } as OpenAIChatCompletionsStreamEvent); -const chat = () => irFromOpenAIChatCompletions(iterate([ +const chat = () => readOpenAIChatCompletionsEvents(iterate([ chatFrame([{ index: 0, delta: { reasoning_text: 'think' } }]), chatFrame([{ index: 0, delta: { content: 'A😀' } }]), chatFrame([{ index: 0, delta: { content: '中B', annotations: [{ type: 'url_citation', url_citation: { start_index: 1, end_index: 3, url: 'https://example.com', title: 'Source' } }] } }]), @@ -35,8 +35,8 @@ const responseFrames = () => iterate([...openaiResponsesResultToEvents({ { type: 'function_call', id: 'f', call_id: 'call', name: 'tool', arguments: '{"a":1}', status: 'completed' }, ], usage: { input_tokens: 11, output_tokens: 7, total_tokens: 18, input_tokens_details: { cached_tokens: 3 }, output_tokens_details: { reasoning_tokens: 2 } }, } as OpenAIResponsesResultEx), doneFrame()]); -const responses = () => irFromOpenAIResponses(responseFrames()); -const messages = () => irFromAnthropicMessages(iterate([ +const responses = () => readOpenAIResponsesEvents(responseFrames()); +const messages = () => readAnthropicMessagesEvents(iterate([ { type: 'message_start', message: { id: 'source', type: 'message', role: 'assistant', model: 'model', content: [], usage: { input_tokens: 6, output_tokens: 0, cache_read_input_tokens: 3, cache_creation_input_tokens: 2 } } }, { type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '', signature: '' } }, { type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'think' } }, @@ -55,11 +55,11 @@ const messages = () => irFromAnthropicMessages(iterate([ ].map(value => eventFrame(value as AnthropicMessagesStreamEventEx)))); const translateChatFrames = async (nativeFrames: readonly any[]) => { - const irFrames = await collect(irFromOpenAIChatCompletions(iterate(nativeFrames as any[]))); - const chatFrames = await collect(openaiChatCompletionsFromIR(iterate(irFrames))); - const responsesFrames = await collect(openaiResponsesFromIR(iterate(irFrames))); - const messagesFrames = await collect(anthropicMessagesFromIR(iterate(irFrames))); - const generateContentFrames = await collect(geminiGenerateContentFromIR(iterate(irFrames))); + const irFrames = await collect(readOpenAIChatCompletionsEvents(iterate(nativeFrames as any[]))); + const chatFrames = await collect(writeOpenAIChatCompletionsEvents(iterate(irFrames))); + const responsesFrames = await collect(writeOpenAIResponsesEvents(iterate(irFrames))); + const messagesFrames = await collect(writeAnthropicMessagesEvents(iterate(irFrames))); + const generateContentFrames = await collect(writeGeminiGenerateContentEvents(iterate(irFrames))); return { irFrames, chatFrames, @@ -90,13 +90,13 @@ describe('IR streaming', () => { for (const [name, source] of [['ChatCompletions', chat], ['Responses', responses], ['Messages', messages]] as const) { it(`ChatCompletions via ${name} emits text and streamed function arguments`, async () => { - const result = await reassembleOpenAIChatCompletionsEvents(events(openaiChatCompletionsFromIR(source(), options))); + const result = await reassembleOpenAIChatCompletionsEvents(events(writeOpenAIChatCompletionsEvents(source(), options))); expect(result.choices[0].message.content).toBe('A😀中B'); expect(result.choices[0].message.tool_calls?.[0]).toMatchObject({ type: 'function', function: { name: 'tool', arguments: '{"a":1}' } }); expect(result.usage?.prompt_tokens).toBe(11); }); it(`Responses via ${name} completes item lifecycles`, async () => { - const frames = await collect(openaiResponsesFromIR(source(), options)); + const frames = await collect(writeOpenAIResponsesEvents(source(), options)); const result = await reassembleOpenAIResponsesEvents(events(iterate(frames))); expect(result.output.filter(item => item.type === 'message').flatMap(item => item.type === 'message' ? item.content.map(p => p.type === 'output_text' ? p.text : '') : []).join('')).toBe('A😀中B'); if (name === 'Responses') { @@ -112,13 +112,13 @@ describe('IR streaming', () => { expect(frames.at(-1)?.type).toBe('event'); }); it(`Messages via ${name} serializes blocks and parses tool input`, async () => { - const result = await reassembleAnthropicMessagesEvents(events(anthropicMessagesFromIR(source(), options))); + const result = await reassembleAnthropicMessagesEvents(events(writeAnthropicMessagesEvents(source(), options))); expect(result.content.filter(block => block.type === 'text').map(block => block.type === 'text' ? block.text : '').join('')).toBe('A😀中B'); expect(result.content.find(block => block.type === 'tool_use')).toMatchObject({ name: 'tool', input: { a: 1 } }); expect(result.usage.output_tokens).toBe(7); }); it(`GenerateContent via ${name} emits complete object args and final usage`, async () => { - const frames = await collect(geminiGenerateContentFromIR(source(), options)); + const frames = await collect(writeGeminiGenerateContentEvents(source(), options)); const result = await reassembleGeminiGenerateContentEvents(events(iterate(frames))); expect(result.candidates?.[0].content?.parts?.filter(p => p.text !== undefined && !p.thought).map(p => p.text).join('')).toBe('A😀中B'); expect(result.candidates?.[0].content?.parts?.find(p => p.functionCall !== undefined)?.functionCall).toMatchObject({ name: 'tool', args: { a: 1 } }); @@ -206,7 +206,7 @@ describe('IR streaming', () => { exhausted = true; yield chatFrame([{ index: 0, delta: {}, finish_reason: 'stop' }]); yield doneFrame(); }; - const stream = openaiChatCompletionsFromIR(irFromOpenAIChatCompletions(upstream()), options); + const stream = writeOpenAIChatCompletionsEvents(readOpenAIChatCompletionsEvents(upstream()), options); let found = false; for await (const frame of stream) if (frame.type === 'event' && !('error' in frame.event) && frame.event.choices[0]?.delta.content === 'first') { expect(exhausted).toBe(false); found = true; break; @@ -215,16 +215,16 @@ describe('IR streaming', () => { }); it('rejects missing terminals and unsafe replacement on an append-only downstream', async () => { - await expect(collectIR(irFromOpenAIChatCompletions(iterate([chatFrame([{ index: 0, delta: { content: 'x' } }])])))).rejects.toThrow('without finish_reason'); + await expect(collectIR(readOpenAIChatCompletionsEvents(iterate([chatFrame([{ index: 0, delta: { content: 'x' } }])])))).rejects.toThrow('without finish_reason'); const frames = completeIR([{ type: 'message', content: [{ type: 'text', text: 'a' }] }]); const part = frames.findIndex(record => record.type === 'write' && record.kind === 'initialize' && record.path.length === 6); frames.splice(part + 1, 0, { type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'b' }); - await expect(collect(openaiChatCompletionsFromIR(iterate(frames), options))).rejects.toThrow('append target cannot be assigned'); + await expect(collect(writeOpenAIChatCompletionsEvents(iterate(frames), options))).rejects.toThrow('append target cannot be assigned'); }); it('handles Responses terminal-only output and implicit audio events', async () => { const result: OpenAIResponsesResultEx = { id: 's', object: 'response', model: 'm', status: 'completed', output: [], error: null, incomplete_details: null }; - const source = irFromOpenAIResponses(iterate([ + const source = readOpenAIResponsesEvents(iterate([ eventFrame({ type: 'response.created', response: result } as OpenAIResponsesStreamEventEx), eventFrame({ type: 'response.audio.delta', delta: 'YWJj' } as OpenAIResponsesStreamEventEx), eventFrame({ type: 'response.audio.transcript.delta', delta: 'abc' } as OpenAIResponsesStreamEventEx), @@ -234,7 +234,7 @@ describe('IR streaming', () => { ])); const ir = await collectIR(source); expect(ir.choices[0].items[0]).toMatchObject({ type: 'message', content: [{ type: 'audio', audio: { data: 'YWJj', transcript: 'abc' } }] }); - const out = await collect(events(openaiResponsesFromIR(iterate(completeIR(ir.choices[0].items)), options))); + const out = await collect(events(writeOpenAIResponsesEvents(iterate(completeIR(ir.choices[0].items)), options))); expect(out).toContainEqual(expect.objectContaining({ type: 'response.audio.delta', delta: 'YWJj' })); }); @@ -260,7 +260,7 @@ describe('IR streaming', () => { b.event({ type: 'finish', status: 'completed' }); yield* b.drain(); }; - const output = events(geminiGenerateContentFromIR(source(), options)); + const output = events(writeGeminiGenerateContentEvents(source(), options)); const result = []; for (const [choice, text] of ['a', 'b'].entries()) { const body = await output.next(); @@ -292,7 +292,7 @@ describe('IR streaming', () => { }); it('ends at candidate termination without emitting an empty final frame when usage is absent', async () => { - const frames = await collect(events(geminiGenerateContentFromIR(iterate(completeIR([{ type: 'message', content: [{ type: 'text', text: 'answer' }] }])), options))); + const frames = await collect(events(writeGeminiGenerateContentEvents(iterate(completeIR([{ type: 'message', content: [{ type: 'text', text: 'answer' }] }])), options))); expect(frames).toEqual([ { responseId: 'target', modelVersion: 'model', candidates: [{ index: 0, content: { role: 'model', parts: [{ text: 'answer' }] } }] }, { responseId: 'target', modelVersion: 'model', candidates: [{ index: 0, content: { role: 'model', parts: [{ thoughtSignature: 'test-sidecar' }] } }] }, @@ -311,7 +311,7 @@ describe('IR streaming', () => { b.event({ type: 'choice_end', choice: 0, finish_reason: 'length' }); b.event({ type: 'choice_end', choice: 1, finish_reason: 'stop' }); b.event({ type: 'finish', status: 'incomplete' }); - await expect(collect(geminiGenerateContentFromIR(iterate(b.drain()), options))).rejects.toThrow(); + await expect(collect(writeGeminiGenerateContentEvents(iterate(b.drain()), options))).rejects.toThrow(); }); it('preserves GenerateContent item order when a tool delays later text and audio', async () => { @@ -336,7 +336,7 @@ describe('IR streaming', () => { b.event({ type: 'choice_end', choice: 0, finish_reason: 'tool_calls' }); b.event({ type: 'finish', status: 'completed' }); - const result = await reassembleGeminiGenerateContentEvents(events(geminiGenerateContentFromIR(iterate(b.drain()), options))); + const result = await reassembleGeminiGenerateContentEvents(events(writeGeminiGenerateContentEvents(iterate(b.drain()), options))); expect(result.candidates?.[0].content?.parts).toEqual([ { functionCall: { id: 'call', name: 'tool', args: {} } }, { text: 'after tool' }, @@ -352,18 +352,18 @@ describe('IR streaming', () => { it('uses target ChatCompletions audio replay metadata across deltas', async () => { const frames = [chatFrame([{ index: 0, delta: { audio: { id: 'audio', expires_at: 123, data: 'YQ==' } } }]), chatFrame([{ index: 0, delta: { audio: { transcript: 'word' } } }]), chatFrame([{ index: 0, delta: {}, finish_reason: 'stop' }]), doneFrame()]; - const result = await reassembleOpenAIChatCompletionsEvents(events(openaiChatCompletionsFromIR(irFromOpenAIChatCompletions(iterate(frames)), { ...options, audioMetadata: () => ({ id: 'target-audio', expires_at: 456 }) }))); + const result = await reassembleOpenAIChatCompletionsEvents(events(writeOpenAIChatCompletionsEvents(readOpenAIChatCompletionsEvents(iterate(frames)), { ...options, audioMetadata: () => ({ id: 'target-audio', expires_at: 456 }) }))); expect(result.choices[0].message.audio).toEqual({ id: 'target-audio', expires_at: 456, data: 'YQ==', transcript: 'word' }); }); it('retains text-image-text order in Responses output', async () => { - const frames = openaiResponsesFromIR(iterate(completeIR([{ type: 'message', content: [{ type: 'text', text: 'before' }, { type: 'image', image: { data: 'YQ==', media_type: 'image/png' } }, { type: 'text', text: 'after' }] }])), options); + const frames = writeOpenAIResponsesEvents(iterate(completeIR([{ type: 'message', content: [{ type: 'text', text: 'before' }, { type: 'image', image: { data: 'YQ==', media_type: 'image/png' } }, { type: 'text', text: 'after' }] }])), options); const result = await reassembleOpenAIResponsesEvents(events(frames)); expect(result.output.map(item => item.type)).toEqual(['message', 'image_generation_call', 'message', 'reasoning']); }); it('marks ChatCompletions length termination incomplete and exposes IR errors', async () => { - const frames = await collect(irFromOpenAIChatCompletions(iterate([chatFrame([{ index: 0, delta: { content: 'text' }, finish_reason: 'length' }]), doneFrame()]))); + const frames = await collect(readOpenAIChatCompletionsEvents(iterate([chatFrame([{ index: 0, delta: { content: 'text' }, finish_reason: 'length' }]), doneFrame()]))); expect(frames.find(record => record.type === 'finish')).toMatchObject({ status: 'incomplete' }); await expect(collectIR(iterate([{ type: 'error', error: { message: 'failure' } }]))).rejects.toThrow(); }); @@ -374,7 +374,7 @@ describe('IR streaming', () => { const resultImage = { ...image, status: success ? 'completed' : 'failed', result: success ? 'YQ==' : null }; const response: any = { id: 's', model: 'm', output: [resultImage, message], usage: null }; const input: any[] = [{ type: 'response.created', response: { ...response, output: [] } }, { type: 'response.output_item.added', output_index: 0, item: image }, { type: 'response.output_item.added', output_index: 1, item: message }, { type: 'response.output_item.done', output_index: 1, item: message }, { type: 'response.output_item.done', output_index: 0, item: resultImage }, { type: 'response.completed', response }]; - const frames = await collect(irFromOpenAIResponses(iterate(input.map(eventFrame)))); + const frames = await collect(readOpenAIResponsesEvents(iterate(input.map(eventFrame)))); const ir = await collectIR(iterate(frames)); const parts = ir.choices[0].items.flatMap(item => item.type === 'message' ? item.content : []); expect(parts.map(part => part.type)).toEqual(success ? ['image', 'text'] : ['text']); @@ -385,12 +385,12 @@ describe('IR streaming', () => { const item: any = { type: 'message', id: 'msg', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: 'text', annotations: [] }] }; const response: any = { id: 's', model: 'm', status: 'completed', output: [item], usage: null }; const input: any[] = [{ type: 'response.created', response: { ...response, output: [] } }, { type: 'response.output_item.added', output_index: 0, item: { ...item, status: 'in_progress', content: [] } }, { type: 'response.content_part.added', output_index: 0, content_index: 0, part: { type: 'output_text', text: '', annotations: [] } }, { type: 'response.output_text.delta', output_index: 0, content_index: 0, delta: 'text' }, { type: 'response.content_part.done', output_index: 0, content_index: 0, part: item.content[0] }, { type: 'response.output_item.done', output_index: 0, item }, { type: 'response.completed', response }]; - const records = await collect(irFromOpenAIResponses(iterate(input.map(eventFrame)))); + const records = await collect(readOpenAIResponsesEvents(iterate(input.map(eventFrame)))); expect(records.filter(record => record.type === 'part_end')).toEqual([{ type: 'part_end', choice: 0, item: 0, part: 0 }]); expect(records.findIndex(record => record.type === 'part_end')).toBeLessThan(records.findIndex(record => record.type === 'item_end')); }); - it.each([['Messages', anthropicMessagesFromIR, reassembleAnthropicMessagesEvents], ['GenerateContent', geminiGenerateContentFromIR, reassembleGeminiGenerateContentEvents]] as const)('allows configured JSON repair in %s', async (_name, adapter, reassemble) => { + it.each([['Messages', writeAnthropicMessagesEvents, reassembleAnthropicMessagesEvents], ['GenerateContent', writeGeminiGenerateContentEvents, reassembleGeminiGenerateContentEvents]] as const)('allows configured JSON repair in %s', async (_name, adapter, reassemble) => { const frames = completeIR([{ type: 'function_call', name: 'tool', call_id: 'c', arguments: '{"a":1' }]); const output = adapter(iterate(frames), { ...options, parseToolArguments: text => JSON.parse(`${text}}`) }); const result: any = await (reassemble as any)(events(output)); @@ -399,7 +399,7 @@ describe('IR streaming', () => { }); it('waits for function names before Responses item creation', async () => { - const frames = await collect(openaiResponsesFromIR(chat(), options)); + const frames = await collect(writeOpenAIResponsesEvents(chat(), options)); const added = frames.flatMap(frame => frame.type === 'event' && frame.event.type === 'response.output_item.added' && frame.event.item.type === 'function_call' ? [frame.event.item] : []); expect(added).toHaveLength(1); expect(added[0]).toMatchObject({ name: 'tool', call_id: 'call' }); @@ -415,7 +415,7 @@ describe('IR streaming', () => { { type: 'write', kind: 'initialize', path: ['choices', 0, 'logprobs'], value: [] }, { type: 'write', kind: 'initialize', path: ['choices', 0, 'logprobs', 0], value: { scope: 'text_part', item_index: 0, content_index: 0, tokens: [] } }, ...tokens.map((value, index) => ({ type: 'write' as const, kind: 'assign' as const, path: ['choices', 0, 'logprobs', 0, 'tokens', index], value }))); - const frames = await collect(openaiResponsesFromIR(iterate(records), options)); + const frames = await collect(writeOpenAIResponsesEvents(iterate(records), options)); const emitted = frames.flatMap(frame => frame.type === 'event' && frame.event.type === 'response.output_text.delta' ? frame.event.logprobs ?? [] : []); expect(emitted).toEqual(tokens); const result = await reassembleOpenAIResponsesEvents(events(iterate(frames))); @@ -431,7 +431,7 @@ describe('IR streaming', () => { { type: 'write', kind: 'assign', path: ['choices', 0, 'logprobs', 0, 'tokens', 0], value: { token: 'x', logprob: -1 } }, ] as const; frames.splice(end, 0, ...logprobs); - await expect(collect(openaiChatCompletionsFromIR(iterate(frames), options))).rejects.toThrow(); + await expect(collect(writeOpenAIChatCompletionsEvents(iterate(frames), options))).rejects.toThrow(); }); it('uses the same lifecycle validation when collecting IR', async () => { @@ -441,7 +441,7 @@ describe('IR streaming', () => { }); it('preserves Responses generated image format', async () => { - const result = await reassembleOpenAIResponsesEvents(events(openaiResponsesFromIR(iterate(completeIR([{ type: 'message', content: [{ type: 'image', image: { data: 'YQ==', media_type: 'image/jpeg' } }] }])), options))); + const result = await reassembleOpenAIResponsesEvents(events(writeOpenAIResponsesEvents(iterate(completeIR([{ type: 'message', content: [{ type: 'image', image: { data: 'YQ==', media_type: 'image/jpeg' } }] }])), options))); expect(result.output[0]).toMatchObject({ type: 'image_generation_call', output_format: 'jpeg' }); }); @@ -450,36 +450,36 @@ describe('IR streaming', () => { const transcript = { type: 'response.audio.transcript.delta', delta: 'hello' }; const response = { id: 's', model: 'm', output: [] }; const input: any[] = [{ type: 'response.created', response }, ...(transcriptFirst ? [transcript, data] : [data, transcript]), { type: 'response.audio.done' }, { type: 'response.audio.transcript.done' }, { type: 'response.completed', response }]; - const source = () => irFromOpenAIResponses(iterate(input.map(eventFrame))); - const messages = await reassembleAnthropicMessagesEvents(events(anthropicMessagesFromIR(source(), options))); + const source = () => readOpenAIResponsesEvents(iterate(input.map(eventFrame))); + const messages = await reassembleAnthropicMessagesEvents(events(writeAnthropicMessagesEvents(source(), options))); expect(messages.content.filter(block => block.type !== 'redacted_thinking')).toMatchObject([{ type: 'text', text: 'hello' }]); - const chat = await reassembleOpenAIChatCompletionsEvents(events(openaiChatCompletionsFromIR(source(), { ...options, audioMetadata: () => ({ id: 'a', expires_at: 123 }) }))); + const chat = await reassembleOpenAIChatCompletionsEvents(events(writeOpenAIChatCompletionsEvents(source(), { ...options, audioMetadata: () => ({ id: 'a', expires_at: 123 }) }))); expect(chat.choices[0].message.audio).toEqual({ id: 'a', expires_at: 123, data: 'YQ==', transcript: 'hello' }); expect(chat.choices[0].message.content).toBeNull(); - await expect(reassembleGeminiGenerateContentEvents(events(geminiGenerateContentFromIR(source(), options)))) + await expect(reassembleGeminiGenerateContentEvents(events(writeGeminiGenerateContentEvents(source(), options)))) .rejects.toThrow('GenerateContent inline data requires a MIME type'); }); it('emits a transcript-only audio part once in GenerateContent', async () => { - const result = await reassembleGeminiGenerateContentEvents(events(geminiGenerateContentFromIR(iterate(completeIR([{ type: 'message', content: [{ type: 'audio', audio: { transcript: 'hello' } }] }])), options))); + const result = await reassembleGeminiGenerateContentEvents(events(writeGeminiGenerateContentEvents(iterate(completeIR([{ type: 'message', content: [{ type: 'audio', audio: { transcript: 'hello' } }] }])), options))); expect(result.candidates?.[0].content?.parts).toEqual([{ text: 'hello' }, { thoughtSignature: 'test-sidecar' }]); }); it('emits transcript-only audio before ChatCompletions finish_reason', async () => { - const frames = await collect(openaiChatCompletionsFromIR(iterate(completeIR([{ type: 'message', content: [{ type: 'audio', audio: { transcript: 'hello' } }] }])), options)); + const frames = await collect(writeOpenAIChatCompletionsEvents(iterate(completeIR([{ type: 'message', content: [{ type: 'audio', audio: { transcript: 'hello' } }] }])), options)); const chunks = frames.flatMap(frame => frame.type === 'event' && !('error' in frame.event) ? frame.event.choices : []); expect(chunks.findIndex(choice => choice.delta.content === 'hello')).toBeLessThan(chunks.findIndex(choice => choice.finish_reason === 'stop')); }); - it.each([['Responses', openaiResponsesFromIR], ['Messages', anthropicMessagesFromIR]] as const)('rejects completed unnamed tools in %s', async (_name, adapter) => { + it.each([['Responses', writeOpenAIResponsesEvents], ['Messages', writeAnthropicMessagesEvents]] as const)('rejects completed unnamed tools in %s', async (_name, adapter) => { await expect(collect(adapter(iterate(completeIR([{ type: 'function_call', name: '', arguments: '{}' }])), options))).rejects.toThrow('require a name'); }); it.each([ - ['ChatCompletions', openaiChatCompletionsFromIR], - ['Responses', openaiResponsesFromIR], - ['Messages', anthropicMessagesFromIR], - ['GenerateContent', geminiGenerateContentFromIR], + ['ChatCompletions', writeOpenAIChatCompletionsEvents], + ['Responses', writeOpenAIResponsesEvents], + ['Messages', writeAnthropicMessagesEvents], + ['GenerateContent', writeGeminiGenerateContentEvents], ] as const)('%s rejects IR start without a model', async (_name, adapter) => { await expect(collect(adapter(iterate([{ type: 'start', id: 'source' }] as any), options))).rejects.toThrow('requires an IR model'); }); diff --git a/packages/translate/__tests__/protocols/usage_test.ts b/packages/translate/__tests__/protocols/usage_test.ts index b6348755b9..8ddcc21aaa 100644 --- a/packages/translate/__tests__/protocols/usage_test.ts +++ b/packages/translate/__tests__/protocols/usage_test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; import { fixtureFrames } from './fixtures.ts'; -import { geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; +import { writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; import { usageFromIR as messagesUsageFromIR } from '../../src/protocols/anthropic-messages/as-source/usage.ts'; import { usageToIR as messagesUsageToIR } from '../../src/protocols/anthropic-messages/as-target/usage.ts'; import { usageFromIR } from '../../src/protocols/gemini-generate-content/as-source/usage.ts'; @@ -15,9 +15,9 @@ import { eventFrame } from '@floway-dev/protocols/common'; const initialUsage = { input_tokens: 17, output_tokens: 2, total_tokens: 19, input_tokens_details: { cached_tokens: 3, cache_write_tokens: 4 } }; const sources = [ - ['ChatCompletions', irFromOpenAIChatCompletions, { id: 'resp_usage', model: 'served', created: 1, choices: [], service_tier: 'priority', usage: { prompt_tokens: 17, completion_tokens: 2, total_tokens: 19, prompt_tokens_details: { cached_tokens: 3, cache_write_tokens: 4 } } }, 'priority'], - ['Responses', irFromOpenAIResponses, { type: 'response.created', response: { id: 'resp_usage', model: 'served', created_at: 1, status: 'in_progress', output: [], service_tier: 'fast', usage: initialUsage } }, 'fast'], - ['Messages', irFromAnthropicMessages, { type: 'message_start', message: { id: 'msg_usage', model: 'served', usage: { input_tokens: 10, output_tokens: 2, cache_read_input_tokens: 3, cache_creation_input_tokens: 4, speed: 'fast' } } }, 'fast'], + ['ChatCompletions', readOpenAIChatCompletionsEvents, { id: 'resp_usage', model: 'served', created: 1, choices: [], service_tier: 'priority', usage: { prompt_tokens: 17, completion_tokens: 2, total_tokens: 19, prompt_tokens_details: { cached_tokens: 3, cache_write_tokens: 4 } } }, 'priority'], + ['Responses', readOpenAIResponsesEvents, { type: 'response.created', response: { id: 'resp_usage', model: 'served', created_at: 1, status: 'in_progress', output: [], service_tier: 'fast', usage: initialUsage } }, 'fast'], + ['Messages', readAnthropicMessagesEvents, { type: 'message_start', message: { id: 'msg_usage', model: 'served', usage: { input_tokens: 10, output_tokens: 2, cache_read_input_tokens: 3, cache_creation_input_tokens: 4, speed: 'fast' } } }, 'fast'], ] as const; test.each(sources)('Responses via %s exposes known initial accounting before pulling another upstream frame', async (_name, reader, event, tier) => { @@ -27,7 +27,7 @@ test.each(sources)('Responses via %s exposes known initial accounting before pul pulledNext = true; throw new Error('The converter pulled past the initial upstream frame'); }; - const stream = openaiResponsesFromIR(reader(upstream())); + const stream = writeOpenAIResponsesEvents(reader(upstream())); try { for (const type of ['response.created', 'response.in_progress']) { const frame = (await stream.next()).value; @@ -45,7 +45,7 @@ test('Responses preserves absent initial accounting and later measured usage', a { type: 'response.completed', response: { id: 'resp_usage', model: 'served', created_at: 1, status: 'completed', output: [], service_tier: 'fast', usage: initialUsage } }, ].map(event => eventFrame(event as any)); const output = []; - for await (const frame of openaiResponsesFromIR(irFromOpenAIResponses(iterate(upstream)))) output.push(frame); + for await (const frame of writeOpenAIResponsesEvents(readOpenAIResponsesEvents(iterate(upstream)))) output.push(frame); expect(output[0]).toMatchObject({ event: { response: { usage: null } } }); expect(output[0].type === 'event' && output[0].event.type === 'response.created' && output[0].event.response).not.toHaveProperty('service_tier'); expect(output.at(-1)).toMatchObject({ event: { type: 'response.completed', response: { usage: initialUsage, service_tier: 'fast' } } }); @@ -58,7 +58,7 @@ test('ChatCompletions via Messages publishes its initial role with already-known pulledNext = true; throw new Error('The converter pulled past message_start'); }; - const stream = openaiChatCompletionsFromIR(irFromAnthropicMessages(upstream())); + const stream = writeOpenAIChatCompletionsEvents(readAnthropicMessagesEvents(upstream())); try { expect((await stream.next()).value).toMatchObject({ event: { model: 'served', service_tier: 'fast', choices: [{ delta: { role: 'assistant' } }] } }); expect(pulledNext).toBe(false); @@ -70,7 +70,7 @@ test('ChatCompletions via Messages publishes its initial role with already-known test.each([['fast', 'priority'], ['default', 'standard']])('GenerateContent maps the %s alias to its %s tier', async (tier, serviceTier) => { const source = fixtureFrames('openai-responses', { text: ['answer'], usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 }, tier }); const output = []; - for await (const frame of geminiGenerateContentFromIR(irFromOpenAIResponses(iterate(source)))) output.push(frame); + for await (const frame of writeGeminiGenerateContentEvents(readOpenAIResponsesEvents(iterate(source)))) output.push(frame); expect(output.at(-1)).toMatchObject({ event: { usageMetadata: { serviceTier } } }); }); @@ -105,7 +105,7 @@ test('Messages reader accepts a message_delta without usage', async () => { { type: 'message_delta', delta: { stop_reason: 'end_turn' } }, { type: 'message_stop' }, ].map(event => eventFrame(event as any)); - const result = await collectIR(irFromAnthropicMessages(iterate(upstream))); + const result = await collectIR(readAnthropicMessagesEvents(iterate(upstream))); expect(result.usage).toMatchObject({ input_tokens_inclusive: 1, output_tokens_inclusive: 0 }); }); @@ -137,6 +137,6 @@ test.each([ { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 2, ...late } }, { type: 'message_stop' }, ].map(event => eventFrame(event as any)); - const result = await collectIR(irFromAnthropicMessages(iterate(upstream))); + const result = await collectIR(readAnthropicMessagesEvents(iterate(upstream))); expect(result.usage).toMatchObject({ input_tokens_inclusive: 10 + total, output_tokens_inclusive: 2, cache_creation_input_tokens: total }); }); diff --git a/packages/translate/src/ir/output/schema.ts b/packages/translate/src/ir/output/schema.ts index 1ab93b5261..034e3fd3ea 100644 --- a/packages/translate/src/ir/output/schema.ts +++ b/packages/translate/src/ir/output/schema.ts @@ -34,14 +34,14 @@ export interface IRUnionNode { readonly kind: 'opaque'; readonly value?: V } +// A value that is only ever replaced as a whole. `phantom` exists for the type only. +export interface IROpaqueNode { readonly kind: 'opaque'; readonly phantom?: V } export type IRSchemaNode = IRStringNode | IRNumberNode | IRNullableNode | IRArrayNode | IRObjectNode | IRUnionNode | IROpaqueNode; export interface IRSchemaFields { readonly [key: string]: IRSchemaNode } const string = { kind: 'string' } as const; const appendString = { kind: 'string', append: true } as const; -const callId = { kind: 'string', absent: true } as const; +const absentString = { kind: 'string', absent: true } as const; const number = { kind: 'number' } as const; const nullable = (of: N): IRNullableNode => ({ kind: 'nullable', of }); const array = (of: N): IRArrayNode => ({ kind: 'array', of }); @@ -69,8 +69,8 @@ const item = union('type', { summary: array(appendString), content: array(appendString), }, { optional: ['summary', 'content'] }), - function_call: object({ call_id: callId, name: string, arguments: appendString }, { optional: ['call_id'] }), - custom_tool_call: object({ call_id: callId, name: string, input: appendString }, { optional: ['call_id'] }), + function_call: object({ call_id: absentString, name: string, arguments: appendString }, { optional: ['call_id'] }), + custom_tool_call: object({ call_id: absentString, name: string, input: appendString }, { optional: ['call_id'] }), }, 'item'); const logprobs = nullable(array(union('scope', { @@ -121,4 +121,4 @@ type Mutual = [A] extends [B] ? ([B] extends [A] ? true : false) : false; // Compile-time proof that the schema and the IR types describe the same values: a field the // schema declares optional, nullable, appendable or `IRAbsent`-capable differs from the IR type // exactly when this stops being `true`. -export const irSchemaMatchesIRTypes: Mutual, IROutput> = true; +export const irSchemaTypeEquality: Mutual, IROutput> = true; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/events.ts similarity index 98% rename from packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts rename to packages/translate/src/protocols/anthropic-messages/as-source/events.ts index 456e5d9a5e..7ed73dcdc3 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/events.ts @@ -13,8 +13,8 @@ import { createIRUnitQueue } from '../../../ir/output/unit-queue.ts'; import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; import { appendContent, createContentState, type ContentState, type ContentStates } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; -import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; +import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; import { parseJSONObject, parseJSONObjectAtCompletion, requireJSONObject } from '../../../shared/json/object.ts'; import type { AnthropicMessagesAssistantState, AnthropicMessagesInputAssistantTurn } from '../as-target/assistant-turn.ts'; import { @@ -87,13 +87,13 @@ const outputTextKind = (unit: MessagesUnit, record: IRAppendRecord): 'text' | 't return undefined; }; -interface AnthropicMessagesSSEOutputOptions extends RoundTripOutputOptions<'anthropicMessages'> { +interface AnthropicMessagesEventWriterOptions extends SourceEventWriterOptions<'anthropicMessages'> { parseToolArguments?: (text: string) => IRJSONObject; } -export const anthropicMessagesFromIR = async function* ( +export const writeAnthropicMessagesEvents = async function* ( records: AsyncIterable, - options: AnthropicMessagesSSEOutputOptions, + options: AnthropicMessagesEventWriterOptions, ): AsyncGenerator> { let metadata = { id: '', model: '' }; let started = false; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/sse-from.ts b/packages/translate/src/protocols/anthropic-messages/as-target/events.ts similarity index 99% rename from packages/translate/src/protocols/anthropic-messages/as-target/sse-from.ts rename to packages/translate/src/protocols/anthropic-messages/as-target/events.ts index 4225141cee..00e981efe4 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/sse-from.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/events.ts @@ -61,7 +61,7 @@ interface MessagesBlockState { wrappedInput: string; } -export const irFromAnthropicMessages = async function* ( +export const readAnthropicMessagesEvents = async function* ( frames: AsyncIterable>, options: { customToolNames?: ReadonlySet; roundTrip: RoundTripReader<'anthropicMessages'> }, ): AsyncGenerator { diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts index efbdc00b2d..7d1a62d87a 100644 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts +++ b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts @@ -1,12 +1,12 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromOpenAIChatCompletions } from '../../openai-chat-completions/as-target/sse-from.ts'; -import { anthropicMessagesFromIR } from '../as-source/sse-to.ts'; +import { readOpenAIChatCompletionsEvents } from '../../openai-chat-completions/as-target/events.ts'; +import { writeAnthropicMessagesEvents } from '../as-source/events.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec, maxChoices = 1): AsyncIterable> => { const stream = createRoundTripStream('anthropicMessages', 'openaiChatCompletions', codec); - return anthropicMessagesFromIR(irFromOpenAIChatCompletions(frames, { maxChoices, roundTrip: stream.reader }), { roundTrip: stream.writer }); + return writeAnthropicMessagesEvents(readOpenAIChatCompletionsEvents(frames, { maxChoices, roundTrip: stream.reader }), { roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts index 777ceb4971..bc6d985145 100644 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts +++ b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts @@ -1,12 +1,12 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromOpenAIResponses } from '../../openai-responses/as-target/sse-from.ts'; -import { anthropicMessagesFromIR } from '../as-source/sse-to.ts'; +import { readOpenAIResponsesEvents } from '../../openai-responses/as-target/events.ts'; +import { writeAnthropicMessagesEvents } from '../as-source/events.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { const stream = createRoundTripStream('anthropicMessages', 'openaiResponses', codec); - return anthropicMessagesFromIR(irFromOpenAIResponses(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); + return writeAnthropicMessagesEvents(readOpenAIResponsesEvents(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts similarity index 98% rename from packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts rename to packages/translate/src/protocols/gemini-generate-content/as-source/events.ts index 545f856c1a..d72c8ed9c7 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts @@ -12,8 +12,8 @@ import { createIRUnitQueue, type IRUnitQueue } from '../../../ir/output/unit-que import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; import { appendContent, createContentState, type ContentStates, type ContentState as ReferenceContentState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; -import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; +import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; import { isCompleteJSONObject, parseJSONObjectAtCompletion } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import type { GeminiGenerateContentAssistantState, GeminiGenerateContentInputAssistantTurn } from '../as-target/assistant-turn.ts'; @@ -57,13 +57,13 @@ interface TextSegment { targetStart: number; } -interface GeminiGenerateContentSSEOutputOptions extends RoundTripOutputOptions<'geminiGenerateContent'> { +interface GeminiGenerateContentEventWriterOptions extends SourceEventWriterOptions<'geminiGenerateContent'> { parseToolArguments?: (text: string) => IRJSONObject; } -export const geminiGenerateContentFromIR = async function* ( +export const writeGeminiGenerateContentEvents = async function* ( records: AsyncIterable, - options: GeminiGenerateContentSSEOutputOptions, + options: GeminiGenerateContentEventWriterOptions, ): AsyncGenerator> { let metadata = { id: '', model: '' }; let started = false; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts index b80a1d6eb8..0f574ddb00 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts @@ -1,12 +1,12 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromAnthropicMessages } from '../../anthropic-messages/as-target/sse-from.ts'; -import { geminiGenerateContentFromIR } from '../as-source/sse-to.ts'; +import { readAnthropicMessagesEvents } from '../../anthropic-messages/as-target/events.ts'; +import { writeGeminiGenerateContentEvents } from '../as-source/events.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { const stream = createRoundTripStream('geminiGenerateContent', 'anthropicMessages', codec); - return geminiGenerateContentFromIR(irFromAnthropicMessages(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); + return writeGeminiGenerateContentEvents(readAnthropicMessagesEvents(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts index 98467f304d..eeae06afe8 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts @@ -1,12 +1,12 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromOpenAIChatCompletions } from '../../openai-chat-completions/as-target/sse-from.ts'; -import { geminiGenerateContentFromIR } from '../as-source/sse-to.ts'; +import { readOpenAIChatCompletionsEvents } from '../../openai-chat-completions/as-target/events.ts'; +import { writeGeminiGenerateContentEvents } from '../as-source/events.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec, maxChoices = 1): AsyncIterable> => { const stream = createRoundTripStream('geminiGenerateContent', 'openaiChatCompletions', codec); - return geminiGenerateContentFromIR(irFromOpenAIChatCompletions(frames, { maxChoices, roundTrip: stream.reader }), { roundTrip: stream.writer }); + return writeGeminiGenerateContentEvents(readOpenAIChatCompletionsEvents(frames, { maxChoices, roundTrip: stream.reader }), { roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts index 7d472247a2..5fd713d617 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts @@ -1,12 +1,12 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromOpenAIResponses } from '../../openai-responses/as-target/sse-from.ts'; -import { geminiGenerateContentFromIR } from '../as-source/sse-to.ts'; +import { readOpenAIResponsesEvents } from '../../openai-responses/as-target/events.ts'; +import { writeGeminiGenerateContentEvents } from '../as-source/events.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { const stream = createRoundTripStream('geminiGenerateContent', 'openaiResponses', codec); - return geminiGenerateContentFromIR(irFromOpenAIResponses(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); + return writeGeminiGenerateContentEvents(readOpenAIResponsesEvents(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts similarity index 98% rename from packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts rename to packages/translate/src/protocols/openai-chat-completions/as-source/events.ts index 74dfdade36..2941fd9b60 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts @@ -9,7 +9,7 @@ import type { IROutputItem, IROutputMessageItem, IROutputTokenLogprob } from '.. import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; import { appendContent, createContentState, type ContentStates, type ContentState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; -import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; +import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIChatCompletionsInputAssistantTurn, type OpenAIChatCompletionsOutputAssistantState } from '../as-target/assistant-turn.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; @@ -30,7 +30,7 @@ const irChatError = (error: IRErrorPayload): IRErrorPayload => ({ ...(error.provider_specific_fields === undefined ? {} : { provider_specific_fields: error.provider_specific_fields }), }); -interface OpenAIChatCompletionsSSEOutputOptions extends RoundTripOutputOptions<'openaiChatCompletions'> { +interface OpenAIChatCompletionsEventWriterOptions extends SourceEventWriterOptions<'openaiChatCompletions'> { // Replay identity belongs to the newly emitted target audio, not the source IR content. audioMetadata?: (choice: number) => { id: string; expires_at: number }; continuousUsageStats?: boolean; @@ -82,9 +82,9 @@ type ChatChoiceChunk = Omit>; type ChatTextField = 'content' | 'refusal' | 'reasoning_text'; -export const openaiChatCompletionsFromIR = async function* ( +export const writeOpenAIChatCompletionsEvents = async function* ( records: AsyncIterable, - options: OpenAIChatCompletionsSSEOutputOptions, + options: OpenAIChatCompletionsEventWriterOptions, ): AsyncGenerator> { let metadata = { id: '', model: '', created: 0 }; let started = false; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/events.ts similarity index 99% rename from packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts rename to packages/translate/src/protocols/openai-chat-completions/as-target/events.ts index 534a901812..ee815569a3 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/events.ts @@ -117,7 +117,7 @@ interface ChatChoiceState { incomplete: boolean; } -export const irFromOpenAIChatCompletions = async function* ( +export const readOpenAIChatCompletionsEvents = async function* ( frames: AsyncIterable>, options: { customToolNames?: ReadonlySet; maxChoices?: number; roundTrip: RoundTripReader<'openaiChatCompletions'> }, ): AsyncGenerator { diff --git a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts index 3a9743ea27..f391712fc0 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts @@ -1,7 +1,7 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromAnthropicMessages } from '../../anthropic-messages/as-target/sse-from.ts'; -import { openaiChatCompletionsFromIR } from '../as-source/sse-to.ts'; +import { readAnthropicMessagesEvents } from '../../anthropic-messages/as-target/events.ts'; +import { writeOpenAIChatCompletionsEvents } from '../as-source/events.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsStreamOptionsEx } from '@floway-dev/protocols/openai-chat-completions'; @@ -13,5 +13,5 @@ export const translateToSourceEvents = ( ): AsyncIterable> => { const outputOptions = { continuousUsageStats: streamOptions.include_usage === true && streamOptions.continuous_usage_stats === true }; const stream = createRoundTripStream('openaiChatCompletions', 'anthropicMessages', codec); - return openaiChatCompletionsFromIR(irFromAnthropicMessages(frames, { roundTrip: stream.reader }), { ...outputOptions, roundTrip: stream.writer }); + return writeOpenAIChatCompletionsEvents(readAnthropicMessagesEvents(frames, { roundTrip: stream.reader }), { ...outputOptions, roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts index b09f7e7e74..7b2b5171aa 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts @@ -1,7 +1,7 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromOpenAIResponses } from '../../openai-responses/as-target/sse-from.ts'; -import { openaiChatCompletionsFromIR } from '../as-source/sse-to.ts'; +import { readOpenAIResponsesEvents } from '../../openai-responses/as-target/events.ts'; +import { writeOpenAIChatCompletionsEvents } from '../as-source/events.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsStreamOptionsEx } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; @@ -13,5 +13,5 @@ export const translateToSourceEvents = ( ): AsyncIterable> => { const outputOptions = { continuousUsageStats: streamOptions.include_usage === true && streamOptions.continuous_usage_stats === true }; const stream = createRoundTripStream('openaiChatCompletions', 'openaiResponses', codec); - return openaiChatCompletionsFromIR(irFromOpenAIResponses(frames, { roundTrip: stream.reader }), { ...outputOptions, roundTrip: stream.writer }); + return writeOpenAIChatCompletionsEvents(readOpenAIResponsesEvents(frames, { roundTrip: stream.reader }), { ...outputOptions, roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts b/packages/translate/src/protocols/openai-responses/as-source/events.ts similarity index 99% rename from packages/translate/src/protocols/openai-responses/as-source/sse-to.ts rename to packages/translate/src/protocols/openai-responses/as-source/events.ts index 78b2c93993..64fc6b19df 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/events.ts @@ -13,7 +13,7 @@ import { createIRUnitQueue } from '../../../ir/output/unit-queue.ts'; import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; import { appendContent, createContentState, type ContentState, type ContentStates } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; -import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; +import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; import { isCompleteJSONObject, parseJSONObjectAtCompletion } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIResponsesInputAssistantTurn, type OpenAIResponsesItemState, type OpenAIResponsesOutputAssistantState } from '../as-target/assistant-turn.ts'; @@ -50,7 +50,7 @@ type ResponsesOutputUnit = ResponsesOutputUnitBase & ( | { readonly kind: 'function_call' | 'custom_tool_call'; toolContentState?: ContentState<'text'> } ); -type OpenAIResponsesSSEOutputOptions = RoundTripOutputOptions<'openaiResponses'> & { namespaceToolNames: NamespaceToolNames }; +type OpenAIResponsesEventWriterOptions = SourceEventWriterOptions<'openaiResponses'> & { namespaceToolNames: NamespaceToolNames }; const IMAGE_OUTPUT_FORMATS = { 'image/png': 'png', 'image/jpeg': 'jpeg', 'image/webp': 'webp' } as const; @@ -178,9 +178,9 @@ const textOrRefusalPart = (state: IROutput, path: IRPath): Extract, - options: OpenAIResponsesSSEOutputOptions, + options: OpenAIResponsesEventWriterOptions, ): AsyncGenerator> { let metadata = { id: '', model: '', created: 0 }; let started = false; diff --git a/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts b/packages/translate/src/protocols/openai-responses/as-target/events.ts similarity index 99% rename from packages/translate/src/protocols/openai-responses/as-target/sse-from.ts rename to packages/translate/src/protocols/openai-responses/as-target/events.ts index 3eba5a9b29..68b382a78f 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/events.ts @@ -137,7 +137,7 @@ const irItemSeed = (item: OpenAIResponsesOutputItemEx): Record const itemId = (item: OpenAIResponsesOutputItemEx): unknown => 'id' in item ? item.id : undefined; -export const irFromOpenAIResponses = async function* ( +export const readOpenAIResponsesEvents = async function* ( frames: AsyncIterable>, options: { roundTrip: RoundTripReader<'openaiResponses'> }, ): AsyncGenerator { diff --git a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts index 2450fc20f0..b21b9da9ad 100644 --- a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts +++ b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts @@ -1,13 +1,13 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromAnthropicMessages } from '../../anthropic-messages/as-target/sse-from.ts'; +import { readAnthropicMessagesEvents } from '../../anthropic-messages/as-target/events.ts'; +import { writeOpenAIResponsesEvents } from '../as-source/events.ts'; import type { NamespaceToolNames } from '../as-source/namespace-tools.ts'; -import { openaiResponsesFromIR } from '../as-source/sse-to.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; export const translateToSourceEvents = (frames: AsyncIterable>, responseId: string, customToolNames: ReadonlySet, codec: SidecarCodec, namespaceToolNames: NamespaceToolNames): AsyncIterable> => { const stream = createRoundTripStream('openaiResponses', 'anthropicMessages', codec); - return openaiResponsesFromIR(irFromAnthropicMessages(frames, { customToolNames, roundTrip: stream.reader }), { id: responseId, namespaceToolNames, roundTrip: stream.writer }); + return writeOpenAIResponsesEvents(readAnthropicMessagesEvents(frames, { customToolNames, roundTrip: stream.reader }), { id: responseId, namespaceToolNames, roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts index 28f41fb471..44ac3b6ec0 100644 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts +++ b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts @@ -1,13 +1,13 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromOpenAIChatCompletions } from '../../openai-chat-completions/as-target/sse-from.ts'; +import { readOpenAIChatCompletionsEvents } from '../../openai-chat-completions/as-target/events.ts'; +import { writeOpenAIResponsesEvents } from '../as-source/events.ts'; import type { NamespaceToolNames } from '../as-source/namespace-tools.ts'; -import { openaiResponsesFromIR } from '../as-source/sse-to.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; export const translateToSourceEvents = (frames: AsyncIterable>, customToolNames: ReadonlySet, codec: SidecarCodec, namespaceToolNames: NamespaceToolNames, maxChoices = 1): AsyncIterable> => { const stream = createRoundTripStream('openaiResponses', 'openaiChatCompletions', codec); - return openaiResponsesFromIR(irFromOpenAIChatCompletions(frames, { customToolNames, maxChoices, roundTrip: stream.reader }), { namespaceToolNames, roundTrip: stream.writer }); + return writeOpenAIResponsesEvents(readOpenAIChatCompletionsEvents(frames, { customToolNames, maxChoices, roundTrip: stream.reader }), { namespaceToolNames, roundTrip: stream.writer }); }; diff --git a/packages/translate/src/round-trip/stream.ts b/packages/translate/src/round-trip/stream.ts index 17d393f9c0..d216fe5db1 100644 --- a/packages/translate/src/round-trip/stream.ts +++ b/packages/translate/src/round-trip/stream.ts @@ -57,9 +57,3 @@ export const createRoundTripStream = < }; return { reader, writer }; }; - -export interface RoundTripOutputOptions { - id?: string; - created?: number; - roundTrip: RoundTripWriter; -} diff --git a/packages/translate/src/shared/event-writer-options.ts b/packages/translate/src/shared/event-writer-options.ts new file mode 100644 index 0000000000..5db95628da --- /dev/null +++ b/packages/translate/src/shared/event-writer-options.ts @@ -0,0 +1,8 @@ +import type { AssistantTurnByProtocol } from '../round-trip/assistant-turn.ts'; +import type { RoundTripWriter } from '../round-trip/stream.ts'; + +export interface SourceEventWriterOptions { + id?: string; + created?: number; + roundTrip: RoundTripWriter; +} From 287ef1623aba1284c037ec4effd6f963f9d26fa9 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 06:13:42 +0800 Subject: [PATCH 09/66] refactor(translate): finish replay contract ownership Unify recursive and assistant-turn restoration through RestoreResult.value while retaining the pairwise request boundary's native turn wrapper. Centralize the four protocols' existing digest material projection and version dispatch in shared digest factories. Preserve synchronous projector failures and skip projection for unsupported versions. Validated 38 named test files (309 tests), translate typecheck, and 14 touched-file lint targets. Production HMAC/CBOR bytes and restored native values match main; all four protocol digest projections and version behavior also match main. --- .../as-target/sse-from_test.ts | 2 +- .../as-target/sse-from_test.ts | 10 +++---- .../reference/reference-builder_test.ts | 4 +-- .../reference/reference-cost-json_test.ts | 2 +- .../reference/reference-cost_test.ts | 10 +++---- .../round-trip/reference/restore_test.ts | 30 +++++++++---------- .../__tests__/round-trip/turn-digest_test.ts | 30 +++++++++++++++++-- .../as-source/round-trip.ts | 17 ++--------- .../as-source/round-trip.ts | 17 ++--------- .../as-source/round-trip.ts | 17 ++--------- .../openai-responses/as-source/round-trip.ts | 17 ++--------- .../src/round-trip/reference/restore.ts | 8 ++--- packages/translate/src/round-trip/resolve.ts | 2 +- .../translate/src/round-trip/turn-digest.ts | 13 ++++++++ 14 files changed, 86 insertions(+), 93 deletions(-) diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts index bac4d22ca8..8a4e25052b 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts @@ -95,7 +95,7 @@ const traceTools = async (frames: ReturnType[], options: { prefix? const [, turn, fieldState] = captured; const finalized = await referenceAssistantTurn(turn, fieldState, builder, collectEmittedFieldPlacements(text, content)); const hydrated = await restoreAssistantTurn('openaiChatCompletions', [text], finalized.targetTurn, finalized.referencedContentHashes); - expect(hydrated).toEqual({ ok: true, turn }); + expect(hydrated).toEqual({ ok: true, value: turn }); return { turn, fieldState, appends, assigns, finalized }; }; diff --git a/packages/translate/__tests__/protocols/openai-responses/as-target/sse-from_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-target/sse-from_test.ts index ab2cb3bd23..7e69b2f6fa 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-target/sse-from_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-target/sse-from_test.ts @@ -114,7 +114,7 @@ test('keeps a tiny complete snapshot-only atom literal and hydrates the native R ['hello'], finalized.targetTurn, finalized.referencedContentHashes, - )).toEqual({ ok: true, turn: captured.turn }); + )).toEqual({ ok: true, value: captured.turn }); }); test('invalidates streamed refusal provenance when its done snapshot supplies a suffix', async () => { @@ -138,7 +138,7 @@ test('invalidates streamed refusal provenance when its done snapshot supplies a expect(itemState.content[0]).toMatchObject({ refusal: { valid: false } }); const finalized = await referenceAssistantTurn<'openaiResponses'>(captured.turn, captured.fieldState, captured.builder, collectEmittedFieldPlacements('refused', captured.content)); expect(finalized).not.toHaveProperty('referencedContentHashes'); - expect(await restoreAssistantTurn('openaiResponses', ['refused'], finalized.targetTurn, finalized.referencedContentHashes)).toEqual({ ok: true, turn: captured.turn }); + expect(await restoreAssistantTurn('openaiResponses', ['refused'], finalized.targetTurn, finalized.referencedContentHashes)).toEqual({ ok: true, value: captured.turn }); }); test('collects a snapshot-only refusal through its refusal field state', async () => { @@ -154,7 +154,7 @@ test('collects a snapshot-only refusal through its refusal field state', async ( expect(captured.appends).toEqual([{ text: 'refused', textFragmentIds: [0] }]); const finalized = await referenceAssistantTurn<'openaiResponses'>(captured.turn, captured.fieldState, captured.builder, collectEmittedFieldPlacements('refused', captured.content)); expect(finalized).not.toHaveProperty('referencedContentHashes'); - expect(await restoreAssistantTurn('openaiResponses', ['refused'], finalized.targetTurn, finalized.referencedContentHashes)).toEqual({ ok: true, turn: captured.turn }); + expect(await restoreAssistantTurn('openaiResponses', ['refused'], finalized.targetTurn, finalized.referencedContentHashes)).toEqual({ ok: true, value: captured.turn }); }); type ToolItem = Extract; @@ -191,7 +191,7 @@ test.each(toolCases)('collects streamed native $type text through its existing s const finalized = await referenceAssistantTurn<'openaiResponses'>(captured.turn, captured.fieldState, captured.builder, collectEmittedFieldPlacements(text, captured.content)); expect(finalized.targetTurn[0]).toHaveProperty(field, text); expect(finalized).not.toHaveProperty('referencedContentHashes'); - expect(await restoreAssistantTurn('openaiResponses', [text], finalized.targetTurn, finalized.referencedContentHashes)).toEqual({ ok: true, turn: captured.turn }); + expect(await restoreAssistantTurn('openaiResponses', [text], finalized.targetTurn, finalized.referencedContentHashes)).toEqual({ ok: true, value: captured.turn }); }); test.each(toolCases)('collects a snapshot-only native $type atom and inlines the tiny field', async ({ type, field, prefix, suffix }) => { @@ -208,7 +208,7 @@ test.each(toolCases)('collects a snapshot-only native $type atom and inlines the const finalized = await referenceAssistantTurn<'openaiResponses'>(captured.turn, captured.fieldState, captured.builder, collectEmittedFieldPlacements(text, captured.content)); expect(finalized.targetTurn[0]).toHaveProperty(field, text); expect(finalized).not.toHaveProperty('referencedContentHashes'); - expect(await restoreAssistantTurn('openaiResponses', [text], finalized.targetTurn, finalized.referencedContentHashes)).toEqual({ ok: true, turn: captured.turn }); + expect(await restoreAssistantTurn('openaiResponses', [text], finalized.targetTurn, finalized.referencedContentHashes)).toEqual({ ok: true, value: captured.turn }); }); test.each(toolCases)('invalidates native $type provenance when its done snapshot extends a delta', async ({ type, field, prefix, suffix }) => { diff --git a/packages/translate/__tests__/round-trip/reference/reference-builder_test.ts b/packages/translate/__tests__/round-trip/reference/reference-builder_test.ts index b3181500ab..ea04380d45 100644 --- a/packages/translate/__tests__/round-trip/reference/reference-builder_test.ts +++ b/packages/translate/__tests__/round-trip/reference/reference-builder_test.ts @@ -402,7 +402,7 @@ it('uses the final JSON text view to reference and restore a B object', async () }; expect(await restoreAssistantTurn('anthropicMessages', [original], turn, finalizer.referencedContentHashes)).toEqual({ ok: true, - turn: { role: 'assistant', content: [{ type: 'tool_use', id: 'call', name: 'lookup', input: original }] }, + value: { role: 'assistant', content: [{ type: 'tool_use', id: 'call', name: 'lookup', input: original }] }, }); }); @@ -421,7 +421,7 @@ it('round-trips raw JSON numbers and keeps changed key order literal', async () const hydrated = await restoreAssistantTurn('anthropicMessages', [original], turn, finalizer.referencedContentHashes); expect(hydrated.ok).toBe(true); if (!hydrated.ok) throw new Error('Expected JSON hydration'); - expect(JSON.stringify(hydrated.turn.content[0])).toContain('9007199254740993'); + expect(JSON.stringify(hydrated.value.content[0])).toContain('9007199254740993'); const reorderedField = createReadFieldState('json'); const reorderedPart = appendReadField(builder, reorderedField, '{"a":1,"b":2}'); diff --git a/packages/translate/__tests__/round-trip/reference/reference-cost-json_test.ts b/packages/translate/__tests__/round-trip/reference/reference-cost-json_test.ts index 86867400e3..f66205bce9 100644 --- a/packages/translate/__tests__/round-trip/reference/reference-cost-json_test.ts +++ b/packages/translate/__tests__/round-trip/reference/reference-cost-json_test.ts @@ -31,5 +31,5 @@ it.each<{ input: IRJSONObject; referenced: boolean }>([ expect(result.targetTurn.content).toMatchObject([{ input: new ContentReference('json', [0]) }]); expect(result.referencedContentHashes).toHaveLength(1); } - expect(await restoreAssistantTurn('anthropicMessages', [input], result.targetTurn, result.referencedContentHashes)).toEqual({ ok: true, turn }); + expect(await restoreAssistantTurn('anthropicMessages', [input], result.targetTurn, result.referencedContentHashes)).toEqual({ ok: true, value: turn }); }); diff --git a/packages/translate/__tests__/round-trip/reference/reference-cost_test.ts b/packages/translate/__tests__/round-trip/reference/reference-cost_test.ts index e035a0dcca..0fe7137e9d 100644 --- a/packages/translate/__tests__/round-trip/reference/reference-cost_test.ts +++ b/packages/translate/__tests__/round-trip/reference/reference-cost_test.ts @@ -61,14 +61,14 @@ describe('thin reference selection', () => { const result = await finalizeTexts(texts); expect(result.referencedContentHashes).toEqual([await hashRoundTripContent(texts[0])]); expect(result.targetTurn.content).toEqual(texts.map(() => ({ type: 'text', text: new ContentReference('text', [0]), citations: null }))); - expect(await restoreAssistantTurn('anthropicMessages', texts, result.targetTurn, result.referencedContentHashes)).toEqual({ ok: true, turn: result.turn }); + expect(await restoreAssistantTurn('anthropicMessages', texts, result.targetTurn, result.referencedContentHashes)).toEqual({ ok: true, value: result.turn }); }); it.each(['汉'.repeat(80), 'a'.repeat(80)])('selects equally long Unicode and ASCII text using format-independent lengths: %j', async text => { const result = await finalizeTexts([text]); expect(result.targetTurn.content).toEqual([{ type: 'text', text: new ContentReference('text', [0]), citations: null }]); expect(result.referencedContentHashes).toEqual([await hashRoundTripContent(text)]); - expect(await restoreAssistantTurn('anthropicMessages', [text], result.targetTurn, result.referencedContentHashes)).toEqual({ ok: true, turn: result.turn }); + expect(await restoreAssistantTurn('anthropicMessages', [text], result.targetTurn, result.referencedContentHashes)).toEqual({ ok: true, value: result.turn }); }); it('drops unprofitable hashes and compacts the surviving index', async () => { @@ -80,7 +80,7 @@ describe('thin reference selection', () => { ...small.map(text => ({ type: 'text', text, citations: null })), { type: 'text', text: new ContentReference('text', [0]), citations: null }, ]); - expect(await restoreAssistantTurn('anthropicMessages', [...small, large], result.targetTurn, result.referencedContentHashes)).toEqual({ ok: true, turn: result.turn }); + expect(await restoreAssistantTurn('anthropicMessages', [...small, large], result.targetTurn, result.referencedContentHashes)).toEqual({ ok: true, value: result.turn }); }); it('selects the hashes of one atomic mixed reference together after merging adjacent ranges', async () => { @@ -101,7 +101,7 @@ describe('thin reference selection', () => { const result = await referenceAssistantTurn<'anthropicMessages'>(turn, { content: [{ text: field }] }, builder, views); expect(result.referencedContentHashes).toEqual([await hashRoundTripContent(text), await hashRoundTripContent('!')]); expect(result.targetTurn.content).toEqual([{ type: 'text', text: new ContentReference('text', [0, 1]), citations: null }]); - expect(await restoreAssistantTurn('anthropicMessages', [text, '!'], result.targetTurn, result.referencedContentHashes)).toEqual({ ok: true, turn }); + expect(await restoreAssistantTurn('anthropicMessages', [text, '!'], result.targetTurn, result.referencedContentHashes)).toEqual({ ok: true, value: turn }); }); it.each([{ length: 50, referenced: false }, { length: 80, referenced: true }])('does not fund the selected table with rejected atomic fields: %j', async ({ length, referenced }) => { @@ -127,6 +127,6 @@ describe('thin reference selection', () => { ]); if (referenced) expect(result.referencedContentHashes).toEqual([await hashRoundTripContent(shared)]); else expect(result).not.toHaveProperty('referencedContentHashes'); - expect(await restoreAssistantTurn('anthropicMessages', [shared, suffix], result.targetTurn, result.referencedContentHashes)).toEqual({ ok: true, turn }); + expect(await restoreAssistantTurn('anthropicMessages', [shared, suffix], result.targetTurn, result.referencedContentHashes)).toEqual({ ok: true, value: turn }); }); }); diff --git a/packages/translate/__tests__/round-trip/reference/restore_test.ts b/packages/translate/__tests__/round-trip/reference/restore_test.ts index 7cd25968cf..fff73b3943 100644 --- a/packages/translate/__tests__/round-trip/reference/restore_test.ts +++ b/packages/translate/__tests__/round-trip/reference/restore_test.ts @@ -24,9 +24,9 @@ describe('restoreAssistantTurn', () => { const object = input.object; const hydrated = await restoreAssistantTurn('anthropicMessages', [], turn); if (!hydrated.ok) throw new Error('Expected a literal assistant turn'); - expect(hydrated.turn).toBe(turn); - expect(hydrated.turn.content).toBe(blocks); - expect(hydrated.turn.content[0]).toBe(block); + expect(hydrated.value).toBe(turn); + expect(hydrated.value.content).toBe(blocks); + expect(hydrated.value.content[0]).toBe(block); if (block.type !== 'tool_use') throw new Error('Expected a tool block'); expect(block.input).toBe(input); expect(input.nested).toBe(nested); @@ -41,10 +41,10 @@ describe('restoreAssistantTurn', () => { const turn: GeminiGenerateContentReferenceableInputAssistantTurn = [content]; const hydrated = await restoreAssistantTurn('geminiGenerateContent', [], turn); if (!hydrated.ok) throw new Error('Expected a literal assistant turn'); - expect(hydrated.turn).toBe(turn); - expect(hydrated.turn[0]).toBe(content); - expect(hydrated.turn[0].parts).toBe(parts); - expect(hydrated.turn[0].parts?.[0]).toBe(part); + expect(hydrated.value).toBe(turn); + expect(hydrated.value[0]).toBe(content); + expect(hydrated.value[0].parts).toBe(parts); + expect(hydrated.value[0].parts?.[0]).toBe(part); }); it('restores text and JSON reference leaves in place while retaining their containers', async () => { @@ -57,10 +57,10 @@ describe('restoreAssistantTurn', () => { await hashRoundTripContent('[answer]'), await hashRoundTripContent(JSON.stringify(candidate)), ]); if (!hydrated.ok) throw new Error('Expected reference hydration'); - expect(hydrated.turn).toBe(turn); - expect(hydrated.turn.content).toBe(blocks); - expect(hydrated.turn.content[0]).toBe(text); - expect(hydrated.turn.content[1]).toBe(tool); + expect(hydrated.value).toBe(turn); + expect(hydrated.value.content).toBe(blocks); + expect(hydrated.value.content[0]).toBe(text); + expect(hydrated.value.content[1]).toBe(tool); expect(text.text).toBe('answer'); expect(tool.input).toEqual(candidate); expect(tool.input).not.toBe(candidate); @@ -77,9 +77,9 @@ describe('restoreAssistantTurn', () => { }; const hydrated = await restoreAssistantTurn('openaiChatCompletions', ['answer'], turn, [await hashRoundTripContent('answer')]); if (!hydrated.ok) throw new Error('Expected reference hydration'); - expect(hydrated.turn).toBe(turn); - expect(hydrated.turn.content).toBe('answer'); - expect(hydrated.turn.provider_specific_fields).toBe(metadata); + expect(hydrated.value).toBe(turn); + expect(hydrated.value.content).toBe('answer'); + expect(hydrated.value.provider_specific_fields).toBe(metadata); expect(metadata.bytes).toBe(bytes); expect(metadata.number).toBe(number); expect(metadata.nested).toBe(nested); @@ -92,7 +92,7 @@ describe('restoreAssistantTurn', () => { const turn: OpenAIChatCompletionsReferenceableInputAssistantTurn = { role: 'assistant', content: 'unchanged' }; const digest = vi.spyOn(crypto.subtle, 'digest'); try { - expect(await restoreAssistantTurn('openaiChatCompletions', ['unused candidate'], turn)).toEqual({ ok: true, turn }); + expect(await restoreAssistantTurn('openaiChatCompletions', ['unused candidate'], turn)).toEqual({ ok: true, value: turn }); expect(digest).not.toHaveBeenCalled(); } finally { digest.mockRestore(); diff --git a/packages/translate/__tests__/round-trip/turn-digest_test.ts b/packages/translate/__tests__/round-trip/turn-digest_test.ts index 8d72045124..59afafe184 100644 --- a/packages/translate/__tests__/round-trip/turn-digest_test.ts +++ b/packages/translate/__tests__/round-trip/turn-digest_test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; -import { computeRoundTripTurnDigest, verifyRoundTripTurnDigest } from '../../src/round-trip/turn-digest.ts'; +import { computeRoundTripTurnDigest, createTurnDigestComputer, createTurnDigestVerifier, verifyRoundTripTurnDigest } from '../../src/round-trip/turn-digest.ts'; describe('round-trip turn digests', () => { it('computes and verifies replay material', async () => { @@ -26,4 +26,30 @@ describe('round-trip turn digests', () => { expect(await verifyRoundTripTurnDigest(material, sourceTurnDigest.subarray(0, 7))).toBe(false); expect(await verifyRoundTripTurnDigest(material, new Uint8Array([...sourceTurnDigest, 0]))).toBe(false); }); + + it('projects each current turn and skips material evaluation for unsupported versions', async () => { + const materialOf = vi.fn((turn: { content: string[] }) => ({ content: turn.content })); + const compute = createTurnDigestComputer(materialOf); + const verify = createTurnDigestVerifier(materialOf); + const turn = { content: ['answer'] }; + const digest = await compute(turn); + + expect(digest).toEqual(new Uint8Array([68, 150, 67, 115, 244, 243, 164, 21])); + expect(await verify(turn, digest, 1)).toBe(true); + turn.content[0] = 'edited'; + expect(await verify(turn, digest, 1)).toBe(false); + materialOf.mockClear(); + for (const version of [0, 2]) expect(await verify(turn, digest, version)).toBe(false); + expect(materialOf).not.toHaveBeenCalled(); + }); + + it('preserves synchronous material projection failures in both factory results', () => { + const failure = new Error('Invalid protocol material'); + const materialOf = (_turn: string): never => { throw failure; }; + const compute = createTurnDigestComputer(materialOf); + const verify = createTurnDigestVerifier(materialOf); + + expect(() => compute('invalid')).toThrow(failure); + expect(() => verify('invalid', new Uint8Array(8), 1)).toThrow(failure); + }); }); diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts b/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts index 6e27ff9f1d..ed7290d175 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts @@ -2,8 +2,7 @@ import type { IRJSONObject, IRJSONValue } from '../../../ir/types.ts'; import type { ReplayContent } from '../../../round-trip/reference/types.ts'; import type { SidecarOccurrence } from '../../../round-trip/resolve.ts'; import { collectTextGroups, collectTextPlacementIndex, type TextContribution, type TextPlacementIndex } from '../../../round-trip/text-groups.ts'; -import { computeRoundTripTurnDigest, replayMaterialsByType, verifyRoundTripTurnDigest } from '../../../round-trip/turn-digest.ts'; -import type { RoundTripTurnDigest } from '../../../round-trip/turn-digest.ts'; +import { createTurnDigestComputer, replayMaterialsByType, createTurnDigestVerifier } from '../../../round-trip/turn-digest.ts'; import { isPlainJSONObject, requireJSONObject } from '../../../shared/json/object.ts'; import type { AnthropicMessagesAssistantContentBlock, AnthropicMessagesAssistantMessage, AnthropicMessagesRedactedThinkingBlock } from '@floway-dev/protocols/anthropic-messages'; @@ -50,8 +49,7 @@ export const findAnthropicMessagesSidecarOccurrences = (turn: AnthropicMessagesS }]); }); -export const computeAnthropicMessagesTurnDigest = (turn: AnthropicMessagesSourceAssistantTurn): Promise => - computeRoundTripTurnDigest(messagesTurnDigestMaterialV1(turn)); +export const computeAnthropicMessagesTurnDigest = createTurnDigestComputer(messagesTurnDigestMaterialV1); export const collectAnthropicMessagesReplayContents = (turn: AnthropicMessagesSourceAssistantTurn): ReplayContent[] => { const candidates: ReplayContent[] = []; @@ -68,16 +66,7 @@ export const collectAnthropicMessagesReplayContents = (turn: AnthropicMessagesSo return candidates; }; -export const verifyAnthropicMessagesTurnDigest = ( - turn: AnthropicMessagesSourceAssistantTurn, - sourceTurnDigest: RoundTripTurnDigest, - version: number, -): Promise => { - switch (version) { - case 1: return verifyRoundTripTurnDigest(messagesTurnDigestMaterialV1(turn), sourceTurnDigest); - default: return Promise.resolve(false); - } -}; +export const verifyAnthropicMessagesTurnDigest = createTurnDigestVerifier(messagesTurnDigestMaterialV1); export const createAnthropicMessagesSidecarBlock = (data: string): AnthropicMessagesRedactedThinkingBlock => ({ type: 'redacted_thinking', data }); diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts index a5ec653f27..6f94f579e0 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts @@ -2,8 +2,7 @@ import type { IRJSONObject } from '../../../ir/types.ts'; import type { ReplayContent } from '../../../round-trip/reference/types.ts'; import type { SidecarOccurrence } from '../../../round-trip/resolve.ts'; import { collectTextGroups, collectTextPlacementIndex, type TextContribution, type TextPlacementIndex } from '../../../round-trip/text-groups.ts'; -import { computeRoundTripTurnDigest, verifyRoundTripTurnDigest } from '../../../round-trip/turn-digest.ts'; -import type { RoundTripTurnDigest } from '../../../round-trip/turn-digest.ts'; +import { createTurnDigestComputer, createTurnDigestVerifier } from '../../../round-trip/turn-digest.ts'; import { isPlainJSONObject, requireJSONObject } from '../../../shared/json/object.ts'; import type { GeminiGenerateContentContent, GeminiGenerateContentPart } from '@floway-dev/protocols/gemini-generate-content'; @@ -52,8 +51,7 @@ export const findGeminiGenerateContentSidecarOccurrences = (turn: GeminiGenerate }]) ?? []; }); -export const computeGeminiGenerateContentTurnDigest = (turn: GeminiGenerateContentSourceAssistantTurn): Promise => - computeRoundTripTurnDigest(geminiTurnDigestMaterialV1(turn)); +export const computeGeminiGenerateContentTurnDigest = createTurnDigestComputer(geminiTurnDigestMaterialV1); export const collectGeminiGenerateContentReplayContents = (turn: GeminiGenerateContentSourceAssistantTurn): ReplayContent[] => { const candidates: ReplayContent[] = []; @@ -70,16 +68,7 @@ export const collectGeminiGenerateContentReplayContents = (turn: GeminiGenerateC return candidates; }; -export const verifyGeminiGenerateContentTurnDigest = ( - turn: GeminiGenerateContentSourceAssistantTurn, - sourceTurnDigest: RoundTripTurnDigest, - version: number, -): Promise => { - switch (version) { - case 1: return verifyRoundTripTurnDigest(geminiTurnDigestMaterialV1(turn), sourceTurnDigest); - default: return Promise.resolve(false); - } -}; +export const verifyGeminiGenerateContentTurnDigest = createTurnDigestVerifier(geminiTurnDigestMaterialV1); // Gemini returns model-side thought signatures as Part metadata. // https://ai.google.dev/gemini-api/docs/generate-content/thought-signatures diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts index d20f61abdb..fb9280aaf9 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts @@ -2,8 +2,7 @@ import type { IRJSONObject, IRJSONValue } from '../../../ir/types.ts'; import type { ReplayContent } from '../../../round-trip/reference/types.ts'; import type { SidecarOccurrence } from '../../../round-trip/resolve.ts'; import { collectTextGroups, collectTextPlacementIndex, type TextContribution, type TextPlacementIndex } from '../../../round-trip/text-groups.ts'; -import { computeRoundTripTurnDigest, verifyRoundTripTurnDigest } from '../../../round-trip/turn-digest.ts'; -import type { RoundTripTurnDigest } from '../../../round-trip/turn-digest.ts'; +import { createTurnDigestComputer, createTurnDigestVerifier } from '../../../round-trip/turn-digest.ts'; import type { OpenAIChatCompletionsAssistantExtensions, OpenAIChatCompletionsAssistantMessage, OpenAIChatCompletionsAssistantOutputMessage, OpenAIChatCompletionsToolCallEx } from '@floway-dev/protocols/openai-chat-completions'; type OpenAIChatCompletionsReplayPart = NonNullable>[number]; @@ -83,8 +82,7 @@ export const findOpenAIChatCompletionsSidecarOccurrences = (turn: OpenAIChatComp }]); }; -export const computeOpenAIChatCompletionsTurnDigest = (turn: OpenAIChatCompletionsSourceAssistantTurn): Promise => - computeRoundTripTurnDigest(chatTurnDigestMaterialV1(turn)); +export const computeOpenAIChatCompletionsTurnDigest = createTurnDigestComputer(chatTurnDigestMaterialV1); export const collectOpenAIChatCompletionsReplayContents = (turn: OpenAIChatCompletionsSourceAssistantTurn): ReplayContent[] => { const candidates: ReplayContent[] = []; @@ -105,16 +103,7 @@ export const collectOpenAIChatCompletionsReplayContents = (turn: OpenAIChatCompl return candidates; }; -export const verifyOpenAIChatCompletionsTurnDigest = ( - turn: OpenAIChatCompletionsSourceAssistantTurn, - sourceTurnDigest: RoundTripTurnDigest, - version: number, -): Promise => { - switch (version) { - case 1: return verifyRoundTripTurnDigest(chatTurnDigestMaterialV1(turn), sourceTurnDigest); - default: return Promise.resolve(false); - } -}; +export const verifyOpenAIChatCompletionsTurnDigest = createTurnDigestVerifier(chatTurnDigestMaterialV1); // Our carrier uses indexed OpenRouter reasoning_details. // https://openrouter.ai/docs/guides/best-practices/reasoning-tokens#reasoning-details-api-shape diff --git a/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts b/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts index 62a5faa2b9..43ef4dff3b 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts @@ -2,8 +2,7 @@ import type { IRJSONObject, IRJSONValue } from '../../../ir/types.ts'; import type { ReplayContent } from '../../../round-trip/reference/types.ts'; import type { SidecarOccurrence } from '../../../round-trip/resolve.ts'; import { collectTextGroups, collectTextPlacementIndex, type TextContribution, type TextPlacementIndex } from '../../../round-trip/text-groups.ts'; -import { computeRoundTripTurnDigest, replayMaterialsByType, verifyRoundTripTurnDigest } from '../../../round-trip/turn-digest.ts'; -import type { RoundTripTurnDigest } from '../../../round-trip/turn-digest.ts'; +import { createTurnDigestComputer, replayMaterialsByType, createTurnDigestVerifier } from '../../../round-trip/turn-digest.ts'; import type { CanonicalOpenAIResponsesInputItem, OpenAIResponsesInputContent, OpenAIResponsesOutputContentBlock, OpenAIResponsesOutputItemEx, OpenAIResponsesOutputReasoning } from '@floway-dev/protocols/openai-responses'; type OpenAIResponsesMessagePart = OpenAIResponsesInputContent | OpenAIResponsesOutputContentBlock; @@ -91,8 +90,7 @@ export const findOpenAIResponsesSidecarOccurrences = (turn: OpenAIResponsesSourc }, }]); -export const computeOpenAIResponsesTurnDigest = (turn: OpenAIResponsesSourceAssistantTurn): Promise => - computeRoundTripTurnDigest(responsesTurnDigestMaterialV1(turn)); +export const computeOpenAIResponsesTurnDigest = createTurnDigestComputer(responsesTurnDigestMaterialV1); export const collectOpenAIResponsesReplayContents = (turn: OpenAIResponsesSourceAssistantTurn): ReplayContent[] => { const candidates: ReplayContent[] = []; @@ -112,16 +110,7 @@ export const collectOpenAIResponsesReplayContents = (turn: OpenAIResponsesSource return candidates; }; -export const verifyOpenAIResponsesTurnDigest = ( - turn: OpenAIResponsesSourceAssistantTurn, - sourceTurnDigest: RoundTripTurnDigest, - version: number, -): Promise => { - switch (version) { - case 1: return verifyRoundTripTurnDigest(responsesTurnDigestMaterialV1(turn), sourceTurnDigest); - default: return Promise.resolve(false); - } -}; +export const verifyOpenAIResponsesTurnDigest = createTurnDigestVerifier(responsesTurnDigestMaterialV1); export const createOpenAIResponsesSidecarItem = (id: string, data: string): OpenAIResponsesSidecarItem => ({ type: 'reasoning', diff --git a/packages/translate/src/round-trip/reference/restore.ts b/packages/translate/src/round-trip/reference/restore.ts index 72fb8a47f2..e62e3180a9 100644 --- a/packages/translate/src/round-trip/reference/restore.ts +++ b/packages/translate/src/round-trip/reference/restore.ts @@ -7,11 +7,9 @@ import type { InputAssistantTurnByProtocol, InputAssistantTurnFor } from '../inp import { encodeHex } from '@floway-dev/protocols/common'; export type RestoreResult = - | { ok: true; turn: T } + | { ok: true; value: T } | { ok: false; reason: 'missing-reference' }; -type RestoreValue = { ok: true; value: unknown } | { ok: false; reason: 'missing-reference' }; - const referencedText = (payload: readonly ContentReferenceSegment[], referencedContentHashes: readonly Uint8Array[] | undefined, textsByHash: ReadonlyMap): string | undefined => { if (referencedContentHashes === undefined) throw new TypeError('Thin reference requires referenced contents'); const text: string[] = []; @@ -26,7 +24,7 @@ const referencedText = (payload: readonly ContentReferenceSegment[], referencedC return text.join(''); }; -const restoreValue = (value: unknown, referencedContentHashes: readonly Uint8Array[] | undefined, textsByHash: ReadonlyMap): RestoreValue => { +const restoreValue = (value: unknown, referencedContentHashes: readonly Uint8Array[] | undefined, textsByHash: ReadonlyMap): RestoreResult => { if (rawJSON.isRawJSON(value) || value instanceof Uint8Array) return { ok: true, value }; if (value instanceof ContentReference) { const text = referencedText(value.parts, referencedContentHashes, textsByHash); @@ -60,6 +58,6 @@ export const restoreAssistantTurn = async } + ? { ok: true, value: result.value as InputAssistantTurnFor } : result; }; diff --git a/packages/translate/src/round-trip/resolve.ts b/packages/translate/src/round-trip/resolve.ts index 18d9fe73a7..8853c3bf33 100644 --- a/packages/translate/src/round-trip/resolve.ts +++ b/packages/translate/src/round-trip/resolve.ts @@ -40,5 +40,5 @@ export const roundTripAssistantTurn = async byte === sourceTurnDigest[index]); }; + +export const createTurnDigestComputer = ( + materialOf: (turn: Turn) => IRJSONValue, +): (turn: Turn) => Promise => turn => computeRoundTripTurnDigest(materialOf(turn)); + +export const createTurnDigestVerifier = ( + materialOf: (turn: Turn) => IRJSONValue, +): (turn: Turn, sourceTurnDigest: RoundTripTurnDigest, version: number) => Promise => (turn, sourceTurnDigest, version) => { + switch (version) { + case 1: return verifyRoundTripTurnDigest(materialOf(turn), sourceTurnDigest); + default: return Promise.resolve(false); + } +}; From b50a719290b07330517055dd987cbe063f05c4b9 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 06:22:07 +0800 Subject: [PATCH 10/66] fix(translate): complete prerequisite integration contracts Retain the lossless error fields in existing output assertions after their ownership migration. Order imports at the combined output and replay owners without changing runtime behavior. --- .../protocols/anthropic-messages/as-source/events_test.ts | 2 +- .../via-openai-responses/events-protocol_test.ts | 2 ++ .../__tests__/protocols/cross-protocol-errors_test.ts | 2 +- packages/translate/__tests__/protocols/edge-cases_test.ts | 4 ++-- packages/translate/__tests__/protocols/helpers.ts | 2 +- .../openai-chat-completions/as-target/events_test.ts | 2 +- .../via-anthropic-messages/events-protocol_test.ts | 2 +- packages/translate/__tests__/protocols/routes_test.ts | 2 +- .../src/protocols/anthropic-messages/as-source/events.ts | 2 +- .../src/protocols/anthropic-messages/as-target/events.ts | 2 +- .../via-openai-chat-completions/events.ts | 2 +- .../anthropic-messages/via-openai-responses/events.ts | 2 +- .../protocols/gemini-generate-content/as-source/events.ts | 2 +- .../via-anthropic-messages/events.ts | 2 +- .../via-openai-chat-completions/events.ts | 2 +- .../gemini-generate-content/via-openai-responses/events.ts | 2 +- .../protocols/openai-chat-completions/as-source/events.ts | 2 +- .../protocols/openai-chat-completions/as-target/events.ts | 2 +- .../via-anthropic-messages/events.ts | 2 +- .../openai-chat-completions/via-openai-responses/events.ts | 2 +- .../src/protocols/openai-responses/as-source/events.ts | 6 +++--- .../src/protocols/openai-responses/as-target/events.ts | 2 +- .../openai-responses/via-anthropic-messages/events.ts | 2 +- .../openai-responses/via-openai-chat-completions/events.ts | 2 +- 24 files changed, 28 insertions(+), 26 deletions(-) diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-source/events_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/events_test.ts index 5b4e0a05db..9f4768a45a 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-source/events_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-source/events_test.ts @@ -2,9 +2,9 @@ import { expect, test } from 'vitest'; import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; import { writeAnthropicMessagesEvents } from '../../../../src/protocols/anthropic-messages/as-source/events.ts'; +import { hashRoundTripContent } from '../../../../src/round-trip/hash.ts'; import { appendReadField, collectEmittedFieldPlacements, createReferenceBuilder, createReadFieldState, createFieldReferencer } from '../../../../src/round-trip/reference/reference-builder.ts'; import type { RoundTripWriter } from '../../../../src/round-trip/writer.ts'; -import { hashRoundTripContent } from '../../../../src/round-trip/hash.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; test.each(['text', 'refusal', 'audio'] as const)('resolves blocked %s against the emitted Messages text field', async kind => { diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts index f3d783b4e0..d2295824ac 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts @@ -163,6 +163,7 @@ test('translateToSourceEvents translates OpenAI Responses failed terminal to Ant error: { type: 'api_error', message: 'upstream failed', + provider_specific_fields: { code: 'server_error', type: 'server_error' }, }, } satisfies AnthropicMessagesStreamEventEx), ]); @@ -193,6 +194,7 @@ test('translateToSourceEvents translates OpenAI Responses error terminal to Anth error: { type: 'api_error', message: 'upstream overloaded', + provider_specific_fields: { code: 'overloaded_error' }, }, } satisfies AnthropicMessagesStreamEventEx), ]); diff --git a/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts b/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts index 9e9932c526..caaf76e559 100644 --- a/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts +++ b/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts @@ -5,9 +5,9 @@ import type { IRErrorPayload } from '../../src/ir/output/error.ts'; import { readAnthropicMessagesEvents } from '../../src/protocols/anthropic-messages/as-target/events.ts'; import { readOpenAIChatCompletionsEvents } from '../../src/protocols/openai-chat-completions/as-target/events.ts'; import { readOpenAIResponsesEvents } from '../../src/protocols/openai-responses/as-target/events.ts'; -import { collect, iterate } from '../shared/iterables.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { createRoundTripReaderCapture } from '../round-trip/test-helpers.ts'; +import { collect, iterate } from '../shared/iterables.ts'; import { PROMPT_TOO_LONG_MESSAGE } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { diff --git a/packages/translate/__tests__/protocols/edge-cases_test.ts b/packages/translate/__tests__/protocols/edge-cases_test.ts index f846411515..389e5f0a45 100644 --- a/packages/translate/__tests__/protocols/edge-cases_test.ts +++ b/packages/translate/__tests__/protocols/edge-cases_test.ts @@ -34,7 +34,7 @@ for (const [code, category] of [['bio_policy', 'bio'], ['cyber_policy', 'cyber'] test.each(['The model failed.', 'The safety policy service is unavailable.'])('a server error stays an error regardless of message keywords: %s', async message => { const frames = await collect(generateContentViaResponses(iterate([eventFrame({ type: 'response.failed', response: { id: 'resp_failed', model: 'served', output: [], status: 'failed', error: { code: 'server_error', message } } })] as any), createTestSidecarCodec())); - expect(frames).toEqual([eventFrame({ error: { code: 500, status: 'INTERNAL', message } })]); + expect(frames).toEqual([eventFrame({ error: { code: 500, status: 'INTERNAL', message, details: [{ '@type': 'type.googleapis.com/google.protobuf.Struct', value: { code: 'server_error' } }] } })]); }); test('a failed Responses stream without an error description still fails with a message', async () => { @@ -44,7 +44,7 @@ test('a failed Responses stream without an error description still fails with a test('rate limiting becomes a native GenerateContent error', async () => { const frames = await collect(generateContentViaResponses(iterate([eventFrame({ type: 'error', code: 'rate_limit_exceeded', message: 'Try later.' })] as any), createTestSidecarCodec())); - expect(frames).toEqual([eventFrame({ error: { code: 429, status: 'RESOURCE_EXHAUSTED', message: 'Try later.' } })]); + expect(frames).toEqual([eventFrame({ error: { code: 429, status: 'RESOURCE_EXHAUSTED', message: 'Try later.', details: [{ '@type': 'type.googleapis.com/google.protobuf.Struct', value: { code: 'rate_limit_exceeded' } }] } })]); }); test('ChatCompletions ignores empty identity frames and starts when a model appears', async () => { diff --git a/packages/translate/__tests__/protocols/helpers.ts b/packages/translate/__tests__/protocols/helpers.ts index 47c1520a89..2f5cd64403 100644 --- a/packages/translate/__tests__/protocols/helpers.ts +++ b/packages/translate/__tests__/protocols/helpers.ts @@ -7,8 +7,8 @@ import { readOpenAIChatCompletionsEvents as readOpenAIChatCompletionsEventsProdu import { writeOpenAIResponsesEvents as writeOpenAIResponsesEventsProduction } from '../../src/protocols/openai-responses/as-source/events.ts'; import { flattenNamespaceTools } from '../../src/protocols/openai-responses/as-source/namespace-tools.ts'; import { readOpenAIResponsesEvents as readOpenAIResponsesEventsProduction } from '../../src/protocols/openai-responses/as-target/events.ts'; -import { createReferenceBuilder } from '../../src/round-trip/reference/reference-builder.ts'; import type { RoundTripReader } from '../../src/round-trip/reader.ts'; +import { createReferenceBuilder } from '../../src/round-trip/reference/reference-builder.ts'; import type { RoundTripWriter } from '../../src/round-trip/writer.ts'; const reader = (): RoundTripReader => ({ diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts index c2316a4b2f..6ca0f93d1e 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts @@ -3,9 +3,9 @@ import { expect, test } from 'vitest'; import type { IRRecord } from '../../../../src/ir/output/records.ts'; import type { OpenAIChatCompletionsInputAssistantTurn } from '../../../../src/protocols/openai-chat-completions/as-target/assistant-turn.ts'; import { readOpenAIChatCompletionsEvents } from '../../../../src/protocols/openai-chat-completions/as-target/events.ts'; +import type { RoundTripReader } from '../../../../src/round-trip/reader.ts'; import { appendEmittedField, collectEmittedFieldPlacements, createEmittedFieldState, createReferenceBuilder } from '../../../../src/round-trip/reference/reference-builder.ts'; import { referenceAssistantTurn } from '../../../../src/round-trip/reference/reference-selection.ts'; -import type { RoundTripReader } from '../../../../src/round-trip/reader.ts'; import { restoreAssistantTurn } from '../../../../src/round-trip/reference/restore.ts'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; diff --git a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts b/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts index c47c4cd04e..1e177c38f0 100644 --- a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts @@ -60,7 +60,7 @@ test('translateToSourceEvents translates Anthropic Messages error terminal and s eventFrame({ type: 'error', message: 'upstream overloaded', - code: 'overloaded_error', + provider_specific_fields: { type: 'overloaded_error' }, sequence_number: 0, }), ); diff --git a/packages/translate/__tests__/protocols/routes_test.ts b/packages/translate/__tests__/protocols/routes_test.ts index 6487275c32..cc0ca51478 100644 --- a/packages/translate/__tests__/protocols/routes_test.ts +++ b/packages/translate/__tests__/protocols/routes_test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; -import { requireTrip } from './require-trip.ts'; 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 { findAnthropicMessagesSidecarOccurrences } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; import { findGeminiGenerateContentSidecarOccurrences } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/events.ts b/packages/translate/src/protocols/anthropic-messages/as-source/events.ts index 77dd4946a0..f514ce3550 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/events.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/events.ts @@ -11,8 +11,8 @@ import type { IROutput } from '../../../ir/output/types.ts'; import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; import { createIRUnitQueue } from '../../../ir/output/unit-queue.ts'; import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; -import { appendEmittedField, createEmittedFieldState, type EmittedFieldState, type EmittedFieldStates } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/provenance.ts'; +import { appendEmittedField, createEmittedFieldState, type EmittedFieldState, type EmittedFieldStates } from '../../../round-trip/reference/reference-builder.ts'; import { errorDetailsOf } from '../../../shared/api-error.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/events.ts b/packages/translate/src/protocols/anthropic-messages/as-target/events.ts index 9d572d3327..1dc62b6aa4 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/events.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/events.ts @@ -3,8 +3,8 @@ import { type AnthropicMessagesInputCounts, usageToIR } from './usage.ts'; import { createIRBuilder, type IRItemHandle, type IRPartHandle } from '../../../ir/output/builder.ts'; import type { IRRecord } from '../../../ir/output/records.ts'; import type { IROutputSourceCitation } from '../../../ir/output/types.ts'; -import { appendReadField, createReadFieldState, type ReadFieldState, type ReadFieldStates } from '../../../round-trip/reference/reference-builder.ts'; import type { RoundTripReader } from '../../../round-trip/reader.ts'; +import { appendReadField, createReadFieldState, type ReadFieldState, type ReadFieldStates } from '../../../round-trip/reference/reference-builder.ts'; import { requireJSONObject } from '../../../shared/json/object.ts'; import { unwrapCustomToolInput } from '../../openai-responses/common/custom-tool-wrap.ts'; import { diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts index 6e76cc09e9..2621b137c2 100644 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts +++ b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts @@ -1,5 +1,5 @@ -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import type { SidecarCodec } from '../../../round-trip/codec.ts'; +import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import { readOpenAIChatCompletionsEvents } from '../../openai-chat-completions/as-target/events.ts'; import { writeAnthropicMessagesEvents } from '../as-source/events.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts index 742838a38e..a199b410ab 100644 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts +++ b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts @@ -1,5 +1,5 @@ -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import type { SidecarCodec } from '../../../round-trip/codec.ts'; +import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import { readOpenAIResponsesEvents } from '../../openai-responses/as-target/events.ts'; import { writeAnthropicMessagesEvents } from '../as-source/events.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts index 7a993c9f6f..92d8fb4444 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts @@ -10,8 +10,8 @@ import { consumeIRRecords } from '../../../ir/output/stream.ts'; import type { IROutput, IROutputSourceCitation, IROutputTokenLogprob } from '../../../ir/output/types.ts'; import { createIRUnitQueue, type IRUnitQueue } from '../../../ir/output/unit-queue.ts'; import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; -import { appendEmittedField, createEmittedFieldState, type EmittedFieldStates, type EmittedFieldState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/provenance.ts'; +import { appendEmittedField, createEmittedFieldState, type EmittedFieldStates, type EmittedFieldState } from '../../../round-trip/reference/reference-builder.ts'; import { errorDetailsOf } from '../../../shared/api-error.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts index 72466c6019..00b75e1095 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts @@ -1,5 +1,5 @@ -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import type { SidecarCodec } from '../../../round-trip/codec.ts'; +import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import { readAnthropicMessagesEvents } from '../../anthropic-messages/as-target/events.ts'; import { writeGeminiGenerateContentEvents } from '../as-source/events.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts index a3401f1aac..2c78e08e90 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts @@ -1,5 +1,5 @@ -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import type { SidecarCodec } from '../../../round-trip/codec.ts'; +import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import { readOpenAIChatCompletionsEvents } from '../../openai-chat-completions/as-target/events.ts'; import { writeGeminiGenerateContentEvents } from '../as-source/events.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts index 77782beb24..2164c0de90 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts @@ -1,5 +1,5 @@ -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import type { SidecarCodec } from '../../../round-trip/codec.ts'; +import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import { readOpenAIResponsesEvents } from '../../openai-responses/as-target/events.ts'; import { writeGeminiGenerateContentEvents } from '../as-source/events.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts index efaebb4521..5ca16ec901 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts @@ -7,8 +7,8 @@ import type { IRRecord } from '../../../ir/output/records.ts'; import { consumeIRRecords } from '../../../ir/output/stream.ts'; import type { IROutputItem, IROutputMessageItem, IROutputTokenLogprob } from '../../../ir/output/types.ts'; import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; -import { appendEmittedField, createEmittedFieldState, type EmittedFieldStates, type EmittedFieldState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/provenance.ts'; +import { appendEmittedField, createEmittedFieldState, type EmittedFieldStates, type EmittedFieldState } from '../../../round-trip/reference/reference-builder.ts'; import { errorDetailsOf } from '../../../shared/api-error.ts'; import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/events.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/events.ts index 8f609a36b4..cdfc556d6b 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/events.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/events.ts @@ -5,8 +5,8 @@ import { createIRBuilder, type IRItemHandle, type IRPartHandle, type IRWriteHand import type { IRErrorPayload } from '../../../ir/output/error.ts'; import type { IRRecord } from '../../../ir/output/records.ts'; import type { IROutputSourceCitation } from '../../../ir/output/types.ts'; -import { appendReadField, createReadFieldState, type ReadFieldStates, type ReadFieldState, type TextFragmentId } from '../../../round-trip/reference/reference-builder.ts'; import type { RoundTripReader } from '../../../round-trip/reader.ts'; +import { appendReadField, createReadFieldState, type ReadFieldStates, type ReadFieldState, type TextFragmentId } from '../../../round-trip/reference/reference-builder.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { unwrapCustomToolInput } from '../../openai-responses/common/custom-tool-wrap.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; diff --git a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts index b1766936e4..12b6d1a994 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts @@ -1,5 +1,5 @@ -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import type { SidecarCodec } from '../../../round-trip/codec.ts'; +import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import { readAnthropicMessagesEvents } from '../../anthropic-messages/as-target/events.ts'; import { writeOpenAIChatCompletionsEvents } from '../as-source/events.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; diff --git a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts index 70b0dde386..39e386f614 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts @@ -1,5 +1,5 @@ -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import type { SidecarCodec } from '../../../round-trip/codec.ts'; +import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import { readOpenAIResponsesEvents } from '../../openai-responses/as-target/events.ts'; import { writeOpenAIChatCompletionsEvents } from '../as-source/events.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; diff --git a/packages/translate/src/protocols/openai-responses/as-source/events.ts b/packages/translate/src/protocols/openai-responses/as-source/events.ts index 62bfabbd23..09570290a4 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/events.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/events.ts @@ -3,8 +3,8 @@ import { createRandomOpenAIResponsesResponseId } from './response-id.ts'; import { collectOpenAIResponsesTextPlacements, computeOpenAIResponsesTurnDigest, createOpenAIResponsesSidecarItem } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; import { IRAbsent } from '../../../ir/output/absent.ts'; -import type { IRErrorPayload } from '../../../ir/output/error.ts'; import type { IRCollector } from '../../../ir/output/collector.ts'; +import type { IRErrorPayload } from '../../../ir/output/error.ts'; import { irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/output/operation-paths.ts'; import type { IRPath, IRRecord } from '../../../ir/output/records.ts'; import { consumeIRRecords } from '../../../ir/output/stream.ts'; @@ -12,10 +12,10 @@ import type { IROutput, IROutputItem, IROutputMessageItem, IROutputSourceCitatio import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; import { createIRUnitQueue } from '../../../ir/output/unit-queue.ts'; import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; -import { appendEmittedField, createEmittedFieldState, type EmittedFieldState, type EmittedFieldStates } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/provenance.ts'; -import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; +import { appendEmittedField, createEmittedFieldState, type EmittedFieldState, type EmittedFieldStates } from '../../../round-trip/reference/reference-builder.ts'; import { errorDetailsOf } from '../../../shared/api-error.ts'; +import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; import { isCompleteJSONObject, parseJSONObjectAtCompletion } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIResponsesInputAssistantTurn, type OpenAIResponsesItemState, type OpenAIResponsesOutputAssistantTurnState } from '../as-target/assistant-turn.ts'; diff --git a/packages/translate/src/protocols/openai-responses/as-target/events.ts b/packages/translate/src/protocols/openai-responses/as-target/events.ts index 132918ad23..24ca5559a4 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/events.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/events.ts @@ -3,8 +3,8 @@ import { usageToIR } from './usage.ts'; import { createIRBuilder, type IRItemHandle, type IRPartHandle, type IRRelativePath, type IRWriteHandle } from '../../../ir/output/builder.ts'; import type { IRRecord } from '../../../ir/output/records.ts'; import type { IROutputSourceCitation } from '../../../ir/output/types.ts'; -import { appendReadField, createReadFieldState, invalidateReadField, type ReadFieldStates } from '../../../round-trip/reference/reference-builder.ts'; import type { RoundTripReader } from '../../../round-trip/reader.ts'; +import { appendReadField, createReadFieldState, invalidateReadField, type ReadFieldStates } from '../../../round-trip/reference/reference-builder.ts'; import { errorPayloadFromEvent, errorPayloadFromWire } from '../../../shared/api-error.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; diff --git a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts index 895872457a..1e5b36763b 100644 --- a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts +++ b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts @@ -1,5 +1,5 @@ -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import type { SidecarCodec } from '../../../round-trip/codec.ts'; +import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import { readAnthropicMessagesEvents } from '../../anthropic-messages/as-target/events.ts'; import { writeOpenAIResponsesEvents } from '../as-source/events.ts'; import type { NamespaceToolNames } from '../as-source/namespace-tools.ts'; diff --git a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts index 22030bd771..30ea2a5d62 100644 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts +++ b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts @@ -1,5 +1,5 @@ -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import type { SidecarCodec } from '../../../round-trip/codec.ts'; +import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; import { readOpenAIChatCompletionsEvents } from '../../openai-chat-completions/as-target/events.ts'; import { writeOpenAIResponsesEvents } from '../as-source/events.ts'; import type { NamespaceToolNames } from '../as-source/namespace-tools.ts'; From 3807659ee0ec4db81d32d6a14adb2d942047cc86 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 07:41:10 +0800 Subject: [PATCH 11/66] docs(translate): explain replay ownership contracts Apply the accepted reader/writer, digest material ownership, and text placement comments at their replay owners. Verify unchanged compiler-token and directive streams for all four files, scoped lint, translate typecheck, named replay tests, and production byte and source digest invariance. --- packages/translate/src/round-trip/reader.ts | 1 + packages/translate/src/round-trip/text-groups.ts | 1 + packages/translate/src/round-trip/turn-digest.ts | 1 + packages/translate/src/round-trip/writer.ts | 1 + 4 files changed, 4 insertions(+) diff --git a/packages/translate/src/round-trip/reader.ts b/packages/translate/src/round-trip/reader.ts index 6c9a9c4194..904ca37b03 100644 --- a/packages/translate/src/round-trip/reader.ts +++ b/packages/translate/src/round-trip/reader.ts @@ -2,6 +2,7 @@ import type { InputAssistantTurnByProtocol, InputAssistantTurnFor } from './inpu import type { InputAssistantTurnStateFor } from './reference/assistant-turn-types.ts'; import type { ReadFieldStates, ReferenceBuilder } from './reference/reference-builder.ts'; +/** The coordinator's inbound half: a target reader registers the turns it stated. */ export interface RoundTripReader { builder: ReferenceBuilder; onAssistantTurn: ( diff --git a/packages/translate/src/round-trip/text-groups.ts b/packages/translate/src/round-trip/text-groups.ts index 5d2e7be386..ec7d292e0a 100644 --- a/packages/translate/src/round-trip/text-groups.ts +++ b/packages/translate/src/round-trip/text-groups.ts @@ -10,6 +10,7 @@ export interface TextGroup { } export interface TextPlacement { + /** Concatenated text channel in which offset locates this field. */ readonly enclosingText: string; readonly offset: number; } diff --git a/packages/translate/src/round-trip/turn-digest.ts b/packages/translate/src/round-trip/turn-digest.ts index 38d7dc0979..2e1c308c12 100644 --- a/packages/translate/src/round-trip/turn-digest.ts +++ b/packages/translate/src/round-trip/turn-digest.ts @@ -27,6 +27,7 @@ export const verifyRoundTripTurnDigest = async (material: IRJSONValue, sourceTur return digest.length === sourceTurnDigest.length && digest.every((byte, index) => byte === sourceTurnDigest[index]); }; +/** Protocols provide native digest material; the shared digest format owns version selection. */ export const createTurnDigestComputer = ( materialOf: (turn: Turn) => IRJSONValue, ): (turn: Turn) => Promise => turn => computeRoundTripTurnDigest(materialOf(turn)); diff --git a/packages/translate/src/round-trip/writer.ts b/packages/translate/src/round-trip/writer.ts index 0bf5f90c1b..4a370f8534 100644 --- a/packages/translate/src/round-trip/writer.ts +++ b/packages/translate/src/round-trip/writer.ts @@ -4,6 +4,7 @@ import type { EmittedFieldStates, ReferenceBuilder } from './reference/reference import type { TextPlacementIndex } from './text-groups.ts'; import type { RoundTripTurnDigest } from './turn-digest.ts'; +/** The coordinator's outbound half: a source writer states its turn's sidecar. */ export interface RoundTripWriter { builder: ReferenceBuilder; encodeSidecar: ( From 5ea091a57ebc416a3f449c2ecfb722e594be5b04 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 07:44:02 +0800 Subject: [PATCH 12/66] feat: translate requests through complete input IR Read source requests into immutable input concepts and compose target writers with lazy authenticated native replay. Preserve complete tool declarations, logical call identity, request controls and media references across all public routes.\n\nRemove pairwise request conversion and carry the resolved Gemini model in the formal source request. Keep prerequisite output lifecycle, error projection and signed replay behavior intact. --- .gitignore | 2 + .../openai-chat-completions/attempt_test.ts | 9 +- .../chat/openai-responses/attempt_test.ts | 9 +- .../interceptors/server-tool-shim_test.ts | 41 +- .../namespace-descriptions-http_test.ts | 20 +- .../chat/anthropic-messages/attempt.ts | 4 +- .../chat/gemini-generate-content/attempt.ts | 18 +- .../chat/openai-chat-completions/attempt.ts | 5 +- .../chat/openai-responses/attempt.ts | 43 +- .../chat/shared/external-image-loader.ts | 5 +- packages/protocols/SOURCES.md | 2 +- .../protocols/src/anthropic-messages/index.ts | 15 +- packages/translate/AGENTS.md | 44 +- .../__tests__/composition/helpers.ts | 93 ++++ .../__tests__/composition/reading_test.ts | 49 ++ .../translate/__tests__/input-error_test.ts | 51 ++ .../__tests__/ir/input/media_test.ts | 35 ++ .../__tests__/ir/input/types_test.ts | 61 +++ packages/translate/__tests__/iterables.ts | 5 + .../as-source/events_test.ts | 12 +- .../as-source/native-reassembly_test.ts | 8 +- .../as-target/reasoning-effort_test.ts | 19 - .../events_test.ts | 4 - .../via-openai-responses/events_test.ts | 27 -- .../protocols/call-association_test.ts | 222 +++++++++ .../protocols/callable-names_test.ts | 84 ++++ .../protocols/chat-legacy-round-trip_test.ts | 128 +++-- .../chat-reasoning-round-trip_test.ts | 8 +- .../protocols/copy-ownership_test.ts | 16 + .../protocols/cross-protocol-errors_test.ts | 14 +- .../__tests__/protocols/edge-cases_test.ts | 76 +-- .../translate/__tests__/protocols/events.ts | 48 ++ .../translate/__tests__/protocols/fixtures.ts | 10 +- .../as-source/events_test.ts | 10 +- .../{reasoning_test.ts => input-to_test.ts} | 6 +- .../as-source/native-reassembly_test.ts | 8 +- .../round-trip-request_test.ts | 20 +- .../via-anthropic-messages/events_test.ts | 4 - .../events_test.ts | 4 - .../via-openai-responses/events_test.ts | 4 - .../translate/__tests__/protocols/helpers.ts | 50 -- .../protocols/initial-envelope_test.ts | 7 +- .../__tests__/protocols/lifecycle_test.ts | 32 +- .../as-source/continuous-usage_test.ts | 12 +- .../as-source/events_test.ts | 2 +- .../as-source/tool-kind_test.ts | 26 + .../as-target/events_test.ts | 26 +- .../as-target/legacy-calls_test.ts | 46 ++ .../via-anthropic-messages/events_test.ts | 4 - .../as-source/allowed-tools_test.ts | 140 +++++- .../as-source/conversation-turns_test.ts | 6 +- .../openai-responses/as-source/events_test.ts | 103 ---- ...tialize_test.ts => initialization_test.ts} | 4 +- .../as-source/input-to_test.ts | 61 +++ .../as-source/namespace-allocation_test.ts | 56 --- .../as-source/namespace-descriptions_test.ts | 20 - .../as-source/namespace-events_test.ts | 212 -------- .../as-source/namespace-tools_test.ts | 203 -------- .../as-source/programmatic-tooling_test.ts | 9 +- .../openai-responses/as-target/events_test.ts | 11 + .../as-target/snapshots_test.ts | 4 +- .../round-trip-request_test.ts | 79 +-- .../via-anthropic-messages/events_test.ts | 5 - .../events_test.ts | 5 - .../__tests__/protocols/ownership_test.ts | 17 +- ...-chat-completions-events-protocol_test.ts} | 12 +- ...-to-openai-chat-completions-events_test.ts | 7 + ...o-openai-chat-completions-request_test.ts} | 186 ++++++-- ...-openai-responses-events-protocol_test.ts} | 23 +- ...essages-to-openai-responses-events_test.ts | 7 + ...sages-to-openai-responses-request_test.ts} | 220 +++++++-- ...ntent-to-anthropic-messages-events_test.ts | 7 + ...ent-to-anthropic-messages-request_test.ts} | 148 +++--- ...-to-openai-chat-completions-events_test.ts | 7 + ...o-openai-chat-completions-request_test.ts} | 128 +++-- ...content-to-openai-responses-events_test.ts | 7 + ...ntent-to-openai-responses-request_test.ts} | 95 ++-- .../pairs/input-controls-request_test.ts | 205 ++++++++ ...nthropic-messages-events-protocol_test.ts} | 13 +- ...tions-to-anthropic-messages-events_test.ts | 7 + ...ons-to-anthropic-messages-request_test.ts} | 221 ++++----- ...etions-to-openai-responses-events_test.ts} | 24 +- ...tions-to-openai-responses-request_test.ts} | 95 ++-- ...nthropic-messages-events-protocol_test.ts} | 20 +- ...onses-to-anthropic-messages-events_test.ts | 7 + ...ses-to-anthropic-messages-request_test.ts} | 304 ++++++------ ...-to-openai-chat-completions-events_test.ts | 7 + ...o-openai-chat-completions-request_test.ts} | 170 ++++--- .../protocols/public-boundary_test.ts | 23 +- .../protocols/round-trip-receive_test.ts | 68 +-- .../__tests__/protocols/routes_test.ts | 123 +++-- .../__tests__/protocols/streams_test.ts | 14 +- ...nrecognized-assistant-turn-warning_test.ts | 109 ----- .../__tests__/protocols/usage_test.ts | 7 +- .../__tests__/request/call-ids_test.ts | 58 +++ .../__tests__/request/tool-names_test.ts | 114 +++++ .../__tests__/round-trip/protocols_test.ts | 70 ++- .../__tests__/round-trip/resolve_test.ts | 14 +- .../round-trip/sidecar-candidates_test.ts | 6 +- .../__tests__/round-trip/test-helpers.ts | 3 - .../__tests__/shared/api-error_test.ts | 2 +- .../custom-tool_test.ts} | 37 +- .../__tests__/shared/json/completion_test.ts | 17 + .../__tests__/shared/reasoning-budget_test.ts | 15 + packages/translate/__tests__/trip.ts | 6 + packages/translate/src/composition/index.ts | 85 ++++ packages/translate/src/composition/reading.ts | 68 +++ packages/translate/src/index.ts | 6 +- packages/translate/src/ir/input/media.ts | 34 ++ packages/translate/src/ir/input/streaming.ts | 7 + packages/translate/src/ir/input/types.ts | 269 +++++++++++ packages/translate/src/ir/output/schema.ts | 4 +- packages/translate/src/ir/output/types.ts | 2 + .../protocols/anthropic-messages/as-source.ts | 21 + .../as-source/client-tools.ts | 6 - .../anthropic-messages/as-source/events.ts | 31 +- .../anthropic-messages/as-source/input-to.ts | 451 ++++++++++++++++++ .../as-source/round-trip.ts | 4 +- .../protocols/anthropic-messages/as-target.ts | 45 ++ .../as-target/cache-breakpoints.ts | 25 +- .../anthropic-messages/as-target/events.ts | 82 ++-- .../as-target/input-from.ts | 337 +++++++++++++ .../as-target/reasoning-effort.ts | 33 -- .../as-target/remote-images.ts | 66 --- .../as-target/segmented-request.ts | 44 ++ .../as-target/tool-input-schema.ts | 7 - .../via-openai-chat-completions/events.ts | 12 - .../via-openai-chat-completions/request.ts | 335 ------------- .../via-openai-chat-completions/translate.ts | 24 - .../via-openai-responses/events.ts | 12 - .../via-openai-responses/reasoning.ts | 12 - .../via-openai-responses/request.ts | 325 ------------- .../via-openai-responses/translate.ts | 23 - .../gemini-generate-content/as-source.ts | 20 + .../as-source/content.ts | 102 ---- .../as-source/conversation-turns.ts | 4 +- .../as-source/events.ts | 41 +- .../as-source/input-to.ts | 396 +++++++++++++++ .../as-source/reasoning.ts | 45 -- .../as-source/round-trip.ts | 5 +- .../as-source/tools.ts | 72 --- .../via-anthropic-messages/events.ts | 12 - .../via-anthropic-messages/request.ts | 278 ----------- .../via-anthropic-messages/translate.ts | 24 - .../via-openai-chat-completions/events.ts | 12 - .../via-openai-chat-completions/request.ts | 301 ------------ .../via-openai-chat-completions/translate.ts | 19 - .../via-openai-responses/events.ts | 12 - .../via-openai-responses/request.ts | 243 ---------- .../via-openai-responses/translate.ts | 23 - .../openai-chat-completions/as-source.ts | 24 + .../as-source/conversation-turns.ts | 27 +- .../as-source/events.ts | 42 +- .../as-source/input-to.ts | 346 ++++++++++++++ .../as-source/round-trip.ts | 12 +- .../openai-chat-completions/as-target.ts | 67 +++ .../as-target/events.ts | 87 ++-- .../as-target/input-from.ts | 316 ++++++++++++ .../as-target/legacy-calls.ts | 73 +++ .../as-target/segmented-request.ts | 26 + .../via-anthropic-messages/events.ts | 17 - .../via-anthropic-messages/request.ts | 300 ------------ .../via-anthropic-messages/translate.ts | 28 -- .../via-openai-responses/content.ts | 39 -- .../via-openai-responses/events.ts | 17 - .../via-openai-responses/request.ts | 208 -------- .../via-openai-responses/translate.ts | 24 - .../protocols/openai-responses/as-source.ts | 34 ++ .../as-source/allowed-tools.ts | 88 +--- .../as-source/conversation-turns.ts | 22 +- .../openai-responses/as-source/events.ts | 100 ++-- .../openai-responses/as-source/input-to.ts | 400 ++++++++++++++++ .../as-source/namespace-tools.ts | 240 ---------- .../as-source/programmatic-tooling.ts | 18 +- .../openai-responses/as-source/response-id.ts | 1 - .../openai-responses/as-source/round-trip.ts | 4 +- .../protocols/openai-responses/as-target.ts | 24 + .../openai-responses/as-target/events.ts | 82 ++-- .../openai-responses/as-target/input-from.ts | 309 ++++++++++++ .../as-target/segmented-request.ts | 30 ++ .../via-anthropic-messages/events.ts | 13 - .../via-anthropic-messages/reasoning.ts | 7 - .../via-anthropic-messages/request.ts | 441 ----------------- .../via-anthropic-messages/translate.ts | 35 -- .../via-openai-chat-completions/content.ts | 48 -- .../via-openai-chat-completions/events.ts | 13 - .../via-openai-chat-completions/request.ts | 281 ----------- .../via-openai-chat-completions/translate.ts | 28 -- packages/translate/src/protocols/routes.ts | 44 +- packages/translate/src/request/call-ids.ts | 97 ++++ packages/translate/src/request/segments.ts | 40 ++ packages/translate/src/request/tool-names.ts | 171 +++++++ packages/translate/src/round-trip/resolve.ts | 30 +- .../src/round-trip/source-assistant-turn.ts | 18 + .../custom-tool.ts} | 13 +- .../translate/src/shared/json/completion.ts | 8 + packages/translate/src/shared/json/object.ts | 3 + .../translate/src/shared/reasoning-budget.ts | 14 + packages/translate/src/types.ts | 25 +- 199 files changed, 7255 insertions(+), 6047 deletions(-) create mode 100644 packages/translate/__tests__/composition/helpers.ts create mode 100644 packages/translate/__tests__/composition/reading_test.ts create mode 100644 packages/translate/__tests__/input-error_test.ts create mode 100644 packages/translate/__tests__/ir/input/media_test.ts create mode 100644 packages/translate/__tests__/ir/input/types_test.ts create mode 100644 packages/translate/__tests__/iterables.ts delete mode 100644 packages/translate/__tests__/protocols/anthropic-messages/as-target/reasoning-effort_test.ts delete mode 100644 packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events_test.ts delete mode 100644 packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events_test.ts create mode 100644 packages/translate/__tests__/protocols/call-association_test.ts create mode 100644 packages/translate/__tests__/protocols/callable-names_test.ts create mode 100644 packages/translate/__tests__/protocols/copy-ownership_test.ts create mode 100644 packages/translate/__tests__/protocols/events.ts rename packages/translate/__tests__/protocols/gemini-generate-content/as-source/{reasoning_test.ts => input-to_test.ts} (51%) delete mode 100644 packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/events_test.ts delete mode 100644 packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/events_test.ts delete mode 100644 packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/events_test.ts delete mode 100644 packages/translate/__tests__/protocols/helpers.ts create mode 100644 packages/translate/__tests__/protocols/openai-chat-completions/as-source/tool-kind_test.ts create mode 100644 packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts delete mode 100644 packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events_test.ts delete mode 100644 packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts rename packages/translate/__tests__/protocols/openai-responses/as-source/{initialize_test.ts => initialization_test.ts} (89%) create mode 100644 packages/translate/__tests__/protocols/openai-responses/as-source/input-to_test.ts delete mode 100644 packages/translate/__tests__/protocols/openai-responses/as-source/namespace-allocation_test.ts delete mode 100644 packages/translate/__tests__/protocols/openai-responses/as-source/namespace-descriptions_test.ts delete mode 100644 packages/translate/__tests__/protocols/openai-responses/as-source/namespace-events_test.ts delete mode 100644 packages/translate/__tests__/protocols/openai-responses/as-source/namespace-tools_test.ts delete mode 100644 packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events_test.ts delete mode 100644 packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/events_test.ts rename packages/translate/__tests__/protocols/{anthropic-messages/via-openai-chat-completions/events-protocol_test.ts => pairs/anthropic-messages-to-openai-chat-completions-events-protocol_test.ts} (61%) create mode 100644 packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-events_test.ts rename packages/translate/__tests__/protocols/{anthropic-messages/via-openai-chat-completions/request_test.ts => pairs/anthropic-messages-to-openai-chat-completions-request_test.ts} (69%) rename packages/translate/__tests__/protocols/{anthropic-messages/via-openai-responses/events-protocol_test.ts => pairs/anthropic-messages-to-openai-responses-events-protocol_test.ts} (86%) create mode 100644 packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-events_test.ts rename packages/translate/__tests__/protocols/{anthropic-messages/via-openai-responses/request_test.ts => pairs/anthropic-messages-to-openai-responses-request_test.ts} (63%) create mode 100644 packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-events_test.ts rename packages/translate/__tests__/protocols/{gemini-generate-content/via-anthropic-messages/request_test.ts => pairs/gemini-generate-content-to-anthropic-messages-request_test.ts} (70%) create mode 100644 packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-events_test.ts rename packages/translate/__tests__/protocols/{gemini-generate-content/via-openai-chat-completions/request_test.ts => pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts} (73%) create mode 100644 packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-events_test.ts rename packages/translate/__tests__/protocols/{gemini-generate-content/via-openai-responses/request_test.ts => pairs/gemini-generate-content-to-openai-responses-request_test.ts} (74%) create mode 100644 packages/translate/__tests__/protocols/pairs/input-controls-request_test.ts rename packages/translate/__tests__/protocols/{openai-chat-completions/via-anthropic-messages/events-protocol_test.ts => pairs/openai-chat-completions-to-anthropic-messages-events-protocol_test.ts} (73%) create mode 100644 packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-events_test.ts rename packages/translate/__tests__/protocols/{openai-chat-completions/via-anthropic-messages/request_test.ts => pairs/openai-chat-completions-to-anthropic-messages-request_test.ts} (85%) rename packages/translate/__tests__/protocols/{openai-chat-completions/via-openai-responses/events_test.ts => pairs/openai-chat-completions-to-openai-responses-events_test.ts} (93%) rename packages/translate/__tests__/protocols/{openai-chat-completions/via-openai-responses/request_test.ts => pairs/openai-chat-completions-to-openai-responses-request_test.ts} (67%) rename packages/translate/__tests__/protocols/{openai-responses/via-anthropic-messages/events-protocol_test.ts => pairs/openai-responses-to-anthropic-messages-events-protocol_test.ts} (74%) create mode 100644 packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-events_test.ts rename packages/translate/__tests__/protocols/{openai-responses/via-anthropic-messages/request_test.ts => pairs/openai-responses-to-anthropic-messages-request_test.ts} (79%) create mode 100644 packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-events_test.ts rename packages/translate/__tests__/protocols/{openai-responses/via-openai-chat-completions/request_test.ts => pairs/openai-responses-to-openai-chat-completions-request_test.ts} (80%) delete mode 100644 packages/translate/__tests__/protocols/unrecognized-assistant-turn-warning_test.ts create mode 100644 packages/translate/__tests__/request/call-ids_test.ts create mode 100644 packages/translate/__tests__/request/tool-names_test.ts rename packages/translate/__tests__/{protocols/openai-responses/common/custom-tool-wrap_test.ts => shared/custom-tool_test.ts} (74%) create mode 100644 packages/translate/__tests__/shared/json/completion_test.ts create mode 100644 packages/translate/__tests__/shared/reasoning-budget_test.ts create mode 100644 packages/translate/__tests__/trip.ts create mode 100644 packages/translate/src/composition/index.ts create mode 100644 packages/translate/src/composition/reading.ts create mode 100644 packages/translate/src/ir/input/media.ts create mode 100644 packages/translate/src/ir/input/streaming.ts create mode 100644 packages/translate/src/ir/input/types.ts create mode 100644 packages/translate/src/protocols/anthropic-messages/as-source.ts delete mode 100644 packages/translate/src/protocols/anthropic-messages/as-source/client-tools.ts create mode 100644 packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts create mode 100644 packages/translate/src/protocols/anthropic-messages/as-target.ts create mode 100644 packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts delete mode 100644 packages/translate/src/protocols/anthropic-messages/as-target/reasoning-effort.ts delete mode 100644 packages/translate/src/protocols/anthropic-messages/as-target/remote-images.ts create mode 100644 packages/translate/src/protocols/anthropic-messages/as-target/segmented-request.ts delete mode 100644 packages/translate/src/protocols/anthropic-messages/as-target/tool-input-schema.ts delete mode 100644 packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts delete mode 100644 packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/request.ts delete mode 100644 packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/translate.ts delete mode 100644 packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts delete mode 100644 packages/translate/src/protocols/anthropic-messages/via-openai-responses/reasoning.ts delete mode 100644 packages/translate/src/protocols/anthropic-messages/via-openai-responses/request.ts delete mode 100644 packages/translate/src/protocols/anthropic-messages/via-openai-responses/translate.ts create mode 100644 packages/translate/src/protocols/gemini-generate-content/as-source.ts delete mode 100644 packages/translate/src/protocols/gemini-generate-content/as-source/content.ts create mode 100644 packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts delete mode 100644 packages/translate/src/protocols/gemini-generate-content/as-source/reasoning.ts delete mode 100644 packages/translate/src/protocols/gemini-generate-content/as-source/tools.ts delete mode 100644 packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts delete mode 100644 packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/request.ts delete mode 100644 packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/translate.ts delete mode 100644 packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts delete mode 100644 packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts delete mode 100644 packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/translate.ts delete mode 100644 packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts delete mode 100644 packages/translate/src/protocols/gemini-generate-content/via-openai-responses/request.ts delete mode 100644 packages/translate/src/protocols/gemini-generate-content/via-openai-responses/translate.ts create mode 100644 packages/translate/src/protocols/openai-chat-completions/as-source.ts create mode 100644 packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts create mode 100644 packages/translate/src/protocols/openai-chat-completions/as-target.ts create mode 100644 packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts create mode 100644 packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts create mode 100644 packages/translate/src/protocols/openai-chat-completions/as-target/segmented-request.ts delete mode 100644 packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts delete mode 100644 packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/request.ts delete mode 100644 packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/translate.ts delete mode 100644 packages/translate/src/protocols/openai-chat-completions/via-openai-responses/content.ts delete mode 100644 packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts delete mode 100644 packages/translate/src/protocols/openai-chat-completions/via-openai-responses/request.ts delete mode 100644 packages/translate/src/protocols/openai-chat-completions/via-openai-responses/translate.ts create mode 100644 packages/translate/src/protocols/openai-responses/as-source.ts create mode 100644 packages/translate/src/protocols/openai-responses/as-source/input-to.ts delete mode 100644 packages/translate/src/protocols/openai-responses/as-source/namespace-tools.ts delete mode 100644 packages/translate/src/protocols/openai-responses/as-source/response-id.ts create mode 100644 packages/translate/src/protocols/openai-responses/as-target.ts create mode 100644 packages/translate/src/protocols/openai-responses/as-target/input-from.ts create mode 100644 packages/translate/src/protocols/openai-responses/as-target/segmented-request.ts delete mode 100644 packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts delete mode 100644 packages/translate/src/protocols/openai-responses/via-anthropic-messages/reasoning.ts delete mode 100644 packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts delete mode 100644 packages/translate/src/protocols/openai-responses/via-anthropic-messages/translate.ts delete mode 100644 packages/translate/src/protocols/openai-responses/via-openai-chat-completions/content.ts delete mode 100644 packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts delete mode 100644 packages/translate/src/protocols/openai-responses/via-openai-chat-completions/request.ts delete mode 100644 packages/translate/src/protocols/openai-responses/via-openai-chat-completions/translate.ts create mode 100644 packages/translate/src/request/call-ids.ts create mode 100644 packages/translate/src/request/segments.ts create mode 100644 packages/translate/src/request/tool-names.ts create mode 100644 packages/translate/src/round-trip/source-assistant-turn.ts rename packages/translate/src/{protocols/openai-responses/common/custom-tool-wrap.ts => shared/custom-tool.ts} (62%) create mode 100644 packages/translate/src/shared/json/completion.ts create mode 100644 packages/translate/src/shared/reasoning-budget.ts diff --git a/.gitignore b/.gitignore index d9a3c228b4..3646a7f651 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,8 @@ apps/web/.react-router/ .env .pnpm-store/ /wrangler.jsonc +.tmp/ +.research/ # Local node-target dev runtime (sqlite db + uploaded files). One under the # repo root for `pnpm run dev:node` invoked at the root, one under the package diff --git a/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/attempt_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/attempt_test.ts index 6bb6f33a3d..97ae48aa46 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/attempt_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/attempt_test.ts @@ -186,11 +186,10 @@ test('generate translates through the Anthropic Messages target when only that e assertEquals(anthropicBeta, []); }); -test('generate injects the platform external-image loader into OpenAI-Chat-Completions-to-Anthropic-Messages translation', async () => { +test('generate states a remote image URL to Anthropic Messages without fetching it', async () => { installRepo(); - initExternalResourceFetcher(url => { - assertEquals(url.href, 'https://example.com/image.png'); - return Promise.resolve(new Response(Uint8Array.of(1, 2, 3), { headers: { 'content-type': 'image/png' } })); + initExternalResourceFetcher(() => { + throw new Error('translation must not fetch images'); }); let observedBody: Omit | undefined; const callAnthropicMessages = vi.fn(async (_model, body): Promise> => { @@ -216,7 +215,7 @@ test('generate injects the platform external-image loader into OpenAI-Chat-Compl assert(message?.role === 'user' && Array.isArray(message.content)); const image = message.content.find(block => block.type === 'image'); assert(image?.type === 'image'); - assertEquals(image.source, { type: 'base64', media_type: 'image/png', data: 'AQID' }); + assertEquals(image.source, { type: 'url', url: 'https://example.com/image.png' }); }); test('generate translates through the OpenAI Responses target when only that endpoint is exposed', async () => { diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/attempt_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/attempt_test.ts index dad47c2ee6..7ce6137d20 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/attempt_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/attempt_test.ts @@ -427,11 +427,10 @@ test('compact returns the clean upstream result for source-edge affinity and sto assertEquals(result.result.id, compactionResult.id); }); -test('generate strips disallowed headers and injects external image loading across translation to Anthropic Messages', async () => { +test('generate strips disallowed headers and states a remote image URL to Anthropic Messages', async () => { installRepo(); - initExternalResourceFetcher(url => { - assertEquals(url.href, 'https://example.com/image.png'); - return Promise.resolve(new Response(Uint8Array.of(1, 2, 3), { headers: { 'content-type': 'image/png' } })); + initExternalResourceFetcher(() => { + throw new Error('translation must not fetch images'); }); let observedHeaders: Headers | undefined; let observedAnthropicBeta: readonly string[] | undefined; @@ -493,7 +492,7 @@ test('generate strips disallowed headers and injects external image loading acro assert(message?.role === 'user' && Array.isArray(message.content)); const image = message.content.find(block => block.type === 'image'); assert(image?.type === 'image'); - assertEquals(image.source, { type: 'base64', media_type: 'image/png', data: 'AQID' }); + assertEquals(image.source, { type: 'url', url: 'https://example.com/image.png' }); }); test('generate preserves order and downgrades a disallowed system turn across translation to Anthropic Messages', async () => { diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/server-tool-shim_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/server-tool-shim_test.ts index fab9d21a0c..a1d62e2608 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 @@ -1,5 +1,6 @@ import { beforeEach, expect, test, vi } from 'vitest'; +import { inputErrorResult } from '../../../../../src/data-plane/chat/openai-responses/errors.ts'; import { withOpenAIResponsesServerToolShim } from '../../../../../src/data-plane/chat/openai-responses/interceptors/server-tool-shim.ts'; import { consumeTurnStreaming, @@ -36,7 +37,6 @@ 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'; @@ -777,6 +777,7 @@ const makeInvocation = (overrides: InvocationOverrides = {}): OpenAIResponsesInv targetApi: overrides.targetApi ?? 'openaiChatCompletions', payload: { model: 'claude-x', + stream: true, input: [{ type: 'message', role: 'user', content: 'hi' }], tools: [{ type: 'web_search' }], // Opt the wire item into `results` so most tests can inspect them @@ -7351,7 +7352,8 @@ for (const targetApi of ['openaiChatCompletions', 'anthropicMessages', 'openaiRe ]]).run(); } if (targetApi === 'openaiChatCompletions') { - const trip = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(inv.payload, { model: 'm', sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) })); + const trip = await routeOpenAIResponsesViaOpenAIChatCompletions(inv.payload, { sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }); + if (trip.type === 'input-error') return inputErrorResult(trip); const client = trip.target.tools?.[1]; assert(client?.type === 'function'); const events = (async function* (): AsyncGenerator> { @@ -7360,7 +7362,8 @@ for (const targetApi of ['openaiChatCompletions', 'anthropicMessages', 'openaiRe })(); return eventResult(trip.events(events), testTelemetryModelIdentity); } - const trip = await requireTrip(routeOpenAIResponsesViaAnthropicMessages(inv.payload, { model: 'm', sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), loadRemoteImage: async () => { throw new Error('Unexpected image'); } })); + const trip = await routeOpenAIResponsesViaAnthropicMessages(inv.payload, { sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }); + if (trip.type === 'input-error') return inputErrorResult(trip); const tool = trip.target.tools?.[1]; const name = tool !== undefined && 'name' in tool ? tool.name : undefined; assert(typeof name === 'string'); @@ -7398,7 +7401,7 @@ test('native helper injection allocates past namespace children while retaining for (const target of ['openaiChatCompletions', 'anthropicMessages'] as const) { for (const mode of ['forced', 'auto', 'required'] as const) { - test(`${target} ${mode} helper choice follows a namespace-induced alias and restores its original echo`, async () => { + test(`${target} ${mode} helper choice follows a namespace-induced alias and preserves translation constraints`, async () => { const { backend } = makeStubDeps(); const selector = { type: 'function' as const, name: SHIM_TOOL_NAME }; const choice: OpenAIResponsesToolChoice = mode === 'forced' ? selector : { type: 'allowed_tools', mode, tools: [selector] }; @@ -7413,25 +7416,43 @@ for (const target of ['openaiChatCompletions', 'anthropicMessages'] as const) { const alias = `${SHIM_TOOL_NAME}_2`; const script = scriptedRun([fcTurn(0, 'hosted', alias, '{"search_query":[{"q":"hosted"}]}'), messageTurn('done')]); const choices: unknown[] = []; - const { frames } = await runShimAndDrain(withOpenAIResponsesWebSearchShim, inv, makeGatewayCtx(), async () => { + const run = async () => { choices.push(structuredClone(inv.payload.tool_choice)); const before = structuredClone(inv.payload); const trip = target === 'openaiChatCompletions' - ? 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'); } })); + ? await routeOpenAIResponsesViaOpenAIChatCompletions(inv.payload, { sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }) + : await routeOpenAIResponsesViaAnthropicMessages(inv.payload, { sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }); + if (trip.type === 'input-error') return inputErrorResult(trip); 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'); + assert(wire.tools.some(tool => (tool.function?.name ?? tool.name) === alias), 'the complete declaration list must retain the helper alias'); assertEquals(inv.payload, before, 'repeated translation must preserve the outer loop payload'); return await script.run(); - }); + }; + if (target === 'anthropicMessages' && mode === 'auto') { + const { result } = await runShimAndDrain(withOpenAIResponsesWebSearchShim, inv, makeGatewayCtx(), run); + assert(result.type === 'api-error'); + assertEquals(result.status, 400); + assert(new TextDecoder().decode(result.body).includes('allowed_tools auto subset')); + assertEquals(script.callCount(), 0); + assertEquals(backend.calls.length, 0); + assertEquals(choices, [{ type: 'allowed_tools', mode: 'auto', tools: [{ type: 'function', name: alias }] }]); + return; + } + const { frames } = await runShimAndDrain(withOpenAIResponsesWebSearchShim, inv, makeGatewayCtx(), run); const renamed = { type: 'function', name: alias }; assertEquals(choices, mode === 'forced' ? [renamed, 'auto'] : [ { type: 'allowed_tools', mode, tools: [renamed] }, { type: 'allowed_tools', mode: 'auto', tools: [renamed] }, ]); assertEquals(choice, original); - assertEquals(script.callCount(), 2); assertEquals(backend.calls.length, 1); + if (target === 'anthropicMessages' && mode === 'required') { + assertEquals(script.callCount(), 1); + const failed = frames.find(frame => frame.type === 'event' && frame.event.type === 'response.failed'); + expect(failed).toMatchObject({ event: { response: { status: 'failed', error: { message: expect.stringContaining('allowed_tools auto subset') } } } }); + return; + } + assertEquals(script.callCount(), 2); assertEquals(findResponseCompleted(frames).response.tool_choice, original); }); } diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/namespace-descriptions-http_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/namespace-descriptions-http_test.ts index 73c21c2347..f2fe39248a 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/namespace-descriptions-http_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/namespace-descriptions-http_test.ts @@ -55,6 +55,7 @@ for (const target of ['openaiChatCompletions', 'anthropicMessages'] as const) { const choice = { type: 'allowed_tools', mode, tools: [selector] }; const input = [{ type: 'message', role: 'user', content: 'Read my account.' }]; const payload = { model: 'model', stream: false, store: false, tool_choice: choice, ...(representation !== 'Standard' ? { input: [{ type: 'additional_tools', role: 'developer', tools: [namespace] }, ...input] } : { input, tools: [namespace] }) }; + const unrepresentable = target === 'anthropicMessages' && mode === 'auto' && selection === 'callable'; const wire: WireRequest[] = []; await withMockedFetch(async request => { if (new URL(request.url).pathname === '/v1/models') return Response.json({ data: [{ id: 'model' }] }); @@ -64,6 +65,13 @@ for (const target of ['openaiChatCompletions', 'anthropicMessages'] as const) { return toolCallResponse(target, 'payments_read'); }, async () => { const response = await requestApp('/v1/responses', { method: 'POST', headers: { authorization: `Bearer ${apiKey.key}`, 'content-type': 'application/json' }, body: JSON.stringify(payload) }); + if (unrepresentable) { + assertEquals(response.status, 400); + const failure = await response.json() as { error: { message: string } }; + assert(failure.error.message.includes('cannot express an allowed_tools auto subset')); + await flushBackground(); + return; + } const resource = await response.json() as OpenAIResponsesResultEx; assertEquals(response.status, 200); assertEquals(resource.status, 'completed'); @@ -73,11 +81,17 @@ for (const target of ['openaiChatCompletions', 'anthropicMessages'] as const) { assertEquals([call.name, call.namespace], ['read', 'payments']); await flushBackground(); }); + if (unrepresentable) { + assertEquals(wire.length, 0, 'an unrepresentable selector must fail before contacting the provider'); + return; + } assertEquals(wire.length, 1, 'the final provider serializer must actually run once'); const selected = selection === 'namespace' ? namespace.tools : namespace.tools.slice(0, 1); - assertEquals(wire[0].tools?.map(tool => tool.function?.name ?? tool.name), selected.map(tool => `payments_${tool.name}`)); - assertEquals(wire[0].tools?.map(tool => tool.function?.description ?? tool.description), selected.map(tool => `${namespace.description}\n\n${tool.description}`)); - assertEquals(wire[0].tool_choice, target === 'openaiChatCompletions' ? mode : { type: mode === 'required' ? 'any' : 'auto' }); + assertEquals(wire[0].tools?.map(tool => tool.function?.name ?? tool.name), namespace.tools.map(tool => `payments_${tool.name}`)); + assertEquals(wire[0].tools?.map(tool => tool.function?.description ?? tool.description), namespace.tools.map(tool => `${namespace.description}\n\n${tool.description}`)); + assertEquals(wire[0].tool_choice, target === 'openaiChatCompletions' + ? { type: 'allowed_tools', allowed_tools: { mode, tools: selected.map(tool => ({ type: 'function', function: { name: `payments_${tool.name}` } })) } } + : selection === 'callable' ? { type: 'tool', name: 'payments_read' } : { type: mode === 'required' ? 'any' : 'auto' }); }); } } 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 838fbd4f72..2c60152a70 100644 --- a/packages/gateway/src/data-plane/chat/anthropic-messages/attempt.ts +++ b/packages/gateway/src/data-plane/chat/anthropic-messages/attempt.ts @@ -115,7 +115,7 @@ const dispatchAnthropicMessages = async ( if (targetApi === 'openaiResponses') { return await executeTranslation( invocation.payload, - p => routeAnthropicMessagesViaOpenAIResponses(p, { model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), + p => routeAnthropicMessagesViaOpenAIResponses(p, { sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), translated => openaiResponsesAttempt.generate({ payload: translated, ctx, candidate, headers: invocation.headers, }), @@ -125,7 +125,7 @@ const dispatchAnthropicMessages = async ( if (targetApi === 'openaiChatCompletions') { return await executeTranslation( invocation.payload, - p => routeAnthropicMessagesViaOpenAIChatCompletions(p, { model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), + p => routeAnthropicMessagesViaOpenAIChatCompletions(p, { 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/gemini-generate-content/attempt.ts b/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts index a38b0366ec..58326e86bd 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 @@ -64,7 +64,6 @@ export const geminiGenerateContentAttempt = { // payload-mutators are applied inline here before translation; the // attempt-owned payload clone keeps the caller's source intact. const transCtx = { - model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), fallbackMaxOutputTokens: candidate.model.limits.max_output_tokens, }; @@ -72,7 +71,9 @@ export const geminiGenerateContentAttempt = { stripUnsupportedPartFieldsFromPayload(cleaned); stripUnsupportedToolsFromPayload(cleaned); delete cleaned.safetySettings; - const trip = await routeGeminiGenerateContentViaAnthropicMessages(cleaned, transCtx); + // The model travels in the URL rather than the body, so the request this + // trip reads is the cleaned payload with the resolved model folded in. + const trip = await routeGeminiGenerateContentViaAnthropicMessages({ ...cleaned, model: candidate.model.id }, transCtx); if (trip.type === 'input-error') { const failure = inputErrorResult(trip, ctx.attempt.telemetry); return plainResult(failure.status, failure.headers, failure.body); @@ -137,18 +138,15 @@ const dispatchGeminiGenerateContent = async ( 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. + // The model comes from the URL and is folded into the translation request. + const request = { ...invocation.payload, model: candidate.model.id }; 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, + request, p => routeGeminiGenerateContentViaAnthropicMessages(p, transCtx), translated => anthropicMessagesAttempt.generate({ payload: translated, ctx, candidate, headers: invocation.headers, anthropicBeta: [], @@ -158,7 +156,7 @@ const dispatchGeminiGenerateContent = async ( } if (targetApi === 'openaiResponses') { return await executeTranslation( - invocation.payload, + request, p => routeGeminiGenerateContentViaOpenAIResponses(p, transCtx), translated => openaiResponsesAttempt.generate({ payload: translated, ctx, candidate, headers: invocation.headers, @@ -168,7 +166,7 @@ const dispatchGeminiGenerateContent = async ( } if (targetApi === 'openaiChatCompletions') { return await executeTranslation( - invocation.payload, + request, p => routeGeminiGenerateContentViaOpenAIChatCompletions(p, transCtx), translated => openaiChatCompletionsAttempt.generate({ payload: translated, ctx, candidate, headers: invocation.headers, 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 3c642d6313..a48c3b36e4 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 @@ -7,7 +7,6 @@ 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'; @@ -68,10 +67,8 @@ const dispatchOpenAIChatCompletions = async ( 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: [], @@ -82,7 +79,7 @@ const dispatchOpenAIChatCompletions = async ( if (targetApi === 'openaiResponses') { return await executeTranslation( invocation.payload, - p => routeOpenAIChatCompletionsViaOpenAIResponses(p, { model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), + p => routeOpenAIChatCompletionsViaOpenAIResponses(p, { sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), translated => openaiResponsesAttempt.generate({ payload: translated, ctx, candidate, headers: invocation.headers, }), diff --git a/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts b/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts index 4da1ac83f0..4c04b62003 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts @@ -13,7 +13,6 @@ 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'; @@ -42,40 +41,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; @@ -174,10 +147,8 @@ const dispatchOpenAIResponses = async ( return await executeTranslation( invocation.payload, p => routeOpenAIResponsesViaAnthropicMessages(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: [], @@ -190,7 +161,7 @@ const dispatchOpenAIResponses = async ( } return await executeTranslation( invocation.payload, - p => routeOpenAIResponsesViaOpenAIChatCompletions(p, { model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), + p => routeOpenAIResponsesViaOpenAIChatCompletions(p, { 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/shared/external-image-loader.ts b/packages/gateway/src/data-plane/chat/shared/external-image-loader.ts index 1ae6afabc0..c6b8cbb58c 100644 --- a/packages/gateway/src/data-plane/chat/shared/external-image-loader.ts +++ b/packages/gateway/src/data-plane/chat/shared/external-image-loader.ts @@ -1,5 +1,4 @@ import { getExternalResourceFetcher } from '@floway-dev/platform'; -import type { RemoteImageLoader } from '@floway-dev/translate'; const MAX_REDIRECTS = 5; const MAX_IMAGE_BYTES = 50 * 1024 * 1024; @@ -133,7 +132,9 @@ export const createExternalImageFetcher = (downstreamSignal?: AbortSignal): Exte }; }; -export const createExternalImageLoader = (downstreamSignal?: AbortSignal): RemoteImageLoader => { +type ExternalImageLoader = (url: string) => Promise<{ mediaType: string | null; data: Uint8Array } | null>; + +export const createExternalImageLoader = (downstreamSignal?: AbortSignal): ExternalImageLoader => { const fetchImage = createExternalImageFetcher(downstreamSignal); return async url => { const result = await fetchImage(url); diff --git a/packages/protocols/SOURCES.md b/packages/protocols/SOURCES.md index 1ab8d03531..08fe981bd6 100644 --- a/packages/protocols/SOURCES.md +++ b/packages/protocols/SOURCES.md @@ -22,7 +22,7 @@ Provider additions and broader wire views use `Ex`. Chat assistant and tool-call Messages collectors follow the pinned SDK's cumulative usage counters, citation events, complete signature replacement, and compaction final-value assignment, including null. Standard usage deltas exclude fixed metadata. Incoming gateway TTL deltas and Floway's late fixed metadata use `AnthropicMessagesUsageDeltaEx`, following the complete-usage convention used by [new-api](https://github.com/QuantumNous/new-api/blob/6370b29424168039e94d40d610191e7d2e65dbf4/relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go#L215-L235). This preserves realtime streaming without a new carrier field. Floway's collector and accounting reader understand the extension; the official SDK's standard accumulator does not interpret those extra fields. Accounting snapshots remain separate from wire types. -Recognizing an official block does not add a translation implementation. Unimplemented Messages-to-Chat blocks and compaction stops fail explicitly; context-window exhaustion maps to Chat `length`. Display-only alias rules update existing enabled/adaptive thinking and preserve other modes. System/developer text part boundaries and empty history turns remain represented. Native Chat custom tools, calls, and choices map to Responses; function `allowed_tools` subsets map to Messages. Unrepresentable inputs fail instead of receiving invented defaults. +Recognizing an official block does not add a translation implementation. Unimplemented Messages-to-Chat blocks and compaction stops fail explicitly; context-window exhaustion maps to Chat `length`. Display-only alias rules update existing enabled/adaptive thinking and preserve other modes. System/developer text part boundaries and empty history turns remain represented. Native Chat custom tools, calls, and choices map to Responses. Messages preserves tool declarations and accepts only `allowed_tools` selections its native choice modes can express. Unrepresentable inputs fail instead of receiving invented defaults. Gemini Schema conversion preserves constraints, reorders properties before removing `propertyOrdering`, and emits int64 constraints as exact JSON numeric tokens. The `klona/json` and streaming JSON serializer patches retain immutable raw JSON values; ordered schema properties retain their enumeration order through request cloning and serialization, including integer-named keys. Boolean JSON schemas use exact object equivalents; destination schema restrictions remain enforced by the destination boundary or upstream. Mutually exclusive schema fields fail explicitly. Omitted Chat schema is not replaced with an unconstrained schema. diff --git a/packages/protocols/src/anthropic-messages/index.ts b/packages/protocols/src/anthropic-messages/index.ts index a7b5d5448a..42755faee3 100644 --- a/packages/protocols/src/anthropic-messages/index.ts +++ b/packages/protocols/src/anthropic-messages/index.ts @@ -73,6 +73,19 @@ export type AnthropicMessagesImageSource = | { type: 'file'; file_id: string }; export interface AnthropicMessagesImageBlock { type: 'image'; source: AnthropicMessagesImageSource; cache_control?: AnthropicMessagesCacheControl | null; transformations?: { oversized_image?: 'downsize' | 'error' } | null } +// The snapshot pins a binary document's `media_type` to `'application/pdf'`, +// while the wire treats it as a MIME string: an upstream decides what it +// accepts. Translation forwards the caller's media type verbatim, which the +// snapshot cannot express without claiming the bytes are a PDF they are not. +export type AnthropicMessagesDocumentBlockEx = { + type: 'document'; + source: Omit, 'media_type'> & { media_type: string }; + cache_control?: AnthropicMessagesCacheControl | null; + citations?: Native.CitationsConfigParam | null; + context?: string | null; + title?: string | null; +}; + export type AnthropicMessagesSearchResultBlock = Native.SearchResultBlockParam | Beta.BetaSearchResultBlockParam; export type AnthropicMessagesWebSearchResultBlock = Native.WebSearchResultBlock | Beta.BetaWebSearchResultBlock; @@ -132,7 +145,7 @@ export type AnthropicMessagesUserContentBlock = AnthropicMessagesInputContentBlo export type AnthropicMessagesAssistantContentBlock = Native.ContentBlock | Beta.BetaContentBlock; export type AnthropicMessagesAssistantInputContentBlock = Native.ContentBlockParam | Beta.BetaContentBlockParam; -export type AnthropicMessagesInputContentBlock = AnthropicMessagesAssistantInputContentBlock; +export type AnthropicMessagesInputContentBlock = AnthropicMessagesAssistantInputContentBlock | AnthropicMessagesDocumentBlockEx; export interface AnthropicMessagesUserMessage extends Omit { role: 'user'; diff --git a/packages/translate/AGENTS.md b/packages/translate/AGENTS.md index 259f55a506..d123db12fc 100644 --- a/packages/translate/AGENTS.md +++ b/packages/translate/AGENTS.md @@ -2,56 +2,58 @@ `src/protocols//` owns translation entrypoints and protocol-specific code. `__tests__/protocols/` mirrors production ownership. Package callers -import the package root and call -`routeVia(input, options)` to create an independent trip. +import the pair's named export from the package root and call it with a source +payload and `TranslateOptions`, receiving a `TranslateTrip` or a `TranslateInputError`. ## Categories -1. **Single-pair helper** — keep it in - `protocols//via-/`, beside its request and event translators. +1. **Composition** — a route pairs a source and a target and states nothing else. + Anything a pair alone needs belongs to one of its sides; if it truly needs + both, it belongs in `composition/index.ts`. 2. **Source-only helper** — keep it in `protocols//as-source/`. Only routes whose source is that protocol may import it. 3. **Target-only helper** — keep it in `protocols//as-target/`. Only routes whose target is that protocol may import it. -4. **Both directions** — keep it in `protocols//common/`. - Routes using that protocol as source or target may import it. Keep another - protocol's wire mapping in the matching `via-*` route. Retained files use - functional names without another protocol name. -5. **Output IR** — keep private types, its path schema, stream operations, +4. **Output IR** — keep private types, its path schema, stream operations, validation, accumulation, operation paths, and native unit index allocation under - `ir/output/`. Native SSE adapters + `ir/output/`, and the input model under `ir/input/`. A reading holds the + source's native assistant turns beside the IR it would produce, so it belongs + to composition rather than to the IR. Protocol event adapters belong to their protocol: `as-target/events.ts` reads upstream protocol events and `as-source/events.ts` writes client protocol events with its sidecar. The return path is - upstream events → IR records → client events with sidecar. IR stays internal to translate. -6. **Assistant-turn round trips** — keep owner-specific sidecar validation, replay + upstream protocol events → output IR records → client protocol events with sidecar. IR stays internal to translate. +5. **Assistant-turn round trips** — keep owner-specific sidecar validation, replay 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 `TranslateOptions.sidecarCodec`. -7. **Protocol-independent utility** — keep it in `shared/`, with tests under +6. **Protocol-independent utility** — keep it in `shared/`, with tests under `__tests__/shared/`. Protocol behavior belongs in its owning protocol directory. ## Current Production Subdirectories -- `ir/output/` — output model, its path schema, and protocol-independent streaming state. +- `ir/common/` — the JSON value shape shared by both directions. +- `ir/input/` — the request conversation model, its types, and the target-side + selection helpers. +- `composition/` — what a trip needs before the IR is written: a + source conversation read into turn slots and resolved through exclusive native or IR paths. +- `request/` — target request preparation: callable names, call IDs, and segmentation around restored turns. +- `ir/output/` — the returned-stream model, its path schema, builder, record consumer, and protocol-independent streaming state. - `round-trip/` — authenticated assistant-turn sidecars, replay validation, and native stream coordination. - `protocols/anthropic-messages/as-source/` — interprets Messages requests and emits Messages SSE. - `protocols/anthropic-messages/as-target/` — maps requests to Messages and consumes Messages SSE. - `protocols/gemini-generate-content/as-source/` — interprets GenerateContent parts and schemas and emits GenerateContent SSE. - `protocols/openai-chat-completions/as-source/` — emits ChatCompletions SSE. - `protocols/openai-chat-completions/as-target/` — consumes ChatCompletions SSE. -- `protocols/openai-chat-completions/common/` — reads scalar reasoning aliases and summary content. - `protocols/openai-responses/as-source/` — interprets Responses tools, restores event identities, and emits Responses SSE. - `protocols/openai-responses/as-target/` — consumes Responses SSE. -- `protocols/openai-responses/common/` — owns wrapper formats used in both translation directions. -- `protocols//via-/` — owns request translation and composes native SSE adapters for one protocol pair. -- `shared/` — cross-route coordinate conversion, error classification, JSON handling, and token validation. +- `composition/index.ts` — pairs one source protocol with one target protocol into a trip; the generic composition boundary. +- `protocols//as-source.ts` — states that protocol as the side a request comes from. +- `protocols//as-target.ts` — states that protocol as the side a request goes to. +- `shared/` — cross-route coordinates, error classification, JSON handling, token validation, and callable wrapper formats. ## Rules -- Inspect helpers by content and consumers. Keep most translation logic in the - owning `via-*` route; extract only cohesive logic shared by multiple routes. - - Shallow wrappers that only rename or stringify must be inlined at every call site, not extracted. A helper that defines a format two or more routes must agree on is not such a wrapper. diff --git a/packages/translate/__tests__/composition/helpers.ts b/packages/translate/__tests__/composition/helpers.ts new file mode 100644 index 0000000000..af9408948e --- /dev/null +++ b/packages/translate/__tests__/composition/helpers.ts @@ -0,0 +1,93 @@ +import type { SourceProtocol, TargetProtocol } from '../../src/composition/index.ts'; +import { resolveReading } from '../../src/composition/reading.ts'; +import type { SourceReading, RestoredAssistantTurn } from '../../src/composition/reading.ts'; +import type { IRInputTurn } from '../../src/ir/input/types.ts'; +import { anthropicMessagesSourceProtocol } from '../../src/protocols/anthropic-messages/as-source.ts'; +import { anthropicMessagesTargetProtocol } from '../../src/protocols/anthropic-messages/as-target.ts'; +import { geminiGenerateContentSourceProtocol } from '../../src/protocols/gemini-generate-content/as-source.ts'; +import { openaiChatCompletionsSourceProtocol } from '../../src/protocols/openai-chat-completions/as-source.ts'; +import { openaiChatCompletionsTargetProtocol } from '../../src/protocols/openai-chat-completions/as-target.ts'; +import { openaiResponsesSourceProtocol } from '../../src/protocols/openai-responses/as-source.ts'; +import { openaiResponsesTargetProtocol } from '../../src/protocols/openai-responses/as-target.ts'; +import { createRoundTripCoordinator } from '../../src/round-trip/coordinator.ts'; +import type { AssistantTurnProtocol, InputAssistantTurnFor } from '../../src/round-trip/input-assistant-turn.ts'; +import { roundTripAssistantTurn, type SidecarRecognition } from '../../src/round-trip/resolve.ts'; +import type { SourceAssistantTurnFor } from '../../src/round-trip/source-assistant-turn.ts'; +import type { TranslateOptions } from '../../src/types.ts'; +import type { AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; +import type { ProtocolFrame } from '@floway-dev/protocols/common'; +import type { GeminiGenerateContentRequest } from '@floway-dev/protocols/gemini-generate-content'; +import type { OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; +import type { OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; + +export const pairs = { + messagesToChat: [anthropicMessagesSourceProtocol, openaiChatCompletionsTargetProtocol], + messagesToResponses: [anthropicMessagesSourceProtocol, openaiResponsesTargetProtocol], + chatToMessages: [openaiChatCompletionsSourceProtocol, anthropicMessagesTargetProtocol], + chatToResponses: [openaiChatCompletionsSourceProtocol, openaiResponsesTargetProtocol], + responsesToMessages: [openaiResponsesSourceProtocol, anthropicMessagesTargetProtocol], + responsesToChat: [openaiResponsesSourceProtocol, openaiChatCompletionsTargetProtocol], + geminiToMessages: [geminiGenerateContentSourceProtocol, anthropicMessagesTargetProtocol], + geminiToChat: [geminiGenerateContentSourceProtocol, openaiChatCompletionsTargetProtocol], + geminiToResponses: [geminiGenerateContentSourceProtocol, openaiResponsesTargetProtocol], +} as const; + +export const emptyPayload = { + messages: { model: 'm', max_tokens: 16, stream: true, messages: [] } as AnthropicMessagesPayload, + chat: { model: 'm', stream: true, messages: [] } as OpenAIChatCompletionsPayloadEx, + responses: { model: 'm', stream: true, input: [] } as OpenAIResponsesRequestPayloadEx, + gemini: { model: 'model', contents: [] } as GeminiGenerateContentRequest, +}; + +/** + * Settles every assistant slot that carries a sidecar this target can restore. + * + * A slot whose sidecar was recognised is written natively by the target; one + * that carries no sidecar for this target falls back to the IR its own thunk + * builds. The two paths are exclusive, which is why the thunk is only called on + * the second. + */ +const resolveReadingTurns = async < + SrcName extends AssistantTurnProtocol, + TgtName extends AssistantTurnProtocol, +>( + source: { readonly name: SrcName; readonly sidecarRecognition: SidecarRecognition> }, + target: { readonly name: TgtName }, + reading: SourceReading>, + options: TranslateOptions, +): Promise<(IRInputTurn | RestoredAssistantTurn>)[]> => + await resolveReading(reading, turn => roundTripAssistantTurn( + source.name, target.name, turn.assistantTurn, options.sidecarCodec, source.sidecarRecognition, + )); + +export const buildTargetPayloadFor = async < + SrcName extends AssistantTurnProtocol, SrcPayload, SrcEvent, + TgtName extends AssistantTurnProtocol, TgtPayload, TgtEvent, Context, +>( + source: SourceProtocol, + target: TargetProtocol, + payload: SrcPayload, + options: TranslateOptions, + reading: SourceReading> = source.read(payload), +): Promise => + target.write(reading.request, await resolveReadingTurns(source, target, reading, options), options).payload; + +export const buildSourceEventsFor = < + SrcName extends AssistantTurnProtocol, SrcPayload, SrcEvent, + TgtName extends AssistantTurnProtocol, TgtPayload, TgtEvent, Context, +>( + source: SourceProtocol, + target: TargetProtocol, + payload: SrcPayload, + options: TranslateOptions, + frames: AsyncIterable>, +): AsyncIterable> => { + const reading = source.read(payload); + const coordinator = createRoundTripCoordinator(source.name, target.name, options.sidecarCodec); + // Stating the answer needs what writing the request recorded, so this walks + // the same writing step the composed trip does. + return (async function* () { + const { context } = target.write(reading.request, await resolveReadingTurns(source, target, reading, options), options); + yield* source.writeEvents(payload, target.readEvents(frames, context, coordinator.reader), coordinator.writer); + })(); +}; diff --git a/packages/translate/__tests__/composition/reading_test.ts b/packages/translate/__tests__/composition/reading_test.ts new file mode 100644 index 0000000000..86173ffe52 --- /dev/null +++ b/packages/translate/__tests__/composition/reading_test.ts @@ -0,0 +1,49 @@ +import { expect, test } from 'vitest'; + +import { resolveReading } from '../../src/composition/reading.ts'; +import { readAnthropicMessagesRequest } from '../../src/protocols/anthropic-messages/as-source/input-to.ts'; + +test('joins parallel server results and the next user message into one client turn', async () => { + const reading = readAnthropicMessagesRequest({ + model: 'model', max_tokens: 32, messages: [ + { role: 'user', content: 'Q' }, + { + role: 'assistant', content: [ + { type: 'server_tool_use', id: 's1', name: 'web_search', input: { query: 'one' } }, + { type: 'server_tool_use', id: 's2', name: 'web_search', input: { query: 'two' } }, + { type: 'web_search_tool_result', tool_use_id: 's1', content: [] }, + { type: 'web_search_tool_result', tool_use_id: 's2', content: [] }, + ], + }, + { role: 'user', content: 'Next' }, + ], + }); + expect(await resolveReading(reading, async () => undefined)).toEqual([ + { kind: 'client', items: [{ type: 'message', content: [{ type: 'text', text: 'Q' }] }] }, + { + kind: 'assistant', items: [ + { type: 'function_call', call_id: 's1', name: 'web_search', arguments: '{"query":"one"}' }, + { type: 'function_call', call_id: 's2', name: 'web_search', arguments: '{"query":"two"}' }, + ], + }, + { + kind: 'client', items: [ + { type: 'tool_result', call_id: 's1', output: '[]' }, + { type: 'tool_result', call_id: 's2', output: '[]' }, + { type: 'message', content: [{ type: 'text', text: 'Next' }] }, + ], + }, + ]); +}); + +test('keeps prompt and restored assistant boundaries while joining client pieces', async () => { + const reading = readAnthropicMessagesRequest({ + model: 'model', max_tokens: 32, system: 'Rules', messages: [ + { role: 'user', content: 'Before' }, + { role: 'assistant', content: 'Native' }, + { role: 'user', content: 'After' }, + ], + }); + expect((await resolveReading(reading, async () => 'restored')).map(turn => turn.kind)) + .toEqual(['client', 'client', 'restored', 'client']); +}); diff --git a/packages/translate/__tests__/input-error_test.ts b/packages/translate/__tests__/input-error_test.ts new file mode 100644 index 0000000000..85fa1e03f2 --- /dev/null +++ b/packages/translate/__tests__/input-error_test.ts @@ -0,0 +1,51 @@ +import { expect, test } from 'vitest'; + +import { createTestSidecarCodec } from './round-trip/sidecar-codec.ts'; +import { createOpenAIChatCompletionsSidecarDetails } from '../src/protocols/openai-chat-completions/as-source/round-trip.ts'; +import type { OpenAIChatCompletionsAssistantMessageEx } from '@floway-dev/protocols/openai-chat-completions'; +import * as translate from '@floway-dev/translate'; + +const options = { sidecarCodec: createTestSidecarCodec() }; +const messages = { + model: 'm', stream: true, max_tokens: 32, + messages: [{ role: 'user' as const, content: [{ type: 'document' as const, source: { type: 'content' as const, content: [] } }] }], +}; +const chat = { + model: 'm', stream: true, + messages: [{ role: 'user' as const, content: [{ type: 'input_audio' as const, input_audio: { format: 'wav' as const, data: 'AAAA' } }] }], +}; +const responses = { model: 'm', stream: true, input: [{ type: 'item_reference' as const, id: 'opaque' }] }; +const gemini = { + model: 'm', contents: [{ role: 'user' as const, parts: [{ fileData: { mimeType: 'application/pdf', fileUri: 'https://example.com/a.pdf' } }] }], +}; + +const routes = [ + () => translate.routeAnthropicMessagesViaOpenAIChatCompletions(messages, options), + () => translate.routeAnthropicMessagesViaOpenAIResponses(messages, options), + () => translate.routeOpenAIChatCompletionsViaAnthropicMessages(chat, options), + () => translate.routeOpenAIChatCompletionsViaOpenAIResponses(chat, options), + () => translate.routeOpenAIResponsesViaAnthropicMessages(responses, options), + () => translate.routeOpenAIResponsesViaOpenAIChatCompletions(responses, options), + () => translate.routeGeminiGenerateContentViaAnthropicMessages(gemini, options), + () => translate.routeGeminiGenerateContentViaOpenAIChatCompletions(gemini, options), + () => translate.routeGeminiGenerateContentViaOpenAIResponses(gemini, options), +]; + +test.each(routes.map((run, index) => ({ index, run })))('route $index returns an ordinary input error object', async ({ run }) => { + const result = await run(); + expect(result.type).toBe('input-error'); + expect(result).not.toBeInstanceOf(Error); + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + if (result.type !== 'input-error') throw new Error('Expected an input failure'); + expect(result.message.length).toBeGreaterThan(0); +}); + +test('an unexpected codec failure retains its original error and cause', async () => { + const cause = new Error('codec cause'); + const failure = new Error('codec failure', { cause }); + const codec = { ...createTestSidecarCodec(), decode: async (): Promise => { throw failure; } }; + const turn: OpenAIChatCompletionsAssistantMessageEx = { + role: 'assistant', content: 'history', reasoning_details: createOpenAIChatCompletionsSidecarDetails('sidecar'), + }; + await expect(translate.routeOpenAIChatCompletionsViaAnthropicMessages({ model: 'm', stream: true, messages: [turn] }, { sidecarCodec: codec })).rejects.toBe(failure); +}); diff --git a/packages/translate/__tests__/ir/input/media_test.ts b/packages/translate/__tests__/ir/input/media_test.ts new file mode 100644 index 0000000000..6c7be03935 --- /dev/null +++ b/packages/translate/__tests__/ir/input/media_test.ts @@ -0,0 +1,35 @@ +import { test } from 'vitest'; + +import { mediaFromURL } from '../../../src/ir/input/media.ts'; +import { assertEquals } from '@floway-dev/test-utils'; + +test('a base64 data URL states its media type and body apart', () => { + assertEquals(mediaFromURL('data:application/pdf;base64,JVBERi0='), { + source_kind: 'base64', media_type: 'application/pdf', data: 'JVBERi0=', + }); +}); + +test('a media type carrying a parameter keeps only its essence', () => { + // RFC 2397 lets the type carry parameters; the payload is what follows the + // `;base64` marker, not the whole URL text. + assertEquals(mediaFromURL('data:text/csv;charset=utf-8;base64,YQ=='), { + source_kind: 'base64', media_type: 'text/csv', data: 'YQ==', + }); + assertEquals(mediaFromURL('data:image/png;charset=utf-8;base64,AAAA'), { + source_kind: 'base64', media_type: 'image/png', data: 'AAAA', + }); +}); + +test('a base64 data URL that states no media type still decodes', () => { + assertEquals(mediaFromURL('data:;base64,AAAA'), { source_kind: 'base64', data: 'AAAA' }); +}); + +test('a data URL this cannot decode is neither a URL nor raw base64', () => { + // Reporting it as a URL would let a caller carry the URL text as the payload. + assertEquals(mediaFromURL('data:text/plain,hi'), undefined); +}); + +test('a value that is not a data URL stays a URL', () => { + assertEquals(mediaFromURL('https://example.com/a.png'), { source_kind: 'url', url: 'https://example.com/a.png' }); + assertEquals(mediaFromURL('AAAA'), { source_kind: 'url', url: 'AAAA' }); +}); diff --git a/packages/translate/__tests__/ir/input/types_test.ts b/packages/translate/__tests__/ir/input/types_test.ts new file mode 100644 index 0000000000..880f29b3c6 --- /dev/null +++ b/packages/translate/__tests__/ir/input/types_test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from 'vitest'; + +import type { IRInputAssistantItem, IRInputAssistantTurn, IRInputToolResultContentPart, IRInputToolResultItem, IRInputUserContentPart, IRInputClientItem, IRInputClientTurn } from '../../../src/ir/input/types.ts'; + +describe('input IR shape', () => { + const userParts: IRInputUserContentPart[] = [ + { type: 'text', text: 'hi' }, + { type: 'image', image: { source_kind: 'url', url: 'https://example.com/a.png' }, detail: 'high' }, + { type: 'image', image: { source_kind: 'base64', media_type: 'image/png', data: 'AAAA' } }, + { type: 'file', file: { source_kind: 'file', file_id: 'file_1' }, filename: 'a.pdf' }, + { type: 'file', file: { source_kind: 'base64', data: 'AAAA' } }, + { type: 'audio', audio: { media_type: 'audio/wav', data: 'AAAA' } }, + ]; + + test('a conversation carries user and assistant turns in order', () => { + const clientItems: IRInputClientItem[] = [ + { type: 'prompt', role: 'system', content: [{ type: 'text', text: 'be brief' }] }, + { type: 'prompt', role: 'developer', content: [{ type: 'text', text: 'tools are strict' }] }, + { type: 'message', content: userParts }, + { type: 'tool_result', call_id: 'call_1', output: 'ok' }, + { type: 'tool_result', call_id: 'call_2', output: [{ type: 'text', text: 'see' }, { type: 'image', image: { source_kind: 'url', url: 'https://example.com/b.png' } }], is_error: true }, + ]; + const assistantItems: IRInputAssistantItem[] = [ + { type: 'reasoning', summary: ['thought'], content: ['detail'] }, + { type: 'message', content: [{ type: 'text', text: 'answer' }, { type: 'refusal', refusal: 'no' }] }, + { type: 'function_call', call_id: 'call_1', name: 'lookup', arguments: '{"q":1}' }, + { type: 'custom_tool_call', call_id: 'call_2', name: 'edit', input: 'raw' }, + ]; + const user: IRInputClientTurn = { kind: 'client', items: clientItems }; + const assistant: IRInputAssistantTurn = { kind: 'assistant', items: assistantItems }; + const turns = [user, assistant]; + + expect(turns.map(turn => turn.kind)).toEqual(['client', 'assistant']); + expect(user.items.map(item => item.type)).toEqual(['prompt', 'prompt', 'message', 'tool_result', 'tool_result']); + expect(assistant.items.map(item => item.type)).toEqual(['reasoning', 'message', 'function_call', 'custom_tool_call']); + }); + + test('a malformed tool call keeps its arguments as text rather than a parsed wrapper', () => { + const call: IRInputAssistantItem = { type: 'function_call', call_id: 'call_1', name: 'f', arguments: 'not json' }; + expect(call.type === 'function_call' && call.arguments).toBe('not json'); + }); + + test('a structured tool result keeps its parts instead of stringifying them', () => { + const parts: IRInputToolResultContentPart[] = [ + { type: 'text', text: 'caption' }, + { type: 'file', file: { source_kind: 'base64', media_type: 'application/pdf', data: 'AAAA' }, filename: 'r.pdf' }, + ]; + const result: IRInputToolResultItem = { type: 'tool_result', call_id: 'call_1', output: parts }; + expect(Array.isArray(result.output) && result.output.map(part => part.type)).toEqual(['text', 'file']); + }); + + test('a file may omit media type, while audio must state one', () => { + const typedFile: IRInputUserContentPart = { type: 'file', file: { source_kind: 'base64', media_type: 'application/pdf', data: 'AAAA' } }; + const untypedFile: IRInputUserContentPart = { type: 'file', file: { source_kind: 'base64', data: 'AAAA' } }; + const audio: IRInputUserContentPart = { type: 'audio', audio: { media_type: 'audio/mp3', data: 'AAAA' } }; + // @ts-expect-error Chat Completions names a format enum and GenerateContent a + // MIME type; neither admits an untyped audio payload. + const untypedAudio: IRInputUserContentPart = { type: 'audio', audio: { data: 'AAAA' } }; + expect([typedFile.type, untypedFile.type, audio.type, untypedAudio.type]).toEqual(['file', 'file', 'audio', 'audio']); + }); +}); diff --git a/packages/translate/__tests__/iterables.ts b/packages/translate/__tests__/iterables.ts new file mode 100644 index 0000000000..b56dd306ad --- /dev/null +++ b/packages/translate/__tests__/iterables.ts @@ -0,0 +1,5 @@ +import type { ProtocolFrame } from '@floway-dev/protocols/common'; + +export const iterate = async function* (values: readonly T[]): AsyncGenerator { yield* values; }; +export const collect = async (values: AsyncIterable): Promise => { const result: T[] = []; for await (const value of values) result.push(value); return result; }; +export const events = async function* (frames: AsyncIterable>): AsyncGenerator { for await (const frame of frames) if (frame.type === 'event') yield frame.event; }; diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-source/events_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/events_test.ts index 9f4768a45a..28d00eccc3 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-source/events_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-source/events_test.ts @@ -4,8 +4,8 @@ import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; import { writeAnthropicMessagesEvents } from '../../../../src/protocols/anthropic-messages/as-source/events.ts'; import { hashRoundTripContent } from '../../../../src/round-trip/hash.ts'; import { appendReadField, collectEmittedFieldPlacements, createReferenceBuilder, createReadFieldState, createFieldReferencer } from '../../../../src/round-trip/reference/reference-builder.ts'; -import type { RoundTripWriter } from '../../../../src/round-trip/writer.ts'; -import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; +import { type RoundTripWriter } from '../../../../src/round-trip/writer.ts'; +import { type AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; test.each(['text', 'refusal', 'audio'] as const)('resolves blocked %s against the emitted Messages text field', async kind => { const references = createReferenceBuilder(); @@ -31,8 +31,8 @@ test.each(['text', 'refusal', 'audio'] as const)('resolves blocked %s against th let prepared = false; const roundTrip: RoundTripWriter<'anthropicMessages'> = { builder: references, - encodeSidecar: async (_choice, _check, turn, state, textViews) => { - const views = collectEmittedFieldPlacements(turn, state, textViews); + encodeSidecar: async (_choice, _check, turn, state, textPlacements) => { + const views = collectEmittedFieldPlacements(turn, state, textPlacements); expect([...views.values()]).toContainEqual({ enclosingText: 'Hi', offset: 0 }); const finalizer = createFieldReferencer(references, views); expect(await finalizer.referenceField('Hi', field)).toMatchObject({ parts: [0] }); @@ -105,8 +105,8 @@ test.each(['{"x":1,}', '{"x": 1}'])('tracks the emitted canonical JSON view with let prepared = false; const roundTrip: RoundTripWriter<'anthropicMessages'> = { builder: references, - encodeSidecar: async (_choice, _check, turn, state, textViews) => { - const views = collectEmittedFieldPlacements(turn, state, textViews); + encodeSidecar: async (_choice, _check, turn, state, textPlacements) => { + const views = collectEmittedFieldPlacements(turn, state, textPlacements); expect([...views.values()]).toContainEqual({ enclosingText: '{"x":1}', offset: 0 }); const finalizer = createFieldReferencer(references, views); expect(await finalizer.referenceField(original, field)).toBe(original); 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 9eda683e9a..94f3b1b9e1 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,7 +2,7 @@ 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 { expectTrip } from '../../../trip.ts'; import { createAnthropicMessagesEventAccumulator, reassembleAnthropicMessagesEvents } from '@floway-dev/protocols/anthropic-messages'; import { openaiResponsesResultToEvents } from '@floway-dev/protocols/openai-responses'; import { routeAnthropicMessagesViaOpenAIResponses } from '@floway-dev/translate'; @@ -22,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 requireTrip(routeAnthropicMessagesViaOpenAIResponses({ - model: 'source', max_tokens: 4096, messages: [{ role: 'user', content: 'question' }], - }, { model: 'target', sidecarCodec: codec })); + const trip = expectTrip(await routeAnthropicMessagesViaOpenAIResponses({ + model: 'source', max_tokens: 4096, stream: true, messages: [{ role: 'user', content: 'question' }], + }, { sidecarCodec: codec })); const frames = async function* () { yield* openaiResponsesResultToEvents({ id: 'response', object: 'response', model: 'target', diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-target/reasoning-effort_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-target/reasoning-effort_test.ts deleted file mode 100644 index 57ea9da719..0000000000 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-target/reasoning-effort_test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { test } from 'vitest'; - -import { anthropicMessagesReasoningFieldsFromEffort } from '../../../../src/protocols/anthropic-messages/as-target/reasoning-effort.ts'; -import { assertEquals } from '@floway-dev/test-utils'; - -test('effort none becomes the Anthropic Messages native disable shape rather than an output_config level', () => { - assertEquals(anthropicMessagesReasoningFieldsFromEffort('none'), { thinking: { type: 'disabled' } }); -}); - -test('every other effort passes through to output_config verbatim', () => { - for (const effort of ['', 'minimal', 'low', 'medium', 'high', 'max', 'vendor-specific-level']) { - assertEquals(anthropicMessagesReasoningFieldsFromEffort(effort), { effort }); - } -}); - -test('an absent effort selects neither slot', () => { - assertEquals(anthropicMessagesReasoningFieldsFromEffort(undefined), {}); - assertEquals(anthropicMessagesReasoningFieldsFromEffort(null), {}); -}); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events_test.ts deleted file mode 100644 index a03fca1f03..0000000000 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events_test.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { translateToSourceEvents } from '../../../../src/protocols/anthropic-messages/via-openai-chat-completions/events.ts'; -import { testOutputTranslation } from '../../fixtures.ts'; - -testOutputTranslation('anthropic-messages', 'openai-chat-completions', (frames, codec) => translateToSourceEvents(frames, codec)); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events_test.ts deleted file mode 100644 index 3234ee801f..0000000000 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events_test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { expect, test } from 'vitest'; - -import { translateToSourceEvents } from '../../../../src/protocols/anthropic-messages/via-openai-responses/events.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; -import { testOutputTranslation } from '../../fixtures.ts'; -import { parseAnthropicMessagesStream, collectAnthropicMessagesProtocolEventsToResult, anthropicMessagesProtocolFrameToSSEFrame } from '@floway-dev/protocols/anthropic-messages'; -import { parseOpenAIResponsesStream } from '@floway-dev/protocols/openai-responses'; - -test('Responses late fixed usage reaches the Messages client through full usage delta', async () => { - const response = { id: 'resp_usage', model: 'gpt-test', object: 'response', output: [], error: null, incomplete_details: null }; - const events = [ - { type: 'response.created', response: { ...response, status: 'in_progress' } }, - { type: 'response.completed', response: { ...response, status: 'completed', service_tier: 'priority', usage: { input_tokens: 3, output_tokens: 4, total_tokens: 7 } } }, - ]; - const sourceSSE = events.map((event, sequence_number) => `event: ${event.type}\ndata: ${JSON.stringify({ ...event, sequence_number })}\n\n`).join(''); - let clientSSE = ''; - for await (const frame of translateToSourceEvents(parseOpenAIResponsesStream(new Response(sourceSSE).body!), createTestSidecarCodec())) { - const sse = anthropicMessagesProtocolFrameToSSEFrame(frame); - if (sse) clientSSE += `event: ${sse.event}\ndata: ${sse.data}\n\n`; - } - const result = await collectAnthropicMessagesProtocolEventsToResult(parseAnthropicMessagesStream(new Response(clientSSE).body!)); - expect(result.usage.service_tier).toBe('priority'); - expect(result.usage.input_tokens).toBe(3); - expect(result.usage.output_tokens).toBe(4); -}); - -testOutputTranslation('anthropic-messages', 'openai-responses', (frames, codec) => translateToSourceEvents(frames, codec)); diff --git a/packages/translate/__tests__/protocols/call-association_test.ts b/packages/translate/__tests__/protocols/call-association_test.ts new file mode 100644 index 0000000000..9677f7e628 --- /dev/null +++ b/packages/translate/__tests__/protocols/call-association_test.ts @@ -0,0 +1,222 @@ +import { expect, test, vi } from 'vitest'; + +import { openaiResponsesSourceProtocol } from '../../src/protocols/openai-responses/as-source.ts'; +import { toOpenAIResponsesInputAssistantTurn } from '../../src/protocols/openai-responses/as-target/assistant-turn.ts'; +import { iterate } from '../iterables.ts'; +import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { expectTrip } from '../trip.ts'; +import { createAnthropicMessagesUsage, toAnthropicMessagesUsageDelta, type AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; +import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; +import { collectGeminiGenerateContentProtocolEventsToResult } from '@floway-dev/protocols/gemini-generate-content'; +import type { GeminiGenerateContentRequest } from '@floway-dev/protocols/gemini-generate-content'; +import { collectOpenAIChatCompletionsProtocolEventsToResult, type OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; +import { collectOpenAIResponsesProtocolEventsToResult } from '@floway-dev/protocols/openai-responses'; +import type { OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx, OpenAIResponsesTool } from '@floway-dev/protocols/openai-responses'; +import { routeGeminiGenerateContentViaAnthropicMessages, routeGeminiGenerateContentViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIResponses, routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses, routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; + +const chatFrames = (name: string, arguments_: string, legacy = false): ProtocolFrame[] => [ + eventFrame({ + id: 'chat', object: 'chat.completion.chunk', created: 1, model: 'm', + choices: [{ + index: 0, delta: { + role: 'assistant', reasoning_content: 'native reasoning', + ...(legacy ? { function_call: { name, arguments: arguments_ } } : { tool_calls: [{ index: 0, type: 'function', id: 'native', function: { name, arguments: arguments_ } }] }), + }, finish_reason: legacy ? 'function_call' : 'tool_calls', + }], + }), + doneFrame(), +]; + +const messagesFrames = (name: string, arguments_: string): ProtocolFrame[] => [ + eventFrame({ type: 'message_start', message: { id: 'message', type: 'message', role: 'assistant', model: 'm', content: [], stop_reason: null, stop_sequence: null, stop_details: null, container: null, usage: createAnthropicMessagesUsage(1, 0) } }), + eventFrame({ type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: 'native reasoning', signature: 'native signature' } }), + eventFrame({ type: 'content_block_stop', index: 0 }), + eventFrame({ type: 'content_block_start', index: 1, content_block: { type: 'redacted_thinking', data: 'native encrypted blob' } }), + eventFrame({ type: 'content_block_stop', index: 1 }), + eventFrame({ type: 'content_block_start', index: 2, content_block: { type: 'tool_use', id: 'native', name, input: {} } }), + eventFrame({ type: 'content_block_delta', index: 2, delta: { type: 'input_json_delta', partial_json: arguments_ } }), + eventFrame({ type: 'content_block_stop', index: 2 }), + eventFrame({ type: 'message_delta', delta: { stop_reason: 'tool_use', stop_sequence: null, container: null, stop_details: null }, usage: toAnthropicMessagesUsageDelta(createAnthropicMessagesUsage(1, 1)) }), + eventFrame({ type: 'message_stop' }), +]; + +const responsesFrames = (): ProtocolFrame[] => { + const item = { type: 'function_call' as const, id: 'item', call_id: 'native', name: 'same', arguments: '{"n":1}', status: 'completed' as const }; + return [ + eventFrame({ type: 'response.created', sequence_number: 0, response: { id: 'r', object: 'response', model: 'm', status: 'in_progress', incomplete_details: null, error: null, output: [] } }), + eventFrame({ type: 'response.output_item.added', sequence_number: 1, output_index: 0, item: { ...item, arguments: '', status: 'in_progress' } }), + eventFrame({ type: 'response.function_call_arguments.delta', sequence_number: 2, item_id: 'item', output_index: 0, delta: item.arguments }), + eventFrame({ type: 'response.function_call_arguments.done', sequence_number: 3, item_id: 'item', output_index: 0, arguments: item.arguments }), + eventFrame({ type: 'response.output_item.done', sequence_number: 4, output_index: 0, item }), + eventFrame({ type: 'response.completed', sequence_number: 5, response: { id: 'r', object: 'response', model: 'm', status: 'completed', incomplete_details: null, error: null, output: [item] } }), + ]; +}; + +const group = (kind: 'function' | 'custom'): OpenAIResponsesTool => ({ + type: 'namespace', name: 'web', description: 'Web tools', + tools: [kind === 'function' ? { type: 'function', name: 'run', parameters: { type: 'object', properties: {} } } : { type: 'custom', name: 'run', format: { type: 'text' } }], +}); + +test.each([['chat', 'function'], ['chat', 'custom'], ['messages', 'function'], ['messages', 'custom']] as const)('native %s %s calls share logical aliases with declarations, selectors and ordinary history', async (target, kind) => { + const options = { sidecarCodec: createTestSidecarCodec() }; + const source: OpenAIResponsesRequestPayloadEx = { model: 'm', stream: true, input: 'start', tools: [group(kind)] }; + const args = kind === 'function' ? '{}' : '{"input":"raw"}'; + const result = target === 'chat' + ? await collectOpenAIResponsesProtocolEventsToResult(expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions(source, options)).events(iterate(chatFrames('web_run', args)))) + : await collectOpenAIResponsesProtocolEventsToResult(expectTrip(await routeOpenAIResponsesViaAnthropicMessages(source, options)).events(iterate(messagesFrames('web_run', args)))); + const call = result.output.find(item => item.type === 'function_call' || item.type === 'custom_tool_call'); + if (call === undefined) throw new Error('Expected the public callable and its replay sidecar.'); + expect(call).toMatchObject({ type: kind === 'function' ? 'function_call' : 'custom_tool_call', name: 'run', namespace: 'web' }); + const request: OpenAIResponsesRequestPayloadEx = { + model: 'm', stream: true, + tools: [group(kind), { type: 'function', name: 'web_run', parameters: { type: 'object', properties: {} } }], + tool_choice: { type: 'allowed_tools', mode: 'required', tools: [{ type: kind, name: 'run', namespace: 'web' }] }, + input: [ + ...toOpenAIResponsesInputAssistantTurn(result.output), + kind === 'function' ? { type: 'function_call_output', call_id: call.call_id, output: 'ok' } : { type: 'custom_tool_call_output', call_id: call.call_id, output: 'ok' }, + kind === 'function' ? { type: 'function_call', call_id: 'ordinary', name: 'run', namespace: 'web', arguments: '{}' } : { type: 'custom_tool_call', call_id: 'ordinary', name: 'run', namespace: 'web', input: 'again' }, + ], + }; + const original = structuredClone(request); + const read = openaiResponsesSourceProtocol.read; + const toIR = vi.fn(() => { throw new Error('Native replay must not build IR.'); }); + const spy = vi.spyOn(openaiResponsesSourceProtocol, 'read').mockImplementation(payload => { + const reading = read(payload); + return { ...reading, turns: reading.turns.map(turn => turn.kind === 'assistant' && turn.assistantTurn.some(item => item.type === 'reasoning' && typeof item.encrypted_content === 'string') ? { ...turn, toIRInputTurns: toIR } : turn) }; + }); + try { + if (target === 'chat') { + const trip = expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions(request, options)); + expect(trip.target.tools?.map(tool => tool.type === 'function' ? tool.function.name : tool.custom.name)).toEqual(['web_run_2', 'web_run']); + expect(trip.target.tool_choice).toEqual({ type: 'allowed_tools', allowed_tools: { mode: 'required', tools: [{ type: 'function', function: { name: 'web_run_2' } }] } }); + const calls = trip.target.messages.flatMap(message => message.role === 'assistant' ? message.tool_calls ?? [] : []); + expect(calls.map(item => item.type === 'function' ? item.function.name : item.custom.name)).toEqual(['web_run_2', 'web_run_2']); + expect(trip.target.messages[0]).toMatchObject({ reasoning_content: 'native reasoning' }); + const returned = await collectOpenAIResponsesProtocolEventsToResult(trip.events(iterate(chatFrames('web_run_2', args)))); + expect(returned.output.find(item => item.type === 'function_call' || item.type === 'custom_tool_call')).toMatchObject({ type: kind === 'function' ? 'function_call' : 'custom_tool_call', name: 'run', namespace: 'web' }); + } else { + const trip = expectTrip(await routeOpenAIResponsesViaAnthropicMessages(request, options)); + expect(trip.target.tools?.map(tool => 'name' in tool ? tool.name : undefined)).toEqual(['web_run_2', 'web_run']); + expect(trip.target.tool_choice).toEqual({ type: 'tool', name: 'web_run_2' }); + const calls = trip.target.messages.flatMap(message => message.role === 'assistant' && Array.isArray(message.content) ? message.content.flatMap(block => block.type === 'tool_use' ? [block] : []) : []); + expect(calls.map(item => item.name)).toEqual(['web_run_2', 'web_run_2']); + expect(trip.target.messages[0].content).toContainEqual({ type: 'thinking', thinking: 'native reasoning', signature: 'native signature' }); + expect(trip.target.messages[0].content).toContainEqual({ type: 'redacted_thinking', data: 'native encrypted blob' }); + const returned = await collectOpenAIResponsesProtocolEventsToResult(trip.events(iterate(messagesFrames('web_run_2', args)))); + expect(returned.output.find(item => item.type === 'function_call' || item.type === 'custom_tool_call')).toMatchObject({ type: kind === 'function' ? 'function_call' : 'custom_tool_call', name: 'run', namespace: 'web' }); + } + expect(toIR).not.toHaveBeenCalled(); + expect(request).toEqual(original); + } finally { + spy.mockRestore(); + } +}); + +test('native legacy Chat aliases are rebound without adding a wire call ID', async () => { + const options = { sidecarCodec: createTestSidecarCodec() }; + const first = expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'm', stream: true, input: 'start', tools: [group('function')] }, options)); + const result = await collectOpenAIResponsesProtocolEventsToResult(first.events(iterate(chatFrames('web_run', '{}', true)))); + const call = result.output.find(item => item.type === 'function_call'); + if (call === undefined) throw new Error('Expected the public legacy call.'); + const replay = expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions({ + model: 'm', stream: true, tools: [group('function'), { type: 'function', name: 'web_run', parameters: { type: 'object', properties: {} } }], + input: [...toOpenAIResponsesInputAssistantTurn(result.output), { type: 'function_call_output', call_id: call.call_id, output: 'ok' }], + }, options)); + expect(replay.target.messages[0]).toMatchObject({ role: 'assistant', function_call: { name: 'web_run_2', arguments: '{}' }, reasoning_content: 'native reasoning' }); + expect(replay.target.messages[0]).not.toHaveProperty('tool_calls'); + expect(replay.target.messages[1]).toEqual({ role: 'function', name: 'web_run_2', content: 'ok' }); +}); + +test.each(['chat', 'messages', 'responses'] as const)('Gemini id-less results answer the complete native and ordinary %s history in FIFO order', async target => { + const options = { sidecarCodec: createTestSidecarCodec() }; + const source: GeminiGenerateContentRequest = { model: 'm', contents: [{ role: 'user', parts: [{ text: 'start' }] }] }; + const first = target === 'chat' + ? await collectGeminiGenerateContentProtocolEventsToResult(expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions(source, options)).events(iterate(chatFrames('same', '{"n":1}')))) + : target === 'messages' + ? await collectGeminiGenerateContentProtocolEventsToResult(expectTrip(await routeGeminiGenerateContentViaAnthropicMessages(source, options)).events(iterate(messagesFrames('same', '{"n":1}')))) + : await collectGeminiGenerateContentProtocolEventsToResult(expectTrip(await routeGeminiGenerateContentViaOpenAIResponses(source, options)).events(iterate(responsesFrames()))); + const native = first.candidates?.[0]?.content; + if (native === undefined) throw new Error('Expected a public Gemini assistant turn.'); + const request: GeminiGenerateContentRequest = { + model: 'm', contents: [ + ...source.contents ?? [], native, + { role: 'user', parts: [{ text: 'also call it again' }] }, + { role: 'model', parts: [{ functionCall: { name: 'same', args: { n: 2 } } }] }, + { role: 'user', parts: [{ functionResponse: { name: 'same', response: { answer: 'FIRST' } } }, { functionResponse: { name: 'same', response: { answer: 'SECOND' } } }] }, + ], + }; + if (target === 'chat') { + const trip = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions(request, options)); + const answers = trip.target.messages.filter(message => message.role === 'tool'); + expect(answers.map(message => message.tool_call_id)).toEqual(['native', 'same_call_1']); + expect(answers.map(message => message.content)).toEqual(['{"answer":"FIRST"}', '{"answer":"SECOND"}']); + expect(trip.target.messages[1]).toMatchObject({ reasoning_content: 'native reasoning' }); + } else if (target === 'messages') { + const trip = expectTrip(await routeGeminiGenerateContentViaAnthropicMessages(request, options)); + const answers = trip.target.messages.flatMap(message => message.role === 'user' && Array.isArray(message.content) ? message.content.flatMap(block => block.type === 'tool_result' ? [block] : []) : []); + expect(answers.map(block => block.tool_use_id)).toEqual(['native', 'same_call_1']); + expect(answers.map(block => block.content)).toEqual(['{"answer":"FIRST"}', '{"answer":"SECOND"}']); + } else { + const trip = expectTrip(await routeGeminiGenerateContentViaOpenAIResponses(request, options)); + const answers = trip.target.input.filter(item => item.type === 'function_call_output'); + expect(answers.map(item => item.call_id)).toEqual(['native', 'same_call_1']); + expect(answers.map(item => item.output)).toEqual(['{"answer":"FIRST"}', '{"answer":"SECOND"}']); + } +}); + +test('Gemini target allocation reserves explicit IDs against every missing-ID call', async () => { + const options = { sidecarCodec: createTestSidecarCodec() }; + const source: GeminiGenerateContentRequest = { + model: 'm', contents: [ + { role: 'model', parts: [{ functionCall: { name: 'first', args: {} } }, { functionCall: { id: 'first_call_1', name: 'second', args: {} } }, { functionCall: { id: 'gemini_call_0_0', name: 'third', args: {} } }] }, + { role: 'user', parts: [{ functionResponse: { id: 'first_call_1', name: 'second', response: { answer: 'SECOND' } } }, { functionResponse: { name: 'first', response: { answer: 'FIRST' } } }, { functionResponse: { id: 'gemini_call_0_0', name: 'third', response: { answer: 'THIRD' } } }] }, + ], + }; + const trip = expectTrip(await routeGeminiGenerateContentViaOpenAIResponses(source, options)); + const calls = trip.target.input.filter(item => item.type === 'function_call'); + const answers = trip.target.input.filter(item => item.type === 'function_call_output'); + expect(new Set(calls.map(call => call.call_id)).size).toBe(3); + expect(answers.map(item => item.call_id)).toEqual(['first_call_1', calls[0].call_id, 'gemini_call_0_0']); +}); + +test.each(['messages', 'responses'] as const)('Chat legacy IDs stay distinct from modern explicit IDs through %s translation', async target => { + const options = { sidecarCodec: createTestSidecarCodec() }; + const source = { + model: 'm', stream: true, messages: [ + { role: 'assistant' as const, content: null, function_call: { name: 'first', arguments: '{}' } }, + { role: 'user' as const, content: 'next' }, + { role: 'assistant' as const, content: null, tool_calls: [{ id: 'legacy__first__0', type: 'function' as const, function: { name: 'second', arguments: '{}' } }] }, + { role: 'tool' as const, tool_call_id: 'legacy__first__0', content: 'SECOND' }, + { role: 'function' as const, name: 'first', content: 'FIRST' }, + ], + }; + if (target === 'responses') { + const trip = expectTrip(await routeOpenAIChatCompletionsViaOpenAIResponses(source, options)); + const calls = trip.target.input.filter(item => item.type === 'function_call'); + expect(new Set(calls.map(call => call.call_id)).size).toBe(2); + expect(trip.target.input.filter(item => item.type === 'function_call_output').map(item => item.call_id)).toEqual(['legacy__first__0', calls[0].call_id]); + } else { + const trip = expectTrip(await routeOpenAIChatCompletionsViaAnthropicMessages(source, options)); + const calls = trip.target.messages.flatMap(message => message.role === 'assistant' && Array.isArray(message.content) ? message.content.flatMap(block => block.type === 'tool_use' ? [block] : []) : []); + expect(new Set(calls.map(call => call.id)).size).toBe(2); + expect(trip.target.messages.flatMap(message => message.role === 'user' && Array.isArray(message.content) ? message.content.flatMap(block => block.type === 'tool_result' ? [block.tool_use_id] : []) : [])).toEqual(['legacy__first__0', calls[0].id]); + } +}); + +test('Chat source custom identity renames native Messages calls independently from same-name functions', async () => { + const options = { sidecarCodec: createTestSidecarCodec() }; + const first = expectTrip(await routeOpenAIChatCompletionsViaAnthropicMessages({ + model: 'm', stream: true, messages: [{ role: 'user', content: 'start' }], + tools: [{ type: 'custom', custom: { name: 'edit' } }], + }, options)); + const result = await collectOpenAIChatCompletionsProtocolEventsToResult(first.events(iterate(messagesFrames('edit', '{"input":"raw"}')))); + const replay = expectTrip(await routeOpenAIChatCompletionsViaAnthropicMessages({ + model: 'm', stream: true, + tools: [{ type: 'function', function: { name: 'edit', parameters: { type: 'object', properties: {} } } }, { type: 'custom', custom: { name: 'edit' } }], + messages: [result.choices[0].message, { role: 'tool', tool_call_id: 'native', content: 'ok' }, { role: 'assistant', content: null, tool_calls: [{ type: 'custom', id: 'later', custom: { name: 'edit', input: 'later' } }] }], + }, options)); + const calls = replay.target.messages.flatMap(message => message.role === 'assistant' && Array.isArray(message.content) ? message.content.flatMap(block => block.type === 'tool_use' ? [block] : []) : []); + expect(calls.map(call => call.name)).toEqual(['edit_2', 'edit_2']); + expect(calls.map(call => call.input)).toEqual([{ input: 'raw' }, { input: 'later' }]); + expect(replay.target.messages[0].content).toContainEqual({ type: 'thinking', thinking: 'native reasoning', signature: 'native signature' }); +}); diff --git a/packages/translate/__tests__/protocols/callable-names_test.ts b/packages/translate/__tests__/protocols/callable-names_test.ts new file mode 100644 index 0000000000..1645e5bff2 --- /dev/null +++ b/packages/translate/__tests__/protocols/callable-names_test.ts @@ -0,0 +1,84 @@ +import { expect, test } from 'vitest'; + +import { fixtureFrames } from './fixtures.ts'; +import type { IRInputRequestHeader } from '../../src/ir/input/types.ts'; +import { type IRRecord } from '../../src/ir/output/records.ts'; +import { consumeIRRecords } from '../../src/ir/output/stream.ts'; +import { anthropicMessagesTargetProtocol } from '../../src/protocols/anthropic-messages/as-target.ts'; +import { openaiChatCompletionsTargetProtocol } from '../../src/protocols/openai-chat-completions/as-target.ts'; +import { iterate } from '../iterables.ts'; +import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { createRoundTripReaderCapture } from '../round-trip/test-helpers.ts'; +import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; +import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; +import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; + +const request = (namespace: string | undefined): IRInputRequestHeader => ({ + settings: { model: 'm', stream: true }, + tools: [{ type: 'function', namespace, name: 'edit', parameters: { type: 'object', properties: {} } }, { type: 'custom', namespace, name: 'edit' }], +}); + +const expectOriginalCalls = async (records: AsyncIterable, namespace: string | undefined): Promise => { + let completed = false; + for await (const { record, collector } of consumeIRRecords(records)) { + if (record.type !== 'finish') continue; + completed = true; + const items = collector.state.choices[0].items; + expect(items).toMatchObject([ + { type: 'function_call', name: 'edit', arguments: '{}' }, + { type: 'custom_tool_call', name: 'edit', input: 'raw' }, + ]); + for (const item of items) { + if (namespace === undefined) expect(item).not.toHaveProperty('namespace'); + else expect(item).toHaveProperty('namespace', namespace); + } + } + expect(completed).toBe(true); +}; + +test.each([undefined, 'files'])('Chat restores callable names and kinds in namespace %s while retaining native aliases', async namespace => { + const written = openaiChatCompletionsTargetProtocol.write(request(namespace), [], { sidecarCodec: createTestSidecarCodec() }); + const names = written.payload.tools?.flatMap(tool => tool.type === 'function' ? [tool.function.name] : []); + expect(names).toHaveLength(2); + const [functionName, customName] = names!; + expect(functionName).not.toBe(customName); + const frames: ProtocolFrame[] = [eventFrame({ + id: 'chat', object: 'chat.completion.chunk', created: 1, model: 'm', choices: [{ + index: 0, delta: { + role: 'assistant', tool_calls: [ + { index: 0, id: 'f', type: 'function', function: { name: functionName, arguments: '{}' } }, + { index: 1, id: 'c', type: 'function', function: { name: customName, arguments: '{"input":"raw"}' } }, + ], + }, finish_reason: 'tool_calls', + }], + }), doneFrame()]; + const capture = createRoundTripReaderCapture<'openaiChatCompletions'>(); + await expectOriginalCalls(openaiChatCompletionsTargetProtocol.readEvents(iterate(frames), written.context, capture.roundTrip), namespace); + expect(capture.turns.get(0)?.turn.tool_calls).toMatchObject([ + { function: { name: functionName } }, { function: { name: customName } }, + ]); +}); + +test.each([undefined, 'files'])('Messages restores callable names and kinds in namespace %s while retaining native aliases', async namespace => { + const written = anthropicMessagesTargetProtocol.write(request(namespace), [], { sidecarCodec: createTestSidecarCodec() }); + const names = written.payload.tools?.flatMap(tool => 'name' in tool ? [tool.name] : []); + expect(names).toHaveLength(2); + const [functionName, customName] = names!; + expect(functionName).not.toBe(customName); + const ordinary = fixtureFrames('anthropic-messages', {}); + const frames: ProtocolFrame[] = [ + ...ordinary.filter(frame => frame.type === 'event' && frame.event.type === 'message_start'), + eventFrame({ type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'f', name: functionName, input: {} } }), + eventFrame({ type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '{}' } }), + eventFrame({ type: 'content_block_stop', index: 0 }), + eventFrame({ type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'c', name: customName, input: {} } }), + eventFrame({ type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"input":"raw"}' } }), + eventFrame({ type: 'content_block_stop', index: 1 }), + ...ordinary.filter(frame => frame.type === 'event' && (frame.event.type === 'message_delta' || frame.event.type === 'message_stop')), + ]; + const capture = createRoundTripReaderCapture<'anthropicMessages'>(); + await expectOriginalCalls(anthropicMessagesTargetProtocol.readEvents(iterate(frames), written.context, capture.roundTrip), namespace); + expect(capture.turns.get(0)?.turn.content).toMatchObject([ + { type: 'tool_use', name: functionName }, { type: 'tool_use', name: customName }, + ]); +}); 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 9a1f76834b..e95d2283c2 100644 --- a/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts +++ b/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts @@ -1,12 +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 { iterate } from '../iterables.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; -import { iterate } from '../shared/iterables.ts'; +import { expectTrip } from '../trip.ts'; import { collectAnthropicMessagesProtocolEventsToResult, type AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; -import { collectGeminiGenerateContentProtocolEventsToResult, type GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; +import { collectGeminiGenerateContentProtocolEventsToResult, type GeminiGenerateContentRequest } 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 { routeAnthropicMessagesViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIChatCompletions, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; @@ -27,12 +27,25 @@ const nativeFrames = (mode: 'legacy' | 'modern' | 'mixed', name: string, argumen ]; }; -const assertNativeHistory = (target: OpenAIChatCompletionsPayload, name: string, contents: string[]): void => { - expect(target.messages.filter(message => message.role === 'function' || message.role === 'tool')).toEqual([ - { role: 'function', name, content: contents[0] }, - { role: 'tool', tool_call_id: 'native-modern', content: contents[1] }, - { role: 'function', name, content: contents[2] }, - ]); +/** + * What this wire states for three alternating legacy/modern calls. + * + * A result answers the shape its assistant turn stated: a call the turn named by + * id gets a `tool` message, and a legacy `function_call` — which states no id — + * gets the `function` message this wire pairs with it by name. That pairing is + * read off the restored turn, not off the source protocol, so every source + * reaches it the same way. + */ +const assertNativeHistory = ( + target: OpenAIChatCompletionsPayload, + name: string, + contents: string[], +): void => { + const results = target.messages.filter(message => message.role === 'tool' || message.role === 'function'); + expect(results.map(message => message.content)).toEqual(contents); + expect(results[0]).toEqual({ role: 'function', name, content: contents[0] }); + expect(results[1]).toEqual({ role: 'tool', tool_call_id: 'native-modern', content: contents[1] }); + expect(results[2]).toEqual({ role: 'function', name, content: contents[2] }); const assistants = target.messages.filter(message => message.role === 'assistant'); expect(assistants).toHaveLength(3); expect(assistants[0].function_call).toEqual({ name, arguments: '{"x":1}' }); @@ -44,32 +57,32 @@ const assertNativeHistory = (target: OpenAIChatCompletionsPayload, name: string, }; test('Messages returns matching legacy function results while modern calls remain tool messages across turns', async () => { - const context = { model: 'native-model', sidecarCodec: createTestSidecarCodec() }; + const context = { sidecarCodec: createTestSidecarCodec() }; const payload: AnthropicMessagesPayload = { - model: 'public-model', max_tokens: 64, + model: 'public-model', max_tokens: 64, stream: true, messages: [{ role: 'user', content: 'Call lookup' }], 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 requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)); + const trip = expectTrip(await 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 requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)); + const replay = expectTrip(await routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)); assertNativeHistory(replay.target, 'lookup', ['result-0', 'result-1', 'result-2']); }); test('Responses binds public namespaced call IDs to the native legacy function name', async () => { - const context = { model: 'native-model', sidecarCodec: createTestSidecarCodec() }; + const context = { sidecarCodec: createTestSidecarCodec() }; const payload: OpenAIResponsesRequestPayloadEx = { - model: 'public-model', input: [{ type: 'message', role: 'user', content: 'Call lookup' }], + model: 'public-model', stream: true, input: [{ type: 'message', role: 'user', content: 'Call lookup' }], tools: [{ type: 'namespace', name: 'public', description: 'Public lookup tools', tools: [{ type: 'function', name: 'lookup', parameters: { type: 'object', properties: { x: { type: 'integer' } } } }] }], }; let nativeName = ''; for (const [index, mode] of (['legacy', 'modern', 'legacy'] as const).entries()) { - const trip = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(payload, context)); + const trip = expectTrip(await 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; @@ -82,18 +95,19 @@ 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 requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(payload, context)); + const replay = expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions(payload, context)); assertNativeHistory(replay.target, nativeName, ['result-0', 'result-1', 'result-2']); }); test('Gemini matches ID-less legacy responses by public name and explicit modern responses by ID', async () => { - const context = { model: 'native-model', sidecarCodec: createTestSidecarCodec() }; - const payload: GeminiGenerateContentPayload = { + const context = { sidecarCodec: createTestSidecarCodec() }; + const payload: GeminiGenerateContentRequest = { + model: 'model', contents: [{ role: 'user', parts: [{ text: 'Call lookup' }] }], 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 requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(payload, context)); + const trip = expectTrip(await 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; @@ -101,20 +115,21 @@ 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 requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(payload, context)); + const replay = expectTrip(await 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 requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(source, context)); + const context = { sidecarCodec: createTestSidecarCodec() }; + const source: GeminiGenerateContentRequest = { model: 'model', contents: [{ role: 'user', parts: [{ text: 'Call lookup' }] }] }; + const trip = expectTrip(await 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 requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions({ + const replay = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions({ + model: 'model', contents: [ ...source.contents ?? [], content, { @@ -128,21 +143,70 @@ test('Gemini keeps modern IDs separate when a vendor stream includes both call f 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}' } }]); - expect(replay.target.messages.slice(-2)).toEqual([ - { role: 'tool', tool_call_id: 'native-modern', content: '{"modern":true}' }, - { role: 'function', name: 'lookup', content: '{"legacy":true}' }, - ]); + // The id-less legacy response answers the call still awaiting a result. That + // call is stated as a legacy `function_call`, which carries no id, so the + // answer is written as the matching `function` message and named by the call. + const results = replay.target.messages.slice(-2); + expect(results.map(message => message.content)).toEqual(['{"modern":true}', '{"legacy":true}']); + expect(results[0]).toMatchObject({ role: 'tool', tool_call_id: 'native-modern' }); + expect(results[1]).toMatchObject({ role: 'function', name: 'lookup' }); }); test('restored legacy results retain the ordinary Responses input_file rejection', async () => { - const context = { model: 'native-model', sidecarCodec: createTestSidecarCodec() }; - const trip = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'public-model', input: 'Call lookup' }, context)); + const context = { sidecarCodec: createTestSidecarCodec() }; + const trip = expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'public-model', stream: true, 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(requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'public-model', input: [...history, output] }, context))).rejects.toThrow('Cannot translate input_file tool output to OpenAI Chat Completions.'); + await expect(routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'public-model', stream: true, input: [...history, output] }, context)).resolves.toMatchObject({ type: 'input-error', message: expect.stringContaining('Cannot translate file tool output to OpenAI Chat Completions.') }); + } +}); + +test('a legacy call answered after another assistant turn is refused', async () => { + // A legacy call states no id, so nothing names it across an assistant turn + // that follows it: the answer could pair with either turn's calls. + const context = { sidecarCodec: createTestSidecarCodec() }; + const payload: AnthropicMessagesPayload = { + model: 'public-model', max_tokens: 64, stream: true, + messages: [{ role: 'user', content: 'Call lookup' }], + tools: [{ name: 'lookup', input_schema: { type: 'object', properties: { x: { type: 'integer' } } } }], + }; + const trip = expectTrip(await routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)); + const result = await collectAnthropicMessagesProtocolEventsToResult(trip.events(iterate(nativeFrames('legacy', '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: 'still thinking' }, + { role: 'assistant', content: 'meanwhile' }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: call.id, content: 'late' }] }, + ); + await expect(routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)).resolves.toMatchObject({ type: 'input-error', message: expect.stringContaining('does not answer it') }); +}); + +test('a legacy call and its answer share one client turn without being refused', async () => { + // The result may sit anywhere inside the turn that follows the call: it is + // the turn, not the item's position, that pairs them. + const context = { sidecarCodec: createTestSidecarCodec() }; + for (const order of ['before', 'after'] as const) { + const payload: AnthropicMessagesPayload = { + model: 'public-model', max_tokens: 64, stream: true, + messages: [{ role: 'user', content: 'Call lookup' }], + tools: [{ name: 'lookup', input_schema: { type: 'object', properties: { x: { type: 'integer' } } } }], + }; + const trip = expectTrip(await routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)); + const result = await collectAnthropicMessagesProtocolEventsToResult(trip.events(iterate(nativeFrames('legacy', 'lookup')))); + const call = result.content.find(block => block.type === 'tool_use'); + if (call === undefined) throw new Error('Expected a public Messages tool invocation'); + const answer: { role: 'user'; content: string } | { role: 'user'; content: [{ type: 'tool_result'; tool_use_id: string; content: string }] } = order === 'before' + ? { role: 'user', content: [{ type: 'tool_result', tool_use_id: call.id, content: 'the-answer' }] } + : { role: 'user', content: 'chatter' }; + const rest = order === 'before' ? { role: 'user' as const, content: 'chatter' } : { role: 'user' as const, content: [{ type: 'tool_result' as const, tool_use_id: call.id, content: 'the-answer' }] }; + payload.messages.push({ role: 'assistant', content: result.content }, answer, rest); + const replay = expectTrip(await routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)); + expect(replay.target.messages.filter(message => message.role === 'function')).toEqual([{ role: 'function', name: 'lookup', content: 'the-answer' }]); } }); 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 16f1a9b44a..dece37080e 100644 --- a/packages/translate/__tests__/protocols/chat-reasoning-round-trip_test.ts +++ b/packages/translate/__tests__/protocols/chat-reasoning-round-trip_test.ts @@ -1,8 +1,8 @@ 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 { expectTrip } from '../trip.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'; @@ -13,8 +13,8 @@ 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 requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'source', input: [{ role: 'user', content: 'question' }] }, context)); + const context = { sidecarCodec: createTestSidecarCodec() }; + const trip = expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'source', stream: true, 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' }] }); @@ -26,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 requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'source', input: toOpenAIResponsesInputAssistantTurn(result.output) }, context)); + const restored = expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'source', stream: true, 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/copy-ownership_test.ts b/packages/translate/__tests__/protocols/copy-ownership_test.ts new file mode 100644 index 0000000000..2958604af5 --- /dev/null +++ b/packages/translate/__tests__/protocols/copy-ownership_test.ts @@ -0,0 +1,16 @@ +import { expect, test } from 'vitest'; + +import { collect, iterate } from '../iterables.ts'; +import { readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents } from './events.ts'; +import { eventFrame } from '@floway-dev/protocols/common'; + +test.each([ + ['ChatCompletions', readOpenAIChatCompletionsEvents, (error: object) => ({ id: 'chat', model: 'm', choices: [], error })], + ['Responses', readOpenAIResponsesEvents, (error: object) => ({ type: 'error', error })], + ['Messages', readAnthropicMessagesEvents, (error: object) => ({ type: 'error', error })], +] as const)('%s reader transfers immutable error objects without copying', async (_name, reader, event) => { + const error = Object.freeze({ type: 'api_error', message: 'original', details: Object.freeze([Object.freeze({ text: 'original' })]) }); + const frames = await collect(reader(iterate([eventFrame(event(error) as any)]))); + const record = frames.find(record => record.type === 'error'); + expect(record?.error).toBe(error); +}); diff --git a/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts b/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts index caaf76e559..ecb78f0c6b 100644 --- a/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts +++ b/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts @@ -24,11 +24,11 @@ import { type TranslateTrip, } from '@floway-dev/translate'; -const options = () => ({ model: 'm', sidecarCodec: createTestSidecarCodec('error-routes') }); -const chat = { model: 'm', messages: [{ role: 'user' as const, content: 'hello' }] }; -const responses = { model: 'm', input: 'hello' }; -const messages = { model: 'm', max_tokens: 16, messages: [{ role: 'user' as const, content: 'hello' }] }; -const gemini = { contents: [{ role: 'user' as const, parts: [{ text: 'hello' }] }] }; +const options = () => ({ sidecarCodec: createTestSidecarCodec('error-routes') }); +const chat = { stream: true, model: 'm', messages: [{ role: 'user' as const, content: 'hello' }] }; +const responses = { stream: true, model: 'm', input: 'hello' }; +const messages = { stream: true, model: 'm', max_tokens: 16, messages: [{ role: 'user' as const, content: 'hello' }] }; +const gemini = { model: 'm', contents: [{ role: 'user' as const, parts: [{ text: 'hello' }] }] }; const failure = { message: 'bad field', type: 'invalid_request_error', code: 'invalid_prompt', param: 'temperature', @@ -98,9 +98,9 @@ const verifyPhases = async ( const cases = [ ['Chat via Responses', () => verifyPhases(routeOpenAIChatCompletionsViaOpenAIResponses(chat, options()), nestedEvent(failure), expectedChat)], - ['Chat via Messages', () => verifyPhases(routeOpenAIChatCompletionsViaAnthropicMessages(chat, { ...options(), loadRemoteImage: async () => null, fallbackMaxOutputTokens: 16 }), nestedEvent(failure), expectedChat)], + ['Chat via Messages', () => verifyPhases(routeOpenAIChatCompletionsViaAnthropicMessages(chat, { ...options(), fallbackMaxOutputTokens: 16 }), nestedEvent(failure), expectedChat)], ['Responses via Chat', () => verifyPhases(routeOpenAIResponsesViaOpenAIChatCompletions(responses, options()), chatEvent(failure), expectedResponses)], - ['Responses via Messages', () => verifyPhases(routeOpenAIResponsesViaAnthropicMessages(responses, { ...options(), loadRemoteImage: async () => null, fallbackMaxOutputTokens: 16 }), nestedEvent(failure), expectedResponses)], + ['Responses via Messages', () => verifyPhases(routeOpenAIResponsesViaAnthropicMessages(responses, { ...options(), fallbackMaxOutputTokens: 16 }), nestedEvent(failure), expectedResponses)], ['Messages via Chat', () => verifyPhases(routeAnthropicMessagesViaOpenAIChatCompletions(messages, options()), chatEvent(failure), expectedMessages)], ['Messages via Responses', () => verifyPhases(routeAnthropicMessagesViaOpenAIResponses(messages, options()), nestedEvent(failure), expectedMessages)], ['Gemini via Chat', () => verifyPhases(routeGeminiGenerateContentViaOpenAIChatCompletions(gemini, options()), chatEvent(failure), expectedGemini)], diff --git a/packages/translate/__tests__/protocols/edge-cases_test.ts b/packages/translate/__tests__/protocols/edge-cases_test.ts index 389e5f0a45..597070aa31 100644 --- a/packages/translate/__tests__/protocols/edge-cases_test.ts +++ b/packages/translate/__tests__/protocols/edge-cases_test.ts @@ -1,22 +1,36 @@ import { expect, test, vi } from 'vitest'; +import { writeAnthropicMessagesEvents, writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './events.ts'; +import { collectIR, completeIR } from '../ir/output/helpers.ts'; +import { collect, iterate } from '../iterables.ts'; import { fixtureFrames, nativeResult } from './fixtures.ts'; -import { writeAnthropicMessagesEvents, writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; import { createIRBuilder } from '../../src/ir/output/builder.ts'; -import { translateToSourceEvents as messagesViaChat } from '../../src/protocols/anthropic-messages/via-openai-chat-completions/events.ts'; -import { buildTargetRequest as messagesRequestViaChat } from '../../src/protocols/anthropic-messages/via-openai-chat-completions/request.ts'; -import { translateToSourceEvents as messagesViaResponses } from '../../src/protocols/anthropic-messages/via-openai-responses/events.ts'; -import { translateToSourceEvents as generateContentViaChat } from '../../src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts'; -import { translateToSourceEvents as generateContentViaResponses } from '../../src/protocols/gemini-generate-content/via-openai-responses/events.ts'; -import { readOpenAIChatCompletionsEvents as readOpenAIChatCompletionsEventsProduction } from '../../src/protocols/openai-chat-completions/as-target/events.ts'; -import { translateToSourceEvents as responsesViaMessages } from '../../src/protocols/openai-responses/via-anthropic-messages/events.ts'; -import { translateToSourceEvents as responsesViaChat } from '../../src/protocols/openai-responses/via-openai-chat-completions/events.ts'; -import { collectIR, completeIR } from '../ir/output/helpers.ts'; +import { readOpenAIChatCompletionsEvents as irFromOpenAIChatCompletionsProduction } from '../../src/protocols/openai-chat-completions/as-target/events.ts'; +import { buildSourceEventsFor, buildTargetPayloadFor, emptyPayload, pairs } from '../composition/helpers.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; -import { createRoundTripReaderCapture, emptyNamespaceToolNames } from '../round-trip/test-helpers.ts'; -import { collect, iterate } from '../shared/iterables.ts'; +import { createRoundTripReaderCapture } from '../round-trip/test-helpers.ts'; import { eventFrame } from '@floway-dev/protocols/common'; +const events = (pair: readonly [any, any], payload: any, codec: any) => + (frames: AsyncIterable) => buildSourceEventsFor(pair[0], pair[1], payload, { sidecarCodec: codec }, frames); +const messagesViaChat = (frames: AsyncIterable, codec: any) => events(pairs.messagesToChat, emptyPayload.messages, codec)(frames); +const messagesViaResponses = (frames: AsyncIterable, codec: any) => events(pairs.messagesToResponses, emptyPayload.messages, codec)(frames); +const generateContentViaChat = (frames: AsyncIterable, codec: any) => events(pairs.geminiToChat, emptyPayload.gemini, codec)(frames); +const generateContentViaResponses = (frames: AsyncIterable, codec: any) => events(pairs.geminiToResponses, emptyPayload.gemini, codec)(frames); +// The freeform tool names these cases restore come from the request's own +// declarations, so a case that expects one states it there. +const responsesViaMessages = (frames: AsyncIterable, names: ReadonlySet, codec: any) => + buildSourceEventsFor(pairs.responsesToMessages[0], pairs.responsesToMessages[1], responsesPayload(names), { sidecarCodec: codec }, frames); +const responsesViaChat = (frames: AsyncIterable, names: ReadonlySet, codec: any) => + buildSourceEventsFor(pairs.responsesToChat[0], pairs.responsesToChat[1], responsesPayload(names), { sidecarCodec: codec }, frames); +const responsesPayload = (names: ReadonlySet) => ({ + ...emptyPayload.responses, + tools: [...names].map(name => ({ type: 'custom' as const, name, description: '', format: { type: 'text' as const } })), + tool_choice: 'auto' as const, +}); +const messagesRequestViaChat = (payload: any, codec: any) => + buildTargetPayloadFor(pairs.messagesToChat[0], pairs.messagesToChat[1], payload, { sidecarCodec: codec }); + for (const [code, category] of [['bio_policy', 'bio'], ['cyber_policy', 'cyber']] as const) { test(`${code} becomes a structured Messages classifier refusal without invented body text`, async () => { const source = [eventFrame({ type: 'response.failed', response: { id: 'resp_policy', model: 'served', output: [], status: 'failed', error: { code, message: 'Classifier declined.' } } })]; @@ -65,7 +79,7 @@ test('ChatCompletions rejects generated content before any non-empty model', asy ])))).rejects.toThrow('before a non-empty model'); }); -test('ChatCompletions role-only choices still require finish_reason without creating IR records', async () => { +test('ChatCompletions role-only choices still require finish_reason without creating output IR records', async () => { const role = eventFrame({ id: 'chat_role', model: 'served', choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] } as any); await expect(collectIR(readOpenAIChatCompletionsEvents(iterate([role])))).rejects.toThrow('ended without finish_reason'); @@ -118,28 +132,14 @@ test('ChatCompletions ignores late identity changes and accepts later model upda expect((await collectIR(readOpenAIChatCompletionsEvents(iterate(input)))).model).toBe('fallback'); }); -test('ChatCompletions choice indexes must arrive in sequence', async () => { +test('ChatCompletions choice indexes must stay in sequence', async () => { + // A stream that opens choice 1 without ever stating choice 0 describes a + // candidate the request never had; the IR cannot represent the gap. await expect(collectIR(readOpenAIChatCompletionsEvents(iterate([ eventFrame({ id: 'chat_choices', model: 'served', choices: [{ index: 1, delta: {}, finish_reason: 'stop' }] } as any), ])))).rejects.toThrow('out of sequence'); }); -test('ChatCompletions accepts every consecutively reported upstream choice', async () => { - const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate([ - eventFrame({ - id: 'chat_choices', model: 'served', choices: [ - { index: 0, delta: { content: 'first' }, finish_reason: 'stop' }, - { index: 1, delta: { content: 'second' }, finish_reason: 'stop' }, - ], - } as any), - ]))); - expect(ir.choices).toHaveLength(2); - expect(ir.choices.map(choice => choice.items)).toEqual([ - [{ type: 'message', content: [{ type: 'text', text: 'first' }] }], - [{ type: 'message', content: [{ type: 'text', text: 'second' }] }], - ]); -}); - test('Responses rejects output events before the initial envelope', async () => { await expect(collectIR(readOpenAIResponsesEvents(iterate([ eventFrame({ type: 'response.output_item.added', output_index: 0, item: { type: 'message', id: 'msg', role: 'assistant', content: [] } } as any), @@ -198,7 +198,7 @@ for (const source of ['openai-chat-completions', 'anthropic-messages'] as const) const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { const frames = fixtureFrames(source, { tools: [{ name: 'run_code', id: 'call_custom', args: wrapped }] }); - const output = source === 'openai-chat-completions' ? responsesViaChat(iterate(frames), new Set(['run_code']), createTestSidecarCodec(), emptyNamespaceToolNames()) : responsesViaMessages(iterate(frames), 'resp_custom', new Set(['run_code']), createTestSidecarCodec(), emptyNamespaceToolNames()); + const output = source === 'openai-chat-completions' ? responsesViaChat(iterate(frames), new Set(['run_code']), createTestSidecarCodec()) : responsesViaMessages(iterate(frames), new Set(['run_code']), createTestSidecarCodec()); const result = await nativeResult('openai-responses', await collect(output)); expect(result.output.slice(0, -1)).toEqual([expect.objectContaining({ type: 'custom_tool_call', call_id: 'call_custom', name: 'run_code', input: 'input' in wrapped ? wrapped.input : JSON.stringify(wrapped) })]); expect(warning).toHaveBeenCalledTimes('input' in wrapped ? 0 : 1); @@ -209,8 +209,8 @@ for (const source of ['openai-chat-completions', 'anthropic-messages'] as const) test('ChatCompletions EOF succeeds after explicit choice completion and fails before it', async () => { const frames = fixtureFrames('openai-chat-completions', { text: ['answer'] }); - expect((await collectIR(readOpenAIChatCompletionsEventsProduction(iterate(frames.slice(0, -1)), { roundTrip: createRoundTripReaderCapture().roundTrip }))).choices[0].items).toMatchObject([{ content: [{ text: 'answer' }] }]); - await expect(collectIR(readOpenAIChatCompletionsEventsProduction(iterate(frames.slice(0, -2)), { roundTrip: createRoundTripReaderCapture().roundTrip }))).rejects.toThrow('finish_reason'); + expect((await collectIR(irFromOpenAIChatCompletionsProduction(iterate(frames.slice(0, -1)), { roundTrip: createRoundTripReaderCapture().roundTrip }))).choices[0].items).toMatchObject([{ content: [{ text: 'answer' }] }]); + await expect(collectIR(irFromOpenAIChatCompletionsProduction(iterate(frames.slice(0, -2)), { roundTrip: createRoundTripReaderCapture().roundTrip }))).rejects.toThrow('finish_reason'); }); test.each([messagesViaChat, generateContentViaChat])('completed empty function JSON is rejected by object-requiring destinations', async translate => { @@ -228,7 +228,7 @@ test.each([ const result = await nativeResult('anthropic-messages', await collect(messagesViaChat(iterate(frames), codec))); expect(result.content[0]).toMatchObject({ type: 'tool_use', input }); expect(result.stop_reason).toBe(stop === 'length' ? 'max_tokens' : 'tool_use'); - const request = await messagesRequestViaChat({ model: 'model', max_tokens: 100, messages: [{ role: 'assistant', content: result.content }] }, codec); + const request = await messagesRequestViaChat({ model: 'model', max_tokens: 100, stream: true, messages: [{ role: 'assistant', content: result.content }] }, codec); expect(request.messages).toMatchObject([{ role: 'assistant', tool_calls: [{ id: 'call_partial', function: { name: 'lookup', arguments: arguments_ } }] }]); }); @@ -243,9 +243,9 @@ test('ignores vendor object arrays and retains scalar reasoning items', async () const frames = fixtureFrames('openai-chat-completions', { thinking: ['ab'], text: ['answer'] }); const records = [{ type: 'vendor_record', id: 'record_a', segments: ['a'] }, { type: 'vendor_record', id: 'record_b', segments: ['b'] }]; frames.splice(-1, 0, eventFrame({ id: 'chatcmpl_test', model: 'served-model', created: 100, choices: [{ index: 0, delta: { vendor_records: records }, finish_reason: null }] })); - const ir = await collectIR(readOpenAIChatCompletionsEventsProduction(iterate(frames), { roundTrip: createRoundTripReaderCapture().roundTrip })); + const ir = await collectIR(irFromOpenAIChatCompletionsProduction(iterate(frames), { roundTrip: createRoundTripReaderCapture().roundTrip })); expect(ir.choices[0].items.filter(item => item.type === 'reasoning')).toEqual([{ type: 'reasoning', summary: ['ab'] }]); - const result = await nativeResult('openai-responses', await collect(responsesViaChat(iterate(frames), new Set(), createTestSidecarCodec(), emptyNamespaceToolNames()))); + const result = await nativeResult('openai-responses', await collect(responsesViaChat(iterate(frames), new Set(), createTestSidecarCodec()))); expect(result.output.filter((item: any) => item.type === 'reasoning' && item.encrypted_content === undefined)).toMatchObject([{ summary: [{ text: 'ab' }] }]); expect(result.output.some((item: any) => ['record_a', 'record_b'].includes(item.id))).toBe(false); expect(result.output.filter((item: any) => item.type === 'message')[0].content[0].text).toBe('answer'); @@ -254,7 +254,7 @@ test('ignores vendor object arrays and retains scalar reasoning items', async () test('late vendor extensions preserve messages on either side of reasoning', async () => { const chunk = (delta: any, finish_reason: string | null = null) => eventFrame({ id: 'chatcmpl_order', model: 'served-model', created: 100, choices: [{ index: 0, delta, finish_reason }] }); const frames = [chunk({ content: 'before' }), chunk({ reasoning_text: 'think' }), chunk({ content: 'after' }), chunk({}, 'stop'), chunk({ vendor_records: [{ type: 'vendor_record', id: 'record_order', segments: ['think'] }] }), { type: 'done' as const }]; - const ir = await collectIR(readOpenAIChatCompletionsEventsProduction(iterate(frames as any), { roundTrip: createRoundTripReaderCapture().roundTrip })); + const ir = await collectIR(irFromOpenAIChatCompletionsProduction(iterate(frames as any), { roundTrip: createRoundTripReaderCapture().roundTrip })); expect(ir.choices[0].items).toEqual([{ type: 'message', content: [{ type: 'text', text: 'before' }] }, { type: 'reasoning', summary: ['think'] }, { type: 'message', content: [{ type: 'text', text: 'after' }] }]); }); @@ -262,7 +262,7 @@ for (const category of ['bio', 'cyber']) { test.each([null, '', ' \t\n', 'Upstream explanation.', ' Original spacing. '])(`Responses via Messages preserves nonblank ${category} explanations and supplies a native error message when absent: %j`, async explanation => { const frames = fixtureFrames('anthropic-messages', { stop: 'content_filter' }); for (const frame of frames) if (frame.type === 'event' && frame.event.type === 'message_delta') frame.event.delta.stop_details = { type: 'refusal', category, explanation }; - const output = await collect(responsesViaMessages(iterate(frames), 'resp_policy', new Set(), createTestSidecarCodec(), emptyNamespaceToolNames())); + const output = await collect(responsesViaMessages(iterate(frames), new Set(), createTestSidecarCodec())); expect(output.at(-1)).toMatchObject({ event: { type: 'response.failed', diff --git a/packages/translate/__tests__/protocols/events.ts b/packages/translate/__tests__/protocols/events.ts new file mode 100644 index 0000000000..e0cf64c751 --- /dev/null +++ b/packages/translate/__tests__/protocols/events.ts @@ -0,0 +1,48 @@ +import type { IRRecord } from '../../src/ir/output/records.ts'; +import { writeAnthropicMessagesEvents as anthropicMessagesFromIRProduction } from '../../src/protocols/anthropic-messages/as-source/events.ts'; +import { readAnthropicMessagesEvents as irFromAnthropicMessagesProduction } from '../../src/protocols/anthropic-messages/as-target/events.ts'; +import { writeGeminiGenerateContentEvents as geminiGenerateContentFromIRProduction } from '../../src/protocols/gemini-generate-content/as-source/events.ts'; +import { writeOpenAIChatCompletionsEvents as openaiChatCompletionsFromIRProduction } from '../../src/protocols/openai-chat-completions/as-source/events.ts'; +import { readOpenAIChatCompletionsEvents as irFromOpenAIChatCompletionsProduction } from '../../src/protocols/openai-chat-completions/as-target/events.ts'; +import { writeOpenAIResponsesEvents as openaiResponsesFromIRProduction } from '../../src/protocols/openai-responses/as-source/events.ts'; +import { readOpenAIResponsesEvents as irFromOpenAIResponsesProduction } from '../../src/protocols/openai-responses/as-target/events.ts'; +import type { RoundTripReader } from '../../src/round-trip/reader.ts'; +import { createReferenceBuilder } from '../../src/round-trip/reference/reference-builder.ts'; +import type { RoundTripWriter } from '../../src/round-trip/writer.ts'; + +const reader = (): RoundTripReader => ({ + builder: createReferenceBuilder(), + onAssistantTurn: () => {}, +}); +const writer = (): RoundTripWriter => ({ + builder: createReferenceBuilder(), + encodeSidecar: async () => 'test-sidecar', +}); + +// These ordinary output IR fixtures use independent mock participants. Provenance is +// exercised by the native-reader and public-route tests sharing one coordinator. +const withoutProvenance = async function* (records: AsyncIterable): AsyncGenerator { + for await (const record of records) { + if (record.type === 'write' && record.kind === 'append') { + const { textFragmentIds: _referencePieceIds, ...operation } = record; + yield operation; + } else yield record; + } +}; + +export const readAnthropicMessagesEvents = (frames: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => + withoutProvenance(irFromAnthropicMessagesProduction(frames, { ...options, roundTrip: reader<'anthropicMessages'>() })); +export const readOpenAIChatCompletionsEvents = (frames: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => + withoutProvenance(irFromOpenAIChatCompletionsProduction(frames, { ...options, roundTrip: reader<'openaiChatCompletions'>() })); +export const readOpenAIResponsesEvents = ( + frames: Parameters[0], + roundTrip: RoundTripReader<'openaiResponses'> = reader<'openaiResponses'>(), +) => withoutProvenance(irFromOpenAIResponsesProduction(frames, { roundTrip })); +export const writeAnthropicMessagesEvents = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => + anthropicMessagesFromIRProduction(records, { ...options, roundTrip: writer<'anthropicMessages'>() }); +export const writeOpenAIChatCompletionsEvents = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => + openaiChatCompletionsFromIRProduction(records, { ...options, roundTrip: writer<'openaiChatCompletions'>() }); +export const writeOpenAIResponsesEvents = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => + openaiResponsesFromIRProduction(records, { ...options, roundTrip: writer<'openaiResponses'>() }); +export const writeGeminiGenerateContentEvents = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => + geminiGenerateContentFromIRProduction(records, { ...options, roundTrip: writer<'geminiGenerateContent'>() }); diff --git a/packages/translate/__tests__/protocols/fixtures.ts b/packages/translate/__tests__/protocols/fixtures.ts index ffa020514f..2e33fc254e 100644 --- a/packages/translate/__tests__/protocols/fixtures.ts +++ b/packages/translate/__tests__/protocols/fixtures.ts @@ -1,9 +1,8 @@ import { expect, test } from 'vitest'; -import type { AssistantTurnProtocol } from '../../src/round-trip/input-assistant-turn.ts'; import { decodeRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; +import { collect, iterate } from '../iterables.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; -import { collect, iterate } from '../shared/iterables.ts'; import { collectAnthropicMessagesProtocolEventsToResult } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, doneFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { collectGeminiGenerateContentProtocolEventsToResult } from '@floway-dev/protocols/gemini-generate-content'; @@ -12,12 +11,12 @@ import { collectOpenAIResponsesProtocolEventsToResult, openaiResponsesResultToEv type Wire = Record; export type IRTestProtocol = 'openai-chat-completions' | 'openai-responses' | 'anthropic-messages' | 'gemini-generate-content'; -const protocolKeys: Record = { +const assistantTurnProtocolOf = { 'openai-chat-completions': 'openaiChatCompletions', 'openai-responses': 'openaiResponses', 'anthropic-messages': 'anthropicMessages', 'gemini-generate-content': 'geminiGenerateContent', -}; +} as const; interface Fixture { text?: string[]; refusal?: string; thinking?: string[]; signature?: string; tools?: { name: string; id: string; args: Wire }[]; stop?: string; usage?: Wire; tier?: string } export const fixtureFrames = (protocol: IRTestProtocol, fixture: Fixture): ProtocolFrame[] => { @@ -116,12 +115,11 @@ export const testOutputTranslation = (target: IRTestProtocol, source: IRTestProt const result = await nativeResult(target, await collect(translate(iterate(fixtureFrames(source, { thinking: ['reason'], signature: 'opaque', text: ['answer'] })), codec))); expect(reasoningText(target, result)).toBe('reason'); expect(bodyText(target, result)).toBe('answer'); - const sourceName = protocolKeys[target]; const carrier = target === 'openai-chat-completions' ? result.choices[0].message.reasoning_details.find((item: Wire) => item.type === 'reasoning.encrypted').data : target === 'openai-responses' ? result.output.at(-1).encrypted_content : target === 'anthropic-messages' ? result.content.at(-1).data : result.candidates[0].content.parts.at(-1).thoughtSignature; - const sidecar = await decodeRoundTripSidecar(codec, sourceName, carrier); + const sidecar = await decodeRoundTripSidecar(codec, assistantTurnProtocolOf[target], carrier); expect(JSON.stringify(sidecar)).toContain('opaque'); if (target === 'anthropic-messages') expect(result.content.find((part: Wire) => part.type === 'thinking').signature).toBe('AA=='); }); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/events_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/events_test.ts index 95ba186551..f9998b430d 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/events_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/events_test.ts @@ -3,7 +3,7 @@ import { expect, test } from 'vitest'; import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; import { writeGeminiGenerateContentEvents } from '../../../../src/protocols/gemini-generate-content/as-source/events.ts'; import { appendReadField, collectEmittedFieldPlacements, createReferenceBuilder, createReadFieldState, createFieldReferencer } from '../../../../src/round-trip/reference/reference-builder.ts'; -import type { RoundTripWriter } from '../../../../src/round-trip/writer.ts'; +import { type RoundTripWriter } from '../../../../src/round-trip/writer.ts'; test('resolves delayed Gemini audio atoms within the complete emitted data field', async () => { const references = createReferenceBuilder(); @@ -24,8 +24,8 @@ test('resolves delayed Gemini audio atoms within the complete emitted data field let prepared = false; const roundTrip: RoundTripWriter<'geminiGenerateContent'> = { builder: references, - encodeSidecar: async (_choice, _check, turn, state, textViews) => { - const views = collectEmittedFieldPlacements(turn, state, textViews); + encodeSidecar: async (_choice, _check, turn, state, textPlacements) => { + const views = collectEmittedFieldPlacements(turn, state, textPlacements); expect([...views.values()]).toEqual([{ enclosingText: 'YQ==', offset: 0 }]); const finalizer = createFieldReferencer(references, views); expect(await finalizer.referenceField('Q==', field)).toMatchObject({ parts: [[0, 1, 4]] }); @@ -53,8 +53,8 @@ test.each(['{"x":1,}', '{"x": 1}'])('tracks the emitted canonical JSON view with let prepared = false; const roundTrip: RoundTripWriter<'geminiGenerateContent'> = { builder: references, - encodeSidecar: async (_choice, _check, turn, state, textViews) => { - const views = collectEmittedFieldPlacements(turn, state, textViews); + encodeSidecar: async (_choice, _check, turn, state, textPlacements) => { + const views = collectEmittedFieldPlacements(turn, state, textPlacements); expect([...views.values()]).toContainEqual({ enclosingText: '{"x":1}', offset: 0 }); const finalizer = createFieldReferencer(references, views); expect(await finalizer.referenceField(original, field)).toBe(original); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/reasoning_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/input-to_test.ts similarity index 51% rename from packages/translate/__tests__/protocols/gemini-generate-content/as-source/reasoning_test.ts rename to packages/translate/__tests__/protocols/gemini-generate-content/as-source/input-to_test.ts index eaedc91cbc..e2fe3221bd 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/reasoning_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/input-to_test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; -import { geminiGenerateContentReasoningEffort, geminiGenerateContentThinkingLevelEffort } from '../../../../src/protocols/gemini-generate-content/as-source/reasoning.ts'; +import { readGeminiGenerateContentRequest } from '../../../../src/protocols/gemini-generate-content/as-source/input-to.ts'; import { assertEquals } from '@floway-dev/test-utils'; test.each([ @@ -13,7 +13,5 @@ test.each([ ['VENDOR_Turbo', 'VENDOR_Turbo'], ['Low', 'Low'], ] as const)('thinkingLevel %s maps to effort %s', (thinkingLevel, effort) => { - const config = { thinkingLevel }; - assertEquals(geminiGenerateContentThinkingLevelEffort(config), effort); - assertEquals(geminiGenerateContentReasoningEffort(config), effort ?? null); + assertEquals(readGeminiGenerateContentRequest({ model: 'm', contents: [], generationConfig: { thinkingConfig: { thinkingLevel } } }).request.settings.reasoning_effort, effort); }); 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 8dbd1ed9df..75f32524c6 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,7 +2,7 @@ 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 { expectTrip } from '../../../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'; @@ -34,9 +34,9 @@ const chatFrames = async function* (count: number): AsyncGenerator { vi.mocked(createGeminiGenerateContentEventAccumulator).mockClear(); const codec = createTestSidecarCodec(); - const trip = await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions({ - contents: [{ role: 'user', parts: [{ text: 'question' }] }], generationConfig: { candidateCount: count }, - }, { model: 'target', sidecarCodec: codec })); + const trip = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions({ + model: 'model', contents: [{ role: 'user', parts: [{ text: 'question' }] }], generationConfig: { candidateCount: count }, + }, { 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 25708c0be3..620f22c0e3 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,7 +3,7 @@ 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 { expectTrip } from '../../trip.ts'; import type { GeminiGenerateContentContent } from '@floway-dev/protocols/gemini-generate-content'; import { routeGeminiGenerateContentViaAnthropicMessages, routeGeminiGenerateContentViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIResponses } from '@floway-dev/translate'; @@ -15,16 +15,16 @@ const replayedContents = (name: string, carrier: string): GeminiGenerateContentC { role: 'model', parts: [{ functionCall: { name: 'other', args: {} } }] }, ]; -test.each(['lookup', '__proto__'])('Gemini via Chat uses restored call IDs for id-less responses and original indices for later bare calls for %s', async name => { +test.each(['lookup', '__proto__'])('Gemini via Chat uses restored call IDs for id-less responses and target-allocated ids for later calls for %s', async name => { 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 requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions({ contents: replayedContents(name, carrier) }, { model: 'test', sidecarCodec: createTestSidecarCodec('test-codec-scope') })); + const { target: request } = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions({ model: 'model', contents: replayedContents(name, carrier) }, { sidecarCodec: createTestSidecarCodec('test-codec-scope') })); expect(request.messages).toEqual([ { role: 'user', content: 'question' }, native, { role: 'tool', tool_call_id: 'native-chat-call', content: '{"answer":42}' }, - { role: 'assistant', content: null, tool_calls: [{ id: 'gemini_call_3_0', type: 'function', function: { name: 'other', arguments: '{}' } }] }, + { role: 'assistant', content: null, tool_calls: [{ id: 'other_call_1', type: 'function', function: { name: 'other', arguments: '{}' } }] }, ]); }); @@ -32,21 +32,21 @@ 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 requireTrip(routeGeminiGenerateContentViaAnthropicMessages({ contents: replayedContents(name, carrier) }, { model: 'test', sidecarCodec: createTestSidecarCodec('test-codec-scope'), fallbackMaxOutputTokens: undefined })); + const { target: request } = expectTrip(await routeGeminiGenerateContentViaAnthropicMessages({ model: 'model', contents: replayedContents(name, carrier) }, { 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' } }] }); + expect(request.messages[3]).toEqual({ role: 'assistant', content: [{ type: 'tool_use', id: 'other_call_1', name: 'other', input: {}, cache_control: { type: 'ephemeral' } }] }); }); -test.each(['lookup', '__proto__'])('Gemini via Responses uses restored call IDs and preserves later bare call positions for %s', async name => { +test.each(['lookup', '__proto__'])('Gemini via Responses uses restored call IDs and allocates later bare call ids for %s', async name => { 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 requireTrip(routeGeminiGenerateContentViaOpenAIResponses({ contents: replayedContents(name, carrier) }, { model: 'test', sidecarCodec: createTestSidecarCodec('test-codec-scope') })); + const { target: request } = expectTrip(await routeGeminiGenerateContentViaOpenAIResponses({ model: 'model', contents: replayedContents(name, carrier) }, { sidecarCodec: createTestSidecarCodec('test-codec-scope') })); expect(request.input).toEqual([ - { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'question' }] }, + { type: 'message', role: 'user', content: 'question' }, ...native, { type: 'function_call_output', call_id: 'native-responses-call', output: '{"answer":42}', status: 'completed' }, - { type: 'function_call', call_id: 'gemini_call_3_0', name: 'other', arguments: '{}', status: 'completed' }, + { type: 'function_call', call_id: 'other_call_1', name: 'other', arguments: '{}', status: 'completed' }, ]); }); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/events_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/events_test.ts deleted file mode 100644 index 9576b13585..0000000000 --- a/packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/events_test.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { translateToSourceEvents } from '../../../../src/protocols/gemini-generate-content/via-anthropic-messages/events.ts'; -import { testOutputTranslation } from '../../fixtures.ts'; - -testOutputTranslation('gemini-generate-content', 'anthropic-messages', (frames, codec) => translateToSourceEvents(frames, codec)); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/events_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/events_test.ts deleted file mode 100644 index 803a949a56..0000000000 --- a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/events_test.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { translateToSourceEvents } from '../../../../src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts'; -import { testOutputTranslation } from '../../fixtures.ts'; - -testOutputTranslation('gemini-generate-content', 'openai-chat-completions', (frames, codec) => translateToSourceEvents(frames, codec)); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/events_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/events_test.ts deleted file mode 100644 index da065b0d58..0000000000 --- a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/events_test.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { translateToSourceEvents } from '../../../../src/protocols/gemini-generate-content/via-openai-responses/events.ts'; -import { testOutputTranslation } from '../../fixtures.ts'; - -testOutputTranslation('gemini-generate-content', 'openai-responses', (frames, codec) => translateToSourceEvents(frames, codec)); diff --git a/packages/translate/__tests__/protocols/helpers.ts b/packages/translate/__tests__/protocols/helpers.ts deleted file mode 100644 index 2f5cd64403..0000000000 --- a/packages/translate/__tests__/protocols/helpers.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { IRRecord } from '../../src/ir/output/records.ts'; -import { writeAnthropicMessagesEvents as writeAnthropicMessagesEventsProduction } from '../../src/protocols/anthropic-messages/as-source/events.ts'; -import { readAnthropicMessagesEvents as readAnthropicMessagesEventsProduction } from '../../src/protocols/anthropic-messages/as-target/events.ts'; -import { writeGeminiGenerateContentEvents as writeGeminiGenerateContentEventsProduction } from '../../src/protocols/gemini-generate-content/as-source/events.ts'; -import { writeOpenAIChatCompletionsEvents as writeOpenAIChatCompletionsEventsProduction } from '../../src/protocols/openai-chat-completions/as-source/events.ts'; -import { readOpenAIChatCompletionsEvents as readOpenAIChatCompletionsEventsProduction } from '../../src/protocols/openai-chat-completions/as-target/events.ts'; -import { writeOpenAIResponsesEvents as writeOpenAIResponsesEventsProduction } from '../../src/protocols/openai-responses/as-source/events.ts'; -import { flattenNamespaceTools } from '../../src/protocols/openai-responses/as-source/namespace-tools.ts'; -import { readOpenAIResponsesEvents as readOpenAIResponsesEventsProduction } from '../../src/protocols/openai-responses/as-target/events.ts'; -import type { RoundTripReader } from '../../src/round-trip/reader.ts'; -import { createReferenceBuilder } from '../../src/round-trip/reference/reference-builder.ts'; -import type { RoundTripWriter } from '../../src/round-trip/writer.ts'; - -const reader = (): RoundTripReader => ({ - builder: createReferenceBuilder(), - onAssistantTurn: () => {}, -}); -const writer = (): RoundTripWriter => ({ - builder: createReferenceBuilder(), - encodeSidecar: async () => 'test-sidecar', -}); -const namespaceToolNames = flattenNamespaceTools({ model: 'test', input: [], tools: [] }).names; - -// These ordinary IR fixtures use independent mock participants. Provenance is -// exercised by the native-reader and public-route tests sharing one coordinator. -const withoutProvenance = async function* (records: AsyncIterable): AsyncGenerator { - for await (const record of records) { - if (record.type === 'write' && record.kind === 'append') { - const { textFragmentIds: _textFragmentIds, ...operation } = record; - yield operation; - } else yield record; - } -}; - -export const readAnthropicMessagesEvents = (frames: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => - withoutProvenance(readAnthropicMessagesEventsProduction(frames, { ...options, roundTrip: reader<'anthropicMessages'>() })); -export const readOpenAIChatCompletionsEvents = (frames: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => - withoutProvenance(readOpenAIChatCompletionsEventsProduction(frames, { ...options, roundTrip: reader<'openaiChatCompletions'>() })); -export const readOpenAIResponsesEvents = ( - frames: Parameters[0], - roundTrip: RoundTripReader<'openaiResponses'> = reader<'openaiResponses'>(), -) => withoutProvenance(readOpenAIResponsesEventsProduction(frames, { roundTrip })); -export const writeAnthropicMessagesEvents = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => - writeAnthropicMessagesEventsProduction(records, { ...options, roundTrip: writer<'anthropicMessages'>() }); -export const writeOpenAIChatCompletionsEvents = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => - writeOpenAIChatCompletionsEventsProduction(records, { ...options, roundTrip: writer<'openaiChatCompletions'>() }); -export const writeOpenAIResponsesEvents = (records: Parameters[0], options: Omit[1], 'roundTrip' | 'namespaceToolNames'> = {}) => - writeOpenAIResponsesEventsProduction(records, { ...options, roundTrip: writer<'openaiResponses'>(), namespaceToolNames }); -export const writeGeminiGenerateContentEvents = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => - writeGeminiGenerateContentEventsProduction(records, { ...options, roundTrip: writer<'geminiGenerateContent'>() }); diff --git a/packages/translate/__tests__/protocols/initial-envelope_test.ts b/packages/translate/__tests__/protocols/initial-envelope_test.ts index 43b3fd14ca..11071d26d4 100644 --- a/packages/translate/__tests__/protocols/initial-envelope_test.ts +++ b/packages/translate/__tests__/protocols/initial-envelope_test.ts @@ -1,14 +1,13 @@ import { expect, test } from 'vitest'; -import { translateToSourceEvents as messagesViaResponses } from '../../src/protocols/anthropic-messages/via-openai-responses/events.ts'; -import { translateToSourceEvents as chatViaResponses } from '../../src/protocols/openai-chat-completions/via-openai-responses/events.ts'; +import { buildSourceEventsFor, emptyPayload, pairs } from '../composition/helpers.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; test.each([ - ['ChatCompletions', (frames: AsyncIterable>) => chatViaResponses(frames, {}, createTestSidecarCodec())], - ['Messages', (frames: AsyncIterable>) => messagesViaResponses(frames, createTestSidecarCodec())], + ['ChatCompletions', (frames: AsyncIterable>) => buildSourceEventsFor(pairs.chatToResponses[0], pairs.chatToResponses[1], emptyPayload.chat, { sidecarCodec: createTestSidecarCodec() }, frames)], + ['Messages', (frames: AsyncIterable>) => buildSourceEventsFor(pairs.messagesToResponses[0], pairs.messagesToResponses[1], emptyPayload.messages, { sidecarCodec: createTestSidecarCodec() }, frames)], ] as const)('%s returns its initial envelope before pulling beyond Responses creation', async (protocol, convert) => { const response = { id: 'resp_initial', object: 'response', model: 'served', created_at: 7, status: 'in_progress', output: [], usage: null, service_tier: 'fast', error: null, incomplete_details: null }; let reached!: () => void; diff --git a/packages/translate/__tests__/protocols/lifecycle_test.ts b/packages/translate/__tests__/protocols/lifecycle_test.ts index 5a26097814..bf0c8b4fe2 100644 --- a/packages/translate/__tests__/protocols/lifecycle_test.ts +++ b/packages/translate/__tests__/protocols/lifecycle_test.ts @@ -1,11 +1,11 @@ import { expect, test, vi } from 'vitest'; +import { writeAnthropicMessagesEvents, writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './events.ts'; import { nativeResult } from './fixtures.ts'; -import { writeAnthropicMessagesEvents, writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; import { IRAbsent } from '../../src/ir/output/absent.ts'; import type { IRRecord } from '../../src/ir/output/records.ts'; import { collectIR } from '../ir/output/helpers.ts'; -import { collect, iterate } from '../shared/iterables.ts'; +import { collect, iterate } from '../iterables.ts'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; const chat = (delta: any, finish_reason: string | null = null) => eventFrame({ id: 'chatcmpl_lifecycle', model: 'served', created: 1, choices: [{ index: 0, delta, finish_reason }] } as any); @@ -300,3 +300,31 @@ test('Messages publishes cumulative usage progress before pulling the rest of th const final = output.findLast(frame => frame.event?.type === 'message_delta'); expect(final).toMatchObject({ event: { delta: { stop_reason: 'end_turn' }, usage: { input_tokens: 10, output_tokens: 2 } } }); }); + +test('Responses states a grouped call its group on the announcement and the snapshot alike', async () => { + // The request declared this call inside a group, so both the opening event and + // the terminal snapshot name it by the pair; an announcement that dropped the + // group would disagree with the snapshot the client already saw. + const records: IRRecord[] = [ + { type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }, + { type: 'write', kind: 'assign', path: ['model'], value: 'served' }, + { type: 'start', id: 'source' }, + { + type: 'write', + kind: 'initialize', + path: ['choices', 0, 'items', 0], + value: { type: 'function_call', call_id: 'call_1', name: 'lookup', namespace: 'public', arguments: '' }, + }, + { type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'arguments'], value: '{}' }, + { type: 'item_end', choice: 0, item: 0 }, + { type: 'choice_end', choice: 0, finish_reason: 'tool_calls' }, + { type: 'finish', status: 'completed' }, + ]; + const frames = await collect(writeOpenAIResponsesEvents(iterate(records))); + + const callItems = frames.flatMap(frame => frame.type === 'event' ? [frame.event] : []) + .flatMap(event => 'item' in event && event.item != null && typeof event.item === 'object' ? [event.item] : []) + .filter(item => 'type' in item && (item.type === 'function_call' || item.type === 'custom_tool_call')); + expect(callItems.map(item => ('name' in item ? item.name : undefined))).toEqual(['lookup', 'lookup']); + expect(callItems.map(item => ('namespace' in item ? item.namespace : undefined))).toEqual(['public', 'public']); +}); 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 bbdfc8de89..e4f454f201 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,9 +1,9 @@ import { expect, test } from 'vitest'; +import { collect, iterate } from '../../../iterables.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; -import { collect, iterate } from '../../../shared/iterables.ts'; +import { expectTrip } from '../../../trip.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'; @@ -14,10 +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 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; + ? expectTrip(await routeOpenAIChatCompletionsViaAnthropicMessages(src, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), fallbackMaxOutputTokens: 100 })) + : expectTrip(await routeOpenAIChatCompletionsViaOpenAIResponses(src, { sidecarCodec: createTestSidecarCodec('test-codec-scope') })); const usage = protocol === 'anthropic-messages' ? { input_tokens: 10, output_tokens: 4 } : { input_tokens: 10, output_tokens: 4, total_tokens: 14 }; const frames = await collect(trip.events(iterate(fixtureFrames(protocol, { text: ['answer'], thinking: ['reason'], tools: [{ id: 'call', name: 'tool', args: { value: 1 } }], usage })))); const chunks = frames.flatMap(frame => frame.type === 'event' && !('error' in frame.event) ? [frame.event] : []); @@ -32,7 +30,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 requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses({ model: 'model', messages: [], stream_options: { include_usage: true, continuous_usage_stats: true } }, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'model' })); + const trip = expectTrip(await routeOpenAIChatCompletionsViaOpenAIResponses({ model: 'model', messages: [], stream: true, stream_options: { include_usage: true, continuous_usage_stats: true } }, { sidecarCodec: createTestSidecarCodec('test-codec-scope') })); 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/as-source/events_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/events_test.ts index a4f6563b6c..0860f8bbfd 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/events_test.ts @@ -3,7 +3,7 @@ import { expect, test } from 'vitest'; import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; import { writeOpenAIChatCompletionsEvents } from '../../../../src/protocols/openai-chat-completions/as-source/events.ts'; import { createReferenceBuilder, collectEmittedFieldPlacements, createReadFieldState, createFieldReferencer, appendReadField } from '../../../../src/round-trip/reference/reference-builder.ts'; -import type { RoundTripWriter } from '../../../../src/round-trip/writer.ts'; +import { type RoundTripWriter } from '../../../../src/round-trip/writer.ts'; test('prepares each Chat choice independently while another choice has partial audio', async () => { const source = createIRBuilder(); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/tool-kind_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/tool-kind_test.ts new file mode 100644 index 0000000000..f36437008e --- /dev/null +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/tool-kind_test.ts @@ -0,0 +1,26 @@ +import { test } from 'vitest'; + +import type { IRInputTurn } from '../../../../src/ir/input/types.ts'; +import { readOpenAIChatCompletionsRequest } from '../../../../src/protocols/openai-chat-completions/as-source/input-to.ts'; +import { assertEquals } from '@floway-dev/test-utils'; + +const itemsOf = (turns: readonly IRInputTurn[]) => turns.flatMap(turn => [...turn.items]); + +test('a tool result keeps the call kind even when a sidecar states the turn that made the call', () => { + const reading = readOpenAIChatCompletionsRequest({ + model: 'gpt-test', + messages: [ + { role: 'user', content: 'go' }, + { role: 'assistant', tool_calls: [{ id: 'call_patch', type: 'custom', custom: { name: 'apply_patch', input: 'x' } }] }, + { role: 'tool', tool_call_id: 'call_patch', content: 'ok' }, + ], + }); + + // The target writes the assistant slot natively, so its `toIRInputTurns` never + // runs. The kind of its call is still needed by the result that answers it, + // which is why opening names every declared call before reading. + const turns = reading.turns.flatMap(turn => (turn.kind === 'assistant' ? [] : turn.toIRInputTurns())); + + const result = itemsOf(turns).find(item => item.type === 'tool_result'); + assertEquals(result?.type === 'tool_result' ? result.tool_kind : undefined, 'custom'); +}); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts index 6ca0f93d1e..229c2e4e8f 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts @@ -70,7 +70,7 @@ test('collects every native Chat choice when a sibling omits delta', async () => ]); }); -const traceTools = async (frames: ReturnType[], options: { prefix?: string; customToolNames?: ReadonlySet } = {}) => { +const traceTools = async (frames: ReturnType[], options: { prefix?: string; customToolNames?: ReadonlySet; originalToolNames?: ReadonlyMap } = {}) => { const builder = createReferenceBuilder(); const content = createEmittedFieldState(builder, 'text'); let text = options.prefix ?? ''; @@ -82,7 +82,7 @@ const traceTools = async (frames: ReturnType[], options: { prefix? builder, onAssistantTurn: (...args) => { captured = args; }, }; - for await (const record of readOpenAIChatCompletionsEvents((async function* () { yield* frames; yield doneFrame(); })(), { roundTrip, customToolNames: options.customToolNames })) { + for await (const record of readOpenAIChatCompletionsEvents((async function* () { yield* frames; yield doneFrame(); })(), { roundTrip, customToolNames: options.customToolNames, originalToolNames: options.originalToolNames })) { if (record.type === 'write' && record.kind === 'assign') assigns.push(record); if (record.type !== 'write' || record.kind !== 'append') continue; const field = record.path.at(-1); @@ -99,7 +99,7 @@ const traceTools = async (frames: ReturnType[], options: { prefix? return { turn, fieldState, appends, assigns, finalized }; }; -test('keeps merged pre-name arguments in one IR append with every original atom ID', async () => { +test('keeps merged pre-name arguments in one output IR append with every original atom ID', async () => { const result = await traceTools([ chunk([{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call', type: 'function', function: { arguments: '{"x":' } }] }, finish_reason: null }]), chunk([{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: '1}' } }] }, finish_reason: null }]), @@ -167,3 +167,23 @@ test('keeps the name of an open tool call when later fragments repeat an empty n expect(result.assigns.filter(record => record.path.at(-1) === 'name').map(record => record.value)).not.toContain(''); expect(result.finalized.targetTurn).toMatchObject({ tool_calls: [{ function: { name: 'bash', arguments: '{"command":"ls"}' } }] }); }); + +test('states the pair again when a grouped call renames after it opened', async () => { + // The request sent `web__run` for the pair `web`/`run`; a later fragment + // renames the call. Both writes have to state the pair, or the item ends with + // a flat name beside the group's namespace. + const originalToolNames = new Map([ + ['web__run', { namespace: 'web', name: 'run' }], + ['web__fetch', { namespace: 'web', name: 'fetch' }], + ]); + const result = await traceTools([ + chunk([{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'web__run', arguments: '{"u":' } }] }, finish_reason: null }]), + chunk([{ index: 0, delta: { tool_calls: [{ index: 0, function: { name: 'web__fetch', arguments: '"x"}' } }] }, finish_reason: 'tool_calls' }]), + ], { originalToolNames }); + + const nameAssigns = result.assigns.filter(record => record.path.at(-1) === 'name').map(record => record.value); + expect(nameAssigns).toEqual(['fetch']); + expect(result.finalized.targetTurn).toMatchObject({ + tool_calls: [{ function: { name: 'web__fetch', arguments: '{"u":"x"}' } }], + }); +}); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts new file mode 100644 index 0000000000..ba49505df4 --- /dev/null +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts @@ -0,0 +1,46 @@ +import { test } from 'vitest'; + +import type { RestoredAssistantTurn } from '../../../../src/composition/reading.ts'; +import type { IRInputTurn } from '../../../../src/ir/input/types.ts'; +import { chatLegacyCallsOf } from '../../../../src/protocols/openai-chat-completions/as-target/legacy-calls.ts'; +import type { InputAssistantTurnFor } from '../../../../src/round-trip/input-assistant-turn.ts'; +import { TranslateInputException } from '../../../../src/shared/input-error.ts'; +import { assertEquals, assertThrows } from '@floway-dev/test-utils'; + +type LegacyTurn = InputAssistantTurnFor<'openaiChatCompletions'>; +const legacy = (name: string): RestoredAssistantTurn => ({ kind: 'restored', callIdentities: [], turn: { role: 'assistant', function_call: { name, arguments: '{}' } } }); +const modern = (callId: string): RestoredAssistantTurn => ({ kind: 'restored', callIdentities: [], turn: { role: 'assistant', tool_calls: [{ id: callId, type: 'function', function: { name: 'lookup', arguments: '{}' } }] } }); +const message = (text: string): IRInputTurn => ({ kind: 'client', items: [{ type: 'message', content: [{ type: 'text', text }] }] }); +const result = (callId: string): IRInputTurn => ({ kind: 'client', items: [{ type: 'tool_result', call_id: callId, output: '' }] }); + +test('an answer naming no stated call is the one the legacy call gets', () => { + // The client invented this id because the wire stated none for the call. + const { callIds, names } = chatLegacyCallsOf([legacy('lookup'), result('client-invented')]); + assertEquals([...names.entries()], [['client-invented', 'lookup']]); + assertEquals(callIds.size, 1); +}); + +test('an answer naming a call the conversation states is left alone', () => { + assertEquals(chatLegacyCallsOf([modern('native-1'), result('native-1')]).names.size, 0); +}); + +test('two answers naming no stated call is refused as ambiguous', () => { + // Neither can be told apart, so nothing says which answers the legacy call. + const twoAnswers: IRInputTurn = { kind: 'client', items: [{ type: 'tool_result', call_id: 'a', output: '' }, { type: 'tool_result', call_id: 'b', output: '' }] }; + assertThrows(() => chatLegacyCallsOf([legacy('lookup'), twoAnswers]), TranslateInputException); +}); + +test('a legacy call the next turn does not answer is refused', () => { + // A wire that states no id for the call cannot say which later answer means it. + assertThrows(() => chatLegacyCallsOf([legacy('lookup'), message('later')]), TranslateInputException); +}); + +test('a legacy call answered in the turn after it, after other client content, is accepted', () => { + const turn: IRInputTurn = { kind: 'client', items: [{ type: 'message', content: [{ type: 'text', text: 'chatter' }] }, { type: 'tool_result', call_id: 'client-invented', output: '' }] }; + assertEquals([...chatLegacyCallsOf([legacy('lookup'), turn]).names.entries()], [['client-invented', 'lookup']]); +}); + +test('a legacy call still awaiting its answer at the end is accepted', () => { + // The conversation has not moved past the call, so nothing is missing yet. + assertEquals(chatLegacyCallsOf([legacy('lookup')]).names.size, 0); +}); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events_test.ts deleted file mode 100644 index 3fdcfbc082..0000000000 --- a/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events_test.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { translateToSourceEvents } from '../../../../src/protocols/openai-chat-completions/via-anthropic-messages/events.ts'; -import { testOutputTranslation } from '../../fixtures.ts'; - -testOutputTranslation('openai-chat-completions', 'anthropic-messages', (frames, codec) => translateToSourceEvents(frames, {}, codec)); 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 58a9a2d950..6de1a4f6bd 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 @@ -1,14 +1,22 @@ 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 { requireCallableSelector } from '../../../../src/protocols/openai-responses/as-source/allowed-tools.ts'; +import type { SidecarCodec } from '../../../../src/round-trip/codec.ts'; import { TranslateInputException } from '../../../../src/shared/input-error.ts'; +import { buildTargetPayloadFor, pairs } from '../../../composition/helpers.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'; +import { assertEquals, assertRejects, assertThrows } from '@floway-dev/test-utils'; + +const buildChat = async (payload: OpenAIResponsesRequestPayloadEx, codec: SidecarCodec) => { + return { target: await buildTargetPayloadFor(pairs.responsesToChat[0], pairs.responsesToChat[1], payload, { sidecarCodec: codec }) }; +}; +const buildMessages = async (payload: OpenAIResponsesRequestPayloadEx, codec: SidecarCodec, options: { fallbackMaxOutputTokens?: number } = {}) => { + return { target: await buildTargetPayloadFor(pairs.responsesToMessages[0], pairs.responsesToMessages[1], payload, { sidecarCodec: codec, ...options }) }; +}; const source = (tool_choice: OpenAIResponsesToolChoice): OpenAIResponsesRequestPayloadEx => ({ - model: 'model', input: 'Read only.', + model: 'model', stream: true, input: 'Read only.', tools: [ { type: 'function', name: 'read', parameters: { type: 'object', properties: {} } }, { type: 'function', name: 'charge', parameters: { type: 'object', properties: {} } }, @@ -22,17 +30,23 @@ for (const target of ['chat', 'messages'] as const) { ? await buildChat(payload, createTestSidecarCodec()) : await buildMessages(payload, createTestSidecarCodec(), { fallbackMaxOutputTokens: undefined, - loadRemoteImage: async () => null, }); for (const mode of ['auto', 'required'] as const) { - test(`${target} request serializes only the allowed function/custom subset and ${mode} mode`, async () => { + test(`${target} preserves declarations or rejects an unrepresentable ${mode} subset`, async () => { const payload = source({ type: 'allowed_tools', mode, tools: [{ type: 'custom', name: 'edit' }, { type: 'function', name: 'read' }] }); const original = structuredClone(payload); + if (target === 'messages') { + await assertRejects(() => build(payload), TranslateInputException, 'cannot express an allowed_tools'); + assertEquals(payload, original); + return; + } const result = await build(payload); + const baseline = await build({ ...payload, tool_choice: 'auto' }); + assertEquals(result.target.tools, baseline.target.tools); const wire = JSON.parse(JSON.stringify(result.target)) as { tools: Array<{ name?: string; function?: { name: string } }>; tool_choice: unknown }; - assertEquals(wire.tools.map(tool => tool.function?.name ?? tool.name), ['read', 'edit']); - assertEquals(wire.tool_choice, target === 'chat' ? mode : { type: mode === 'required' ? 'any' : 'auto' }); - assertEquals(result.customToolNames, new Set(['edit'])); + assertEquals(wire.tools.map(tool => tool.function?.name ?? tool.name), ['read', 'charge', 'edit']); + assertEquals(wire.tool_choice, { type: 'allowed_tools', allowed_tools: { mode, tools: [{ type: 'function', function: { name: 'edit' } }, { type: 'function', function: { name: 'read' } }] } }); + assertEquals(payload, original); }); } @@ -41,29 +55,34 @@ for (const target of ['chat', 'messages'] as const) { const payload = source({ type: 'allowed_tools', mode: 'required', tools: [{ type: 'custom', name: 'read' }] }); payload.tools!.push({ type: 'custom', name: 'read' }); const result = await build(payload); - assertEquals(result.target.tools?.length, 1); - assertEquals(result.customToolNames, new Set(['read'])); + assertEquals(result.target.tools?.length, 4); + }); for (const history of ['additional_tools', 'tool_search_output'] as const) { - test(`${target} request rejects flat function/custom collisions from ${history} before converting calls`, async () => { + test(`${target} request allocates distinct names for function/custom declarations from ${history}`, async () => { const custom: OpenAIResponsesTool = { type: 'custom', name: 'web_search' }; const item: CanonicalOpenAIResponsesInputItem = history === 'additional_tools' ? { type: 'additional_tools', role: 'developer', tools: [custom] } : { type: 'tool_search_output', execution: 'client', tools: [custom] }; const payload: OpenAIResponsesRequestPayloadEx = { - model: 'model', + model: 'model', stream: true, tools: [{ type: 'function', name: 'web_search', parameters: { type: 'object' } }], input: [item], }; - await assertRejects(() => build(payload), TranslateInputException, "distinct callable kinds sharing 'web_search'"); + const result = await build(payload); + const wire = JSON.parse(JSON.stringify(result.target)) as { tools: Array<{ name?: string; function?: { name: string } }> }; + assertEquals(wire.tools.map(tool => tool.function?.name ?? tool.name), ['web_search', 'web_search_2']); }); } - test(`${target} request represents an empty auto subset as no tools`, async () => { + test(`${target} request disables calling without removing declarations for an empty auto subset`, async () => { const result = await build(source({ type: 'allowed_tools', mode: 'auto', tools: [] })); - assertEquals(result.target.tools, undefined); - assertEquals(result.target.tool_choice, target === 'chat' ? 'none' : { type: 'none' }); + const baseline = await build(source('auto')); + assertEquals(result.target.tools, baseline.target.tools); + assertEquals(result.target.tool_choice, target === 'chat' + ? { type: 'allowed_tools', allowed_tools: { mode: 'auto', tools: [] } } + : { type: 'none' }); }); test(`${target} request rejects a flat selector that would erase a declaration namespace`, async () => { @@ -82,7 +101,6 @@ for (const target of ['chat', 'messages'] as const) { ['unknown mode', { type: 'allowed_tools', mode: 'future', tools: [{ type: 'function', name: 'read' }] }], ['malformed tools array', { type: 'allowed_tools', mode: 'auto', tools: null }], ['malformed selector', { type: 'allowed_tools', mode: 'auto', tools: [null] }], - ['colliding callable kinds', { type: 'allowed_tools', mode: 'auto', tools: [{ type: 'function', name: 'read' }, { type: 'custom', name: 'read' }] }], ] as const) { test(`${target} request typed-rejects ${label} instead of widening allowed_tools`, async () => { const payload = source(choice as unknown as OpenAIResponsesToolChoice); @@ -91,3 +109,89 @@ for (const target of ['chat', 'messages'] as const) { }); } } + +test('requireCallableSelector rejects a namespace that is not a string', () => { + // The selector's namespace is carried into the IR as a string, so a member + // stating anything else is malformed rather than something to pass through. + assertEquals(requireCallableSelector({ type: 'function', name: 'read', namespace: 'files' }).namespace, 'files'); + assertThrows(() => requireCallableSelector({ type: 'function', name: 'read', namespace: 42 }), TranslateInputException); +}); + +test.each(['auto', 'required'] as const)('Messages expresses a complete %s callable set without changing its declarations', async mode => { + const payload = source({ + type: 'allowed_tools', mode, tools: [ + { type: 'function', name: 'read' }, { type: 'function', name: 'charge' }, { type: 'custom', name: 'edit' }, + ], + }); + const result = await buildMessages(payload, createTestSidecarCodec()); + const baseline = await buildMessages(source('auto'), createTestSidecarCodec()); + assertEquals(result.target.tools, baseline.target.tools); + assertEquals(result.target.tool_choice, { type: mode === 'auto' ? 'auto' : 'any' }); +}); + +test('Messages rejects an optional single-tool subset rather than forcing that call', async () => { + await assertRejects( + () => buildMessages(source({ type: 'allowed_tools', mode: 'auto', tools: [{ type: 'function', name: 'read' }] }), createTestSidecarCodec()), + TranslateInputException, + 'cannot express an allowed_tools auto subset', + ); +}); + +test('a dotted selector is only read as a pair when that group is declared', async () => { + // A flat declaration may spell its name with a dot; that dot is not a group + // separator, and reading it as one bound the selector to a different callable + // — or refused a legal request. + const flat = await buildChat(source({ type: 'function', name: 'a.b' }), createTestSidecarCodec()); + assertEquals(flat.target.tool_choice, { type: 'function', function: { name: 'a.b' } }); +}); + +test.each(['chat', 'messages'] as const)('preserves declarations and selects only the namespaced custom callable on %s', async target => { + const payload: OpenAIResponsesRequestPayloadEx = { + model: 'm', stream: true, input: 'hi', tools: [{ + type: 'namespace', name: 'files', description: '', tools: [ + { type: 'function', name: 'edit', parameters: { type: 'object', properties: {} } }, + { type: 'custom', name: 'edit' }, + ], + }], + tool_choice: { type: 'allowed_tools', mode: 'required', tools: [{ type: 'custom', namespace: 'files', name: 'edit' }] }, + }; + const codec = createTestSidecarCodec(); + if (target === 'chat') { + const result = await buildChat(payload, codec); + const baseline = await buildChat({ ...payload, tool_choice: 'auto' }, codec); + assertEquals(result.target.tools, baseline.target.tools); + assertEquals(result.target.tools?.map(tool => tool.type === 'function' ? tool.function.name : tool.custom.name), ['files_edit', 'files_edit_2']); + assertEquals(result.target.tool_choice, { type: 'allowed_tools', allowed_tools: { mode: 'required', tools: [{ type: 'function', function: { name: 'files_edit_2' } }] } }); + } else { + const result = await buildMessages(payload, codec); + const baseline = await buildMessages({ ...payload, tool_choice: 'auto' }, codec); + assertEquals(result.target.tools, baseline.target.tools); + assertEquals(result.target.tools?.flatMap(tool => 'name' in tool ? [tool.name] : []), ['files_edit', 'files_edit_2']); + assertEquals(result.target.tool_choice, { type: 'tool', name: 'files_edit_2' }); + } +}); + +for (const allowed of [false, true]) { + test.each([ + ['exact flat declaration', 'web.run', 'web.run', 'web', 'run', true], + ['dotted namespace', 'a.b.run', 'a_b_run', 'a.b', 'run', false], + ['double underscore alias', 'web__run', 'web_run', 'web', 'run', false], + ] as const)(`resolves %s from full callable identities (allowed_tools=${allowed})`, async (_label, selected, expected, namespace, name, flat) => { + const payload: OpenAIResponsesRequestPayloadEx = { + model: 'm', stream: true, input: 'hi', + tools: [ + ...(flat ? [{ type: 'function' as const, name: selected, parameters: { type: 'object', properties: {} } }] : []), + { type: 'namespace', name: namespace, description: '', tools: [{ type: 'function', name, parameters: { type: 'object', properties: {} } }] }, + ], + tool_choice: allowed + ? { type: 'allowed_tools', mode: 'required', tools: [{ type: 'function', name: selected }] } + : { type: 'function', name: selected }, + }; + const result = await buildChat(payload, createTestSidecarCodec()); + const baseline = await buildChat({ ...payload, tool_choice: 'auto' }, createTestSidecarCodec()); + assertEquals(result.target.tools, baseline.target.tools); + assertEquals(result.target.tool_choice, allowed + ? { type: 'allowed_tools', allowed_tools: { mode: 'required', tools: [{ type: 'function', function: { name: expected } }] } } + : { type: 'function', function: { name: expected } }); + }); +} diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/conversation-turns_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/conversation-turns_test.ts index 178555219a..4666a0beae 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/conversation-turns_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/conversation-turns_test.ts @@ -12,10 +12,10 @@ test.each(['additional_tools', 'tool_search_output'] as const)('%s belongs to th : { type, call_id: 'load', execution: 'client', tools: [] }; expect(partitionOpenAIResponsesTurns([inventory, first, output, inventory, second, inventory])).toEqual([ - { role: 'bare', items: [inventory] }, + { role: 'client', items: [inventory] }, { role: 'assistant', items: [first] }, - { role: 'bare', items: [output, inventory] }, + { role: 'client', items: [output, inventory] }, { role: 'assistant', items: [second] }, - { role: 'bare', items: [inventory] }, + { role: 'client', items: [inventory] }, ]); }); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts deleted file mode 100644 index 05f9282e53..0000000000 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { expect, test } from 'vitest'; - -import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; -import { writeOpenAIResponsesEvents } from '../../../../src/protocols/openai-responses/as-source/events.ts'; -import { flattenNamespaceTools } from '../../../../src/protocols/openai-responses/as-source/namespace-tools.ts'; -import { appendReadField, collectEmittedFieldPlacements, createReferenceBuilder, createReadFieldState, createFieldReferencer } from '../../../../src/round-trip/reference/reference-builder.ts'; -import type { RoundTripWriter } from '../../../../src/round-trip/writer.ts'; - -test('collects sparse namespace-restored Responses state from emitted IR atoms', async () => { - const namespace = flattenNamespaceTools({ - model: 'model', - input: [], - tools: [{ type: 'namespace', name: 'tools', description: '', tools: [{ type: 'custom', name: 'script' }, { type: 'function', name: 'lookup', parameters: { type: 'object' } }] }], - }); - const flatScript = namespace.names.sourceToTarget.get('tools.script'); - const flatLookup = namespace.names.sourceToTarget.get('tools.lookup'); - if (flatScript === undefined || flatLookup === undefined) throw new Error('Namespace tools were not flattened'); - - const references = createReferenceBuilder(); - const field = (parts: readonly string[]) => { - const state = createReadFieldState('text'); - const textFragmentIds = parts.map(part => appendReadField(references, state, part)); - return { state, textFragmentIds, original: parts.join('') }; - }; - const functionArguments = field(['{"code":', '1}']); - const messageText = field(['A', 'B']); - const refusal = field(['N', 'O']); - const reasoningSummary = field(['S', 'C']); - const reasoningContent = field(['D']); - const customInput = field(['{"q":', '1}']); - let prepared: Parameters['encodeSidecar']> | undefined; - const roundTrip: RoundTripWriter<'openaiResponses'> = { - builder: references, - encodeSidecar: async (...args) => { - prepared = args; - return 'sidecar'; - }, - }; - - const builder = createIRBuilder(); - builder.choice(0); - builder.assign(['model'], 'model'); - builder.event({ type: 'start', id: 'response' }); - - builder.initialize(['choices', 0, 'items', 0], { type: 'function_call', name: flatScript, arguments: '' }); - builder.append(['choices', 0, 'items', 0, 'arguments'], '{"code":', [functionArguments.textFragmentIds[0]]); - builder.append(['choices', 0, 'items', 0, 'arguments'], '1}', [functionArguments.textFragmentIds[1]]); - - builder.initialize(['choices', 0, 'items', 1], { type: 'message', content: [] }); - builder.initialize(['choices', 0, 'items', 1, 'content', 0], { type: 'text', text: '' }); - builder.append(['choices', 0, 'items', 1, 'content', 0, 'text'], 'A', [messageText.textFragmentIds[0]]); - builder.append(['choices', 0, 'items', 1, 'content', 0, 'text'], 'B', [messageText.textFragmentIds[1]]); - builder.initialize(['choices', 0, 'items', 1, 'content', 1], { type: 'refusal', refusal: '' }); - builder.append(['choices', 0, 'items', 1, 'content', 1, 'refusal'], 'N', [refusal.textFragmentIds[0]]); - builder.append(['choices', 0, 'items', 1, 'content', 1, 'refusal'], 'O', [refusal.textFragmentIds[1]]); - - builder.initialize(['choices', 0, 'items', 2], { type: 'reasoning', summary: [''], content: [''] }); - builder.append(['choices', 0, 'items', 2, 'summary', 0], 'S', [reasoningSummary.textFragmentIds[0]]); - builder.append(['choices', 0, 'items', 2, 'summary', 0], 'C', [reasoningSummary.textFragmentIds[1]]); - builder.append(['choices', 0, 'items', 2, 'content', 0], 'D', [reasoningContent.textFragmentIds[0]]); - - builder.initialize(['choices', 0, 'items', 3], { type: 'custom_tool_call', call_id: 'lookup', name: flatLookup, input: '' }); - builder.append(['choices', 0, 'items', 3, 'input'], '{"q":', [customInput.textFragmentIds[0]]); - builder.append(['choices', 0, 'items', 3, 'input'], '1}', [customInput.textFragmentIds[1]]); - - builder.assign(['choices', 0, 'items', 0, 'call_id'], 'script'); - builder.event({ type: 'part_end', choice: 0, item: 1, part: 0 }); - builder.event({ type: 'part_end', choice: 0, item: 1, part: 1 }); - builder.event({ type: 'item_end', choice: 0, item: 0 }); - builder.event({ type: 'item_end', choice: 0, item: 1 }); - builder.event({ type: 'item_end', choice: 0, item: 2 }); - builder.event({ type: 'item_end', choice: 0, item: 3 }); - - builder.initialize(['choices', 0, 'items', 4], { type: 'message', content: [] }); - builder.initialize(['choices', 0, 'items', 4, 'content', 0], { type: 'image', image: { data: 'literal', media_type: 'image/png' } }); - builder.event({ type: 'part_end', choice: 0, item: 4, part: 0 }); - builder.event({ type: 'item_end', choice: 0, item: 4 }); - - builder.event({ type: 'choice_end', choice: 0, finish_reason: 'tool_calls' }); - builder.event({ type: 'finish', status: 'completed' }); - - for await (const _frame of writeOpenAIResponsesEvents((async function* () { yield* builder.drain(); })(), { roundTrip, namespaceToolNames: namespace.names })) {} - if (prepared === undefined) throw new Error('Responses sidecar was not prepared'); - const [, , turn, state] = prepared; - - expect(turn).toMatchObject([ - { type: 'custom_tool_call', namespace: 'tools', name: 'script', input: '{"code":1}' }, - { type: 'message', content: [{ type: 'output_text', text: 'AB' }, { type: 'refusal', refusal: 'NO' }] }, - { type: 'reasoning', summary: [{ text: 'SC' }], content: [{ text: 'D' }] }, - { type: 'function_call', namespace: 'tools', name: 'lookup', arguments: '{"q":1}' }, - { type: 'image_generation_call', result: 'literal' }, - ]); - expect(state[0]).toMatchObject({ input: {} }); - expect(state[1]).toMatchObject({ content: [{ text: {} }, { refusal: {} }] }); - expect(state[2]).toMatchObject({ summary: [{ text: {} }], content: [{ text: {} }] }); - expect(state[3]).toMatchObject({ arguments: {} }); - expect(4 in state).toBe(false); - - const finalizer = createFieldReferencer(references, collectEmittedFieldPlacements(turn, state)); - for (const source of [functionArguments, messageText, refusal, reasoningSummary, reasoningContent, customInput]) { - expect(await finalizer.referenceField(source.original, source.state)).not.toBe(source.original); - } -}); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/initialization_test.ts similarity index 89% rename from packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts rename to packages/translate/__tests__/protocols/openai-responses/as-source/initialization_test.ts index 42fa12acb2..3863d5e45a 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/initialization_test.ts @@ -1,8 +1,8 @@ import { expect, test } from 'vitest'; import type { IRRecord } from '../../../../src/ir/output/records.ts'; -import { collect, iterate } from '../../../shared/iterables.ts'; -import { writeOpenAIResponsesEvents } from '../../helpers.ts'; +import { collect, iterate } from '../../../iterables.ts'; +import { writeOpenAIResponsesEvents } from '../../events.ts'; test('initialize carries real required fields while append targets start empty', async () => { const records: IRRecord[] = [ diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/input-to_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/input-to_test.ts new file mode 100644 index 0000000000..123ab79ec4 --- /dev/null +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/input-to_test.ts @@ -0,0 +1,61 @@ +import { expect, test } from 'vitest'; + +import { routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions, routeOpenAIChatCompletionsViaOpenAIResponses } from '../../../../src/index.ts'; +import { readOpenAIResponsesRequest } from '../../../../src/protocols/openai-responses/as-source/input-to.ts'; +import { openaiResponsesConversationFromIR } from '../../../../src/protocols/openai-responses/as-target/input-from.ts'; +import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import type { OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; + +const source: OpenAIResponsesRequestPayloadEx = { + model: 'm', stream: true, input: [{ + type: 'reasoning', summary: [{ type: 'summary_text', text: 'S' }], + content: [{ type: 'reasoning_text', text: 'C' }], encrypted_content: 'opaque-vendor-data', + }], +}; + +test('readable reasoning stays separate in IR beside an opaque encrypted field', async () => { + const slot = readOpenAIResponsesRequest(source).turns[0]; + expect(slot.toIRInputTurns()).toEqual([{ + kind: 'assistant', items: [{ type: 'reasoning', summary: ['S'], content: ['C'] }], + }]); + const trip = await routeOpenAIResponsesViaOpenAIChatCompletions(source, { sidecarCodec: createTestSidecarCodec() }); + if (trip.type !== 'trip') throw new Error(trip.message); + expect(trip.target.messages).toEqual([{ role: 'assistant', content: null, reasoning_text: 'SC' }]); + expect(JSON.stringify(trip.target)).not.toContain('opaque-vendor-data'); +}); + +test('entirely opaque reasoning preserves an empty assistant turn', () => { + const slot = readOpenAIResponsesRequest({ + model: 'm', stream: true, input: [{ type: 'reasoning', summary: [], encrypted_content: 'opaque-vendor-data' }], + }).turns[0]; + expect(slot.toIRInputTurns()).toEqual([{ kind: 'assistant', items: [] }]); +}); + +test('an incomplete custom result reaches Messages with its failure marker', async () => { + const trip = await routeOpenAIResponsesViaAnthropicMessages({ + model: 'm', stream: true, input: [ + { type: 'custom_tool_call', call_id: 'c', name: 'edit', input: 'run' }, + { type: 'custom_tool_call_output', call_id: 'c', output: 'failed', status: 'incomplete' }, + ], + }, { sidecarCodec: createTestSidecarCodec() }); + if (trip.type !== 'trip') throw new Error(trip.message); + expect(trip.target.messages[1]).toMatchObject({ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'c', content: 'failed', is_error: true }] }); +}); + +test('custom result status reaches the Responses wire from the IR failure marker', () => { + const output = openaiResponsesConversationFromIR([{ + kind: 'client', items: [{ type: 'tool_result', tool_kind: 'custom', call_id: 'c', output: 'failed', is_error: true }], + }], true); + expect(output.input).toEqual([{ type: 'custom_tool_call_output', call_id: 'c', output: 'failed', status: 'incomplete' }]); +}); + +test('native Chat custom results preserve their result shape on Responses', async () => { + const trip = await routeOpenAIChatCompletionsViaOpenAIResponses({ + model: 'm', stream: true, messages: [ + { role: 'assistant', tool_calls: [{ type: 'custom', id: 'c', custom: { name: 'edit', input: 'run' } }] }, + { role: 'tool', tool_call_id: 'c', content: 'done' }, + ], + }, { sidecarCodec: createTestSidecarCodec() }); + if (trip.type !== 'trip') throw new Error(trip.message); + expect(trip.target.input.at(-1)).toEqual({ type: 'custom_tool_call_output', call_id: 'c', output: 'done' }); +}); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-allocation_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-allocation_test.ts deleted file mode 100644 index d1017fc7b1..0000000000 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-allocation_test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { test, vi } from 'vitest'; - -import { flattenNamespaceTools } from '../../../../src/protocols/openai-responses/as-source/namespace-tools.ts'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesTool } from '@floway-dev/protocols/openai-responses'; -import { assert, assertEquals } from '@floway-dev/test-utils'; - -const functionTool = (name: string): Extract => ({ type: 'function', name, parameters: { type: 'object' } }); - -for (const [namespace, reservePreferred] of [['n'.repeat(64), false], ['n'.repeat(59), true]] as const) { - test(`callable projection bounds collision lookups for ${namespace.length}-character namespaces`, () => { - const count = 1000; - const children = Array.from({ length: count }, (_, index) => functionTool(String(index).padStart(4, '0'))); - const reserved = reservePreferred ? children.map(child => functionTool(`${namespace}_${'name' in child ? child.name : ''}`)) : []; - const request: CanonicalOpenAIResponsesPayload = { model: 'm', input: [], tools: [...reserved, { type: 'namespace', name: namespace, description: '', tools: children }] }; - let call: ReturnType; - const has = vi.spyOn(Set.prototype, 'has'); - let checks = 0; - try { - call = flattenNamespaceTools(request); - checks = has.mock.calls.length; - } finally { has.mockRestore(); } - const names = call.payload.tools!.slice(reserved.length).map(tool => 'name' in tool ? tool.name : ''); - assertEquals(names.length, count); - assertEquals(new Set(names).size, count); - assert(names.every(name => typeof name === 'string' && name.length <= 64)); - assert(checks >= count, 'instrument must observe collision membership checks'); - assert(checks <= 2 * count + reserved.length, `expected bounded lookup count, observed ${checks}`); - }); -} - -test('callable projection preserves suffix ordering across digit widths, invalid characters and duplicates', () => { - const namespace = 'n'.repeat(59); - const prefix = `${namespace}_`; - const children = ['aaaa', 'aaab', 'aaac', 'a', 'aa', 'aaaa']; - const reserved = [...children.slice(0, -1).map(name => `${prefix}${name}`), ...Array.from({ length: 8 }, (_, i) => `${prefix}aa_${i + 2}`), `${prefix}a_10`, `${prefix}a_12`]; - const call = flattenNamespaceTools({ model: 'm', input: [], tools: [...reserved.map(functionTool), { type: 'namespace', name: namespace, description: '', tools: children.map(functionTool) }, { type: 'namespace', name: 'bad.ns', description: '', tools: [functionTool('bad/name')] }] }); - assertEquals(call.payload.tools?.slice(reserved.length).map(tool => 'name' in tool ? tool.name : null), [`${prefix}a_11`, `${prefix}a_13`, `${prefix}a_14`, `${prefix}a_2`, `${prefix}a_15`, `${prefix}a_11`, 'bad_ns_bad_name']); -}); - -test('callable projection bounds sanitized flat-name prefixes for a shared long namespace', () => { - const namespace = 'n'.repeat(4096); - const count = 32; - const request: CanonicalOpenAIResponsesPayload = { model: 'm', input: [], tools: [{ type: 'namespace', name: namespace, description: '', tools: Array.from({ length: count }, (_, index) => functionTool(`tool${index}`)) }] }; - let call: ReturnType; - const replacements = vi.spyOn(String.prototype, 'replaceAll'); - let lengths: number[] = []; - try { - call = flattenNamespaceTools(request); - lengths = replacements.mock.contexts.map(context => String(context).length); - } finally { - replacements.mockRestore(); - } - assert(lengths.length > 0, 'instrument must observe the allocator sanitizing names'); - assert(Math.max(...lengths) <= 64, `allocator scanned an unbounded prefix: ${Math.max(...lengths)}`); - assertEquals(new Set(call.payload.tools!.map(tool => 'name' in tool ? tool.name : null)).size, count); -}); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-descriptions_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-descriptions_test.ts deleted file mode 100644 index 1dc8179b1f..0000000000 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-descriptions_test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { test } from 'vitest'; - -import { flattenNamespaceTools } from '../../../../src/protocols/openai-responses/as-source/namespace-tools.ts'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesTool } from '@floway-dev/protocols/openai-responses'; -import { assertEquals } from '@floway-dev/test-utils'; - -const functionTool = (name: string): Extract => ({ type: 'function', name, parameters: { type: 'object' } }); - -test('callable projection retains parent and child descriptions for translated targets', () => { - const request: CanonicalOpenAIResponsesPayload = { - model: 'm', input: [], tools: [ - { type: 'namespace', name: 'files', description: 'Read-only access. Never modify files.', tools: [{ ...functionTool('read'), description: 'Read a file.' }, { type: 'custom', name: 'inspect' }] }, - { type: 'namespace', name: 'empty', description: '', tools: [{ ...functionTool('read'), description: 'Child-only description.' }] }, - ], - }; - const original = structuredClone(request); - const call = flattenNamespaceTools(request); - assertEquals(call.payload.tools?.map(tool => 'description' in tool ? tool.description : undefined), ['Read-only access. Never modify files.\n\nRead a file.', 'Read-only access. Never modify files.', 'Child-only description.']); - assertEquals(request, original); -}); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-events_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-events_test.ts deleted file mode 100644 index a037a4c4bd..0000000000 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-events_test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { expect, test, vi } from 'vitest'; - -import { flattenNamespaceTools, restoreNamespaceEvents } from '../../../../src/protocols/openai-responses/as-source/namespace-tools.ts'; -import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesResultEx, OpenAIResponsesStreamEventEx, OpenAIResponsesTool } from '@floway-dev/protocols/openai-responses'; -import { assert, assertEquals } from '@floway-dev/test-utils'; - -const functionTool = (name: string): Extract => ({ type: 'function', name, parameters: { type: 'object' } }); -const emptyResult = (): OpenAIResponsesResultEx => ({ id: 'resp', object: 'response', model: 'model', status: 'completed', output: [], error: null, incomplete_details: null }); -const framesOf = async function* (events: OpenAIResponsesStreamEventEx[]): AsyncGenerator> { - for (const event of events) yield eventFrame(event); - yield doneFrame(); -}; - -test('callable projection restores item lifecycle, function/custom types, and resource echoes before outer readers', async () => { - const request: CanonicalOpenAIResponsesPayload = { model: 'm', input: [], tools: [{ type: 'namespace', name: 'files', description: '', tools: [{ type: 'custom', name: 'edit' }, functionTool('read')] }], tool_choice: { type: 'custom', name: 'edit', namespace: 'files' } as CanonicalOpenAIResponsesPayload['tool_choice'] }; - const call = flattenNamespaceTools(request); - const output = [ - { type: 'function_call' as const, name: 'files_edit', id: 'fc1', call_id: 'a', arguments: 'patch', status: 'completed' as const }, - { type: 'custom_tool_call' as const, name: 'files_read', id: 'fc2', call_id: 'b', input: '{}' }, - ]; - const upstream = { ...emptyResult(), output, tools: [functionTool('files_edit')], tool_choice: { type: 'function' as const, name: 'files_edit' }, extension: 'kept' }; - const response = restoreNamespaceEvents(framesOf([ - { type: 'response.output_item.added', output_index: 0, item: output[0]! }, - { type: 'response.output_item.done', output_index: 0, item: output[0]! }, - { type: 'response.completed', response: upstream }, - ]), call.names); - const frames: ProtocolFrame[] = []; - for await (const frame of response) frames.push(frame); - const expected = [ - { type: 'custom_tool_call', name: 'edit', namespace: 'files', id: 'fc1', call_id: 'a', input: 'patch', status: 'completed' }, - { type: 'function_call', name: 'read', namespace: 'files', id: 'fc2', call_id: 'b', arguments: '{}', status: 'completed' }, - ]; - assertEquals(frames[0], eventFrame({ type: 'response.output_item.added', output_index: 0, item: expected[0] } as OpenAIResponsesStreamEventEx)); - assertEquals(frames[1], eventFrame({ type: 'response.output_item.done', output_index: 0, item: expected[0] } as OpenAIResponsesStreamEventEx)); - assertEquals(frames[2], eventFrame({ type: 'response.completed', response: { ...upstream, output: expected, tools: request.tools, tool_choice: request.tool_choice } } as OpenAIResponsesStreamEventEx)); - assertEquals(frames[3], doneFrame()); - assertEquals(upstream.output, output); -}); - -for (const type of ['function', 'custom'] as const) { - test(`callable projection restores ${type} argument event types along with callable items`, async () => { - const call = flattenNamespaceTools({ model: 'm', input: [], tools: [{ type: 'namespace', name: 'files', description: '', tools: [{ type, name: 'read' }] }] }); - const sourceIsFunction = type === 'function'; - const item = sourceIsFunction - ? { type: 'custom_tool_call' as const, id: 'item', name: 'files_read', call_id: 'call', input: '{}' } - : { type: 'function_call' as const, id: 'item', name: 'files_read', call_id: 'call', arguments: '{}', status: 'completed' as const }; - const unknown = { type: 'future.event', opaque: { retained: true } } as unknown as OpenAIResponsesStreamEventEx; - const response = restoreNamespaceEvents(framesOf([ - { type: 'response.output_item.added', output_index: 0, item }, - { type: sourceIsFunction ? 'response.custom_tool_call_input.delta' : 'response.function_call_arguments.delta', item_id: 'item', output_index: 0, delta: '{}' }, - sourceIsFunction - ? { type: 'response.custom_tool_call_input.done', item_id: 'item', output_index: 0, input: '{}' } - : { type: 'response.function_call_arguments.done', item_id: 'item', output_index: 0, arguments: '{}', name: 'files_read' } as OpenAIResponsesStreamEventEx, - unknown, - ]), call.names); - const events: OpenAIResponsesStreamEventEx[] = []; - for await (const frame of response) if (frame.type === 'event') events.push(frame.event); - assertEquals(events[1], { type: sourceIsFunction ? 'response.function_call_arguments.delta' : 'response.custom_tool_call_input.delta', item_id: 'item', output_index: 0, delta: '{}' }); - assertEquals(events[2], { type: sourceIsFunction ? 'response.function_call_arguments.done' : 'response.custom_tool_call_input.done', item_id: 'item', output_index: 0, [sourceIsFunction ? 'arguments' : 'input']: '{}', ...(sourceIsFunction ? { name: 'read' } : {}) }); - assert(events[3] === unknown); - }); -} - -test('callable projection restores function arguments.done names without adding a namespace field', async () => { - const call = flattenNamespaceTools({ model: 'm', input: [], tools: [{ type: 'namespace', name: 'files', description: '', tools: [functionTool('read')] }] }); - const response = restoreNamespaceEvents(framesOf([ - { type: 'response.output_item.added', output_index: 0, item: { type: 'function_call', id: 'item', call_id: 'call', name: 'files_read', arguments: '', status: 'in_progress' } }, - { type: 'response.function_call_arguments.done', item_id: 'item', output_index: 0, name: 'files_read', arguments: '{}' } as OpenAIResponsesStreamEventEx, - ]), call.names); - const events: OpenAIResponsesStreamEventEx[] = []; - for await (const frame of response) if (frame.type === 'event') events.push(frame.event); - assertEquals(events[1], { type: 'response.function_call_arguments.done', item_id: 'item', output_index: 0, name: 'read', arguments: '{}' }); -}); - -test('callable projection restores lifecycle-appropriate function status from custom items', async () => { - const call = flattenNamespaceTools({ model: 'm', input: [], tools: [{ type: 'namespace', name: 'files', description: '', tools: [functionTool('read')] }] }); - const item = { type: 'custom_tool_call' as const, id: 'item', call_id: 'call', name: 'files_read', input: '{}' }; - const response = restoreNamespaceEvents(framesOf([ - { type: 'response.output_item.added', output_index: 0, item }, - { type: 'response.created', response: { ...emptyResult(), status: 'in_progress', output: [item] } }, - { type: 'response.output_item.done', output_index: 0, item }, - { type: 'response.completed', response: { ...emptyResult(), output: [item] } }, - ]), call.names); - const statuses: unknown[] = []; - for await (const frame of response) { - if (frame.type !== 'event') continue; - if ('item' in frame.event) statuses.push((frame.event.item as { status?: string }).status); - else if ('response' in frame.event) statuses.push((frame.event.response.output[0] as { status?: string }).status); - } - assertEquals(statuses, ['in_progress', 'in_progress', 'completed', 'completed']); -}); - -test('callable projection projects Standard carriers before namespace allocation without mutating history', async () => { - const developer = { type: 'message' as const, role: 'developer' as const, content: 'Keep this ordinary developer instruction.' }; - const delayed = { type: 'tool_search_output' as const, tools: [functionTool('delayed')] }; - const request: CanonicalOpenAIResponsesPayload = { - model: 'm', tools: [functionTool('files_edit')], - input: [ - developer, - { type: 'additional_tools', role: 'developer', tools: [{ type: 'namespace', name: 'files', description: '', tools: [{ type: 'custom', name: 'edit', description: 'Edit a file.' }] }] }, - { type: 'additional_tools', role: 'developer', tools: [functionTool('read')] }, - { type: 'custom_tool_call', namespace: 'files', name: 'edit', call_id: 'past', input: 'patch' }, - delayed, - ], - tool_choice: { type: 'allowed_tools', mode: 'required', tools: [{ type: 'custom', namespace: 'files', name: 'edit' }] }, - }; - const original = structuredClone(request); - const call = flattenNamespaceTools(request); - const response = restoreNamespaceEvents(framesOf([ - { type: 'response.completed', response: { ...emptyResult(), tools: call.payload.tools ?? undefined, tool_choice: call.payload.tool_choice, output: [{ type: 'function_call', name: 'files_edit_2', call_id: 'current', arguments: 'new patch', status: 'completed' }] } }, - ]), call.names); - assertEquals(call.payload.tools?.map(tool => 'name' in tool ? tool.name : undefined), ['files_edit', 'files_edit_2', 'read', 'delayed']); - assertEquals(call.payload.tools?.[1], { type: 'custom', name: 'files_edit_2', description: 'Edit a file.' }); - const input = call.flattenInput(request.input); - assertEquals(input, [developer, { type: 'custom_tool_call', name: 'files_edit_2', call_id: 'past', input: 'patch' }]); - assert(input[0] === developer); - assertEquals(call.payload.tool_choice, { type: 'allowed_tools', mode: 'required', tools: [{ type: 'custom', name: 'files_edit_2' }] }); - assertEquals(request, original); - const frames: ProtocolFrame[] = []; - for await (const frame of response) frames.push(frame); - const frame = frames[0]; - assert(frame.type === 'event' && frame.event.type === 'response.completed'); - assertEquals(frame.event.response.tools, request.tools); - assertEquals(frame.event.response.tool_choice, request.tool_choice); - assertEquals(frame.event.response.output, [{ type: 'custom_tool_call', name: 'edit', namespace: 'files', call_id: 'current', input: 'new patch', status: 'completed' }]); -}); - -for (const tools of [undefined, [], [functionTool('existing')]]) { - for (const echo of [false, true]) { - test(`carrier projection restores original tool echoes (${tools === undefined ? 'omitted' : tools.length} declarations, upstream echo ${echo})`, async () => { - const request: CanonicalOpenAIResponsesPayload = { model: 'm', input: [{ type: 'additional_tools', role: 'developer', tools: [functionTool('read')] }], ...(tools === undefined ? {} : { tools }) }; - const call = flattenNamespaceTools(request); - const response = restoreNamespaceEvents(framesOf([ - { type: 'response.completed', response: { ...emptyResult(), ...(echo ? { tools: call.payload.tools ?? undefined } : {}) } }, - ]), call.names); - assertEquals(call.flattenInput(request.input), []); - assertEquals(call.payload.tools, [...(tools ?? []), functionTool('read')]); - let completed = 0; - for await (const frame of response) { - if (frame.type !== 'event' || frame.event.type !== 'response.completed') continue; - completed++; - assertEquals(frame.event.response.tools, echo ? tools : undefined); - assertEquals(Object.hasOwn(frame.event.response, 'tools'), echo && tools !== undefined); - } - assertEquals(completed, 1); - }); - } -} - -test('callable restoration retains only echo fields, not the source request or input', () => { - const request: CanonicalOpenAIResponsesPayload = { model: 'm', input: [{ type: 'message', role: 'user', content: 'Long conversation' }], tools: [{ type: 'namespace', name: 'files', description: '', tools: [functionTool('read')] }] }; - const { names } = flattenNamespaceTools(request); - const reachable = new Set(); - const visit = (value: unknown) => { - if (typeof value !== 'object' || value === null || reachable.has(value)) return; - reachable.add(value); - for (const child of value instanceof Map ? value.values() : Object.values(value)) visit(child); - }; - visit(names); - expect(reachable.has(request.tools)).toBe(true); - expect(reachable.has(request)).toBe(false); - expect(reachable.has(request.input)).toBe(false); -}); - -test.each(['forced', 'allowed_tools'] as const)('unchanged flat %s choices retain no echo sources and preserve response frames', async mode => { - const selector = { type: 'function' as const, name: 'read' }; - const request: CanonicalOpenAIResponsesPayload = { model: 'm', input: [], tools: [functionTool('read'), functionTool('write')], tool_choice: mode === 'forced' ? selector : { type: 'allowed_tools', mode: 'auto', tools: [selector] } }; - const { payload, names } = flattenNamespaceTools(request); - expect(payload.tool_choice).toBe(request.tool_choice); - expect(names.toolsChanged).toBe(false); - expect(names.toolChoiceChanged).toBe(false); - expect(names.sourceTools).toBeUndefined(); - expect(names.sourceToolChoice).toBeUndefined(); - const item = { type: 'function_call' as const, id: 'item', call_id: 'call', name: 'read', arguments: '{}', status: 'completed' as const }; - const source: ProtocolFrame[] = [ - eventFrame({ type: 'response.output_item.added', output_index: 0, item }), - eventFrame({ type: 'response.function_call_arguments.done', item_id: 'item', output_index: 0, name: 'read', arguments: '{}' } as OpenAIResponsesStreamEventEx), - eventFrame({ type: 'response.output_item.done', output_index: 0, item }), - eventFrame({ type: 'response.completed', response: { ...emptyResult(), output: [item], tools: request.tools ?? undefined, tool_choice: request.tool_choice } }), - doneFrame(), - ]; - const restored = []; - for await (const frame of restoreNamespaceEvents((async function* () { yield* source; })(), names)) restored.push(frame); - expect(restored).toHaveLength(source.length); - restored.forEach((frame, index) => expect(frame).toBe(source[index])); -}); - -test('callable projection does not rescan unchanged flattened tool references', () => { - const tools = Array.from({ length: 2000 }, (_, index) => functionTool(`tool_${index}`)); - const scans: Array<{ array: unknown[]; visits: number }> = []; - const originalSome = Array.prototype.some; - const spy = vi.spyOn(Array.prototype, 'some').mockImplementation(function (this: unknown[], predicate, thisArg) { - const scan = { array: this, visits: 0 }; - scans.push(scan); - return originalSome.call(this, (value, index, array) => { - scan.visits++; - return predicate.call(thisArg, value, index, array); - }); - }); - const prepared = (() => { - try { - return flattenNamespaceTools({ model: 'm', input: [], tools }); - } finally { - spy.mockRestore(); - } - })(); - expect(prepared.payload.tools).toEqual(tools); - expect(prepared.payload.tools?.every((tool, index) => tool === tools[index])).toBe(true); - expect(scans.some(scan => scan.array === tools && scan.visits === tools.length)).toBe(true); - expect(scans.filter(scan => scan.array === prepared.payload.tools).reduce((total, scan) => total + scan.visits, 0)).toBe(0); -}); 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 deleted file mode 100644 index e33c749b2c..0000000000 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-tools_test.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { expect, test } from 'vitest'; - -import { flattenNamespaceTools, restoreNamespaceEvents, restoreNamespaceOutputItem } from '../../../../src/protocols/openai-responses/as-source/namespace-tools.ts'; -import { requireCanonicalOpenAIResponsesPayload } from '../../../../src/protocols/openai-responses/canonicalize-payload.ts'; -import { buildTargetRequest as buildMessagesRequest } from '../../../../src/protocols/openai-responses/via-anthropic-messages/request.ts'; -import { buildTargetRequest as buildChatRequest } from '../../../../src/protocols/openai-responses/via-openai-chat-completions/request.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; -import { requireTrip } from '../../require-trip.ts'; -import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; -import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesOutputItemEx, OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; -import { routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; - -const payload = (): OpenAIResponsesRequestPayloadEx => ({ - model: 'm', - tools: [ - { type: 'function', name: 'agents_spawn' }, - { - type: 'namespace', name: 'agents', description: '', tools: [ - { type: 'function', name: 'spawn', parameters: { type: 'object' } }, - { type: 'function', name: 'wait', parameters: { type: 'object' } }, - { type: 'custom', name: 'audit' }, - ], - }, - ], - input: [{ type: 'function_call', namespace: 'agents', name: 'wait', call_id: 'old', arguments: '{}', status: 'completed' }], - tool_choice: { type: 'allowed_tools', mode: 'required', tools: [{ type: 'function', namespace: 'agents', name: 'spawn' }] }, -}); - -const messagesRequestOptions = { fallbackMaxOutputTokens: undefined, loadRemoteImage: async () => null }; - -test.each(['additional_tools', 'tool_search_output'] as const)('keeps %s as a user-side boundary between ordinary assistant turns', async type => { - const source: CanonicalOpenAIResponsesPayload = { - model: 'm', - input: [ - { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'A' }] }, - type === 'additional_tools' ? { type, role: 'developer', tools: [] } : { type, tools: [] }, - { type: 'function_call', call_id: 'c', name: 'lookup', arguments: '{}' }, - ], - }; - expect((await buildChatRequest(source, createTestSidecarCodec())).target.messages).toEqual([ - { role: 'assistant', content: 'A' }, - { role: 'assistant', content: null, tool_calls: [{ type: 'function', id: 'c', function: { name: 'lookup', arguments: '{}' } }] }, - ]); - expect((await buildMessagesRequest(source, createTestSidecarCodec(), messagesRequestOptions)).target.messages).toEqual([ - { role: 'assistant', content: [{ type: 'text', text: 'A' }] }, - { role: 'assistant', content: [{ type: 'tool_use', id: 'c', name: 'lookup', input: {}, cache_control: { type: 'ephemeral' } }] }, - ]); -}); - -test('namespace setup preserves the original input and allocates historical names only when explicitly flattened', () => { - const request: CanonicalOpenAIResponsesPayload = { - model: 'm', - input: [ - { type: 'function_call', namespace: 'x.y', name: 'f', call_id: 'first', arguments: '{}' }, - { type: 'function_call', namespace: 'x', name: 'y.f', call_id: 'second', arguments: '{}' }, - ], - }; - const prepared = flattenNamespaceTools(request); - - expect(prepared.payload.input).toBe(request.input); - expect(prepared.names.sourceToTarget.size).toBe(0); - expect(prepared.names.targetToSource.size).toBe(0); - expect(prepared.flattenInput([request.input[0]])).toEqual([{ type: 'function_call', name: 'x_y_f', call_id: 'first', arguments: '{}' }]); - expect(() => prepared.flattenInput([request.input[1]])).toThrow('ambiguous'); -}); - -test('both targets preserve namespace subsets and excluded replay identities', async () => { - const source = payload(); - const chat = await buildChatRequest(source, createTestSidecarCodec()); - const messages = await buildMessagesRequest(source, createTestSidecarCodec(), messagesRequestOptions); - expect(chat.target.tools?.map(tool => tool.type === 'function' ? tool.function.name : '')).toEqual(['agents_spawn_2']); - expect(messages.target.tools?.map(tool => 'name' in tool ? tool.name : undefined)).toEqual(['agents_spawn_2']); - expect((chat.target.messages[0] as OpenAIChatCompletionsAssistantMessageEx).tool_calls?.filter(call => call.type === 'function')[0].function.name).toBe('agents_wait'); - expect(messages.target.messages[0].content).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'tool_use', name: 'agents_wait' })])); - expect(chat.target.tool_choice).toBe('required'); - expect(messages.target.tool_choice).toEqual({ type: 'any' }); -}); - -test('expands namespace selectors and includes deferred declarations on both targets', async () => { - const source = payload(); - source.tool_choice = { type: 'allowed_tools', mode: 'auto', tools: [{ type: 'namespace', name: 'agents' }] }; - source.input = [{ type: 'additional_tools', role: 'developer', tools: source.tools! }]; - source.tools = undefined; - expect((await buildChatRequest(source, createTestSidecarCodec())).target.tools).toHaveLength(3); - expect((await buildMessagesRequest(source, createTestSidecarCodec(), messagesRequestOptions)).target.tools).toHaveLength(3); -}); - -test('history-only namespace calls reserve names without inventing declarations', async () => { - const source = payload(); - source.tools = undefined; - source.tool_choice = undefined; - const chat = await buildChatRequest(source, createTestSidecarCodec()); - const messages = await buildMessagesRequest(source, createTestSidecarCodec(), messagesRequestOptions); - expect(chat.target.tools).toBeUndefined(); - expect(messages.target.tools).toBeUndefined(); - expect((chat.target.messages[0] as OpenAIChatCompletionsAssistantMessageEx).tool_calls?.filter(call => call.type === 'function')[0].function.name).toBe('agents_wait'); - expect(chat.namespaceToolNames.targetToSource.get('agents_wait')).toEqual({ namespace: 'agents', name: 'wait', type: 'function_call' }); -}); - -test('forced namespace choices use the declaration mapping', async () => { - const source = payload(); - source.tool_choice = { type: 'function', namespace: 'agents', name: 'spawn' }; - expect((await buildChatRequest(source, createTestSidecarCodec())).target.tool_choice).toEqual({ type: 'function', function: { name: 'agents_spawn_2' } }); - expect((await buildMessagesRequest(source, createTestSidecarCodec(), messagesRequestOptions)).target.tool_choice).toEqual({ type: 'tool', name: 'agents_spawn_2' }); -}); - -test('restores function and custom calls across item events and terminal snapshots', async () => { - const prepared = flattenNamespaceTools(requireCanonicalOpenAIResponsesPayload(payload())); - const output = [ - { type: 'function_call' as const, name: 'agents_spawn_2', call_id: 'spawn', arguments: '{"name":"agents_spawn_2"}', status: 'completed' }, - { type: 'custom_tool_call' as const, name: 'agents_audit', call_id: 'audit', input: 'agents_audit' }, - ]; - const frames = (async function* (): AsyncGenerator> { - yield eventFrame({ type: 'response.output_item.done', output_index: 0, item: output[0] }); - yield eventFrame({ type: 'response.completed', response: { id: 'r', object: 'response', model: 'm', status: 'completed', error: null, incomplete_details: null, output } }); - })(); - const events: OpenAIResponsesStreamEventEx[] = []; - for await (const frame of restoreNamespaceEvents(frames, prepared.names)) if (frame.type === 'event') events.push(frame.event); - expect(events[0]).toMatchObject({ item: { namespace: 'agents', name: 'spawn', arguments: '{"name":"agents_spawn_2"}' } }); - expect(events[1]).toMatchObject({ - response: { - output: [ - { namespace: 'agents', name: 'spawn' }, { namespace: 'agents', name: 'audit', input: 'agents_audit' }, - ], - }, - }); -}); - -test('restores all function/custom output item type combinations without retaining the replaced payload field', () => { - const names = flattenNamespaceTools(requireCanonicalOpenAIResponsesPayload(payload())).names; - const functionName = names.sourceToTarget.get('agents.spawn'); - const customName = names.sourceToTarget.get('agents.audit'); - if (functionName === undefined || customName === undefined) throw new Error('Expected flattened namespace tool names'); - - const sameFunction = { - type: 'function_call', name: functionName, call_id: 'same-function', arguments: '{}', status: 'completed', trace: 'kept', - } satisfies OpenAIResponsesOutputItemEx & { trace: string }; - const sameCustom = { - type: 'custom_tool_call', name: customName, call_id: 'same-custom', input: 'input', trace: 'kept', - } satisfies OpenAIResponsesOutputItemEx & { trace: string }; - const customAsFunction = { - type: 'custom_tool_call', name: functionName, call_id: 'custom-as-function', input: '{"q":1}', trace: 'kept', - } satisfies OpenAIResponsesOutputItemEx & { trace: string }; - const functionAsCustom = { - type: 'function_call', name: customName, call_id: 'function-as-custom', arguments: 'plain', status: 'completed', trace: 'kept', - } satisfies OpenAIResponsesOutputItemEx & { trace: string }; - - expect(restoreNamespaceOutputItem(sameFunction, names, 'in_progress')).toEqual({ ...sameFunction, name: 'spawn', namespace: 'agents' }); - expect(restoreNamespaceOutputItem(sameCustom, names, 'in_progress')).toEqual({ ...sameCustom, name: 'audit', namespace: 'agents' }); - expect(restoreNamespaceOutputItem(customAsFunction, names, 'in_progress')).toEqual({ - type: 'function_call', name: 'spawn', namespace: 'agents', call_id: 'custom-as-function', arguments: '{"q":1}', status: 'in_progress', trace: 'kept', - }); - expect(restoreNamespaceOutputItem(functionAsCustom, names, 'in_progress')).toEqual({ - type: 'custom_tool_call', name: 'audit', namespace: 'agents', call_id: 'function-as-custom', input: 'plain', status: 'completed', trace: 'kept', - }); -}); - -test.each(['.', '__'])('lowers qualified forced selectors with %s', async separator => { - const source = payload(); - source.tool_choice = { type: 'function', name: `agents${separator}spawn` }; - expect((await buildChatRequest(source, createTestSidecarCodec())).target.tool_choice).toEqual({ type: 'function', function: { name: 'agents_spawn_2' } }); - expect((await buildMessagesRequest(source, createTestSidecarCodec(), messagesRequestOptions)).target.tool_choice).toEqual({ type: 'tool', name: 'agents_spawn_2' }); -}); - -test('rejects function/custom ambiguity and distinct tuples with the same qualified spelling', async () => { - for (const tools of [ - [{ type: 'namespace' as const, name: 'x', description: '', tools: [{ type: 'function' as const, name: 'f' }, { type: 'custom' as const, name: 'f' }] }], - [{ type: 'namespace' as const, name: 'x.y', description: '', tools: [{ type: 'function' as const, name: 'f' }] }, { type: 'namespace' as const, name: 'x', description: '', tools: [{ type: 'function' as const, name: 'y.f' }] }], - ]) { - await expect(buildChatRequest({ model: 'm', input: [], tools }, createTestSidecarCodec())).rejects.toThrow('ambiguous'); - } -}); - -test('the complete Chat Completions trip restores the namespace after target tool calls', async () => { - 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' }] }); - yield doneFrame(); - })(); - const events: OpenAIResponsesStreamEventEx[] = []; - for await (const frame of trip.events(frames)) if (frame.type === 'event') events.push(frame.event); - expect(events).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'response.output_item.done', item: expect.objectContaining({ namespace: 'agents', name: 'spawn', arguments: '{}' }) })])); - expect(events.at(-1)).toMatchObject({ type: 'response.completed', response: { output: [expect.objectContaining({ namespace: 'agents', name: 'spawn' }), expect.objectContaining({ type: 'reasoning', summary: [], encrypted_content: expect.any(String) })] } }); -}); - -test('the complete Anthropic Messages trip restores the namespace after target tool calls', async () => { - 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: {} } }); - yield eventFrame({ type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '{}' } }); - yield eventFrame({ type: 'content_block_stop', index: 0 }); - yield eventFrame({ type: 'message_delta', delta: { container: null, stop_details: null, stop_reason: 'tool_use', stop_sequence: null }, usage: { input_tokens: null, cache_creation_input_tokens: null, cache_read_input_tokens: null, output_tokens_details: null, server_tool_use: null, output_tokens: 1 } }); - yield eventFrame({ type: 'message_stop' }); - })(); - const events: OpenAIResponsesStreamEventEx[] = []; - for await (const frame of trip.events(frames)) if (frame.type === 'event') events.push(frame.event); - expect(events).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'response.output_item.done', item: expect.objectContaining({ namespace: 'agents', name: 'spawn', arguments: '{}' }) })])); - expect(events.at(-1)).toMatchObject({ type: 'response.completed', response: { output: [expect.objectContaining({ namespace: 'agents', name: 'spawn' }), expect.objectContaining({ type: 'reasoning', summary: [], encrypted_content: expect.any(String) })] } }); -}); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/programmatic-tooling_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/programmatic-tooling_test.ts index 72fbd4dedb..13dbf85152 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/programmatic-tooling_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/programmatic-tooling_test.ts @@ -58,11 +58,8 @@ const payloadCases: Array<{ }, ]; -const targetPayloadCases = ['OpenAI Chat Completions', 'Anthropic Messages'].flatMap(target => - payloadCases.map(testCase => ({ target, ...testCase }))); - -test.each(targetPayloadCases)('rejectProgrammaticOpenAIResponsesPayload rejects $name for $target', ({ target, payload, message }) => { - expect(() => rejectProgrammaticOpenAIResponsesPayload({ model: 'gpt-test', input: [], ...payload }, target)).toThrow(message); +test.each(payloadCases)('rejectProgrammaticOpenAIResponsesPayload rejects $name', ({ payload, message }) => { + expect(() => rejectProgrammaticOpenAIResponsesPayload({ model: 'gpt-test', input: [], ...payload })).toThrow(message); }); test('rejectProgramCaller accepts ordinary callers', () => { @@ -74,5 +71,5 @@ test('rejectProgrammaticOpenAIResponsesPayload accepts ordinary function tooling model: 'gpt-test', input: [], tools: [{ type: 'function', name: 'lookup', parameters: {}, strict: true }], - }, 'Anthropic Messages')).not.toThrow(); + })).not.toThrow(); }); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts index e2e0a1de4a..153831f540 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts @@ -227,3 +227,14 @@ test.each(toolCases)('invalidates native $type provenance when its done snapshot expect(finalized.targetTurn).toEqual(captured.turn); expect(finalized).not.toHaveProperty('referencedContentHashes'); }); + +test('rejects a snapshot that moves a tool call into another group', async () => { + const grouped = (namespace: string): ToolItem => ({ type: 'function_call', id: 'tool', call_id: 'call', name: 'lookup', namespace, arguments: '{}', status: 'completed' }); + + await expect(collectProvenance([ + { type: 'response.created', response: response([], 'in_progress') }, + { type: 'response.output_item.added', output_index: 0, item: grouped('files') }, + { type: 'response.output_item.done', output_index: 0, item: grouped('web') }, + { type: 'response.completed', response: response([], 'completed') }, + ], 'arguments')).rejects.toThrow('namespace changed from files to web'); +}); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts index 8a57aa52e4..1e0d5d3b54 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts @@ -1,9 +1,9 @@ import { expect, test } from 'vitest'; import { collectIR } from '../../../ir/output/helpers.ts'; +import { collect, iterate } from '../../../iterables.ts'; import { createRoundTripReaderCapture } from '../../../round-trip/test-helpers.ts'; -import { collect, iterate } from '../../../shared/iterables.ts'; -import { readOpenAIResponsesEvents } from '../../helpers.ts'; +import { readOpenAIResponsesEvents } from '../../events.ts'; import { eventFrame } from '@floway-dev/protocols/common'; const response = (output: any[] = []) => ({ id: 'resp', model: 'served', created_at: 1, status: 'completed', output, usage: null, error: null, incomplete_details: null }); diff --git a/packages/translate/__tests__/protocols/openai-responses/round-trip-request_test.ts b/packages/translate/__tests__/protocols/openai-responses/round-trip-request_test.ts index 7c55acfbc3..e5080e009d 100644 --- a/packages/translate/__tests__/protocols/openai-responses/round-trip-request_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/round-trip-request_test.ts @@ -1,12 +1,20 @@ import { expect, it } from 'vitest'; -import { computeOpenAIResponsesTurnDigest, createOpenAIResponsesSidecarItem } from '../../../src/protocols/openai-responses/as-source/round-trip.ts'; +import { readOpenAIResponsesRequest } from '../../../src/protocols/openai-responses/as-source/input-to.ts'; +import { computeOpenAIResponsesTurnDigest, createOpenAIResponsesSidecarCarrier } from '../../../src/protocols/openai-responses/as-source/round-trip.ts'; import type { OpenAIResponsesSourceAssistantTurn } from '../../../src/protocols/openai-responses/as-source/round-trip.ts'; -import { buildTargetRequest as buildMessagesRequest } from '../../../src/protocols/openai-responses/via-anthropic-messages/request.ts'; -import { buildTargetRequest as buildChatRequest } from '../../../src/protocols/openai-responses/via-openai-chat-completions/request.ts'; +import type { SidecarCodec } from '../../../src/round-trip/codec.ts'; import { encodeRoundTripSidecar } from '../../../src/round-trip/sidecar.ts'; +import { buildTargetPayloadFor, pairs } from '../../composition/helpers.ts'; import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; -import type { CanonicalOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; +import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; + +const buildChatRequest = async (payload: OpenAIResponsesRequestPayloadEx, codec: SidecarCodec) => ({ + target: await buildTargetPayloadFor(pairs.responsesToChat[0], pairs.responsesToChat[1], { ...payload, stream: true }, { sidecarCodec: codec }), +}); +const buildMessagesRequest = async (payload: OpenAIResponsesRequestPayloadEx, codec: SidecarCodec, options: { fallbackMaxOutputTokens?: number } = {}) => ({ + target: await buildTargetPayloadFor(pairs.responsesToMessages[0], pairs.responsesToMessages[1], { ...payload, stream: true }, { sidecarCodec: codec, ...options }), +}); const replayedTurn: OpenAIResponsesSourceAssistantTurn = [ { type: 'function_call', call_id: 'native', name: 'run', namespace: 'web', arguments: '{}', status: 'completed' }, @@ -21,7 +29,7 @@ const request = (sidecar: string): CanonicalOpenAIResponsesPayload => ({ { type: 'message', role: 'system', content: 'instructions' }, { type: 'additional_tools', role: 'developer', tools: [{ type: 'function', name: 'web_run', parameters: { type: 'object', properties: {} } }] }, { type: 'function_call', call_id: 'native', name: 'run', namespace: 'web', arguments: '{}', status: 'completed' }, - createOpenAIResponsesSidecarItem('sidecar', sidecar), + createOpenAIResponsesSidecarCarrier('sidecar', sidecar), { type: 'function_call_output', call_id: 'native', output: 'native result' }, { type: 'function_call', call_id: 'bare', name: 'run', namespace: 'web', arguments: '{}', status: 'completed' }, ], @@ -44,7 +52,7 @@ it('keeps restored Chat history native while translating bare namespace calls', expect(result.target.messages).toEqual([ { role: 'system', content: 'instructions' }, - targetTurn, + { ...targetTurn, tool_calls: targetTurn.tool_calls.map(call => ({ ...call, function: { ...call.function, name: 'web_run_2' } })) }, { role: 'tool', tool_call_id: 'native', content: 'native result' }, { role: 'assistant', content: null, tool_calls: [{ type: 'function', id: 'bare', function: { name: 'web_run_2', arguments: '{}' } }] }, ]); @@ -63,12 +71,12 @@ it('keeps restored Messages history native while hoisting system and translating sourceTurnDigest: await computeOpenAIResponsesTurnDigest(replayedTurn), }); const result = await buildMessagesRequest(request(sidecar), codec, { - loadRemoteImage: async () => null, fallbackMaxOutputTokens: undefined, + fallbackMaxOutputTokens: undefined, }); expect(result.target.system).toEqual([{ type: 'text', text: 'instructions', cache_control: { type: 'ephemeral' } }]); expect(result.target.messages).toEqual([ - targetTurn, + { ...targetTurn, content: targetTurn.content.map(block => ({ ...block, name: 'web_run_2' })) }, { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'native', content: 'native result', is_error: undefined }] }, { role: 'assistant', content: [{ type: 'tool_use', id: 'bare', name: 'web_run_2', input: {}, cache_control: { type: 'ephemeral' } }] }, ]); @@ -91,19 +99,26 @@ it.each(['openaiChatCompletions', 'anthropicMessages'] as const)('bypasses ordin }); const source: CanonicalOpenAIResponsesPayload = { model: 'test', + stream: true, input: [ - ...calls, createOpenAIResponsesSidecarItem('carrier', sidecar), + ...calls, createOpenAIResponsesSidecarCarrier('carrier', sidecar), { type: 'message', role: 'user', content: 'next' }, { type: 'function_call', namespace: 'x', name: 'y.f', call_id: 'bare', arguments: '{}', status: 'completed' }, ], }; - const result = target === 'openaiChatCompletions' - ? await buildChatRequest(source, codec) - : await buildMessagesRequest(source, codec, { loadRemoteImage: async () => null, fallbackMaxOutputTokens: undefined }); - - expect(result.target.messages[0]).toEqual(native); - expect([...result.namespaceToolNames.sourceToTarget]).toEqual([['x.y.f', 'x_y_f']]); - expect([...result.namespaceToolNames.targetToSource]).toEqual([['x_y_f', { namespace: 'x', name: 'y.f', type: 'function_call' }]]); + const ctx = { sidecarCodec: codec, fallbackMaxOutputTokens: undefined }; + const reading = readOpenAIResponsesRequest(source); + const irCall = (r: typeof reading, callId: string): { namespace?: string; name: string } | undefined => r.turns + .flatMap(turn => turn.toIRInputTurns()) + .flatMap(turn => turn.items as readonly { type: string; call_id?: string; name: string; namespace?: string }[]) + .find(item => (item.type === 'function_call' || item.type === 'custom_tool_call') && item.call_id === callId); + const target_ = target === 'openaiChatCompletions' + ? await buildTargetPayloadFor(pairs.responsesToChat[0], pairs.responsesToChat[1], source, ctx, reading) + : await buildTargetPayloadFor(pairs.responsesToMessages[0], pairs.responsesToMessages[1], source, ctx, reading); + // The reading keeps a grouped call's logical identity; flattening it into the + // single name this target's wire uses is the target's own step. + expect(irCall(reading, 'bare')).toMatchObject({ namespace: 'x', name: 'y.f' }); + expect(target_.messages[0]).toEqual(native); expect(source.input[0]).toEqual(calls[0]); expect(source.input[1]).toEqual(calls[1]); }); @@ -118,19 +133,29 @@ it.each(['openaiChatCompletions', 'anthropicMessages'] as const)('allocates bare }); const source: CanonicalOpenAIResponsesPayload = { model: 'test', + stream: true, input: [ { type: 'function_call', namespace: 'a.b', name: 'run', call_id: 'first', arguments: '{}' }, - createOpenAIResponsesSidecarItem('carrier', sidecar), + createOpenAIResponsesSidecarCarrier('carrier', sidecar), { type: 'message', role: 'user', content: 'next' }, { type: 'function_call', namespace: 'a_b', name: 'run', call_id: 'second', arguments: '{}' }, ], }; - const result = target === 'openaiChatCompletions' - ? await buildChatRequest(source, codec) - : await buildMessagesRequest(source, codec, { loadRemoteImage: async () => null, fallbackMaxOutputTokens: undefined }); - - expect([...result.namespaceToolNames.sourceToTarget]).toEqual([['a.b.run', 'a_b_run'], ['a_b.run', 'a_b_run_2']]); - expect(result.target.messages).toHaveLength(3); + const ctx = { sidecarCodec: codec, fallbackMaxOutputTokens: undefined }; + const reading = readOpenAIResponsesRequest(source); + const target_ = target === 'openaiChatCompletions' + ? await buildTargetPayloadFor(pairs.responsesToChat[0], pairs.responsesToChat[1], source, ctx, reading) + : await buildTargetPayloadFor(pairs.responsesToMessages[0], pairs.responsesToMessages[1], source, ctx, reading); + // Two groups spelling the same flat name must not collide on the wire, and + // which one takes the suffix follows the order the conversation states them. + const callNames = target === 'openaiChatCompletions' + ? (target_.messages as Array<{ tool_calls?: Array<{ function: { name: string } }> }>) + .flatMap(message => message.tool_calls?.map(call => call.function.name) ?? []) + : (target_.messages as Array<{ content: unknown }>).flatMap(message => Array.isArray(message.content) + ? message.content.flatMap(block => (block as { type: string; name?: string }).type === 'tool_use' ? [(block as { name: string }).name] : []) + : []); + expect(callNames).toEqual(['a_b_run', 'a_b_run_2']); + expect(target_.messages).toHaveLength(3); }); it.each(['additional_tools', 'tool_search_output'] as const)('restores assistant turns independently across a user-side %s boundary', async type => { @@ -142,7 +167,7 @@ it.each(['additional_tools', 'tool_search_output'] as const)('restores assistant const turns: CanonicalOpenAIResponsesPayload['input'][] = ['first', 'second'].map(text => [ { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }] }, ]); - const carriers = await Promise.all(turns.map(async (turn, index) => createOpenAIResponsesSidecarItem(`carrier_${index}`, await encodeRoundTripSidecar(codec, { + const carriers = await Promise.all(turns.map(async (turn, index) => createOpenAIResponsesSidecarCarrier(`carrier_${index}`, await encodeRoundTripSidecar(codec, { v: 1, source: 'openaiResponses', target, targetTurn: nativeTurns[index], sourceTurnDigest: await computeOpenAIResponsesTurnDigest(turn), @@ -151,12 +176,12 @@ it.each(['additional_tools', 'tool_search_output'] as const)('restores assistant ? { type, role: 'developer' as const, tools: [{ type: 'function' as const, name: 'lookup' }] } : { type, call_id: 'load', execution: 'client' as const, tools: [{ type: 'function' as const, name: 'lookup' }] }; const source: CanonicalOpenAIResponsesPayload = { - model: 'test', input: [...turns[0], carriers[0], inventory, ...turns[1], carriers[1]], + model: 'test', stream: true, input: [...turns[0], carriers[0], inventory, ...turns[1], carriers[1]], }; const original = structuredClone(source); const result = target === 'openaiChatCompletions' - ? await buildChatRequest(source, codec) - : await buildMessagesRequest(source, codec, { loadRemoteImage: async () => null, fallbackMaxOutputTokens: undefined }); + ? { target: await buildTargetPayloadFor(pairs.responsesToChat[0], pairs.responsesToChat[1], source, { sidecarCodec: codec }) } + : await buildMessagesRequest(source, codec, { fallbackMaxOutputTokens: undefined }); expect(result.target.messages).toEqual(nativeTurns); expect(result.target.tools).toHaveLength(1); diff --git a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events_test.ts b/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events_test.ts deleted file mode 100644 index d8873d7dfa..0000000000 --- a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events_test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { translateToSourceEvents } from '../../../../src/protocols/openai-responses/via-anthropic-messages/events.ts'; -import { emptyNamespaceToolNames } from '../../../round-trip/test-helpers.ts'; -import { testOutputTranslation } from '../../fixtures.ts'; - -testOutputTranslation('openai-responses', 'anthropic-messages', (frames, codec) => translateToSourceEvents(frames, 'resp_test', new Set(), codec, emptyNamespaceToolNames())); diff --git a/packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/events_test.ts b/packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/events_test.ts deleted file mode 100644 index c1ffc06400..0000000000 --- a/packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/events_test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { translateToSourceEvents } from '../../../../src/protocols/openai-responses/via-openai-chat-completions/events.ts'; -import { emptyNamespaceToolNames } from '../../../round-trip/test-helpers.ts'; -import { testOutputTranslation } from '../../fixtures.ts'; - -testOutputTranslation('openai-responses', 'openai-chat-completions', (frames, codec) => translateToSourceEvents(frames, new Set(), codec, emptyNamespaceToolNames())); diff --git a/packages/translate/__tests__/protocols/ownership_test.ts b/packages/translate/__tests__/protocols/ownership_test.ts index 05d7a849e8..a6d03eb0c5 100644 --- a/packages/translate/__tests__/protocols/ownership_test.ts +++ b/packages/translate/__tests__/protocols/ownership_test.ts @@ -1,9 +1,9 @@ import { expect, test, vi } from 'vitest'; -import { nativeResult } from './fixtures.ts'; -import { writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; import { collectIR } from '../ir/output/helpers.ts'; -import { collect, iterate } from '../shared/iterables.ts'; +import { collect, iterate } from '../iterables.ts'; +import { writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './events.ts'; +import { nativeResult } from './fixtures.ts'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; const chunk = (delta: any, extra: any = {}) => eventFrame({ id: 'chat_owner', model: 'served', created: 1, choices: [{ index: 0, delta, finish_reason: null, ...extra }] } as any); @@ -109,14 +109,3 @@ test('range-less Messages citations do not expand to whole-part ChatCompletions const generateContentFrames = await collect(writeGeminiGenerateContentEvents(readAnthropicMessagesEvents(iterate(messagesCitationFrames('source quote'))))); expect(generateContentFrames.some(frame => frame.type === 'event' && 'candidates' in frame.event && frame.event.candidates?.some(candidate => candidate.groundingMetadata !== undefined))).toBe(false); }); - -test.each([ - ['ChatCompletions', readOpenAIChatCompletionsEvents, (error: object) => ({ id: 'chat', model: 'm', choices: [], error })], - ['Responses', readOpenAIResponsesEvents, (error: object) => ({ type: 'error', error })], - ['Messages', readAnthropicMessagesEvents, (error: object) => ({ type: 'error', error })], -] as const)('%s reader transfers immutable error objects without copying', async (_name, reader, event) => { - const error = Object.freeze({ type: 'api_error', message: 'original', details: Object.freeze([Object.freeze({ text: 'original' })]) }); - const frames = await collect(reader(iterate([eventFrame(event(error) as any)]))); - const record = frames.find(record => record.type === 'error'); - expect(record?.error).toBe(error); -}); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events-protocol_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-events-protocol_test.ts similarity index 61% rename from packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events-protocol_test.ts rename to packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-events-protocol_test.ts index c9bb209a3a..b8344b8338 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events-protocol_test.ts +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-events-protocol_test.ts @@ -1,10 +1,16 @@ import { test } from 'vitest'; -import { translateToSourceEvents } from '../../../../src/protocols/anthropic-messages/via-openai-chat-completions/events.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; +import type { ProtocolFrame } from '@floway-dev/protocols/common'; import { eventFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; +const codec = createTestSidecarCodec(); +const translate = (frames: AsyncIterable>) => buildSourceEventsFor( + pairs.messagesToChat[0], pairs.messagesToChat[1], emptyPayload.messages, { sidecarCodec: codec }, frames, +); + const drain = async (frames: AsyncIterable): Promise => { for await (const _frame of frames) { // Exhaust the stream so async translator errors surface to the caller. @@ -28,5 +34,5 @@ test('translateToSourceEvents accepts OpenAI Chat Completions streams without DO } satisfies OpenAIChatCompletionsStreamEvent); } - await drain(translateToSourceEvents(stream(), createTestSidecarCodec())); + await drain(translate(stream())); }); diff --git a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-events_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-events_test.ts new file mode 100644 index 0000000000..fb9ce47c51 --- /dev/null +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-events_test.ts @@ -0,0 +1,7 @@ +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { testOutputTranslation } from '../fixtures.ts'; + +testOutputTranslation('anthropic-messages', 'openai-chat-completions', (frames, codec) => { + const [source, target] = pairs.messagesToChat; + return buildSourceEventsFor(source, target, emptyPayload.messages, { sidecarCodec: codec }, frames); +}); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/request_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts similarity index 69% rename from packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/request_test.ts rename to packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts index 9b3dd88a60..55d0bc95cd 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts @@ -1,15 +1,16 @@ import { test } from 'vitest'; -import { buildTargetRequest as buildTargetRequestWithSidecar } from '../../../../src/protocols/anthropic-messages/via-openai-chat-completions/request.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { TranslateInputException } from '../../../src/shared/input-error.ts'; +import { buildTargetPayloadFor, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; import type { AnthropicMessagesPayload, AnthropicMessagesAssistantContentBlock, AnthropicMessagesUserContentBlock } from '@floway-dev/protocols/anthropic-messages'; import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsFunctionTool } from '@floway-dev/protocols/openai-chat-completions'; import { assertEquals, assertFalse, assertRejects } from '@floway-dev/test-utils'; -const buildWithTestSidecar = (payload: Parameters[0]) => - buildTargetRequestWithSidecar(payload, createTestSidecarCodec()); +const buildWithTestSidecar = (payload: AnthropicMessagesPayload) => + buildTargetPayloadFor(pairs.messagesToChat[0], pairs.messagesToChat[1], { ...payload, stream: true }, { sidecarCodec: createTestSidecarCodec() }); -test('buildTargetRequest maps thinking.disabled to reasoning_effort none', async () => { +test('buildTargetPayload maps thinking.disabled to reasoning_effort none', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -20,7 +21,7 @@ test('buildTargetRequest maps thinking.disabled to reasoning_effort none', async assertEquals(result.reasoning_effort, 'none'); }); -test('buildTargetRequest prefers output_config.effort over thinking.disabled', async () => { +test('buildTargetPayload prefers output_config.effort over thinking.disabled', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -32,7 +33,7 @@ test('buildTargetRequest prefers output_config.effort over thinking.disabled', a assertEquals(result.reasoning_effort, 'high'); }); -test('buildTargetRequest forwards empty output_config.effort verbatim', async () => { +test('buildTargetPayload forwards empty output_config.effort verbatim', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -44,8 +45,8 @@ test('buildTargetRequest forwards empty output_config.effort verbatim', async () assertEquals(result.reasoning_effort, ''); }); -test('buildTargetRequest maps thinking.enabled to reasoning_effort medium regardless of budget_tokens', async () => { - for (const budget of [1024, 16384]) { +test('buildTargetPayload projects thinking.enabled budgets onto reasoning effort', async () => { + for (const [budget, effort] of [[1024, 'low'], [16384, 'high']] as const) { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 4096, @@ -53,11 +54,11 @@ test('buildTargetRequest maps thinking.enabled to reasoning_effort medium regard messages: [{ role: 'user', content: 'hi' }], }); - assertEquals(result.reasoning_effort, 'medium'); + assertEquals(result.reasoning_effort, effort); } }); -test('buildTargetRequest maps thinking.adaptive to reasoning_effort medium', async () => { +test('buildTargetPayload maps thinking.adaptive to reasoning_effort medium', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 4096, @@ -68,7 +69,7 @@ test('buildTargetRequest maps thinking.adaptive to reasoning_effort medium', asy assertEquals(result.reasoning_effort, 'medium'); }); -test('buildTargetRequest prefers output_config.effort over thinking.enabled', async () => { +test('buildTargetPayload prefers output_config.effort over thinking.enabled', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 4096, @@ -80,11 +81,12 @@ test('buildTargetRequest prefers output_config.effort over thinking.enabled', as assertEquals(result.reasoning_effort, 'high'); }); -test('buildTargetRequest keeps tool_result and user text as separate chat messages', async () => { +test('buildTargetPayload keeps tool_result and user text as separate chat messages', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, messages: [ + { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_1', name: 'lookup', input: {} }] }, { role: 'user', content: [ @@ -96,6 +98,7 @@ test('buildTargetRequest keeps tool_result and user text as separate chat messag }); assertEquals(result.messages, [ + { role: 'assistant', content: null, tool_calls: [{ id: 'toolu_1', type: 'function', function: { name: 'lookup', arguments: '{}' } }] }, { role: 'tool', tool_call_id: 'toolu_1', content: 'result' }, { role: 'user', content: 'Please continue.' }, ]); @@ -104,7 +107,7 @@ test('buildTargetRequest keeps tool_result and user text as separate chat messag test.each([ { tools: undefined, name: 'lookup' }, { tools: [{ name: 'other', input_schema: { type: 'object' as const } }], name: 'lookup' }, -])('buildTargetRequest rejects named tool_choice for an undeclared tool', async ({ tools, name }) => { +])('buildTargetPayload rejects named tool_choice for an undeclared tool', async ({ tools, name }) => { await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', @@ -118,7 +121,7 @@ test.each([ ); }); -test('buildTargetRequest drops filtered-native tool_choice and rewrites assistant native web-search history as tool-call history', async () => { +test('buildTargetPayload drops filtered-native tool_choice and rewrites assistant native web-search history as tool-call history', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -177,11 +180,51 @@ test('buildTargetRequest drops filtered-native tool_choice and rewrites assistan ]); }); -test('buildTargetRequest flattens text-block tool_result content but serializes search-result arrays', async () => { +test('buildTargetPayload folds a server tool result into the client turn that follows it', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, messages: [ + { role: 'user', content: 'search please' }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'looking' }, + { type: 'server_tool_use', id: 'st_1', name: 'web_search', input: { query: 'q' } }, + { type: 'web_search_tool_result', tool_use_id: 'st_1', content: [] }, + ], + }, + { role: 'user', content: 'thanks' }, + ], + }); + + // The result the API wrote into the assistant message and the caller's next + // message are one client turn in the IR, so they stay one run of messages on + // the wire instead of being split by an invented boundary. + assertEquals(result.messages, [ + { role: 'user', content: 'search please' }, + { + role: 'assistant', + content: 'looking', + tool_calls: [{ id: 'st_1', type: 'function', function: { name: 'web_search', arguments: '{"query":"q"}' } }], + }, + { role: 'tool', tool_call_id: 'st_1', content: '[]' }, + { role: 'user', content: 'thanks' }, + ]); +}); + +test('buildTargetPayload flattens text-block tool_result content but serializes search-result arrays', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [ + { + role: 'assistant', + content: [ + { type: 'tool_use', id: 'toolu_text', name: 'read_text', input: {} }, + { type: 'tool_use', id: 'toolu_search', name: 'search_docs', input: {} }, + ], + }, { role: 'user', content: [ @@ -208,6 +251,13 @@ test('buildTargetRequest flattens text-block tool_result content but serializes }); assertEquals(result.messages, [ + { + role: 'assistant', content: null, + tool_calls: [ + { id: 'toolu_text', type: 'function', function: { name: 'read_text', arguments: '{}' } }, + { id: 'toolu_search', type: 'function', function: { name: 'search_docs', arguments: '{}' } }, + ], + }, { role: 'tool', tool_call_id: 'toolu_text', content: 'hello' }, { role: 'tool', @@ -217,11 +267,18 @@ test('buildTargetRequest flattens text-block tool_result content but serializes ]); }); -test('buildTargetRequest preserves mixed user/tool_result chronology', async () => { +test('buildTargetPayload preserves mixed user/tool_result chronology', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, messages: [ + { + role: 'assistant', + content: [ + { type: 'tool_use', id: 'toolu_1', name: 'first_lookup', input: {} }, + { type: 'tool_use', id: 'toolu_2', name: 'second_lookup', input: {} }, + ], + }, { role: 'user', content: [ @@ -235,6 +292,13 @@ test('buildTargetRequest preserves mixed user/tool_result chronology', async () }); assertEquals(result.messages, [ + { + role: 'assistant', content: null, + tool_calls: [ + { id: 'toolu_1', type: 'function', function: { name: 'first_lookup', arguments: '{}' } }, + { id: 'toolu_2', type: 'function', function: { name: 'second_lookup', arguments: '{}' } }, + ], + }, { role: 'user', content: 'First question.' }, { role: 'tool', tool_call_id: 'toolu_1', content: 'first' }, { role: 'user', content: 'Follow-up.' }, @@ -242,7 +306,7 @@ test('buildTargetRequest preserves mixed user/tool_result chronology', async () ]); }); -test('buildTargetRequest concatenates readable thinking groups and drops signatures', async () => { +test('buildTargetPayload concatenates readable thinking groups and drops signatures', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -265,7 +329,7 @@ test('buildTargetRequest concatenates readable thinking groups and drops signatu } as OpenAIChatCompletionsAssistantMessageEx); }); -test('buildTargetRequest drops redacted opaque blocks while keeping readable thinking', async () => { +test('buildTargetPayload drops redacted opaque blocks while keeping readable thinking', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -289,11 +353,11 @@ test('buildTargetRequest drops redacted opaque blocks while keeping readable thi // OpenAI strict-mode JSON Schema validators reject {type: 'object'} without a // `properties` field. Anthropic accepts that shape, so the input_schema must -// be normalized before forwarding. The reverse direction at -// packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/request.ts already +// be normalized before forwarding. The reverse direction in +// `translate/src/protocols/openai-chat-completions/as-source/input-to.ts` already // defaults `parameters` to {type: 'object', properties: {}}. Ref: // https://github.com/caozhiyuan/copilot-api/commit/ad57069826843c5d17d7b0e5ef2f75050128893c -test('buildTargetRequest defaults missing input_schema.properties to {} for object tools', async () => { +test('buildTargetPayload defaults missing input_schema.properties to {} for object tools', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -313,7 +377,7 @@ test('buildTargetRequest defaults missing input_schema.properties to {} for obje ]); }); -test('buildTargetRequest preserves declared input_schema.properties verbatim', async () => { +test('buildTargetPayload preserves declared input_schema.properties verbatim', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -337,7 +401,7 @@ test('buildTargetRequest preserves declared input_schema.properties verbatim', a }); }); -test('buildTargetRequest does not inject properties for non-object input_schema', async () => { +test('buildTargetPayload does not inject properties for non-object input_schema', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -348,7 +412,7 @@ test('buildTargetRequest does not inject properties for non-object input_schema' assertEquals((result.tools?.[0] as OpenAIChatCompletionsFunctionTool).function.parameters, { type: 'string' }); }); -test('buildTargetRequest wraps output_config.format json_schema as response_format with nested json_schema and strict', async () => { +test('buildTargetPayload wraps output_config.format json_schema as response_format with nested json_schema and strict', async () => { const schema = { type: 'object', properties: { test: { type: 'string' } }, @@ -368,7 +432,7 @@ test('buildTargetRequest wraps output_config.format json_schema as response_form }); }); -test('buildTargetRequest omits response_format when output_config has no format', async () => { +test('buildTargetPayload omits response_format when output_config has no format', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -379,7 +443,7 @@ test('buildTargetRequest omits response_format when output_config has no format' assertFalse('response_format' in result); }); -test('buildTargetRequest rejects an unknown assistant content block type', async () => { +test('buildTargetPayload rejects an unknown assistant content block type', async () => { await assertRejects( () => buildWithTestSidecar({ @@ -392,7 +456,7 @@ test('buildTargetRequest rejects an unknown assistant content block type', async ); }); -test('buildTargetRequest rejects an unknown user content block type', async () => { +test('buildTargetPayload rejects an unknown user content block type', async () => { await assertRejects( () => buildWithTestSidecar({ @@ -405,7 +469,7 @@ test('buildTargetRequest rejects an unknown user content block type', async () = ); }); -test('buildTargetRequest emits in-array role:"system" inline as a CC system message', async () => { +test('buildTargetPayload emits in-array role:"system" inline as a CC system message', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -422,7 +486,7 @@ test('buildTargetRequest emits in-array role:"system" inline as a CC system mess assertEquals(result.messages[2].role, 'user'); }); -test('buildTargetRequest preserves in-array system text blocks as separate content parts', async () => { +test('buildTargetPayload preserves in-array system text blocks as separate content parts', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -447,7 +511,7 @@ test('buildTargetRequest preserves in-array system text blocks as separate conte }); }); -test('buildTargetRequest preserves top-level system text blocks as separate content parts', async () => { +test('buildTargetPayload preserves top-level system text blocks as separate content parts', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -469,7 +533,7 @@ test('buildTargetRequest preserves top-level system text blocks as separate cont }); }); -test('buildTargetRequest skips system message when top-level system is empty array', async () => { +test('buildTargetPayload skips system message when top-level system is empty array', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -481,7 +545,7 @@ test('buildTargetRequest skips system message when top-level system is empty arr assertEquals(result.messages[0].role, 'user'); }); -test('buildTargetRequest preserves chronology of multiple in-array system messages', async () => { +test('buildTargetPayload preserves chronology of multiple in-array system messages', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -505,7 +569,7 @@ test('buildTargetRequest preserves chronology of multiple in-array system messag assertEquals(result.messages[5].role, 'user'); }); -test('buildTargetRequest rejects an unknown message role', async () => { +test('buildTargetPayload rejects an unknown message role', async () => { await assertRejects( () => buildWithTestSidecar({ @@ -520,7 +584,7 @@ test('buildTargetRequest rejects an unknown message role', async () => { // ── speed ↔ service_tier bridge ── -test('buildTargetRequest maps speed:fast to service_tier:fast on the outbound OpenAI Chat Completions payload', async () => { +test('buildTargetPayload maps speed:fast to service_tier:fast on the outbound OpenAI Chat Completions payload', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -531,7 +595,7 @@ test('buildTargetRequest maps speed:fast to service_tier:fast on the outbound Op assertEquals(result.service_tier, 'priority'); }); -test('buildTargetRequest omits service_tier when speed is absent', async () => { +test('buildTargetPayload omits service_tier when speed is absent', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -541,7 +605,7 @@ test('buildTargetRequest omits service_tier when speed is absent', async () => { assertFalse('service_tier' in result); }); -test('buildTargetRequest drops speed values other than fast without emitting service_tier', async () => { +test('buildTargetPayload drops speed values other than fast without emitting service_tier', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -552,7 +616,7 @@ test('buildTargetRequest drops speed values other than fast without emitting ser assertFalse('service_tier' in result); }); -test('buildTargetRequest forwards Anthropic service_tier to OpenAI Chat Completions when speed is absent', async () => { +test('buildTargetPayload forwards Anthropic service_tier to OpenAI Chat Completions when speed is absent', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -563,7 +627,7 @@ test('buildTargetRequest forwards Anthropic service_tier to OpenAI Chat Completi assertEquals(result.service_tier, 'auto'); }); -test('buildTargetRequest forwards service_tier:standard_only to OpenAI Chat Completions when speed is absent', async () => { +test('buildTargetPayload forwards service_tier:standard_only to OpenAI Chat Completions when speed is absent', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -574,13 +638,55 @@ test('buildTargetRequest forwards service_tier:standard_only to OpenAI Chat Comp assertEquals(result.service_tier, 'standard_only'); }); -test('buildTargetRequest asks upstream for continuous streaming usage stats', async () => { +test('buildTargetPayload asks upstream for continuous streaming usage stats', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, messages: [{ role: 'user', content: 'hi' }], + stream: true, }); assertEquals(result.stream, true); assertEquals(result.stream_options, { include_usage: true, continuous_usage_stats: true }); }); + +test('rejects a metadata value the neutral model cannot state as a string pair', async () => { + // Messages admits any JSON value as a tag while every metadata-carrying + // target states string pairs, so a value with no string spelling fails at the + // reader rather than being dropped or coerced on the way to a target. + await assertRejects( + () => buildWithTestSidecar({ + model: 'gpt-test', + stream: true, + max_tokens: 16, + messages: [{ role: 'user', content: 'hi' }], + metadata: { user_id: 'u', nested: { depth: 1 } } as never, + }), + TranslateInputException, + 'nested', + ); +}); + +test('buildTargetPayload encodes a plain-text document source as base64', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [{ + role: 'user', + content: [{ type: 'document', source: { type: 'text', media_type: 'text/plain', data: 'hello world' } }], + }], + }); + const part = result.messages[0].content; + if (!Array.isArray(part)) throw new Error('expected a content array'); + assertEquals(part[0], { type: 'file', file: { file_data: 'data:text/plain;base64,aGVsbG8gd29ybGQ=' } }); +}); + +test.each([true, false])('maps disable_parallel_tool_use %s without changing declarations', async disabled => { + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [], + tools: [{ name: 'f', input_schema: { type: 'object', properties: {} } }], + tool_choice: { type: 'auto', disable_parallel_tool_use: disabled }, + }); + assertEquals(result.parallel_tool_calls, !disabled); + assertEquals(result.tools?.length, 1); +}); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-events-protocol_test.ts similarity index 86% rename from packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts rename to packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-events-protocol_test.ts index d2295824ac..2e3d140ce3 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-events-protocol_test.ts @@ -1,13 +1,18 @@ import { test } from 'vitest'; -import { translateToSourceEvents } from '../../../../src/protocols/anthropic-messages/via-openai-responses/events.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; -import { structuredResponsesFixture } from '../../openai-responses/fixtures.ts'; +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; +import { structuredResponsesFixture } from '../openai-responses/fixtures.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { openaiResponsesResultToEvents, type OpenAIResponsesResultEx, type OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; +const codec = createTestSidecarCodec(); +const translate = (frames: AsyncIterable>) => buildSourceEventsFor( + pairs.messagesToResponses[0], pairs.messagesToResponses[1], emptyPayload.messages, { sidecarCodec: codec }, frames, +); + const makeResponse = (status: OpenAIResponsesResultEx['status']): OpenAIResponsesResultEx => ({ id: 'resp_123', object: 'response', @@ -48,7 +53,7 @@ test('translateToSourceEvents emits structured Anthropic Messages events from th const frames = []; - for await (const frame of translateToSourceEvents(structuredResponsesFixture(stream()), createTestSidecarCodec())) { + for await (const frame of translate(structuredResponsesFixture(stream()))) { frames.push(frame); } @@ -80,7 +85,7 @@ test('translateToSourceEvents stops after OpenAI Responses terminal', async () = const frames = []; - for await (const frame of translateToSourceEvents(structuredResponsesFixture(stream()), createTestSidecarCodec())) { + for await (const frame of translate(structuredResponsesFixture(stream()))) { frames.push(frame); } @@ -118,7 +123,7 @@ test('translateToSourceEvents preserves refusal semantics from JSON fallback', a let refusalDelta: Extract | undefined; - for await (const frame of translateToSourceEvents(structuredResponsesFixture(stream()), createTestSidecarCodec())) { + for await (const frame of translate(structuredResponsesFixture(stream()))) { if (frame.type !== 'event') continue; if (frame.event.type === 'message_delta') refusalDelta = frame.event; } @@ -153,7 +158,7 @@ test('translateToSourceEvents translates OpenAI Responses failed terminal to Ant const frames = []; - for await (const frame of translateToSourceEvents(structuredResponsesFixture(stream()), createTestSidecarCodec())) { + for await (const frame of translate(structuredResponsesFixture(stream()))) { frames.push(frame); } @@ -184,7 +189,7 @@ test('translateToSourceEvents translates OpenAI Responses error terminal to Anth const frames = []; - for await (const frame of translateToSourceEvents(structuredResponsesFixture(stream()), createTestSidecarCodec())) { + for await (const frame of translate(structuredResponsesFixture(stream()))) { frames.push(frame); } @@ -212,7 +217,7 @@ test('translateToSourceEvents rejects truncated OpenAI Responses streams without } await assertRejects( - async () => await drain(translateToSourceEvents(structuredResponsesFixture(stream()), createTestSidecarCodec())), + async () => await drain(translate(structuredResponsesFixture(stream()))), Error, 'before the initial response envelope', ); diff --git a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-events_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-events_test.ts new file mode 100644 index 0000000000..3456ed4660 --- /dev/null +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-events_test.ts @@ -0,0 +1,7 @@ +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { testOutputTranslation } from '../fixtures.ts'; + +testOutputTranslation('anthropic-messages', 'openai-responses', (frames, codec) => { + const [source, target] = pairs.messagesToResponses; + return buildSourceEventsFor(source, target, emptyPayload.messages, { sidecarCodec: codec }, frames); +}); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/request_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts similarity index 63% rename from packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/request_test.ts rename to packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts index 9ee67180c3..6d1e3b24ea 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts @@ -1,15 +1,44 @@ import { test } from 'vitest'; -import { buildTargetRequest as buildTargetRequestWithSidecar } from '../../../../src/protocols/anthropic-messages/via-openai-responses/request.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { buildTargetPayloadFor, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; import type { AnthropicMessagesPayload, AnthropicMessagesAssistantContentBlock, AnthropicMessagesUserContentBlock } from '@floway-dev/protocols/anthropic-messages'; import type { OpenAIResponsesFunctionTool, OpenAIResponsesInputReasoning } from '@floway-dev/protocols/openai-responses'; import { assertEquals, assertFalse, assertRejects } from '@floway-dev/test-utils'; -const buildWithTestSidecar = (payload: Parameters[0]) => - buildTargetRequestWithSidecar(payload, createTestSidecarCodec()); +const buildWithTestSidecar = (payload: AnthropicMessagesPayload) => + buildTargetPayloadFor(pairs.messagesToResponses[0], pairs.messagesToResponses[1], { ...payload, stream: true }, { sidecarCodec: createTestSidecarCodec() }); -test('buildTargetRequest omits tool_choice when Anthropic Messages omits it', async () => { +test('keeps search result runs between the text and images around them', async () => { + const first = { type: 'search_result' as const, source: 'https://example.com/1', title: 'First', content: [{ type: 'text' as const, text: 'one' }] }; + const second = { ...first, source: 'https://example.com/2', title: 'Second' }; + const third = { ...first, source: 'https://example.com/3', title: 'Third' }; + const result = await buildWithTestSidecar({ + model: 'model', max_tokens: 32, messages: [ + { role: 'assistant', content: [{ type: 'tool_use', id: 'c', name: 'lookup', input: {} }] }, + { + role: 'user', content: [{ + type: 'tool_result', tool_use_id: 'c', content: [ + { type: 'text', text: 'Before' }, first, second, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } }, + third, { type: 'text', text: 'After' }, + ], + }], + }, + ], + }); + assertEquals(result.input[1], { + type: 'function_call_output', call_id: 'c', status: 'completed', output: [ + { type: 'input_text', text: 'Before' }, + { type: 'input_text', text: JSON.stringify([first, second]) }, + { type: 'input_image', image_url: 'data:image/png;base64,AAAA' }, + { type: 'input_text', text: JSON.stringify([third]) }, + { type: 'input_text', text: 'After' }, + ], + }); +}); + +test('buildTargetPayload omits tool_choice when Anthropic Messages omits it', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -20,7 +49,7 @@ test('buildTargetRequest omits tool_choice when Anthropic Messages omits it', as assertFalse('tool_choice' in result); }); -test('buildTargetRequest omits tool_choice when Anthropic Messages carries no client tools to apply it to', async () => { +test('buildTargetPayload omits tool_choice when Anthropic Messages carries no client tools to apply it to', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -34,7 +63,7 @@ test('buildTargetRequest omits tool_choice when Anthropic Messages carries no cl test.each([ { tools: undefined, name: 'lookup' }, { tools: [{ name: 'other', input_schema: { type: 'object' as const } }], name: 'lookup' }, -])('buildTargetRequest rejects named tool_choice for an undeclared tool', async ({ tools, name }) => { +])('buildTargetPayload rejects named tool_choice for an undeclared tool', async ({ tools, name }) => { await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', @@ -48,7 +77,7 @@ test.each([ ); }); -test('buildTargetRequest drops filtered-native tool_choice and rewrites assistant native web-search history as function-call history', async () => { +test('buildTargetPayload drops filtered-native tool_choice and rewrites assistant native web-search history as function-call history', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -82,7 +111,9 @@ test('buildTargetRequest drops filtered-native tool_choice and rewrites assistan ], }); - assertEquals(result.tools, []); + // A hosted Messages tool has no declaration on this wire, so the request + // offers none and states no selector. + assertEquals(result.tools, undefined); assertEquals(result.tool_choice, undefined); assertEquals(result.input, [ { @@ -101,7 +132,84 @@ test('buildTargetRequest drops filtered-native tool_choice and rewrites assistan ]); }); -test('buildTargetRequest maps output_config.effort directly to reasoning.effort', async () => { +test('buildTargetPayload pairs each server tool round with its own result', async () => { + const search = (id: string, query: string) => [ + { caller: { type: 'direct' as const }, type: 'server_tool_use' as const, id, name: 'web_search' as const, input: { query } }, + { + type: 'web_search_tool_result' as const, + tool_use_id: id, + content: [{ type: 'web_search_result' as const, url: `https://example.com/${id}`, title: id, encrypted_content: `enc-${id}` }], + }, + ]; + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [ + { + role: 'assistant', + content: [ + { type: 'text', text: 'First search.' }, + ...search('st_1', 'a'), + { type: 'text', text: 'Refining.' }, + ...search('st_2', 'b'), + { type: 'text', text: 'Final answer.' }, + ], + }, + ], + }); + + // Each round is a call and its own result, in the order the source stated + // them: the text that follows a result belongs to the next round, not to the + // end of the message. + assertEquals(result.input, [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'First search.' }] }, + { type: 'function_call', call_id: 'st_1', name: 'web_search', arguments: '{"query":"a"}', status: 'completed' }, + { + type: 'function_call_output', + call_id: 'st_1', + output: '[{"type":"web_search_result","url":"https://example.com/st_1","title":"st_1","encrypted_content":"enc-st_1"}]', + status: 'completed', + }, + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Refining.' }] }, + { type: 'function_call', call_id: 'st_2', name: 'web_search', arguments: '{"query":"b"}', status: 'completed' }, + { + type: 'function_call_output', + call_id: 'st_2', + output: '[{"type":"web_search_result","url":"https://example.com/st_2","title":"st_2","encrypted_content":"enc-st_2"}]', + status: 'completed', + }, + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Final answer.' }] }, + ]); +}); + +test('buildTargetPayload folds a trailing server result into the next client turn', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [ + { + role: 'assistant', + content: [ + { type: 'text', text: 'Searching.' }, + { caller: { type: 'direct' as const }, type: 'server_tool_use' as const, id: 'st_1', name: 'web_search' as const, input: { query: 'a' } }, + { type: 'web_search_tool_result' as const, tool_use_id: 'st_1', content: [] }, + ], + }, + { role: 'user', content: 'Now use those results.' }, + ], + }); + + // The result answers the call, and the client's own message follows it in the + // same turn rather than opening a second one. + assertEquals(result.input, [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Searching.' }] }, + { type: 'function_call', call_id: 'st_1', name: 'web_search', arguments: '{"query":"a"}', status: 'completed' }, + { type: 'function_call_output', call_id: 'st_1', output: '[]', status: 'completed' }, + { type: 'message', role: 'user', content: 'Now use those results.' }, + ]); +}); + +test('buildTargetPayload maps output_config.effort directly to reasoning.effort', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -113,7 +221,7 @@ test('buildTargetRequest maps output_config.effort directly to reasoning.effort' assertFalse('include' in result); }); -test('buildTargetRequest forwards empty output_config.effort verbatim', async () => { +test('buildTargetPayload forwards empty output_config.effort verbatim', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -125,7 +233,7 @@ test('buildTargetRequest forwards empty output_config.effort verbatim', async () assertEquals(result.reasoning, { effort: '' }); }); -test('buildTargetRequest prefers output_config.effort over thinking.disabled', async () => { +test('buildTargetPayload prefers output_config.effort over thinking.disabled', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -137,7 +245,7 @@ test('buildTargetRequest prefers output_config.effort over thinking.disabled', a assertEquals(result.reasoning, { effort: 'high' }); }); -test('buildTargetRequest preserves output_config.effort max at the translation boundary', async () => { +test('buildTargetPayload preserves output_config.effort max at the translation boundary', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -148,8 +256,8 @@ test('buildTargetRequest preserves output_config.effort max at the translation b assertEquals(result.reasoning, { effort: 'max' }); }); -test('buildTargetRequest maps thinking.enabled to reasoning.effort medium regardless of budget_tokens', async () => { - for (const budget of [1024, 16384]) { +test('buildTargetPayload projects thinking.enabled budgets onto reasoning.effort', async () => { + for (const [budget, effort] of [[1024, 'low'], [16384, 'high']] as const) { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 4096, @@ -157,11 +265,11 @@ test('buildTargetRequest maps thinking.enabled to reasoning.effort medium regard messages: [{ role: 'user', content: 'hi' }], }); - assertEquals(result.reasoning, { effort: 'medium' }); + assertEquals(result.reasoning, { effort }); } }); -test('buildTargetRequest maps thinking.adaptive to reasoning.effort medium', async () => { +test('buildTargetPayload maps thinking.adaptive to reasoning.effort medium', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 4096, @@ -172,7 +280,7 @@ test('buildTargetRequest maps thinking.adaptive to reasoning.effort medium', asy assertEquals(result.reasoning, { effort: 'medium' }); }); -test('buildTargetRequest never invents reasoning.context from a source thinking block', async () => { +test('buildTargetPayload never invents reasoning.context from a source thinking block', async () => { // The Anthropic Messages thinking shape carries no reasoning-context mode, so the // target reasoning object must expose effort only — never a synthesized // `all_turns` (or any other) context value. @@ -188,7 +296,7 @@ test('buildTargetRequest never invents reasoning.context from a source thinking assertFalse('context' in (result.reasoning ?? {})); }); -test('buildTargetRequest preserves max_tokens at the translation boundary', async () => { +test('buildTargetPayload preserves max_tokens at the translation boundary', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -198,7 +306,7 @@ test('buildTargetRequest preserves max_tokens at the translation boundary', asyn assertEquals(result.max_output_tokens, 256); }); -test('buildTargetRequest maps thinking.disabled to reasoning.effort none', async () => { +test('buildTargetPayload maps thinking.disabled to reasoning.effort none', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -210,7 +318,7 @@ test('buildTargetRequest maps thinking.disabled to reasoning.effort none', async assertFalse('include' in result); }); -test('buildTargetRequest preserves explicit temperature and omits translated-path defaults', async () => { +test('buildTargetPayload preserves explicit temperature and omits translated-path defaults', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -223,7 +331,7 @@ test('buildTargetRequest preserves explicit temperature and omits translated-pat assertFalse('parallel_tool_calls' in result); }); -test('buildTargetRequest omits temperature when the source omitted it', async () => { +test('buildTargetPayload omits temperature when the source omitted it', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -233,7 +341,7 @@ test('buildTargetRequest omits temperature when the source omitted it', async () assertFalse('temperature' in result); }); -test('buildTargetRequest prepends multi-block top-level system as a leading input system message preserving block boundaries', async () => { +test('buildTargetPayload prepends multi-block top-level system as a leading input system message preserving block boundaries', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -256,7 +364,7 @@ test('buildTargetRequest prepends multi-block top-level system as a leading inpu }); }); -test('buildTargetRequest keeps a single-block top-level system in canonical `instructions` slot', async () => { +test('buildTargetPayload keeps a single-block top-level system in canonical `instructions` slot', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -269,7 +377,7 @@ test('buildTargetRequest keeps a single-block top-level system in canonical `ins assertEquals(input[0].role, 'user'); }); -test('buildTargetRequest preserves text-only thinking input', async () => { +test('buildTargetPayload preserves text-only thinking input', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -293,7 +401,7 @@ test('buildTargetRequest preserves text-only thinking input', async () => { // `properties` field. Anthropic accepts that shape, so the input_schema must // be normalized before forwarding to OpenAI Responses. Ref: // https://github.com/caozhiyuan/copilot-api/commit/ad57069826843c5d17d7b0e5ef2f75050128893c -test('buildTargetRequest defaults missing input_schema.properties to {} for object tools', async () => { +test('buildTargetPayload defaults missing input_schema.properties to {} for object tools', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -311,7 +419,7 @@ test('buildTargetRequest defaults missing input_schema.properties to {} for obje ]); }); -test('buildTargetRequest preserves declared input_schema.properties verbatim', async () => { +test('buildTargetPayload preserves declared input_schema.properties verbatim', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -336,7 +444,7 @@ test('buildTargetRequest preserves declared input_schema.properties verbatim', a }); }); -test('buildTargetRequest does not inject properties for non-object input_schema', async () => { +test('buildTargetPayload does not inject properties for non-object input_schema', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -348,7 +456,7 @@ test('buildTargetRequest does not inject properties for non-object input_schema' assertEquals(tool.parameters, { type: 'string' }); }); -test('buildTargetRequest wraps output_config.format json_schema as text.format with synthesised name and strict', async () => { +test('buildTargetPayload wraps output_config.format json_schema as text.format with synthesised name and strict', async () => { const schema = { type: 'object', properties: { test: { type: 'string' } }, @@ -367,7 +475,7 @@ test('buildTargetRequest wraps output_config.format json_schema as text.format w }); }); -test('buildTargetRequest omits text when output_config has no format', async () => { +test('buildTargetPayload omits text when output_config has no format', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -378,7 +486,7 @@ test('buildTargetRequest omits text when output_config has no format', async () assertFalse('text' in result); }); -test('buildTargetRequest rejects an unknown assistant content block type', async () => { +test('buildTargetPayload rejects an unknown assistant content block type', async () => { await assertRejects( () => buildWithTestSidecar({ @@ -391,7 +499,7 @@ test('buildTargetRequest rejects an unknown assistant content block type', async ); }); -test('buildTargetRequest rejects an unknown user content block type', async () => { +test('buildTargetPayload rejects an unknown user content block type', async () => { await assertRejects( () => buildWithTestSidecar({ @@ -400,11 +508,11 @@ test('buildTargetRequest rejects an unknown user content block type', async () = messages: [{ role: 'user', content: [{ type: 'audio' } as unknown as AnthropicMessagesUserContentBlock] }], }), Error, - "messages.0.content.0.type: 'audio' user content blocks are not supported", + "messages.0.content.0.type: 'audio' content blocks are not supported in user content.", ); }); -test('buildTargetRequest emits in-array role:"system" inline as an OpenAI Responses message input item', async () => { +test('buildTargetPayload emits in-array role:"system" inline as an OpenAI Responses message input item', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -422,7 +530,7 @@ test('buildTargetRequest emits in-array role:"system" inline as an OpenAI Respon assertEquals(input[2].role, 'user'); }); -test('buildTargetRequest preserves in-array system text blocks as separate input_text parts', async () => { +test('buildTargetPayload preserves in-array system text blocks as separate input_text parts', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -449,7 +557,7 @@ test('buildTargetRequest preserves in-array system text blocks as separate input }); }); -test('buildTargetRequest preserves chronology of multiple in-array system messages', async () => { +test('buildTargetPayload hoists a leading in-array system message and keeps later ones in place', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -462,16 +570,18 @@ test('buildTargetRequest preserves chronology of multiple in-array system messag ], }); + // The leading run is what the request states before its first turn, so it is + // the prompt; a system message after a turn happened stays where it was. + assertEquals(result.instructions, 'mid-array A'); const input = result.input as Array<{ type: string; role?: string }>; - assertEquals(input.length, 5); - assertEquals(input[0].role, 'system'); - assertEquals(input[1].role, 'user'); - assertEquals(input[2].role, 'assistant'); - assertEquals(input[3].role, 'system'); - assertEquals(input[4].role, 'user'); + assertEquals(input.length, 4); + assertEquals(input[0].role, 'user'); + assertEquals(input[1].role, 'assistant'); + assertEquals(input[2].role, 'system'); + assertEquals(input[3].role, 'user'); }); -test('buildTargetRequest rejects an unknown message role', async () => { +test('buildTargetPayload rejects an unknown message role', async () => { await assertRejects( () => buildWithTestSidecar({ @@ -484,7 +594,7 @@ test('buildTargetRequest rejects an unknown message role', async () => { ); }); -test('buildTargetRequest collapses Anthropic thinking mode onto reasoning.effort only', async () => { +test('buildTargetPayload projects an Anthropic thinking budget onto reasoning.effort', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -492,14 +602,12 @@ test('buildTargetRequest collapses Anthropic thinking mode onto reasoning.effort thinking: { type: 'enabled', budget_tokens: 4096 }, }); - // `thinking.type === 'enabled'` resolves to the OpenAI-canonical `medium` - // effort; the `budget_tokens` scalar has no OpenAI Responses slot and drops. assertEquals(result.reasoning, { effort: 'medium' }); }); // ── speed ↔ service_tier bridge ── -test('buildTargetRequest maps speed:fast to service_tier:fast on the outbound OpenAI Responses payload', async () => { +test('buildTargetPayload maps speed:fast to service_tier:fast on the outbound OpenAI Responses payload', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -510,7 +618,7 @@ test('buildTargetRequest maps speed:fast to service_tier:fast on the outbound Op assertEquals(result.service_tier, 'priority'); }); -test('buildTargetRequest omits service_tier when speed is absent', async () => { +test('buildTargetPayload omits service_tier when speed is absent', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -520,7 +628,7 @@ test('buildTargetRequest omits service_tier when speed is absent', async () => { assertFalse('service_tier' in result); }); -test('buildTargetRequest drops speed values other than fast without emitting service_tier', async () => { +test('buildTargetPayload drops speed values other than fast without emitting service_tier', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -531,7 +639,7 @@ test('buildTargetRequest drops speed values other than fast without emitting ser assertFalse('service_tier' in result); }); -test('buildTargetRequest forwards Anthropic service_tier to OpenAI Responses when speed is absent', async () => { +test('buildTargetPayload forwards Anthropic service_tier to OpenAI Responses when speed is absent', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -542,7 +650,7 @@ test('buildTargetRequest forwards Anthropic service_tier to OpenAI Responses whe assertEquals(result.service_tier, 'auto'); }); -test('buildTargetRequest forwards service_tier:standard_only to OpenAI Responses when speed is absent', async () => { +test('buildTargetPayload forwards service_tier:standard_only to OpenAI Responses when speed is absent', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -558,3 +666,13 @@ test('preserves expressible Messages metadata extensions and rejects non-string assertEquals((await buildWithTestSidecar(payload)).metadata, { user_id: 'u', tenant: 't' }); await assertRejects(() => buildWithTestSidecar({ ...payload, metadata: { user_id: 'u', tenant: { nested: 't' } } } as AnthropicMessagesPayload), undefined, "metadata field 'tenant'"); }); + +test.each([true, false])('maps disable_parallel_tool_use %s without changing declarations', async disabled => { + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [], + tools: [{ name: 'f', input_schema: { type: 'object', properties: {} } }], + tool_choice: { type: 'auto', disable_parallel_tool_use: disabled }, + }); + assertEquals(result.parallel_tool_calls, !disabled); + assertEquals(result.tools?.length, 1); +}); diff --git a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-events_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-events_test.ts new file mode 100644 index 0000000000..e7a374fb98 --- /dev/null +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-events_test.ts @@ -0,0 +1,7 @@ +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { testOutputTranslation } from '../fixtures.ts'; + +testOutputTranslation('gemini-generate-content', 'anthropic-messages', (frames, codec) => { + const [source, target] = pairs.geminiToMessages; + return buildSourceEventsFor(source, target, emptyPayload.gemini, { sidecarCodec: codec }, frames); +}); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/request_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-request_test.ts similarity index 70% rename from packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/request_test.ts rename to packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-request_test.ts index f09797878a..5d2305371c 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-request_test.ts @@ -1,22 +1,23 @@ import { test } from 'vitest'; -import { buildTargetRequest as buildTargetRequestWithSidecar } from '../../../../src/protocols/gemini-generate-content/via-anthropic-messages/request.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { TranslateInputException } from '../../../src/shared/input-error.ts'; +import { buildTargetPayloadFor, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; import { ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS } from '@floway-dev/protocols/anthropic-messages'; import type { GeminiGenerateContentPart, GeminiGenerateContentContent, GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; -const noOptions: Parameters[3] = { fallbackMaxOutputTokens: undefined }; +const noOptions: { fallbackMaxOutputTokens?: number } = { fallbackMaxOutputTokens: undefined }; const withMaxOutputTokens = (maxOutputTokens: number) => ({ fallbackMaxOutputTokens: maxOutputTokens }); const buildWithTestSidecar = ( - payload: Parameters[0], - model: Parameters[1], - options: Parameters[3], -) => buildTargetRequestWithSidecar(payload, model, createTestSidecarCodec(), options); + payload: GeminiGenerateContentPayload, + model: string, + options: { fallbackMaxOutputTokens?: number }, +) => buildTargetPayloadFor(pairs.geminiToMessages[0], pairs.geminiToMessages[1], { ...payload, model }, { sidecarCodec: createTestSidecarCodec(), ...options }); -test('buildTargetRequest translates LOW to output_config.effort', async () => { +test('buildTargetPayload translates a named thinking level to the effort name', async () => { const request = await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingLevel: 'LOW' } }, @@ -25,7 +26,7 @@ test('buildTargetRequest translates LOW to output_config.effort', async () => { assertEquals(request.output_config, { effort: 'low' }); }); -test('buildTargetRequest forwards an empty thinkingLevel verbatim', async () => { +test('buildTargetPayload forwards an empty thinkingLevel verbatim', async () => { const request = await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingLevel: '' } }, @@ -34,17 +35,19 @@ test('buildTargetRequest forwards an empty thinkingLevel verbatim', async () => assertEquals(request.output_config, { effort: '' }); }); -test('buildTargetRequest preserves native thinkingBudget beside an empty thinkingLevel', async () => { +test('buildTargetPayload preserves native thinkingBudget beside an empty thinkingLevel', async () => { const request = await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: 2048, thinkingLevel: '' } }, }, 'claude-test', noOptions); assertEquals(request.thinking, { type: 'enabled', budget_tokens: 2048 }); + // Messages states a level and a budget on different slots, so an empty level + // travels verbatim beside the budget rather than being replaced by it. assertEquals(request.output_config, { effort: '' }); }); -test('buildTargetRequest maps system, default max tokens, and multimodal user content', async () => { +test('buildTargetPayload maps system, default max tokens, and multimodal user content', async () => { const payload: GeminiGenerateContentPayload = { systemInstruction: { parts: [{ text: 'Be precise.' }, { text: 'Use markdown.' }], @@ -60,7 +63,7 @@ test('buildTargetRequest maps system, default max tokens, and multimodal user co model: 'claude-test', stream: true, max_tokens: ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS, - system: [{ type: 'text', text: 'Be precise.\n\nUse markdown.', cache_control: { type: 'ephemeral' } }], + system: [{ type: 'text', text: 'Be precise.Use markdown.', cache_control: { type: 'ephemeral' } }], messages: [ { role: 'user', @@ -89,20 +92,28 @@ test('buildTargetRequest maps system, default max tokens, and multimodal user co }); }); -test.each(['audio/wav', 'audio/mp3', 'audio/mpeg', 'text/plain'])('buildTargetRequest rejects Gemini inline data MIME type %s for Anthropic Messages', async mimeType => { +test.each(['audio/wav', 'audio/mp3', 'audio/mpeg'])('buildTargetPayload rejects Gemini inline audio MIME type %s for Anthropic Messages', async mimeType => { await assertRejects( () => buildWithTestSidecar({ contents: [{ role: 'user', parts: [{ inlineData: { mimeType, data: 'ZGF0YQ==' } }] }] }, 'claude-test', noOptions), Error, - `Cannot translate Gemini generateContent inlineData MIME type '${mimeType}' to Anthropic Messages.`, + `Cannot translate an audio part of MIME type '${mimeType}' to Anthropic Messages.`, ); }); -test('buildTargetRequest prefers limits.max_output_tokens over the gateway default when payload omits maxOutputTokens', async () => { +test('buildTargetPayload forwards a non-audio inline MIME type from Gemini as a Messages document', async () => { + // GenerateContent states any MIME type inline; the document's media type + // travels verbatim so the selected upstream decides whether it accepts it. + // A `text` source states characters, not the base64 the IR carries. + const request = await buildWithTestSidecar({ contents: [{ role: 'user', parts: [{ inlineData: { mimeType: 'text/plain', data: 'ZGF0YQ==' } }] }] }, 'claude-test', noOptions); + assertEquals(request.messages, [{ role: 'user', content: [{ type: 'document', source: { type: 'text', media_type: 'text/plain', data: 'data' } }] }]); +}); + +test('buildTargetPayload prefers limits.max_output_tokens over the gateway default when payload omits maxOutputTokens', async () => { const request = await buildWithTestSidecar({ contents: [] }, 'claude-test', withMaxOutputTokens(6144)); assertEquals(request.max_tokens, 6144); }); -test('buildTargetRequest maps generation config and thinking controls', async () => { +test('buildTargetPayload maps generation config and thinking controls', async () => { const payload: GeminiGenerateContentPayload = { contents: [], generationConfig: { @@ -112,7 +123,6 @@ test('buildTargetRequest maps generation config and thinking controls', async () topK: 40, stopSequences: ['END'], thinkingConfig: { - thinkingBudget: 2048, thinkingLevel: 'high', }, }, @@ -127,14 +137,26 @@ test('buildTargetRequest maps generation config and thinking controls', async () top_p: 0.8, top_k: 40, stop_sequences: ['END'], - thinking: { type: 'enabled', budget_tokens: 2048 }, output_config: { effort: 'high' }, }); assertEquals((await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: 0 } } }, 'claude-test', noOptions)).thinking, { type: 'disabled' }); }); -test('buildTargetRequest keeps readable thinking and tool calls while dropping thought signatures', async () => { +test('buildTargetPayload refuses a stated budget beside a stated level', async () => { + // GenerateContent rejects the pair itself, so translating it would have to + // pick a winner that a different model family disagrees with. + await assertRejects( + () => buildWithTestSidecar({ + contents: [], + generationConfig: { thinkingConfig: { thinkingBudget: 2048, thinkingLevel: 'HIGH' } }, + }, 'claude-test', noOptions), + TranslateInputException, + 'thinking_budget and thinking_level are not supported together', + ); +}); + +test('buildTargetPayload keeps readable thinking and tool calls while dropping thought signatures', async () => { const payload: GeminiGenerateContentPayload = { contents: [ { @@ -170,14 +192,9 @@ test('buildTargetRequest keeps readable thinking and tool calls while dropping t name: 'lookup', input: { q: 'docs' }, }, - ], - }, - { - role: 'assistant', - content: [ { type: 'tool_use', - id: 'gemini_call_1_0', + id: 'fallback_call_1', name: 'fallback', input: {}, cache_control: { type: 'ephemeral' }, @@ -187,7 +204,7 @@ test('buildTargetRequest keeps readable thinking and tool calls while dropping t ]); }); -test('buildTargetRequest correlates omitted function response ids in call order', async () => { +test('buildTargetPayload correlates omitted function response ids in call order', async () => { const payload: GeminiGenerateContentPayload = { contents: [ { @@ -219,13 +236,13 @@ test('buildTargetRequest correlates omitted function response ids in call order' content: [ { type: 'tool_use', - id: 'gemini_call_0_0', + id: 'lookup_call_1', name: 'lookup', input: { q: 'first' }, }, { type: 'tool_use', - id: 'gemini_call_0_1', + id: 'lookup_call_2', name: 'lookup', input: { q: 'second' }, }, @@ -236,17 +253,12 @@ test('buildTargetRequest correlates omitted function response ids in call order' content: [ { type: 'tool_result', - tool_use_id: 'gemini_call_0_0', + tool_use_id: 'lookup_call_1', content: '{"answer":"first"}', }, - ], - }, - { - role: 'user', - content: [ { type: 'tool_result', - tool_use_id: 'gemini_call_0_1', + tool_use_id: 'lookup_call_2', content: '{"answer":"second"}', cache_control: { type: 'ephemeral' }, }, @@ -255,7 +267,7 @@ test('buildTargetRequest correlates omitted function response ids in call order' ]); }); -test('buildTargetRequest maps tool declarations and tool choice modes', async () => { +test('buildTargetPayload maps tool declarations and tool choice modes', async () => { const payload: GeminiGenerateContentPayload = { contents: [], tools: [ @@ -297,8 +309,8 @@ test('buildTargetRequest maps tool declarations and tool choice modes', async () type: 'object', properties: { query: { type: 'string' } }, }, - cache_control: { type: 'ephemeral' }, }, + { type: 'custom', name: 'ping', input_schema: { type: 'object', properties: {} }, cache_control: { type: 'ephemeral' } }, ], tool_choice: { type: 'tool', name: 'lookup' }, }); @@ -306,10 +318,11 @@ test('buildTargetRequest maps tool declarations and tool choice modes', async () assertEquals((await buildWithTestSidecar({ contents: [], toolConfig: { functionCallingConfig: { mode: 'NONE' } } }, 'claude-test', noOptions)).tool_choice, { type: 'none' }); assertEquals((await buildWithTestSidecar({ contents: [], toolConfig: { functionCallingConfig: { mode: 'AUTO' } } }, 'claude-test', noOptions)).tool_choice, { type: 'auto' }); assertEquals((await buildWithTestSidecar({ contents: [], toolConfig: { functionCallingConfig: { mode: 'VALIDATED' } } }, 'claude-test', noOptions)).tool_choice, { type: 'auto' }); - assertEquals((await buildWithTestSidecar({ contents: [], toolConfig: { functionCallingConfig: { mode: 'ANY' } } }, 'claude-test', noOptions)).tool_choice, { type: 'any' }); + // Without declarations there is no callable to require, so the target choice is omitted. + assertEquals((await buildWithTestSidecar({ contents: [], toolConfig: { functionCallingConfig: { mode: 'ANY' } } }, 'claude-test', noOptions)).tool_choice, undefined); }); -test('buildTargetRequest filters tools to multiple allowed names for ANY mode', async () => { +test('buildTargetPayload rejects an unrepresentable ANY subset without narrowing declarations', async () => { const payload: GeminiGenerateContentPayload = { contents: [], tools: [ @@ -325,40 +338,25 @@ test('buildTargetRequest filters tools to multiple allowed names for ANY mode', }, }; - assertEquals(await buildWithTestSidecar(payload, 'claude-test', noOptions), { - model: 'claude-test', - stream: true, - messages: [], - max_tokens: ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS, - tools: [ - { - type: 'custom', - name: 'lookup', - input_schema: { type: 'object', properties: {} }, - }, - { - type: 'custom', - name: 'ping', - input_schema: { type: 'object', properties: {} }, - cache_control: { type: 'ephemeral' }, - }, - ], - tool_choice: { type: 'any' }, - }); + await assertRejects( + () => buildWithTestSidecar(payload, 'claude-test', noOptions), + TranslateInputException, + 'cannot express an allowed_tools required subset', + ); }); -test('buildTargetRequest maps dynamic thinking budget to adaptive thinking', async () => { +test('buildTargetPayload maps dynamic thinking budget to adaptive thinking', async () => { assertEquals((await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: -1 } } }, 'claude-test', noOptions)).thinking, { type: 'adaptive' }); }); -test('buildTargetRequest wraps generationConfig.responseSchema as output_config.format', async () => { +test('buildTargetPayload wraps generationConfig.responseSchema as output_config.format', async () => { const schema = { type: 'object', properties: { x: { type: 'string' } }, required: ['x'], additionalProperties: false }; const request = await buildWithTestSidecar({ contents: [], generationConfig: { responseSchema: schema } }, 'claude-test', noOptions); assertEquals(request.output_config, { format: { type: 'json_schema', schema } }); }); -test('buildTargetRequest merges thinking-level effort with responseSchema format on a single output_config', async () => { +test('buildTargetPayload merges thinking-level effort with responseSchema format on a single output_config', async () => { const schema = { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'], additionalProperties: false }; const request = await buildWithTestSidecar( { contents: [], generationConfig: { responseSchema: schema, thinkingConfig: { thinkingLevel: 'high' } } }, @@ -369,7 +367,7 @@ test('buildTargetRequest merges thinking-level effort with responseSchema format assertEquals(request.output_config, { effort: 'high', format: { type: 'json_schema', schema } }); }); -test('buildTargetRequest rejects an unknown content role', async () => { +test('buildTargetPayload rejects an unknown content role', async () => { await assertRejects( () => buildWithTestSidecar( @@ -384,7 +382,7 @@ test('buildTargetRequest rejects an unknown content role', async () => { ); }); -test('buildTargetRequest rejects a part with an unsupported kind in user content', async () => { +test('buildTargetPayload rejects a part with an unsupported kind in user content', async () => { await assertRejects( () => buildWithTestSidecar( @@ -399,7 +397,7 @@ test('buildTargetRequest rejects a part with an unsupported kind in user content ); }); -test('buildTargetRequest rejects a function_call part in user content', async () => { +test('buildTargetPayload rejects a function_call part in user content', async () => { await assertRejects( () => buildWithTestSidecar( @@ -414,7 +412,7 @@ test('buildTargetRequest rejects a function_call part in user content', async () ); }); -test('buildTargetRequest rejects an inline_data part in model content', async () => { +test('buildTargetPayload rejects an inline_data part in model content', async () => { await assertRejects( () => buildWithTestSidecar( @@ -429,7 +427,7 @@ test('buildTargetRequest rejects an inline_data part in model content', async () ); }); -test('buildTargetRequest rejects a part that sets conflicting content fields', async () => { +test('buildTargetPayload rejects a part that sets conflicting content fields', async () => { await assertRejects( () => buildWithTestSidecar( @@ -444,7 +442,7 @@ test('buildTargetRequest rejects a part that sets conflicting content fields', a ); }); -test('buildTargetRequest rejects a part with no recognized content field', async () => { +test('buildTargetPayload rejects a part with no recognized content field', async () => { await assertRejects( () => buildWithTestSidecar( @@ -464,3 +462,17 @@ test.each([undefined, []])('preserves an empty model turn with parts %j', async const result = await buildWithTestSidecar(payload, 'target-model', noOptions); assertEquals((result.messages as Array<{ role?: string; content?: unknown }>).filter(item => item.role === 'assistant'), [{ role: 'assistant', content: '' }]); }); + +test('states an empty argument object for a call that carries no args', async () => { + // `input` is required on a Messages tool_use, and the empty object is how + // every target spells a call that takes no argument object. + const result = await buildWithTestSidecar({ + contents: [ + { role: 'user', parts: [{ text: 'go' }] }, + { role: 'model', parts: [{ functionCall: { name: 'f' } }] }, + { role: 'user', parts: [{ functionResponse: { name: 'f', response: { r: 1 } } }] }, + ], + }, 'target-model', noOptions); + const assistant = (result.messages as Array<{ role?: string; content?: unknown }>).find(item => item.role === 'assistant'); + assertEquals((assistant?.content as Array<{ input?: unknown }>)[0]?.input, {}); +}); diff --git a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-events_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-events_test.ts new file mode 100644 index 0000000000..c0246ae1fe --- /dev/null +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-events_test.ts @@ -0,0 +1,7 @@ +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { testOutputTranslation } from '../fixtures.ts'; + +testOutputTranslation('gemini-generate-content', 'openai-chat-completions', (frames, codec) => { + const [source, target] = pairs.geminiToChat; + return buildSourceEventsFor(source, target, emptyPayload.gemini, { sidecarCodec: codec }, frames); +}); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/request_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts similarity index 73% rename from packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/request_test.ts rename to packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts index 1ada2bca4e..23e3065036 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts @@ -1,17 +1,17 @@ import { test } from 'vitest'; -import { buildTargetRequest as buildTargetRequestWithSidecar } from '../../../../src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { buildTargetPayloadFor, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; import type { GeminiGenerateContentPart, GeminiGenerateContentContent, GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIChatCompletionsAssistantMessageEx } from '@floway-dev/protocols/openai-chat-completions'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; const buildWithTestSidecar = ( - payload: Parameters[0], - model: Parameters[1], -) => buildTargetRequestWithSidecar(payload, model, createTestSidecarCodec()); + payload: GeminiGenerateContentPayload, + model: string, +) => buildTargetPayloadFor(pairs.geminiToChat[0], pairs.geminiToChat[1], { ...payload, model }, { sidecarCodec: createTestSidecarCodec() }); -test('buildTargetRequest translates LOW to reasoning_effort', async () => { +test('buildTargetPayload translates LOW to reasoning_effort', async () => { const request = await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingLevel: 'LOW' } }, @@ -20,7 +20,7 @@ test('buildTargetRequest translates LOW to reasoning_effort', async () => { assertEquals(request.reasoning_effort, 'low'); }); -test('buildTargetRequest forwards an empty thinkingLevel verbatim', async () => { +test('buildTargetPayload forwards an empty thinkingLevel verbatim', async () => { const request = await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingLevel: '' } }, @@ -29,16 +29,16 @@ test('buildTargetRequest forwards an empty thinkingLevel verbatim', async () => assertEquals(request.reasoning_effort, ''); }); -test('buildTargetRequest gives thinkingBudget precedence over an empty thinkingLevel', async () => { +test('buildTargetPayload preserves an explicit empty thinkingLevel beside a budget', async () => { const request = await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: 2048, thinkingLevel: '' } }, }, 'gpt-test'); - assertEquals(request.reasoning_effort, 'low'); + assertEquals(request.reasoning_effort, ''); }); -test('buildTargetRequest maps system instruction and multimodal user content', async () => { +test('buildTargetPayload maps system instruction and multimodal user content', async () => { const payload: GeminiGenerateContentPayload = { systemInstruction: { parts: [{ text: 'Be precise.' }, { text: 'Use markdown.' }], @@ -54,8 +54,9 @@ test('buildTargetRequest maps system instruction and multimodal user content', a assertEquals(await buildWithTestSidecar(payload, 'gpt-test'), { model: 'gpt-test', stream: true, + stream_options: { include_usage: true, continuous_usage_stats: true }, messages: [ - { role: 'system', content: 'Be precise.\n\nUse markdown.' }, + { role: 'system', content: 'Be precise.Use markdown.' }, { role: 'user', content: [ @@ -74,7 +75,7 @@ test('buildTargetRequest maps system instruction and multimodal user content', a }); }); -test('buildTargetRequest maps Gemini inline audio to Chat Completions input audio', async () => { +test('buildTargetPayload maps Gemini inline audio to Chat Completions input audio', async () => { const request = await buildWithTestSidecar({ contents: [ { @@ -98,15 +99,14 @@ test('buildTargetRequest maps Gemini inline audio to Chat Completions input audi }]); }); -test('buildTargetRequest rejects unsupported Gemini inline data MIME types for Chat Completions', async () => { - await assertRejects( - () => buildWithTestSidecar({ contents: [{ role: 'user', parts: [{ inlineData: { mimeType: 'text/plain', data: 'dGV4dA==' } }] }] }, 'gpt-test'), - Error, - "Cannot translate Gemini generateContent inlineData MIME type 'text/plain' to OpenAI Chat Completions.", - ); +test('buildTargetPayload forwards a non-audio inline MIME type from Gemini as a Chat file part', async () => { + // Chat carries the media type in the data URL, so it travels verbatim and the + // selected upstream decides whether it accepts it. + const request = await buildWithTestSidecar({ contents: [{ role: 'user', parts: [{ inlineData: { mimeType: 'text/plain', data: 'dGV4dA==' } }] }] }, 'gpt-test'); + assertEquals(request.messages, [{ role: 'user', content: [{ type: 'file', file: { file_data: 'data:text/plain;base64,dGV4dA==' } }] }]); }); -test('buildTargetRequest maps function calls, tool responses, and readable reasoning history', async () => { +test('buildTargetPayload maps function calls, tool responses, and readable reasoning history', async () => { const payload: GeminiGenerateContentPayload = { contents: [ { @@ -134,7 +134,7 @@ test('buildTargetRequest maps function calls, tool responses, and readable reaso reasoning_text: 'private trace', tool_calls: [ { - id: 'gemini_call_0_2', + id: 'lookup_call_1', type: 'function', function: { name: 'lookup', arguments: '{"query":"docs"}' }, }, @@ -142,13 +142,13 @@ test('buildTargetRequest maps function calls, tool responses, and readable reaso } as OpenAIChatCompletionsAssistantMessageEx, { role: 'tool', - tool_call_id: 'gemini_call_0_2', + tool_call_id: 'lookup_call_1', content: '{"answer":42}', }, ]); }); -test('buildTargetRequest matches omitted functionResponse ids to same-name calls in call order', async () => { +test('buildTargetPayload matches omitted functionResponse ids to same-name calls in call order', async () => { const payload: GeminiGenerateContentPayload = { contents: [ { @@ -180,12 +180,12 @@ test('buildTargetRequest matches omitted functionResponse ids to same-name calls content: null, tool_calls: [ { - id: 'gemini_call_0_0', + id: 'lookup_call_1', type: 'function', function: { name: 'lookup', arguments: '{"query":"first"}' }, }, { - id: 'gemini_call_0_1', + id: 'lookup_call_2', type: 'function', function: { name: 'lookup', arguments: '{"query":"second"}' }, }, @@ -193,30 +193,30 @@ test('buildTargetRequest matches omitted functionResponse ids to same-name calls }, { role: 'tool', - tool_call_id: 'gemini_call_0_0', + tool_call_id: 'lookup_call_1', content: '{"answer":"first"}', }, { role: 'tool', - tool_call_id: 'gemini_call_0_1', + tool_call_id: 'lookup_call_2', content: '{"answer":"second"}', }, ]); }); -test('buildTargetRequest does not rematch a prior call already answered by explicit id', async () => { +test('buildTargetPayload does not rematch a prior call already answered by explicit id', async () => { const payload: GeminiGenerateContentPayload = { contents: [ { role: 'model', - parts: [{ functionCall: { name: 'lookup', args: { query: 'first' } } }, { functionCall: { name: 'lookup', args: { query: 'second' } } }], + parts: [{ functionCall: { name: 'lookup', args: { query: 'first' } } }, { functionCall: { id: 'call_second', name: 'lookup', args: { query: 'second' } } }], }, { role: 'user', parts: [ { functionResponse: { - id: 'gemini_call_0_1', + id: 'call_second', name: 'lookup', response: { answer: 'second' }, }, @@ -240,12 +240,12 @@ test('buildTargetRequest does not rematch a prior call already answered by expli content: null, tool_calls: [ { - id: 'gemini_call_0_0', + id: 'lookup_call_1', type: 'function', function: { name: 'lookup', arguments: '{"query":"first"}' }, }, { - id: 'gemini_call_0_1', + id: 'call_second', type: 'function', function: { name: 'lookup', arguments: '{"query":"second"}' }, }, @@ -253,18 +253,18 @@ test('buildTargetRequest does not rematch a prior call already answered by expli }, { role: 'tool', - tool_call_id: 'gemini_call_0_1', + tool_call_id: 'call_second', content: '{"answer":"second"}', }, { role: 'tool', - tool_call_id: 'gemini_call_0_0', + tool_call_id: 'lookup_call_1', content: '{"answer":"first"}', }, ]); }); -test('buildTargetRequest maps generation config and reasoning effort', async () => { +test('buildTargetPayload maps generation config and reasoning effort', async () => { const payload: GeminiGenerateContentPayload = { contents: [], generationConfig: { @@ -284,8 +284,9 @@ test('buildTargetRequest maps generation config and reasoning effort', async () assertEquals(await buildWithTestSidecar(payload, 'gpt-test'), { model: 'gpt-test', stream: true, + stream_options: { include_usage: true, continuous_usage_stats: true }, messages: [], - max_tokens: 512, + max_completion_tokens: 512, temperature: 0.25, top_p: 0.8, stop: ['END'], @@ -298,7 +299,7 @@ test('buildTargetRequest maps generation config and reasoning effort', async () }); }); -test('buildTargetRequest maps structured output schema and zero thinking budget', async () => { +test('buildTargetPayload maps structured output schema and zero thinking budget', async () => { const schema = { type: 'object', properties: { answer: { type: 'string' } }, @@ -320,6 +321,7 @@ test('buildTargetRequest maps structured output schema and zero thinking budget' { model: 'gpt-test', stream: true, + stream_options: { include_usage: true, continuous_usage_stats: true }, messages: [], reasoning_effort: 'none', response_format: { @@ -330,7 +332,7 @@ test('buildTargetRequest maps structured output schema and zero thinking budget' ); }); -test('buildTargetRequest maps tool declarations and tool choice modes', async () => { +test('buildTargetPayload maps tool declarations and tool choice modes', async () => { const payload: GeminiGenerateContentPayload = { contents: [], tools: [ @@ -361,6 +363,7 @@ test('buildTargetRequest maps tool declarations and tool choice modes', async () assertEquals(await buildWithTestSidecar(payload, 'gpt-test'), { model: 'gpt-test', stream: true, + stream_options: { include_usage: true, continuous_usage_stats: true }, messages: [], tools: [ { @@ -374,8 +377,9 @@ test('buildTargetRequest maps tool declarations and tool choice modes', async () }, }, }, + { type: 'function', function: { name: 'ping' } }, ], - tool_choice: { type: 'function', function: { name: 'lookup' } }, + tool_choice: { type: 'allowed_tools', allowed_tools: { mode: 'required', tools: [{ type: 'function', function: { name: 'lookup' } }] } }, }); assertEquals( @@ -425,7 +429,7 @@ test('buildTargetRequest maps tool declarations and tool choice modes', async () assertEquals((await buildWithTestSidecar({ contents: [], toolConfig: { functionCallingConfig: { mode: 'ANY' } } }, 'gpt-test')).tool_choice, undefined); }); -test('buildTargetRequest filters tools to allowed function names for ANY mode', async () => { +test('buildTargetPayload preserves declarations and restricts callable names for ANY mode', async () => { const result = await buildWithTestSidecar( { contents: [], @@ -459,11 +463,12 @@ test('buildTargetRequest filters tools to allowed function names for ANY mode', type: 'function', function: { name: 'ping' }, }, + { type: 'function', function: { name: 'forbidden' } }, ]); - assertEquals(result.tool_choice, 'required'); + assertEquals(result.tool_choice, { type: 'allowed_tools', allowed_tools: { mode: 'required', tools: [{ type: 'function', function: { name: 'lookup' } }, { type: 'function', function: { name: 'ping' } }] } }); }); -test('buildTargetRequest maps thinking budget thresholds', async () => { +test('buildTargetPayload maps thinking budget thresholds', async () => { assertEquals((await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: 0 } } }, 'gpt-test')).reasoning_effort, 'none'); assertEquals((await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: -1 } } }, 'gpt-test')).reasoning_effort, undefined); assertEquals((await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: 2048 } } }, 'gpt-test')).reasoning_effort, 'low'); @@ -471,7 +476,7 @@ test('buildTargetRequest maps thinking budget thresholds', async () => { assertEquals((await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: 8193 } } }, 'gpt-test')).reasoning_effort, 'high'); }); -test('buildTargetRequest rejects an unknown content role', async () => { +test('buildTargetPayload rejects an unknown content role', async () => { await assertRejects( () => buildWithTestSidecar( @@ -485,7 +490,7 @@ test('buildTargetRequest rejects an unknown content role', async () => { ); }); -test('buildTargetRequest rejects a part with an unsupported kind in user content', async () => { +test('buildTargetPayload rejects a part with an unsupported kind in user content', async () => { await assertRejects( () => buildWithTestSidecar( @@ -499,7 +504,7 @@ test('buildTargetRequest rejects a part with an unsupported kind in user content ); }); -test('buildTargetRequest rejects a function_call part in user content', async () => { +test('buildTargetPayload rejects a function_call part in user content', async () => { await assertRejects( () => buildWithTestSidecar( @@ -513,7 +518,7 @@ test('buildTargetRequest rejects a function_call part in user content', async () ); }); -test('buildTargetRequest rejects a function_response part in model content', async () => { +test('buildTargetPayload rejects a function_response part in model content', async () => { await assertRejects( () => buildWithTestSidecar( @@ -527,7 +532,7 @@ test('buildTargetRequest rejects a function_response part in model content', asy ); }); -test('buildTargetRequest rejects a part that sets conflicting content fields', async () => { +test('buildTargetPayload rejects a part that sets conflicting content fields', async () => { await assertRejects( () => buildWithTestSidecar( @@ -541,7 +546,7 @@ test('buildTargetRequest rejects a part that sets conflicting content fields', a ); }); -test('buildTargetRequest rejects a part with no recognized content field', async () => { +test('buildTargetPayload rejects a part with no recognized content field', async () => { await assertRejects( () => buildWithTestSidecar( @@ -555,7 +560,7 @@ test('buildTargetRequest rejects a part with no recognized content field', async ); }); -test('buildTargetRequest extends reasoning_effort enum to recognize xhigh and max', async () => { +test('buildTargetPayload extends reasoning_effort enum to recognize xhigh and max', async () => { const xhigh = await buildWithTestSidecar( { contents: [{ role: 'user', parts: [{ text: 'hi' }] }], generationConfig: { thinkingConfig: { thinkingLevel: 'xhigh' } } }, 'gpt-test', @@ -574,7 +579,7 @@ test('buildTargetRequest extends reasoning_effort enum to recognize xhigh and ma assertEquals(minimal.reasoning_effort, 'minimal'); }); -test('buildTargetRequest forwards a vendor-specific thinkingLevel verbatim (no enum gate)', async () => { +test('buildTargetPayload forwards a vendor-specific thinkingLevel verbatim (no enum gate)', async () => { const turbo = await buildWithTestSidecar( { contents: [{ role: 'user', parts: [{ text: 'hi' }] }], generationConfig: { thinkingConfig: { thinkingLevel: 'turbo' } } }, 'gpt-test', @@ -582,7 +587,7 @@ test('buildTargetRequest forwards a vendor-specific thinkingLevel verbatim (no e assertEquals(turbo.reasoning_effort, 'turbo'); }); -test('buildTargetRequest preserves an explicitly empty model text turn', async () => { +test('buildTargetPayload preserves an explicitly empty model text turn', async () => { assertEquals((await buildWithTestSidecar({ contents: [ { role: 'user', parts: [{ text: 'first' }] }, @@ -601,3 +606,26 @@ test.each([undefined, []])('preserves an empty model turn with parts %j', async const result = await buildWithTestSidecar(payload, 'target-model'); assertEquals((result.messages as Array<{ role?: string; content?: unknown }>).filter(item => item.role === 'assistant'), [{ role: 'assistant', content: '' }]); }); + +test('VALIDATED restricts calls without changing declarations while leaving the choice free', async () => { + // allowed_function_names applies to ANY and VALIDATED; VALIDATED also permits natural language. + // https://github.com/googleapis/googleapis/blob/bc7e3baa28fbb223fa93782e130260fab8205bfc/google/ai/generativelanguage/v1beta/content.proto + const result = await buildWithTestSidecar({ + contents: [], + tools: [{ functionDeclarations: [{ name: 'allowed' }, { name: 'excluded' }] }], + toolConfig: { functionCallingConfig: { mode: 'VALIDATED', allowedFunctionNames: ['allowed'] } }, + }, 'gpt-test'); + + assertEquals(result.tools?.map(tool => tool.type === 'function' ? tool.function.name : undefined), ['allowed', 'excluded']); + assertEquals(result.tool_choice, { type: 'allowed_tools', allowed_tools: { mode: 'auto', tools: [{ type: 'function', function: { name: 'allowed' } }] } }); +}); + +test('a call with no argument object is written with an empty one', async () => { + // GenerateContent states no `args` for a call that takes none, and the IR + // keeps it absent. Chat requires `arguments`, so the empty object is how that + // call's argument list is spelled on the wire. + const request = await buildWithTestSidecar({ contents: [{ role: 'model', parts: [{ functionCall: { name: 'f' } }] }] }, 'gpt-test'); + + const call = request.messages.flatMap(message => message.role === 'assistant' && message.tool_calls !== undefined ? message.tool_calls : [])[0]; + assertEquals(call?.type === 'function' ? call.function.arguments : undefined, '{}'); +}); diff --git a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-events_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-events_test.ts new file mode 100644 index 0000000000..e64ab42a21 --- /dev/null +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-events_test.ts @@ -0,0 +1,7 @@ +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { testOutputTranslation } from '../fixtures.ts'; + +testOutputTranslation('gemini-generate-content', 'openai-responses', (frames, codec) => { + const [source, target] = pairs.geminiToResponses; + return buildSourceEventsFor(source, target, emptyPayload.gemini, { sidecarCodec: codec }, frames); +}); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/request_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts similarity index 74% rename from packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/request_test.ts rename to packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts index c7beb60e92..c3465a1b59 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts @@ -1,16 +1,16 @@ import { test } from 'vitest'; -import { buildTargetRequest as buildTargetRequestWithSidecar } from '../../../../src/protocols/gemini-generate-content/via-openai-responses/request.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { buildTargetPayloadFor, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; import type { GeminiGenerateContentPart, GeminiGenerateContentContent, GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; import { assertEquals, assertFalse, assertRejects } from '@floway-dev/test-utils'; const buildWithTestSidecar = ( - payload: Parameters[0], - model: Parameters[1], -) => buildTargetRequestWithSidecar(payload, model, createTestSidecarCodec()); + payload: GeminiGenerateContentPayload, + model: string, +) => buildTargetPayloadFor(pairs.geminiToResponses[0], pairs.geminiToResponses[1], { ...payload, model }, { sidecarCodec: createTestSidecarCodec() }); -test('buildTargetRequest translates LOW to reasoning.effort', async () => { +test('buildTargetPayload translates LOW to reasoning.effort', async () => { const request = await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingLevel: 'LOW', includeThoughts: true } }, @@ -19,7 +19,7 @@ test('buildTargetRequest translates LOW to reasoning.effort', async () => { assertEquals(request.reasoning, { effort: 'low', summary: 'detailed' }); }); -test('buildTargetRequest forwards an empty thinkingLevel verbatim', async () => { +test('buildTargetPayload forwards an empty thinkingLevel verbatim', async () => { const request = await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingLevel: '', includeThoughts: true } }, @@ -28,16 +28,16 @@ test('buildTargetRequest forwards an empty thinkingLevel verbatim', async () => assertEquals(request.reasoning, { effort: '', summary: 'detailed' }); }); -test('buildTargetRequest gives thinkingBudget precedence over an empty thinkingLevel', async () => { +test('buildTargetPayload preserves an explicit empty thinkingLevel beside a budget', async () => { const request = await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: 2048, thinkingLevel: '', includeThoughts: true } }, }, 'gpt-test'); - assertEquals(request.reasoning, { effort: 'low', summary: 'detailed' }); + assertEquals(request.reasoning, { effort: '', summary: 'detailed' }); }); -test('buildTargetRequest maps instructions and multimodal user input without defaults', async () => { +test('buildTargetPayload maps instructions and multimodal user input without defaults', async () => { const payload: GeminiGenerateContentPayload = { systemInstruction: { parts: [{ text: 'Be precise.' }, { text: 'Use markdown.' }], @@ -52,7 +52,7 @@ test('buildTargetRequest maps instructions and multimodal user input without def assertEquals(await buildWithTestSidecar(payload, 'gpt-test'), { model: 'gpt-test', stream: true, - instructions: 'Be precise.\n\nUse markdown.', + instructions: 'Be precise.Use markdown.', input: [ { type: 'message', @@ -73,15 +73,22 @@ test('buildTargetRequest maps instructions and multimodal user input without def }); }); -test.each(['audio/wav', 'audio/mp3', 'audio/mpeg', 'text/plain'])('buildTargetRequest rejects Gemini inline data MIME type %s for Responses', async mimeType => { +test.each(['audio/wav', 'audio/mp3', 'audio/mpeg'])('buildTargetPayload rejects Gemini inline audio MIME type %s for Responses', async mimeType => { await assertRejects( () => buildWithTestSidecar({ contents: [{ role: 'user', parts: [{ inlineData: { mimeType, data: 'ZGF0YQ==' } }] }] }, 'gpt-test'), Error, - `Cannot translate Gemini generateContent inlineData MIME type '${mimeType}' to OpenAI Responses.`, + `Cannot translate an audio part of MIME type '${mimeType}' to OpenAI Responses.`, ); }); -test('buildTargetRequest maps assistant reasoning, function calls, and call-order outputs', async () => { +test('buildTargetPayload forwards a non-audio inline MIME type from Gemini as a Responses file', async () => { + // Responses carries the media type in the data URL, so it travels verbatim and + // the selected upstream decides whether it accepts it. + const request = await buildWithTestSidecar({ contents: [{ role: 'user', parts: [{ inlineData: { mimeType: 'text/plain', data: 'ZGF0YQ==' } }] }] }, 'gpt-test'); + assertEquals(request.input, [{ type: 'message', role: 'user', content: [{ type: 'input_file', file_data: 'data:text/plain;base64,ZGF0YQ==' }] }]); +}); + +test('buildTargetPayload maps assistant reasoning, function calls, and call-order outputs', async () => { const payload: GeminiGenerateContentPayload = { contents: [ { @@ -129,7 +136,7 @@ test('buildTargetRequest maps assistant reasoning, function calls, and call-orde }, { type: 'function_call', - call_id: 'gemini_call_0_2', + call_id: 'lookup_call_1', name: 'lookup', arguments: '{"query":"first"}', status: 'completed', @@ -143,7 +150,7 @@ test('buildTargetRequest maps assistant reasoning, function calls, and call-orde }, { type: 'function_call_output', - call_id: 'gemini_call_0_2', + call_id: 'lookup_call_1', output: '{"answer":"first"}', status: 'completed', }, @@ -156,7 +163,7 @@ test('buildTargetRequest maps assistant reasoning, function calls, and call-orde ]); }); -test('buildTargetRequest ignores thought signatures when translating to OpenAI Responses', async () => { +test('buildTargetPayload ignores thought signatures when translating to OpenAI Responses', async () => { const payload: GeminiGenerateContentPayload = { contents: [ { @@ -180,7 +187,7 @@ test('buildTargetRequest ignores thought signatures when translating to OpenAI R }, { type: 'function_call', - call_id: 'gemini_call_0_1', + call_id: 'lookup_call_1', name: 'lookup', arguments: '{}', status: 'completed', @@ -188,7 +195,7 @@ test('buildTargetRequest ignores thought signatures when translating to OpenAI R ]); }); -test('buildTargetRequest maps generation config, JSON schema, and reasoning controls', async () => { +test('buildTargetPayload maps generation config, JSON schema, and reasoning controls', async () => { const schema = { type: 'object', properties: { answer: { type: 'string' } }, @@ -225,7 +232,7 @@ test('buildTargetRequest maps generation config, JSON schema, and reasoning cont assertEquals((await buildWithTestSidecar({ contents: [], generationConfig: { responseMimeType: 'application/json' } }, 'gpt-test')).text, { format: { type: 'json_object' } }); }); -test('buildTargetRequest never invents reasoning.context from Gemini generateContent thinking controls', async () => { +test('buildTargetPayload never invents reasoning.context from Gemini generateContent thinking controls', async () => { const result = await buildWithTestSidecar({ contents: [], generationConfig: { @@ -238,7 +245,7 @@ test('buildTargetRequest never invents reasoning.context from Gemini generateCon assertFalse('context' in (result.reasoning ?? {})); }); -test('buildTargetRequest filters tools to allowed function names for ANY mode', async () => { +test('buildTargetPayload preserves declarations and restricts callable names for ANY mode', async () => { const result = await buildWithTestSidecar( { contents: [], @@ -276,11 +283,12 @@ test('buildTargetRequest filters tools to allowed function names for ANY mode', parameters: { type: 'object', properties: {} }, strict: false, }, + { type: 'function', name: 'forbidden', parameters: { type: 'object', properties: {} }, strict: false }, ]); - assertEquals(result.tool_choice, 'required'); + assertEquals(result.tool_choice, { type: 'allowed_tools', mode: 'required', tools: [{ type: 'function', name: 'lookup' }, { type: 'function', name: 'ping' }] }); }); -test('buildTargetRequest maps thinking budget thresholds and zero-budget disable', async () => { +test('buildTargetPayload maps thinking budget thresholds and zero-budget disable', async () => { assertEquals((await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: 2048 } } }, 'gpt-test')).reasoning, { effort: 'low' }); assertEquals((await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: 8192 } } }, 'gpt-test')).reasoning, { effort: 'medium' }); assertEquals((await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: 8193 } } }, 'gpt-test')).reasoning, { effort: 'high' }); @@ -299,7 +307,7 @@ test('buildTargetRequest maps thinking budget thresholds and zero-budget disable assertEquals((await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: -1 } } }, 'gpt-test')).reasoning, undefined); }); -test('buildTargetRequest maps tool declarations and tool choice modes only when tools exist', async () => { +test('buildTargetPayload maps tool declarations and tool choice modes only when tools exist', async () => { const payload: GeminiGenerateContentPayload = { contents: [], tools: [ @@ -342,8 +350,9 @@ test('buildTargetRequest maps tool declarations and tool choice modes only when }, strict: false, }, + { type: 'function', name: 'ping', parameters: { type: 'object', properties: {} }, strict: false }, ], - tool_choice: { type: 'function', name: 'lookup' }, + tool_choice: { type: 'allowed_tools', mode: 'required', tools: [{ type: 'function', name: 'lookup' }] }, }); assertEquals( @@ -393,7 +402,7 @@ test('buildTargetRequest maps tool declarations and tool choice modes only when assertEquals((await buildWithTestSidecar({ contents: [], toolConfig: { functionCallingConfig: { mode: 'ANY' } } }, 'gpt-test')).tool_choice, undefined); }); -test('buildTargetRequest rejects an unknown content role', async () => { +test('buildTargetPayload rejects an unknown content role', async () => { await assertRejects( () => buildWithTestSidecar( @@ -407,7 +416,7 @@ test('buildTargetRequest rejects an unknown content role', async () => { ); }); -test('buildTargetRequest rejects a part with an unsupported kind in user content', async () => { +test('buildTargetPayload rejects a part with an unsupported kind in user content', async () => { await assertRejects( () => buildWithTestSidecar( @@ -421,7 +430,7 @@ test('buildTargetRequest rejects a part with an unsupported kind in user content ); }); -test('buildTargetRequest rejects a function_call part in user content', async () => { +test('buildTargetPayload rejects a function_call part in user content', async () => { await assertRejects( () => buildWithTestSidecar( @@ -435,7 +444,7 @@ test('buildTargetRequest rejects a function_call part in user content', async () ); }); -test('buildTargetRequest rejects an inline_data part in model content', async () => { +test('buildTargetPayload rejects an inline_data part in model content', async () => { await assertRejects( () => buildWithTestSidecar( @@ -449,7 +458,7 @@ test('buildTargetRequest rejects an inline_data part in model content', async () ); }); -test('buildTargetRequest rejects a part that sets conflicting content fields', async () => { +test('buildTargetPayload rejects a part that sets conflicting content fields', async () => { await assertRejects( () => buildWithTestSidecar( @@ -463,7 +472,7 @@ test('buildTargetRequest rejects a part that sets conflicting content fields', a ); }); -test('buildTargetRequest rejects a part with no recognized content field', async () => { +test('buildTargetPayload rejects a part with no recognized content field', async () => { await assertRejects( () => buildWithTestSidecar( @@ -482,3 +491,27 @@ test.each([undefined, []])('preserves an empty model turn with parts %j', async const result = await buildWithTestSidecar(payload, 'target-model'); assertEquals((result.input as Array<{ role?: string; content?: unknown }>).filter(item => item.role === 'assistant'), [{ type: 'message', role: 'assistant', content: '' }]); }); + +test('a call with no argument object is written with an empty one', async () => { + // GenerateContent states no `args` for a call that takes none, and the IR + // keeps it absent. This wire requires `arguments`, so the empty object is how + // that call's argument list is spelled on the wire. + const request = await buildWithTestSidecar({ contents: [{ role: 'model', parts: [{ functionCall: { name: 'f' } }] }] }, 'gpt-test'); + + const call = request.input.find(item => item.type === 'function_call'); + assertEquals(call?.type === 'function_call' ? call.arguments : undefined, '{}'); +}); + +test('an orphaned tool choice is not stated', async () => { + const request = await buildWithTestSidecar({ + contents: [{ role: 'user', parts: [{ text: 'hi' }] }], + toolConfig: { functionCallingConfig: { mode: 'AUTO' } }, + }, 'gpt-test'); + + assertEquals(request.tool_choice, undefined); +}); + +test.each([undefined, 1024, -1])('preserves includeThoughts without a thinking level for budget %s', async thinkingBudget => { + const request = await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget, includeThoughts: true } } }, 'm'); + assertEquals(request.reasoning, { ...(thinkingBudget === 1024 ? { effort: 'low' } : {}), summary: 'detailed' }); +}); diff --git a/packages/translate/__tests__/protocols/pairs/input-controls-request_test.ts b/packages/translate/__tests__/protocols/pairs/input-controls-request_test.ts new file mode 100644 index 0000000000..cae3182595 --- /dev/null +++ b/packages/translate/__tests__/protocols/pairs/input-controls-request_test.ts @@ -0,0 +1,205 @@ +import { expect, test } from 'vitest'; + +import { + routeAnthropicMessagesViaOpenAIChatCompletions, + routeAnthropicMessagesViaOpenAIResponses, + routeOpenAIChatCompletionsViaAnthropicMessages, + routeOpenAIChatCompletionsViaOpenAIResponses, + routeOpenAIResponsesViaOpenAIChatCompletions, +} from '../../../src/index.ts'; +import { readAnthropicMessagesRequest } from '../../../src/protocols/anthropic-messages/as-source/input-to.ts'; +import { readOpenAIChatCompletionsRequest } from '../../../src/protocols/openai-chat-completions/as-source/input-to.ts'; +import { readOpenAIResponsesRequest } from '../../../src/protocols/openai-responses/as-source/input-to.ts'; +import { buildTargetPayloadFor, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; +import type { OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; +import type { OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; + +const options = { sidecarCodec: createTestSidecarCodec() }; + +test.each([ + { combined: 9, responsesCeiling: 9, messagesCeiling: 9 }, + { combined: 0, responsesCeiling: 0, messagesCeiling: 0 }, + { combined: null, responsesCeiling: null, messagesCeiling: 5 }, +])('Chat keeps both output bounds until target projection with combined ceiling $combined', async ({ combined, responsesCeiling, messagesCeiling }) => { + const source = { model: 'm', stream: true, messages: [{ role: 'user' as const, content: 'A' }], max_tokens: 5, max_completion_tokens: combined }; + const { settings } = readOpenAIChatCompletionsRequest(source).request; + expect(settings).toMatchObject({ max_visible_output_tokens: 5, max_output_tokens: combined }); + expect(settings).not.toHaveProperty('max_tokens'); + expect(settings).not.toHaveProperty('max_completion_tokens'); + + const responses = await routeOpenAIChatCompletionsViaOpenAIResponses(source, options); + expect(responses.type).toBe('trip'); + if (responses.type !== 'trip') throw new Error(responses.message); + expect(responses.target.max_output_tokens).toBe(responsesCeiling); + const messages = await routeOpenAIChatCompletionsViaAnthropicMessages(source, options); + expect(messages.type).toBe('trip'); + if (messages.type !== 'trip') throw new Error(messages.message); + expect(messages.target.max_tokens).toBe(messagesCeiling); +}); + +test('nullable token limits and cache controls remain explicit in source IR', async () => { + const source = { + model: 'm', stream: true, messages: [], max_tokens: null, max_completion_tokens: null, + prompt_cache_options: null, prompt_cache_retention: null, user: null, + }; + expect(readOpenAIChatCompletionsRequest(source).request.settings).toMatchObject({ + max_visible_output_tokens: null, max_output_tokens: null, prompt_cache_options: null, prompt_cache_retention: null, user: null, + }); + const responses = await routeOpenAIChatCompletionsViaOpenAIResponses(source, options); + if (responses.type !== 'trip') throw new Error(responses.message); + expect(responses.target.max_output_tokens).toBeNull(); + const chat = await routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'm', stream: true, input: 'hi', max_output_tokens: null }, options); + if (chat.type !== 'trip') throw new Error(chat.message); + expect(chat.target.max_completion_tokens).toBeNull(); +}); + +test.each([ + { max_tokens: 5 }, + { max_tokens: 5, max_completion_tokens: 0 }, +])('Chat token limits %j reach the modern Chat spelling after a Responses hop', async limits => { + const first = await routeOpenAIChatCompletionsViaOpenAIResponses({ model: 'm', stream: true, messages: [], ...limits }, options); + if (first.type !== 'trip') throw new Error(first.message); + const second = await routeOpenAIResponsesViaOpenAIChatCompletions(first.target, options); + if (second.type !== 'trip') throw new Error(second.message); + expect(second.target.max_completion_tokens).toBe(limits.max_completion_tokens ?? limits.max_tokens); + expect(second.target).not.toHaveProperty('max_tokens'); +}); + +const commonControls = { + prompt_cache_retention: '24h', + prompt_cache_options: { mode: 'explicit', ttl: '30m' }, + user: 'opaque-u', + top_logprobs: 4, + presence_penalty: 0.7, + frequency_penalty: -0.2, +} as const; + +test('Chat controls cross the public Responses route with their independent semantics', async () => { + const result = await routeOpenAIChatCompletionsViaOpenAIResponses({ + model: 'm', stream: true, messages: [{ role: 'user', content: 'hi' }], ...commonControls, logprobs: true, + }, options); + if (result.type !== 'trip') throw new Error(result.message); + expect(result.target).toMatchObject(commonControls); + expect(result.target.include).toEqual(['message.output_text.logprobs']); +}); + +test('Responses controls cross the public Chat route without source-only include fields', async () => { + const result = await routeOpenAIResponsesViaOpenAIChatCompletions({ + model: 'm', stream: true, input: 'hi', ...commonControls, + include: ['reasoning.encrypted_content', 'message.output_text.logprobs', 'file_search_call.results'], + }, options); + if (result.type !== 'trip') throw new Error(result.message); + expect(result.target).toMatchObject({ ...commonControls, logprobs: true }); + expect(result.target).not.toHaveProperty('include'); +}); + +test.each([false, null] as const)('Chat logprobs %j survives source reading and Responses projection', async logprobs => { + const source = { + model: 'm', stream: true, messages: [], logprobs, top_logprobs: null, + prompt_cache_retention: null, prompt_cache_options: {}, user: '', presence_penalty: null, frequency_penalty: 0, + }; + expect(readOpenAIChatCompletionsRequest(source).request.settings).toMatchObject({ + logprobs, top_logprobs: null, prompt_cache_retention: null, prompt_cache_options: {}, user: '', presence_penalty: null, frequency_penalty: 0, + }); + const result = await routeOpenAIChatCompletionsViaOpenAIResponses(source, options); + if (result.type !== 'trip') throw new Error(result.message); + expect(result.target).toMatchObject({ + top_logprobs: null, prompt_cache_retention: null, prompt_cache_options: {}, user: '', presence_penalty: null, frequency_penalty: 0, + }); + expect(result.target.include).toEqual(logprobs === null ? null : []); +}); + +test.each([{ include: [] }, { include: null }])('Responses include $include states no requested logprobs while preserving zero controls', async ({ include }) => { + const result = await routeOpenAIResponsesViaOpenAIChatCompletions({ + model: 'm', stream: true, input: 'hi', include: include === null ? null : [...include], + prompt_cache_retention: '', prompt_cache_options: { mode: '', ttl: '' }, user: '', + top_logprobs: 0, presence_penalty: 0, frequency_penalty: null, + }, options); + if (result.type !== 'trip') throw new Error(result.message); + expect(result.target).toMatchObject({ + logprobs: include === null ? null : false, prompt_cache_retention: '', prompt_cache_options: { mode: '', ttl: '' }, user: '', + top_logprobs: 0, presence_penalty: 0, frequency_penalty: null, + }); +}); + +test('request settings own cache options separately from source and target mutations', async () => { + const source = { + model: 'm', stream: true, messages: [], prompt_cache_options: { mode: 'explicit', ttl: '30m' }, + } satisfies OpenAIChatCompletionsPayload; + const reading = readOpenAIChatCompletionsRequest(source); + source.prompt_cache_options.mode = 'source-mutated'; + expect(reading.request.settings.prompt_cache_options).toEqual({ mode: 'explicit', ttl: '30m' }); + const target = await buildTargetPayloadFor(pairs.chatToResponses[0], pairs.chatToResponses[1], source, options, reading); + expect(target.prompt_cache_options).toEqual({ mode: 'explicit', ttl: '30m' }); + if (target.prompt_cache_options === undefined) throw new Error('Expected cache options.'); + target.prompt_cache_options.mode = 'target-mutated'; + expect(reading.request.settings.prompt_cache_options).toEqual({ mode: 'explicit', ttl: '30m' }); + + const reverseSource = { ...target, prompt_cache_options: { mode: 'explicit', ttl: '30m', prewarm: false } }; + const reverseReading = readOpenAIResponsesRequest(reverseSource); + reverseSource.prompt_cache_options.mode = 'source-mutated'; + const reverseTarget = await buildTargetPayloadFor(pairs.responsesToChat[0], pairs.responsesToChat[1], reverseSource, options, reverseReading); + expect(reverseTarget.prompt_cache_options).toEqual({ mode: 'explicit', ttl: '30m' }); + if (reverseTarget.prompt_cache_options == null) throw new Error('Expected cache options.'); + reverseTarget.prompt_cache_options.mode = 'target-mutated'; + expect(reverseReading.request.settings.prompt_cache_options).toEqual({ mode: 'explicit', ttl: '30m' }); +}); + +test.each(['auto', 'none'] as const)('orphan Messages %s stays in source IR and is omitted by both OpenAI writers', async type => { + const source = { model: 'm', stream: true, max_tokens: 10, messages: [{ role: 'user' as const, content: 'A' }], tool_choice: { type } }; + expect(readAnthropicMessagesRequest(source).request.tool_choice).toBe(type); + for (const route of [routeAnthropicMessagesViaOpenAIChatCompletions, routeAnthropicMessagesViaOpenAIResponses]) { + const result = await route(source, options); + if (result.type !== 'trip') throw new Error(result.message); + expect(result.target).not.toHaveProperty('tools'); + expect(result.target).not.toHaveProperty('tool_choice'); + } +}); + +test('tool selection never changes the complete allocated declaration inventory', async () => { + const tools = [ + { type: 'namespace' as const, name: 'x', description: 'group', tools: [{ type: 'function' as const, name: 'f' }, { type: 'custom' as const, name: 'f' }] }, + { type: 'function' as const, name: 'x_f' }, + ]; + const choices: (OpenAIResponsesToolChoice | undefined)[] = [ + undefined, 'auto', 'none', 'required', { type: 'function', namespace: 'x', name: 'f' }, + { type: 'allowed_tools', mode: 'auto', tools: [] }, + { type: 'allowed_tools', mode: 'required', tools: [{ type: 'custom', namespace: 'x', name: 'f' }] }, + ]; + let declarations: unknown; + for (const tool_choice of choices) { + const result = await routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'm', stream: true, input: 'hi', tools, tool_choice }, options); + if (result.type !== 'trip') throw new Error(result.message); + if (tool_choice === undefined) declarations = result.target.tools; + expect(result.target.tools).toEqual(declarations); + expect(result.target.tools).toHaveLength(3); + } +}); + +test.each([[1024, 'low'], [32768, 'high']] as const)('Messages budget %i remains numeric until OpenAI target projection', async (budget_tokens, effort) => { + const source = { model: 'm', stream: true, max_tokens: 60000, thinking: { type: 'enabled' as const, budget_tokens }, messages: [{ role: 'user' as const, content: 'hi' }] }; + const { settings } = readAnthropicMessagesRequest(source).request; + expect(settings.reasoning_budget).toBe(budget_tokens); + expect(settings).not.toHaveProperty('reasoning_effort'); + const chat = await routeAnthropicMessagesViaOpenAIChatCompletions(source, options); + if (chat.type !== 'trip') throw new Error(chat.message); + expect(chat.target.reasoning_effort).toBe(effort); + const responses = await routeAnthropicMessagesViaOpenAIResponses(source, options); + if (responses.type !== 'trip') throw new Error(responses.message); + expect(responses.target.reasoning).toEqual({ effort }); +}); + +test.each(['', 'custom-effort'] as const)('Messages explicit effort %j remains independent of its numeric budget', async effort => { + const source = { + model: 'm', stream: true, max_tokens: 60000, messages: [], + thinking: { type: 'enabled' as const, budget_tokens: 32768 }, output_config: { effort }, + }; + expect(readAnthropicMessagesRequest(source).request.settings).toMatchObject({ reasoning_budget: 32768, reasoning_effort: effort }); + const chat = await routeAnthropicMessagesViaOpenAIChatCompletions(source, options); + if (chat.type !== 'trip') throw new Error(chat.message); + expect(chat.target.reasoning_effort).toBe(effort); + const responses = await routeAnthropicMessagesViaOpenAIResponses(source, options); + if (responses.type !== 'trip') throw new Error(responses.message); + expect(responses.target.reasoning).toEqual({ effort }); +}); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events-protocol_test.ts b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-events-protocol_test.ts similarity index 73% rename from packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events-protocol_test.ts rename to packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-events-protocol_test.ts index 5a357962d7..93c9088b96 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events-protocol_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-events-protocol_test.ts @@ -1,11 +1,16 @@ import { test } from 'vitest'; -import { translateToSourceEvents } from '../../../../src/protocols/openai-chat-completions/via-anthropic-messages/events.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; +const codec = createTestSidecarCodec(); +const translate = (frames: AsyncIterable>) => buildSourceEventsFor( + pairs.chatToMessages[0], pairs.chatToMessages[1], emptyPayload.chat, { sidecarCodec: codec }, frames, +); + const drain = async (frames: AsyncIterable): Promise => { for await (const _frame of frames) { // Exhaust the stream so async translator errors surface to the caller. @@ -24,7 +29,7 @@ test('translateToSourceEvents emits native ChatCompletions errors for Messages f } const frames = []; - for await (const frame of translateToSourceEvents(stream(), {}, createTestSidecarCodec())) frames.push(frame); + for await (const frame of translate(stream())) frames.push(frame); assertEquals(frames, [eventFrame({ error: { type: 'overloaded_error', message: 'upstream overloaded' } })]); }); @@ -46,5 +51,5 @@ test('translateToSourceEvents rejects truncated Anthropic Messages streams witho }); } - await assertRejects(async () => await drain(translateToSourceEvents(stream(), {}, createTestSidecarCodec())), Error, 'Messages stream ended without message_stop'); + await assertRejects(async () => await drain(translate(stream())), Error, 'Messages stream ended without message_stop'); }); diff --git a/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-events_test.ts b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-events_test.ts new file mode 100644 index 0000000000..a7fc9a7f3f --- /dev/null +++ b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-events_test.ts @@ -0,0 +1,7 @@ +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { testOutputTranslation } from '../fixtures.ts'; + +testOutputTranslation('openai-chat-completions', 'anthropic-messages', (frames, codec) => { + const [source, target] = pairs.chatToMessages; + return buildSourceEventsFor(source, target, emptyPayload.chat, { sidecarCodec: codec }, frames); +}); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-request_test.ts similarity index 85% rename from packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/request_test.ts rename to packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-request_test.ts index dc95b357e5..fa2eb87cff 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-request_test.ts @@ -1,8 +1,7 @@ import { test } from 'vitest'; -import { buildTargetRequest as buildTargetRequestWithSidecar } from '../../../../src/protocols/openai-chat-completions/via-anthropic-messages/request.ts'; -import type { RemoteImageLoader } from '../../../../src/types.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { buildTargetPayloadFor, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; import { ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS, type AnthropicMessagesAssistantContentBlock, @@ -13,7 +12,7 @@ import { type AnthropicMessagesToolUseBlock, type AnthropicMessagesUserContentBlock, } from '@floway-dev/protocols/anthropic-messages'; -import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsMessage, OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; +import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsMessage, OpenAIChatCompletionsPayload, OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; import { assertEquals, assertExists, assertFalse, assertRejects } from '@floway-dev/test-utils'; // ── Helpers ── @@ -23,7 +22,7 @@ function mkPayload( messages: OpenAIChatCompletionsPayload['messages']; }, ): OpenAIChatCompletionsPayload { - return { model: 'claude-sonnet-4', ...overrides }; + return { model: 'claude-sonnet-4', stream: true, ...overrides }; } function assistantBlocks(result: AnthropicMessagesPayload, msgIndex = 0): AnthropicMessagesAssistantContentBlock[] { @@ -38,21 +37,16 @@ function userBlocks(result: AnthropicMessagesPayload, msgIndex = 0): AnthropicMe return Array.isArray(msg.content) ? (msg.content as AnthropicMessagesUserContentBlock[]) : [{ type: 'text', text: msg.content as string }]; } -function stubRemoteImageLoader(result: Awaited>): RemoteImageLoader { - return () => Promise.resolve(result); -} - -const testBuildOptions: Parameters[2] = { +const testBuildOptions: { fallbackMaxOutputTokens?: number } = { fallbackMaxOutputTokens: undefined, - loadRemoteImage: async () => null, }; const buildWithTestSidecar = ( - payload: Parameters[0], - options: Partial[2]> = {}, -) => buildTargetRequestWithSidecar(payload, createTestSidecarCodec(), { ...testBuildOptions, ...options }); + payload: OpenAIChatCompletionsPayloadEx, + options: Partial = {}, +) => buildTargetPayloadFor(pairs.chatToMessages[0], pairs.chatToMessages[1], payload, { sidecarCodec: createTestSidecarCodec(), ...testBuildOptions, ...options }); -test('buildTargetRequest preserves scalar and content-part assistant refusals as history text', async () => { +test('buildTargetPayload preserves scalar and content-part assistant refusals as history text', async () => { const result = await buildWithTestSidecar(mkPayload({ messages: [ { role: 'assistant', content: null, refusal: 'Scalar refusal.' }, @@ -70,7 +64,7 @@ test('buildTargetRequest preserves scalar and content-part assistant refusals as // OpenAI renamed priority processing to Fast mode; both spellings ask for the // same lane, so both map onto Anthropic's `speed`. -test.each(['fast', 'priority'])('buildTargetRequest maps service_tier:%s to speed:fast (no service_tier on target)', async serviceTier => { +test.each(['fast', 'priority'])('buildTargetPayload maps service_tier:%s to speed:fast (no service_tier on target)', async serviceTier => { const result = await buildWithTestSidecar( mkPayload({ messages: [{ role: 'user', content: 'hi' }], @@ -82,7 +76,7 @@ test.each(['fast', 'priority'])('buildTargetRequest maps service_tier:%s to spee assertFalse('service_tier' in result); }); -test('buildTargetRequest passes service_tier:auto through as service_tier', async () => { +test('buildTargetPayload passes service_tier:auto through as service_tier', async () => { const result = await buildWithTestSidecar( mkPayload({ messages: [{ role: 'user', content: 'hi' }], @@ -94,7 +88,7 @@ test('buildTargetRequest passes service_tier:auto through as service_tier', asyn assertFalse('speed' in result); }); -test('buildTargetRequest omits both speed and service_tier when service_tier is absent', async () => { +test('buildTargetPayload omits both speed and service_tier when service_tier is absent', async () => { const result = await buildWithTestSidecar( mkPayload({ messages: [{ role: 'user', content: 'hi' }], @@ -616,65 +610,21 @@ test('image_url with data URL → base64 image block', async () => { assertEquals(img.source.data, 'iVBORw0KGgo='); }); -test('image_url with remote image loader → base64 image block', async () => { - const result = await buildWithTestSidecar( - mkPayload({ - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'What is this?' }, - { - type: 'image_url', - image_url: { url: 'https://example.com/image.png' }, - }, - ], - }, +test('a remote image URL is stated as the URL Messages accepts', async () => { + const result = await buildWithTestSidecar(mkPayload({ + messages: [{ + role: 'user', + content: [ + { type: 'text', text: 'What is this?' }, + { type: 'image_url', image_url: { url: 'https://example.com/image.png' } }, ], - }), - { - loadRemoteImage: stubRemoteImageLoader({ - mediaType: 'image/png', - data: new Uint8Array([1, 2, 3]), - }), - }, - ); - const blocks = userBlocks(result, 0); - assertEquals(blocks.length, 2); - assertEquals(blocks[0].type, 'text'); - assertEquals(blocks[1].type, 'image'); - const img = blocks[1] as { - type: 'image'; - source: { type: string; media_type: string; data: string }; - }; - assertEquals(img.source.type, 'base64'); - assertEquals(img.source.media_type, 'image/png'); - assertEquals(img.source.data, 'AQID'); -}); + }], + })); -test('image_url with remote image loader failure → gracefully skipped', async () => { - const result = await buildWithTestSidecar( - mkPayload({ - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'What?' }, - { - type: 'image_url', - image_url: { url: 'https://example.com/nonexistent.png' }, - }, - ], - }, - ], - }), - { - loadRemoteImage: stubRemoteImageLoader(null), - }, - ); + // Preserve remote image URLs; provider-specific fetching belongs at the gateway boundary. const blocks = userBlocks(result, 0); - assertEquals(blocks.length, 1); - assertEquals(blocks[0].type, 'text'); + assertEquals(blocks.length, 2); + assertEquals(blocks[1], { type: 'image', source: { type: 'url', url: 'https://example.com/image.png' }, cache_control: { type: 'ephemeral' } }); }); test('image with jpeg media type', async () => { @@ -699,7 +649,7 @@ test('image with jpeg media type', async () => { assertEquals(img.source.media_type, 'image/jpeg'); }); -test('data URL with unsupported media type → skipped', async () => { +test('a data URL is stated with the media type it declares', async () => { const result = await buildWithTestSidecar( mkPayload({ messages: [ @@ -710,27 +660,25 @@ test('data URL with unsupported media type → skipped', async () => { ], }), ); + // Preserve the caller's media type so the selected upstream decides whether it accepts the image. const blocks = userBlocks(result, 0); assertEquals(blocks.length, 1); - assertEquals(blocks[0].type, 'text'); - assertEquals((blocks[0] as AnthropicMessagesTextBlock).text, ''); + assertEquals(blocks[0], { type: 'image', source: { type: 'base64', media_type: 'image/bmp', data: 'Qk0=' }, cache_control: { type: 'ephemeral' } }); }); -test('content with only non-parseable image → empty text fallback', async () => { - const result = await buildWithTestSidecar( - mkPayload({ - messages: [ - { - role: 'user', - content: [{ type: 'image_url', image_url: { url: 'not-a-url-at-all' } }], - }, - ], - }), +test('content with a value that is not a URL is rejected rather than written', async () => { + // Messages states a URL as a URL, so a caller that named neither a URL nor + // inline bytes has stated something the target cannot carry. + await assertRejects( + () => buildWithTestSidecar(mkPayload({ + messages: [{ + role: 'user', + content: [{ type: 'image_url', image_url: { url: 'not-a-url-at-all' } }], + }], + })), + Error, + 'requires an http or https URL', ); - const blocks = userBlocks(result, 0); - assertEquals(blocks.length, 1); - assertEquals(blocks[0].type, 'text'); - assertEquals((blocks[0] as AnthropicMessagesTextBlock).text, ''); }); // ── Field mapping ── @@ -822,24 +770,20 @@ test('stop array → stop_sequences array', async () => { assertEquals(result.stop_sequences, ['END', 'STOP']); }); -test('always emits stream: true regardless of source stream flag', async () => { - // Translation assumes streaming upstream (provider forces stream=true); - // source `respond.ts` collects SSE when client wants non-stream. - const streamed = await buildWithTestSidecar( - mkPayload({ - messages: [{ role: 'user', content: 'Hi' }], - stream: true, - }), - ); - assertEquals(streamed.stream, true); +test('states the streaming flag the trip requires', async () => { + const result = await buildWithTestSidecar(mkPayload({ messages: [{ role: 'user', content: 'Hi' }] })); + assertEquals(result.stream, true); +}); - const nonStreamed = await buildWithTestSidecar( - mkPayload({ - messages: [{ role: 'user', content: 'Hi' }], - stream: false, - }), +test('rejects a request the trip cannot carry because it states no stream', () => { + // A trip's answer arrives as a stream, so an IR that says otherwise has no + // translation. The boundary above a trip guarantees streaming, so this is a + // broken contract rather than an unsupported request. + return assertRejects( + () => buildWithTestSidecar(mkPayload({ messages: [{ role: 'user', content: 'Hi' }], stream: false })), + Error, + 'streaming', ); - assertEquals(nonStreamed.stream, true); }); // ── Tool choice mapping ── @@ -1115,7 +1059,7 @@ test('interleaved tool and text turns preserve chronology', async () => { assertEquals(a2[0].type, 'text'); }); -test('buildTargetRequest extracts nested response_format json_schema into output_config.format', async () => { +test('buildTargetPayload extracts nested response_format json_schema into output_config.format', async () => { const schema = { type: 'object', properties: { x: { type: 'string' } }, required: ['x'], additionalProperties: false }; const result = await buildWithTestSidecar( mkPayload({ @@ -1127,7 +1071,7 @@ test('buildTargetRequest extracts nested response_format json_schema into output assertEquals(result.output_config, { format: { type: 'json_schema', schema } }); }); -test('buildTargetRequest merges reasoning_effort with structured-output format on a single output_config', async () => { +test('buildTargetPayload merges reasoning_effort with structured-output format on a single output_config', async () => { const schema = { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'], additionalProperties: false }; const result = await buildWithTestSidecar( mkPayload({ @@ -1140,7 +1084,7 @@ test('buildTargetRequest merges reasoning_effort with structured-output format o assertEquals(result.output_config, { effort: 'high', format: { type: 'json_schema', schema } }); }); -test('buildTargetRequest forwards empty reasoning_effort verbatim', async () => { +test('buildTargetPayload forwards empty reasoning_effort verbatim', async () => { const result = await buildWithTestSidecar( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], @@ -1151,7 +1095,7 @@ test('buildTargetRequest forwards empty reasoning_effort verbatim', async () => assertEquals(result.output_config, { effort: '' }); }); -test('buildTargetRequest drops response_format json_object (no Anthropic equivalent)', async () => { +test('buildTargetPayload drops response_format json_object (no Anthropic equivalent)', async () => { const result = await buildWithTestSidecar( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], @@ -1162,19 +1106,19 @@ test('buildTargetRequest drops response_format json_object (no Anthropic equival assertEquals(result.output_config, undefined); }); -test('buildTargetRequest rejects an unknown message role', async () => { +test('buildTargetPayload rejects an unknown message role', async () => { await assertRejects( () => buildWithTestSidecar({ model: 'claude-test', - messages: [{ role: 'function', content: 'hi' } as unknown as OpenAIChatCompletionsMessage], + messages: [{ role: 'critic', content: 'hi' } as unknown as OpenAIChatCompletionsMessage], }), Error, - "Invalid role 'function'", + "Invalid role 'critic'", ); }); -test('buildTargetRequest rejects an unknown user content part type', async () => { +test('buildTargetPayload rejects an unknown user content part type', async () => { await assertRejects( () => buildWithTestSidecar({ @@ -1188,7 +1132,7 @@ test('buildTargetRequest rejects an unknown user content part type', async () => // ── service_tier forwarding ── -test('buildTargetRequest forwards a non-accelerated service_tier verbatim', async () => { +test('buildTargetPayload forwards a non-accelerated service_tier verbatim', async () => { const result = await buildWithTestSidecar( mkPayload({ messages: [{ role: 'user', content: 'hi' }], @@ -1199,7 +1143,7 @@ test('buildTargetRequest forwards a non-accelerated service_tier verbatim', asyn assertEquals(result.service_tier, 'flex'); }); -test('buildTargetRequest maps reasoning_effort none to thinking.disabled', async () => { +test('buildTargetPayload maps reasoning_effort none to thinking.disabled', async () => { const result = await buildWithTestSidecar( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], @@ -1211,7 +1155,7 @@ test('buildTargetRequest maps reasoning_effort none to thinking.disabled', async assertFalse('output_config' in result); }); -test('buildTargetRequest keeps a structured-output format alongside thinking.disabled', async () => { +test('buildTargetPayload keeps a structured-output format alongside thinking.disabled', async () => { const schema = { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'], additionalProperties: false }; const result = await buildWithTestSidecar( mkPayload({ @@ -1225,7 +1169,7 @@ test('buildTargetRequest keeps a structured-output format alongside thinking.dis assertEquals(result.output_config, { format: { type: 'json_schema', schema } }); }); -test('buildTargetRequest leaves thinking absent when reasoning_effort is not none', async () => { +test('buildTargetPayload leaves thinking absent when reasoning_effort is not none', async () => { const result = await buildWithTestSidecar( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], @@ -1237,12 +1181,41 @@ test('buildTargetRequest leaves thinking absent when reasoning_effort is not non assertEquals(result.output_config, { effort: 'high' }); }); -test.each(['auto', 'required'] as const)('allowed_tools %s restricts native Messages declarations', async mode => { +test('allowed_tools required selects one Messages tool without changing declarations', async () => { const result = await buildWithTestSidecar({ - model: 'claude-test', messages: [{ role: 'user', content: 'hello' }], tools: [ + model: 'claude-test', stream: true, messages: [{ role: 'user', content: 'hello' }], tools: [ { type: 'function', function: { name: 'allowed' } }, { type: 'function', function: { name: 'excluded' } }, - ], tool_choice: { type: 'allowed_tools', allowed_tools: { mode, tools: [{ type: 'function', function: { name: 'allowed' } }] } }, + ], tool_choice: { type: 'allowed_tools', allowed_tools: { mode: 'required', tools: [{ type: 'function', function: { name: 'allowed' } }] } }, }); - assertEquals(result.tools?.map(tool => 'name' in tool ? tool.name : undefined), ['allowed']); - assertEquals(result.tool_choice, { type: mode === 'required' ? 'any' : 'auto' }); + assertEquals(result.tools?.map(tool => 'name' in tool ? tool.name : undefined), ['allowed', 'excluded']); + assertEquals(result.tool_choice, { type: 'tool', name: 'allowed' }); +}); + +test('translates a PDF data URL into a typed Messages document', async () => { + const result = await buildWithTestSidecar({ + model: 'model', stream: true, messages: [{ + role: 'user', content: [{ + type: 'file', file: { filename: 'test.pdf', file_data: 'data:application/pdf;base64,JVBERi0xLjQ=' }, + }], + }], + }); + assertEquals(userBlocks(result)[0], { + type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: 'JVBERi0xLjQ=' }, + }); +}); + +test('translates legacy declarations and forced choice into Messages tooling', async () => { + const result = await buildWithTestSidecar({ + model: 'model', stream: true, messages: [], functions: [{ name: 'lookup', parameters: { type: 'object', properties: {} } }], + function_call: { name: 'lookup' }, + }); + assertEquals(result.tools?.map(tool => 'name' in tool ? tool.name : undefined), ['lookup']); + assertEquals(result.tool_choice, { type: 'tool', name: 'lookup' }); +}); + +test.each([true, false])('maps parallel_tool_calls %s into Messages tool choice', async parallel => { + for (const tool_choice of [undefined, 'auto', 'required', 'none'] as const) { + const result = await buildWithTestSidecar(mkPayload({ messages: [], tools: [{ type: 'function', function: { name: 'f', parameters: { type: 'object', properties: {} } } }], tool_choice, parallel_tool_calls: parallel })); + assertEquals(result.tool_choice, tool_choice === 'none' ? { type: 'none' } : { type: tool_choice === 'required' ? 'any' : 'auto', disable_parallel_tool_use: !parallel }); + } }); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-openai-responses-events_test.ts similarity index 93% rename from packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts rename to packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-openai-responses-events_test.ts index 668de97e2c..85e499655c 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-openai-responses-events_test.ts @@ -1,10 +1,11 @@ import { expect, test } from 'vitest'; -import { translateToSourceEvents } from '../../../../src/protocols/openai-chat-completions/via-openai-responses/events.ts'; -import { buildTargetRequest } from '../../../../src/protocols/openai-chat-completions/via-openai-responses/request.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; -import { testOutputTranslation } from '../../fixtures.ts'; -import { structuredResponsesFixture } from '../../openai-responses/fixtures.ts'; +import { openaiChatCompletionsSourceProtocol } from '../../../src/protocols/openai-chat-completions/as-source.ts'; +import { openaiResponsesTargetProtocol } from '../../../src/protocols/openai-responses/as-target.ts'; +import { buildSourceEventsFor, buildTargetPayloadFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; +import { testOutputTranslation } from '../fixtures.ts'; +import { structuredResponsesFixture } from '../openai-responses/fixtures.ts'; import { eventFrame, type ProtocolFrame, type SseFrame, sseFrame } from '@floway-dev/protocols/common'; import { collectOpenAIChatCompletionsProtocolEventsToResult, parseOpenAIChatCompletionsStream, openaiChatCompletionsProtocolFrameToSSEFrame as writeChatFrame, type OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; import { openaiResponsesResultToEvents, parseOpenAIResponsesStream, collectOpenAIResponsesProtocolEventsToResult, type OpenAIResponsesResultEx, type OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; @@ -49,8 +50,10 @@ const toProtocolFrame = (event: OpenAIResponsesStreamEventEx): ProtocolFrame>) => translateToSourceEvents( - structuredResponsesFixture(frames), {}, createTestSidecarCodec(), +const translate = (frames: AsyncIterable>) => buildSourceEventsFor( + pairs.chatToResponses[0], pairs.chatToResponses[1], emptyPayload.chat, + { sidecarCodec: createTestSidecarCodec() }, + structuredResponsesFixture(frames), ); const chatSseFrames = async function* (frames: AsyncIterable>) { @@ -487,9 +490,12 @@ test.each([false, true].flatMap(mixed => [ } const client = await collectOpenAIChatCompletionsProtocolEventsToResult(parseOpenAIChatCompletionsStream(new Response(clientSSE).body!)); expect(client.choices[0].finish_reason).toBe('tool_calls'); - const replay = await buildTargetRequest({ model: response.model, messages: [client.choices[0].message, { role: 'tool', tool_call_id: custom.call_id, content: 'edited' }] }, createTestSidecarCodec()); + const replay = await buildTargetPayloadFor(openaiChatCompletionsSourceProtocol, openaiResponsesTargetProtocol, { model: response.model, stream: true, messages: [client.choices[0].message, { role: 'tool', tool_call_id: custom.call_id, content: 'edited' }] }, { sidecarCodec: createTestSidecarCodec() }); expect(replay.input.slice(0, -1)).toEqual(upstream.output); expect(replay.input.at(-1)).toEqual({ type: 'custom_tool_call_output', call_id: custom.call_id, output: 'edited' }); }); -testOutputTranslation('openai-chat-completions', 'openai-responses', (frames, codec) => translateToSourceEvents(structuredResponsesFixture(frames), {}, codec)); +testOutputTranslation('openai-chat-completions', 'openai-responses', (frames, codec) => { + const [source, target] = pairs.chatToResponses; + return buildSourceEventsFor(source, target, emptyPayload.chat, { sidecarCodec: codec }, structuredResponsesFixture(frames)); +}); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-openai-responses-request_test.ts similarity index 67% rename from packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/request_test.ts rename to packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-openai-responses-request_test.ts index 826b190ded..135bc4ac53 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-openai-responses-request_test.ts @@ -1,15 +1,27 @@ import { test } from 'vitest'; -import { buildTargetRequest as buildTargetRequestWithSidecar } from '../../../../src/protocols/openai-chat-completions/via-openai-responses/request.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 { TranslateInputException } from '../../../src/shared/input-error.ts'; +import { buildTargetPayloadFor, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; +import type { OpenAIChatCompletionsPayload, OpenAIChatCompletionsMessage, OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; import { assertEquals, assertFalse, assertRejects } from '@floway-dev/test-utils'; -const buildWithTestSidecar = (payload: Parameters[0]) => - buildTargetRequestWithSidecar(payload, createTestSidecarCodec()); +const buildWithTestSidecar = (payload: OpenAIChatCompletionsPayloadEx) => + buildTargetPayloadFor(pairs.chatToResponses[0], pairs.chatToResponses[1], { ...payload, stream: true }, { sidecarCodec: createTestSidecarCodec() }); -test('buildTargetRequest preserves scalar and content-part assistant refusals', async () => { +test.each(['auto', 'none', { name: 'lookup' }] as const)('translates legacy declarations and choice %j into modern tooling', async function_call => { + const result = await buildWithTestSidecar({ + model: 'model', messages: [], + functions: [{ name: 'lookup', description: 'Look up facts', parameters: { type: 'object', properties: {} } }], + function_call, + }); + assertEquals(result.tools, [{ type: 'function', name: 'lookup', description: 'Look up facts', parameters: { type: 'object', properties: {} }, strict: false }]); + assertEquals(result.tool_choice, typeof function_call === 'string' ? function_call : { type: 'function', name: 'lookup' }); + assertFalse('functions' in result); + assertFalse('function_call' in result); +}); + +test('buildTargetPayload preserves scalar and content-part assistant refusals', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [ @@ -24,7 +36,7 @@ test('buildTargetRequest preserves scalar and content-part assistant refusals', ]); }); -test('buildTargetRequest rejects tool messages without tool_call_id', async () => { +test('buildTargetPayload rejects tool messages without tool_call_id', async () => { await assertRejects( () => buildWithTestSidecar({ @@ -36,7 +48,7 @@ test('buildTargetRequest rejects tool messages without tool_call_id', async () = ); }); -test('buildTargetRequest maps native custom tool declarations and choices', async () => { +test('buildTargetPayload maps native custom tool declarations and choices', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hello' }], @@ -47,7 +59,7 @@ test('buildTargetRequest maps native custom tool declarations and choices', asyn assertEquals(result.tool_choice, { type: 'custom', name: 'apply_patch' }); }); -test('buildTargetRequest maps custom tool history and its output', async () => { +test('buildTargetPayload maps custom tool history and its output', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [ @@ -61,7 +73,7 @@ test('buildTargetRequest maps custom tool history and its output', async () => { ]); }); -test('buildTargetRequest preserves translated OpenAI request fields', async () => { +test('buildTargetPayload preserves translated OpenAI request fields', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hello' }], @@ -86,7 +98,7 @@ test('buildTargetRequest preserves translated OpenAI request fields', async () = assertFalse('include' in result); }); -test('buildTargetRequest forwards empty reasoning_effort verbatim', async () => { +test('buildTargetPayload forwards empty reasoning_effort verbatim', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hello' }], @@ -96,7 +108,7 @@ test('buildTargetRequest forwards empty reasoning_effort verbatim', async () => assertEquals(result.reasoning, { effort: '' }); }); -test('buildTargetRequest never invents reasoning.context from reasoning_effort', async () => { +test('buildTargetPayload never invents reasoning.context from reasoning_effort', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hello' }], @@ -108,7 +120,7 @@ test('buildTargetRequest never invents reasoning.context from reasoning_effort', assertFalse('context' in (result.reasoning ?? {})); }); -test('buildTargetRequest omits store when OpenAI Chat Completions omits store', async () => { +test('buildTargetPayload omits store when OpenAI Chat Completions omits store', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hello' }], @@ -117,7 +129,7 @@ test('buildTargetRequest omits store when OpenAI Chat Completions omits store', assertFalse('store' in result); }); -test('buildTargetRequest omits tool_choice when OpenAI Chat Completions omits it', async () => { +test('buildTargetPayload omits tool_choice when OpenAI Chat Completions omits it', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hello' }], @@ -127,7 +139,7 @@ test('buildTargetRequest omits tool_choice when OpenAI Chat Completions omits it assertFalse('tool_choice' in result); }); -test('buildTargetRequest omits tool_choice when OpenAI Chat Completions carries no tools to apply it to', async () => { +test('buildTargetPayload omits tool_choice when OpenAI Chat Completions carries no tools to apply it to', async () => { for (const tools of [undefined, null, []]) { const result = await buildWithTestSidecar({ model: 'gpt-test', @@ -140,7 +152,7 @@ test('buildTargetRequest omits tool_choice when OpenAI Chat Completions carries } }); -test('buildTargetRequest preserves explicit null prompt cache and safety fields', async () => { +test('buildTargetPayload preserves explicit null prompt cache and safety fields', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hello' }], @@ -154,7 +166,7 @@ test('buildTargetRequest preserves explicit null prompt cache and safety fields' assertEquals(result.safety_identifier, null); }); -test('buildTargetRequest hoists only the initial contiguous system prefix', async () => { +test('buildTargetPayload hoists only the initial contiguous system prefix', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [ @@ -180,7 +192,23 @@ test('buildTargetRequest hoists only the initial contiguous system prefix', asyn ]); }); -test('buildTargetRequest preserves explicit tool strict and defaults omission to false', async () => { +test('buildTargetPayload hoists a leading developer prefix beside a system one', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + messages: [ + { role: 'developer', content: 'dev-1' }, + { role: 'system', content: 'sys-1' }, + { role: 'user', content: 'u1' }, + ], + }); + + // A developer message is the same prompt boundary a system message is, so a + // leading run of either kind is what `instructions` states. + assertEquals(result.instructions, 'dev-1\n\nsys-1'); + assertEquals(result.input, [{ type: 'message', role: 'user', content: 'u1' }]); +}); + +test('buildTargetPayload preserves explicit tool strict and defaults omission to false', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hello' }], @@ -207,31 +235,35 @@ test('buildTargetRequest preserves explicit tool strict and defaults omission to { type: 'function', name: 'explicit_strict', - parameters: { type: 'object' }, + // An object schema states its property list explicitly: OpenAI's + // strict-mode validators reject one that omits it. + parameters: { type: 'object', properties: {} }, strict: true, }, { type: 'function', name: 'implicit_non_strict', - parameters: { type: 'object' }, + parameters: { type: 'object', properties: {} }, strict: false, }, ]); }); -test('buildTargetRequest rejects an unknown message role', async () => { +test('buildTargetPayload rejects an unknown message role', async () => { + // `function` is a legacy role the reader accepts; only a role no Chat wire + // defines reaches this rejection. await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', - messages: [{ role: 'function', content: 'hi' } as unknown as OpenAIChatCompletionsMessage], + messages: [{ role: 'critic', content: 'hi' } as unknown as OpenAIChatCompletionsMessage], }), Error, - "Invalid role 'function'", + "Invalid role 'critic'", ); }); -test('buildTargetRequest forwards reasoning_effort and service_tier onto the native slots', async () => { +test('buildTargetPayload forwards reasoning_effort and service_tier onto the native slots', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hi' }], @@ -243,7 +275,7 @@ test('buildTargetRequest forwards reasoning_effort and service_tier onto the nat assertEquals(result.service_tier, 'priority'); }); -test.each(['none', '', 'future_effort'])('buildTargetRequest forwards reasoning_effort=%j verbatim', async reasoning_effort => { +test.each(['none', '', 'future_effort'])('buildTargetPayload forwards reasoning_effort=%j verbatim', async reasoning_effort => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hi' }], @@ -253,7 +285,7 @@ test.each(['none', '', 'future_effort'])('buildTargetRequest forwards reasoning_ assertEquals(result.reasoning, { effort: reasoning_effort }); }); -test.each([null, undefined])('buildTargetRequest omits unset reasoning_effort=%j', async reasoning_effort => { +test.each([null, undefined])('buildTargetPayload omits unset reasoning_effort=%j', async reasoning_effort => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hi' }], reasoning_effort }); assertEquals(Object.hasOwn(result, 'reasoning'), false); }); @@ -276,3 +308,12 @@ test('allowed_tools retains both native callable kinds', async () => { }); assertEquals(result.tool_choice, { type: 'allowed_tools', mode: 'required', tools: [{ type: 'function', name: 'lookup' }, { type: 'custom', name: 'edit' }] }); }); + +test.each([ + { name: 'max_completion_tokens only', input: { max_completion_tokens: 64 }, expected: 64 }, + { name: 'max_tokens only', input: { max_tokens: 32 }, expected: 32 }, + { name: 'both, newer spelling wins', input: { max_tokens: 32, max_completion_tokens: 64 }, expected: 64 }, +])('buildTargetPayload forwards $name as max_output_tokens', async ({ input, expected }) => { + const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hi' }], ...input }); + assertEquals(result.max_output_tokens, expected); +}); diff --git a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-events-protocol_test.ts similarity index 74% rename from packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts rename to packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-events-protocol_test.ts index 1e177c38f0..7242e3a216 100644 --- a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-events-protocol_test.ts @@ -1,13 +1,17 @@ import { test } from 'vitest'; -import { translateToSourceEvents } from '../../../../src/protocols/openai-responses/via-anthropic-messages/events.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; -import { emptyNamespaceToolNames } from '../../../round-trip/test-helpers.ts'; -import { fixtureFrames } from '../../fixtures.ts'; +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; +import { fixtureFrames } from '../fixtures.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; +const codec = createTestSidecarCodec(); +const translate = (frames: AsyncIterable>) => buildSourceEventsFor( + pairs.responsesToMessages[0], pairs.responsesToMessages[1], emptyPayload.responses, { sidecarCodec: codec }, frames, +); + const drain = async (frames: AsyncIterable): Promise => { for await (const _frame of frames) { // Exhaust the stream so async translator errors surface to the caller. @@ -32,7 +36,7 @@ test('translateToSourceEvents stops after Anthropic Messages message_stop', asyn }); } - const frames = await collect(translateToSourceEvents(stream(), 'resp_123', new Set(), createTestSidecarCodec(), emptyNamespaceToolNames())); + const frames = await collect(translate(stream())); assertEquals( frames.map(frame => (frame.type === 'event' ? frame.event.type : frame.type)), @@ -52,7 +56,7 @@ test('translateToSourceEvents translates Anthropic Messages error terminal and s yield eventFrame({ type: 'message_stop' }); } - const frames = await collect(translateToSourceEvents(stream(), 'resp_123', new Set(), createTestSidecarCodec(), emptyNamespaceToolNames())); + const frames = await collect(translate(stream())); assertEquals(frames.length, 1); assertEquals( @@ -60,7 +64,7 @@ test('translateToSourceEvents translates Anthropic Messages error terminal and s eventFrame({ type: 'error', message: 'upstream overloaded', - provider_specific_fields: { type: 'overloaded_error' }, + code: 'overloaded_error', sequence_number: 0, }), ); @@ -84,5 +88,5 @@ test('translateToSourceEvents rejects truncated Anthropic Messages streams witho }); } - await assertRejects(async () => await drain(translateToSourceEvents(stream(), 'resp_123', new Set(), createTestSidecarCodec(), emptyNamespaceToolNames())), Error, 'Messages stream ended without message_stop'); + await assertRejects(async () => await drain(translate(stream())), Error, 'Messages stream ended without message_stop'); }); diff --git a/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-events_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-events_test.ts new file mode 100644 index 0000000000..c9fc802641 --- /dev/null +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-events_test.ts @@ -0,0 +1,7 @@ +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { testOutputTranslation } from '../fixtures.ts'; + +testOutputTranslation('openai-responses', 'anthropic-messages', (frames, codec) => { + const [source, target] = pairs.responsesToMessages; + return buildSourceEventsFor(source, target, emptyPayload.responses, { sidecarCodec: codec }, frames); +}); diff --git a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts similarity index 79% rename from packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/request_test.ts rename to packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts index f0897ffe5c..8635985fdc 100644 --- a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts @@ -1,13 +1,11 @@ import { test } from 'vitest'; -import { buildTargetRequest as buildTargetRequestWithSidecar } from '../../../../src/protocols/openai-responses/via-anthropic-messages/request.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { buildTargetPayloadFor, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; import { ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS, type AnthropicMessagesClientTool, type AnthropicMessagesToolResultBlock, type AnthropicMessagesUserContentBlock } from '@floway-dev/protocols/anthropic-messages'; -import type { OpenAIResponsesInputMultiAgentCallOutputItem, OpenAIResponsesTool } from '@floway-dev/protocols/openai-responses'; +import type { OpenAIResponsesInputMultiAgentCallOutputItem, OpenAIResponsesTool, OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; import { assert, assertEquals, assertFalse, assertRejects } from '@floway-dev/test-utils'; -const stubRemoteImageLoader = (result: { mediaType: string | null; data: Uint8Array } | null) => () => Promise.resolve(result); - const minimalPayload = { model: 'claude-test', input: [{ type: 'message' as const, role: 'user' as const, content: 'hi' }], @@ -23,17 +21,20 @@ const minimalPayload = { parallel_tool_calls: true, }; -const testBuildOptions: Parameters[2] = { +const testBuildOptions: { fallbackMaxOutputTokens?: number } = { fallbackMaxOutputTokens: undefined, - loadRemoteImage: async () => null, }; -const buildWithTestSidecar = ( - payload: Parameters[0], - options: Partial[2]> = {}, -) => buildTargetRequestWithSidecar(payload, createTestSidecarCodec(), { ...testBuildOptions, ...options }); +const buildWithTestSidecar = async ( + payload: OpenAIResponsesRequestPayloadEx, + options: Partial = {}, +) => { + const streamed = { ...payload, stream: true }; + const built = await buildTargetPayloadFor(pairs.responsesToMessages[0], pairs.responsesToMessages[1], streamed, { sidecarCodec: createTestSidecarCodec(), ...testBuildOptions, ...options }); + return { target: built }; +}; -test('buildTargetRequest preserves unsafe JSON number tokens in function history', async () => { +test('buildTargetPayload preserves unsafe JSON number tokens in function history', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, input: [{ type: 'function_call', call_id: 'call_1', name: 'tool', arguments: '{"n":1e400}', status: 'completed' }], @@ -45,7 +46,7 @@ test('buildTargetRequest preserves unsafe JSON number tokens in function history assertEquals(JSON.stringify(tool.input), '{"n":1e400}'); }); -test('buildTargetRequest preserves OpenAI Responses assistant refusal history as Anthropic Messages text', async () => { +test('buildTargetPayload preserves OpenAI Responses assistant refusal history as Anthropic Messages text', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, input: [{ @@ -61,7 +62,22 @@ test('buildTargetRequest preserves OpenAI Responses assistant refusal history as assertEquals(message.content[0], { type: 'text', text: 'Cannot help.', cache_control: { type: 'ephemeral' } }); }); -test('buildTargetRequest accepts an implicit message discriminator', async () => { +test('buildTargetPayload keeps an explicitly empty assistant message', async () => { + // An empty string is a stated assistant turn, not an absent one: the source + // chose to record it, so the target receives a message of its own. + const result = await buildWithTestSidecar({ + ...minimalPayload, + input: [ + { type: 'message', role: 'assistant', content: '' }, + { type: 'message', role: 'user', content: 'hi' }, + ], + }); + + assertEquals(result.target.messages.map(message => message.role), ['assistant', 'user']); + assertEquals(result.target.messages[0], { role: 'assistant', content: '' }); +}); + +test('buildTargetPayload accepts an implicit message discriminator', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, input: [{ role: 'user', content: 'hello' }], @@ -75,7 +91,7 @@ test('buildTargetRequest accepts an implicit message discriminator', async () => ]); }); -test('buildTargetRequest rejects an agent_message that no interceptor lowered', async () => { +test('buildTargetPayload rejects an agent_message that no interceptor lowered', async () => { await assertRejects( () => buildWithTestSidecar({ ...minimalPayload, @@ -98,7 +114,7 @@ test.each([ { name: 'multi_agent_call_output', input: [{ type: 'multi_agent_call_output', action: 'spawn_agent', call_id: 'call_1', output: [] as OpenAIResponsesInputMultiAgentCallOutputItem['output'] }] }, { name: 'context_compaction', input: [{ type: 'context_compaction', encrypted_content: 'opaque' }] }, { name: 'item_reference', input: [{ type: 'item_reference', id: 'msg_1' }] }, -] as const)('buildTargetRequest rejects OpenAI-Responses-only $name input', async ({ name, input }) => { +] as const)('buildTargetPayload rejects OpenAI-Responses-only $name input', async ({ name, input }) => { await assertRejects( () => buildWithTestSidecar({ ...minimalPayload, input: [...input] }), Error, @@ -106,7 +122,7 @@ test.each([ ); }); -test('buildTargetRequest wires OpenAI Responses tooling guards', async () => { +test('buildTargetPayload wires OpenAI Responses tooling guards', async () => { await assertRejects( () => buildWithTestSidecar({ ...minimalPayload, @@ -122,34 +138,40 @@ test('buildTargetRequest wires OpenAI Responses tooling guards', async () => { ); }); -test('buildTargetRequest accepts null tool_choice', async () => { +test('buildTargetPayload accepts null tool_choice', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, tool_choice: null }); - assertEquals(result.target.tool_choice, undefined); + assertEquals(result.target.tool_choice, { type: 'auto', disable_parallel_tool_use: false }); }); -test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetRequest rejects input_file in %s', async type => { +test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetPayload rejects input_file in %s', async type => { await assertRejects( () => buildWithTestSidecar({ ...minimalPayload, - input: [{ type, call_id: 'call_1', output: [{ type: 'input_file', file_id: 'file_1' }] }], + input: [ + ...(type === 'function_call_output' + ? [{ type: 'function_call' as const, call_id: 'call_1', name: 'lookup', arguments: '{}' }] + : [{ type: 'custom_tool_call' as const, call_id: 'call_1', name: 'lookup', input: '{}' }]), + { type, call_id: 'call_1', output: [{ type: 'input_file', file_id: 'file_1' }] }, + ], }), Error, - 'input_file tool output', + 'file tool output', ); }); -test('buildTargetRequest rejects file message content', async () => { - await assertRejects( - () => buildWithTestSidecar({ - ...minimalPayload, - input: [{ type: 'message', role: 'user', content: [{ type: 'input_file', file_id: 'file_1' }] }], - }), - Error, - 'input_file message content', - ); +test.each([ + { name: 'file_id', part: { type: 'input_file' as const, file_id: 'file_1' }, source: { type: 'file', file_id: 'file_1' } }, + { name: 'file_url', part: { type: 'input_file' as const, file_url: 'https://example.com/a.pdf' }, source: { type: 'url', url: 'https://example.com/a.pdf' } }, + { name: 'file_data data URL', part: { type: 'input_file' as const, file_data: 'data:application/pdf;base64,JVBERi0=' }, source: { type: 'base64', media_type: 'application/pdf', data: 'JVBERi0=' } }, +])('buildTargetPayload maps input_file $name to a Messages document', async ({ part, source }) => { + const result = await buildWithTestSidecar({ + ...minimalPayload, + input: [{ type: 'message', role: 'user', content: [part] }], + }); + assertEquals(result.target.messages.at(-1), { role: 'user', content: [{ type: 'document', source }] }); }); -test('buildTargetRequest rejects file assistant content', async () => { +test('buildTargetPayload rejects file assistant content', async () => { await assertRejects( () => buildWithTestSidecar({ ...minimalPayload, @@ -160,7 +182,7 @@ test('buildTargetRequest rejects file assistant content', async () => { ); }); -test('buildTargetRequest preserves assistant input_text', async () => { +test('buildTargetPayload preserves assistant input_text', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, input: [{ type: 'message', role: 'assistant', content: [{ type: 'input_text', text: 'prior reply' }] }], @@ -171,7 +193,7 @@ test('buildTargetRequest preserves assistant input_text', async () => { ]); }); -test('buildTargetRequest rejects assistant images', async () => { +test('buildTargetPayload rejects assistant images', async () => { await assertRejects( () => buildWithTestSidecar({ ...minimalPayload, @@ -182,74 +204,74 @@ test('buildTargetRequest rejects assistant images', async () => { ); }); -test('buildTargetRequest rejects file_id-only images', async () => { - await assertRejects( - () => buildWithTestSidecar({ - ...minimalPayload, - input: [{ type: 'message', role: 'user', content: [{ type: 'input_image', file_id: 'file_1', detail: 'auto' }] }], - }), - Error, - 'file_id-only image content', - ); -}); - -test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetRequest rejects file_id-only images in %s', async type => { - await assertRejects( - () => buildWithTestSidecar({ - ...minimalPayload, - input: [{ type, call_id: 'call_1', output: [{ type: 'input_image', file_id: 'file_1', detail: 'auto' }] }], - }), - Error, - 'file_id-only image tool output', - ); +test('buildTargetPayload passes a file_id-only image through to the Messages target', async () => { + const result = await buildWithTestSidecar({ + ...minimalPayload, + input: [{ type: 'message', role: 'user', content: [{ type: 'input_image', file_id: 'file_1', detail: 'auto' }] }], + }); + // Messages accepts a file ID, and whether the upstream behind this Messages + // endpoint knows that ID is the upstream's question. + assertEquals(result.target.messages, [{ + role: 'user', + content: [{ type: 'image', source: { type: 'file', file_id: 'file_1' }, cache_control: { type: 'ephemeral' } }], + }]); }); -test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetRequest rejects unavailable images in %s', async type => { - await assertRejects( - () => buildWithTestSidecar({ - ...minimalPayload, - input: [{ - type, - call_id: 'call_1', - output: [{ type: 'input_image', image_url: 'https://example.com/unavailable.png' }], - }], - }, { loadRemoteImage: stubRemoteImageLoader(null) }), - Error, - 'unavailable or unsupported image tool output', - ); +test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetPayload passes file_id-only images through in %s', async type => { + const result = await buildWithTestSidecar({ + ...minimalPayload, + input: [ + ...(type === 'function_call_output' + ? [{ type: 'function_call' as const, call_id: 'call_1', name: 'lookup', arguments: '{}' }] + : [{ type: 'custom_tool_call' as const, call_id: 'call_1', name: 'lookup', input: '{}' }]), + { type, call_id: 'call_1', output: [{ type: 'input_image', file_id: 'file_1', detail: 'auto' }] }, + ], + }); + assertEquals(result.target.messages, [ + { + role: 'assistant', + // The freeform arm keeps its verbatim string; the function arm states the + // argument object it declares. + content: [{ type: 'tool_use', id: 'call_1', name: 'lookup', input: type === 'function_call_output' ? {} : { input: '{}' } }], + }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'call_1', content: [{ type: 'image', source: { type: 'file', file_id: 'file_1' } }], cache_control: { type: 'ephemeral' } }], + }, + ]); }); // ── service_tier → speed mapping ── -test('buildTargetRequest maps service_tier:fast to speed:fast (no service_tier on target)', async () => { +test('buildTargetPayload maps service_tier:fast to speed:fast (no service_tier on target)', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, service_tier: 'fast' }); assertEquals(result.target.speed, 'fast'); assertFalse('service_tier' in result.target); }); -test('buildTargetRequest maps service_tier:priority to speed:fast (no service_tier on target)', async () => { +test('buildTargetPayload maps service_tier:priority to speed:fast (no service_tier on target)', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, service_tier: 'priority' }); assertEquals(result.target.speed, 'fast'); assertFalse('service_tier' in result.target); }); -test('buildTargetRequest passes service_tier:auto through as service_tier', async () => { +test('buildTargetPayload passes service_tier:auto through as service_tier', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, service_tier: 'auto' }); assertEquals(result.target.service_tier, 'auto'); assertFalse('speed' in result.target); }); -test('buildTargetRequest omits both speed and service_tier when service_tier is absent', async () => { +test('buildTargetPayload omits both speed and service_tier when service_tier is absent', async () => { const result = await buildWithTestSidecar(minimalPayload); assertFalse('speed' in result.target); assertFalse('service_tier' in result.target); }); -test('buildTargetRequest maps reasoning.effort none to thinking.disabled (summary ignored when reasoning is disabled)', async () => { +test('buildTargetPayload maps reasoning.effort none to thinking.disabled (summary ignored when reasoning is disabled)', async () => { const result = await buildWithTestSidecar({ model: 'claude-test', input: [{ type: 'message', role: 'user', content: 'hi' }], @@ -270,7 +292,7 @@ test('buildTargetRequest maps reasoning.effort none to thinking.disabled (summar assertFalse('output_config' in result.target); }); -test('buildTargetRequest maps reasoning.effort directly to output_config.effort', async () => { +test('buildTargetPayload maps reasoning.effort directly to output_config.effort', async () => { const result = await buildWithTestSidecar({ model: 'claude-test', input: [{ type: 'message', role: 'user', content: 'hi' }], @@ -291,7 +313,7 @@ test('buildTargetRequest maps reasoning.effort directly to output_config.effort' assertFalse('thinking' in result.target); }); -test('buildTargetRequest forwards empty reasoning.effort verbatim', async () => { +test('buildTargetPayload forwards empty reasoning.effort verbatim', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, reasoning: { effort: '' }, @@ -301,7 +323,7 @@ test('buildTargetRequest forwards empty reasoning.effort verbatim', async () => assertFalse('thinking' in result.target); }); -test('buildTargetRequest defaults max_tokens to ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS when neither source nor fallbackMaxOutputTokens supplies one', async () => { +test('buildTargetPayload defaults max_tokens to ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS when neither source nor fallbackMaxOutputTokens supplies one', async () => { const result = await buildWithTestSidecar({ model: 'claude-test', input: [{ type: 'message', role: 'user', content: 'hi' }], @@ -320,7 +342,7 @@ test('buildTargetRequest defaults max_tokens to ANTHROPIC_MESSAGES_FALLBACK_MAX_ assertEquals(result.target.max_tokens, ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS); }); -test('buildTargetRequest uses fallbackMaxOutputTokens over the gateway const when the source omitted max_output_tokens', async () => { +test('buildTargetPayload uses fallbackMaxOutputTokens over the gateway const when the source omitted max_output_tokens', async () => { const result = await buildWithTestSidecar( { model: 'claude-test', @@ -342,7 +364,7 @@ test('buildTargetRequest uses fallbackMaxOutputTokens over the gateway const whe assertEquals(result.target.max_tokens, 4096); }); -test('buildTargetRequest omits the signature for a reasoning with no encrypted_content', async () => { +test('buildTargetPayload omits the signature for a reasoning with no encrypted_content', async () => { const result = await buildWithTestSidecar({ model: 'claude-test', input: [ @@ -372,7 +394,7 @@ test('buildTargetRequest omits the signature for a reasoning with no encrypted_c assertEquals(assistant.content[0], { signature: '', type: 'thinking', thinking: 'trace' }); }); -test('buildTargetRequest omits generic metadata instead of coercing it to metadata.user_id', async () => { +test('buildTargetPayload omits generic metadata instead of coercing it to metadata.user_id', async () => { const result = await buildWithTestSidecar({ model: 'claude-test', input: [{ type: 'message', role: 'user', content: 'hi' }], @@ -391,7 +413,7 @@ test('buildTargetRequest omits generic metadata instead of coercing it to metada assertFalse('metadata' in result.target); }); -test('buildTargetRequest resolves remote input images through the shared loader', async () => { +test('buildTargetPayload resolves remote input images through the shared loader', async () => { const result = await buildWithTestSidecar( { model: 'claude-test', @@ -419,12 +441,6 @@ test('buildTargetRequest resolves remote input images through the shared loader' store: false, parallel_tool_calls: true, }, - { - loadRemoteImage: stubRemoteImageLoader({ - mediaType: 'IMAGE/PNG; charset=binary', - data: new Uint8Array([1, 2, 3]), - }), - }, ); const message = result.target.messages[0]; @@ -435,17 +451,13 @@ test('buildTargetRequest resolves remote input images through the shared loader' assertEquals(message.content, [ { type: 'image', - source: { - type: 'base64', - media_type: 'image/png', - data: 'AQID', - }, + source: { type: 'url', url: 'https://example.com/image.png' }, cache_control: { type: 'ephemeral' }, }, ]); }); -test('buildTargetRequest drops reasoning input without readable summary', async () => { +test('buildTargetPayload drops reasoning input without readable summary', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [ @@ -478,7 +490,7 @@ test('buildTargetRequest drops reasoning input without readable summary', async ); }); -test('buildTargetRequest wraps custom tools as single-string function tools and records their names', async () => { +test('buildTargetPayload wraps custom tools as single-string function tools and records their names', async () => { const result = await buildWithTestSidecar({ model: 'claude-test', input: 'hi', @@ -501,9 +513,9 @@ test('buildTargetRequest wraps custom tools as single-string function tools and parallel_tool_calls: true, }); - assertEquals(result.customToolNames.has('apply_patch'), true); assertEquals(result.target.tools, [ { + type: 'custom', name: 'apply_patch', description: 'apply a patch', input_schema: { @@ -522,30 +534,30 @@ test('buildTargetRequest wraps custom tools as single-string function tools and cache_control: { type: 'ephemeral' }, }, ]); - assertEquals(result.target.tool_choice, { type: 'tool', name: 'apply_patch' }); + assertEquals(result.target.tool_choice, { type: 'tool', name: 'apply_patch', disable_parallel_tool_use: false }); }); -test.each(['web_search', 'file_search'])('buildTargetRequest rejects unsupported tool type %s', async type => { +test.each(['web_search', 'file_search'])('buildTargetPayload rejects unsupported tool type %s', async type => { await assertRejects( () => buildWithTestSidecar({ ...minimalPayload, tools: [{ type } as unknown as OpenAIResponsesTool] }), Error, - `Cannot translate OpenAI Responses tool type '${type}' to Anthropic Messages.`, + `Cannot translate OpenAI Responses tool type '${type}'.`, ); }); -test.each(['web_search', 'file_search'])('buildTargetRequest rejects unsupported string tool choice %s', async toolChoice => { +test.each(['web_search', 'file_search'])('buildTargetPayload rejects unsupported string tool choice %s', async toolChoice => { await assertRejects( () => buildWithTestSidecar({ ...minimalPayload, tool_choice: toolChoice as unknown as typeof minimalPayload.tool_choice }), Error, - `Cannot translate OpenAI Responses tool choice '${toolChoice}' to Anthropic Messages.`, + `Cannot translate OpenAI Responses tool choice '${toolChoice}'.`, ); }); -test.each(['web_search', 'file_search'])('buildTargetRequest rejects unsupported object tool choice type %s', async type => { +test.each(['web_search', 'file_search'])('buildTargetPayload rejects unsupported object tool choice type %s', async type => { await assertRejects( () => buildWithTestSidecar({ ...minimalPayload, tool_choice: { type } as unknown as typeof minimalPayload.tool_choice }), Error, - `Cannot translate OpenAI Responses tool choice type '${type}' to Anthropic Messages.`, + `Cannot translate OpenAI Responses tool choice type '${type}'.`, ); }); @@ -553,7 +565,7 @@ test.each([ { name: 'string', output: 'ok', expected: 'ok' }, { name: 'text array', output: [{ type: 'input_text' as const, text: 'first\n' }, { type: 'input_text' as const, text: 'second' }], expected: [{ type: 'text', text: 'first\n' }, { type: 'text', text: 'second' }] }, { name: 'empty array', output: [], expected: '' }, -])('buildTargetRequest projects custom_tool_call history with $name output into wrapped tool_use shape', async ({ output, expected }) => { +])('buildTargetPayload projects custom_tool_call history with $name output into wrapped tool_use shape', async ({ output, expected }) => { const result = await buildWithTestSidecar({ model: 'claude-test', input: [ @@ -608,7 +620,7 @@ test.each([ }); }); -test('buildTargetRequest flattens namespace functions collision-safely and maps replay history', async () => { +test('buildTargetPayload flattens namespace functions collision-safely and maps replay history', async () => { const namespaceTool: OpenAIResponsesTool = { type: 'namespace', name: 'web', @@ -621,7 +633,7 @@ test('buildTargetRequest flattens namespace functions collision-safely and maps strict: false, }], }; - const result = await buildWithTestSidecar({ + const payload: OpenAIResponsesRequestPayloadEx = { ...minimalPayload, input: [ { type: 'message', role: 'user', content: 'search' }, @@ -632,18 +644,21 @@ test('buildTargetRequest flattens namespace functions collision-safely and maps namespaceTool, ], tool_choice: { type: 'function', name: 'web.run' }, - }); + }; + const result = await buildWithTestSidecar(payload); - assertEquals(result.namespaceToolNames.sourceToTarget, new Map([['web.run', 'web_run_2']])); - assertEquals(result.namespaceToolNames.targetToSource, new Map([['web_run_2', { namespace: 'web', name: 'run', type: 'function_call' }]])); + // A grouped declaration and the flat one it would collide with both reach + // this wire, so the group's spelling has to avoid the name already in use. + assertEquals(result.target.tools?.map(tool => 'name' in tool ? tool.name : undefined), ['web_run', 'web_run_2']); assertEquals(result.target.tools, [ { + type: 'custom', name: 'web_run', - description: undefined, input_schema: { type: 'object' }, strict: false, }, { + type: 'custom', name: 'web_run_2', description: 'Web tools.\n\nAccess the web.', input_schema: { type: 'object', properties: { search_query: { type: 'array' } } }, @@ -661,10 +676,10 @@ test('buildTargetRequest flattens namespace functions collision-safely and maps cache_control: { type: 'ephemeral' }, }], }); - assertEquals(result.target.tool_choice, { type: 'tool', name: 'web_run_2' }); + assertEquals(result.target.tool_choice, { type: 'tool', name: 'web_run_2', disable_parallel_tool_use: false }); }); -test('buildTargetRequest gives a schema-less function tool the empty object schema', async () => { +test('buildTargetPayload gives a schema-less function tool the empty object schema', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, tools: [{ type: 'function', name: 'ping' }], @@ -672,6 +687,7 @@ test('buildTargetRequest gives a schema-less function tool the empty object sche assertEquals(result.target.tools, [ { + type: 'custom', name: 'ping', input_schema: { type: 'object', properties: {} }, cache_control: { type: 'ephemeral' }, @@ -679,7 +695,7 @@ test('buildTargetRequest gives a schema-less function tool the empty object sche ]); }); -test('buildTargetRequest keeps plain-text function_call_output as string content', async () => { +test('buildTargetPayload keeps plain-text function_call_output as string content', async () => { const result = await buildWithTestSidecar({ model: 'claude-test', input: [ @@ -706,7 +722,7 @@ test('buildTargetRequest keeps plain-text function_call_output as string content assertEquals(toolResult.content, 'plain text body'); }); -test('buildTargetRequest preserves out-of-range numeric tool arguments as raw JSON', async () => { +test('buildTargetPayload preserves out-of-range numeric tool arguments as raw JSON', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, input: [{ type: 'function_call', call_id: 'call_1', name: 'tool', arguments: '{"value":1e400}', status: 'completed' }], @@ -720,7 +736,7 @@ test('buildTargetRequest preserves out-of-range numeric tool arguments as raw JS assertEquals(JSON.stringify(toolUse.input), '{"value":1e400}'); }); -test.each(['not json', '[1,2,3]'])('buildTargetRequest keeps raw_arguments fallback for invalid or non-object tool arguments', async argumentsJson => { +test.each(['not json', '[1,2,3]'])('buildTargetPayload keeps raw_arguments fallback for invalid or non-object tool arguments', async argumentsJson => { const result = await buildWithTestSidecar({ ...minimalPayload, input: [{ type: 'function_call', call_id: 'call_1', name: 'tool', arguments: argumentsJson, status: 'completed' }], @@ -734,7 +750,7 @@ test.each(['not json', '[1,2,3]'])('buildTargetRequest keeps raw_arguments fallb assertEquals(toolUse.input, { raw_arguments: argumentsJson }); }); -test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetRequest maps multimodal %s into tool_result image and text blocks', async type => { +test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetPayload maps multimodal %s into tool_result image and text blocks', async type => { const result = await buildWithTestSidecar({ model: 'claude-test', input: [ @@ -773,7 +789,7 @@ test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTa ]); }); -test('buildTargetRequest maps incomplete function output to an Anthropic tool error', async () => { +test('buildTargetPayload maps incomplete function output to an Anthropic tool error', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, input: [ @@ -791,8 +807,7 @@ test('buildTargetRequest maps incomplete function output to an Anthropic tool er }); }); -test('buildTargetRequest loads custom tool result images without interpreting its open status', async () => { - const loaded: string[] = []; +test('buildTargetPayload states a custom tool result image as the URL it is', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, input: [ @@ -806,21 +821,16 @@ test('buildTargetRequest loads custom tool result images without interpreting it ], }, ], - }, { - loadRemoteImage: url => { - loaded.push(url); - return Promise.resolve({ mediaType: 'image/png', data: new Uint8Array([1, 2, 3]) }); - }, }); - assertEquals(loaded, ['https://example.com/capture.png']); assertEquals(result.target.messages[1], { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_exec', + is_error: true, content: [ { type: 'text', text: 'partial capture' }, - { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AQID' } }, + { type: 'image', source: { type: 'url', url: 'https://example.com/capture.png' } }, { type: 'text', text: 'capture failed' }, ], cache_control: { type: 'ephemeral' }, @@ -828,7 +838,7 @@ test('buildTargetRequest loads custom tool result images without interpreting it }); }); -test('buildTargetRequest throws on a stray web_search_call input item (shim owns the reverse path)', async () => { +test('buildTargetPayload throws on a stray web_search_call input item (shim owns the reverse path)', async () => { // The OpenAI Responses web-search shim rewrites web_search_call input items into // upstream function_call + function_call_output pairs before this // translator runs. Reaching the translator with a raw web_search_call @@ -858,11 +868,11 @@ test('buildTargetRequest throws on a stray web_search_call input item (shim owns parallel_tool_calls: true, }), Error, - "Invalid input item type 'web_search_call'", + 'Invalid input item: {"type":"web_search_call"', ); }); -test('buildTargetRequest throws on a stray compaction_trigger input item (compact-shim owns the strip)', async () => { +test('buildTargetPayload throws on a stray compaction_trigger input item (compact-shim owns the strip)', async () => { // The compact-shim is structurally required on non-openai-responses targets and // strips compaction_trigger items before reaching this translator. // Reaching here with one in input means the shim disengaged; the @@ -890,7 +900,7 @@ test('buildTargetRequest throws on a stray compaction_trigger input item (compac ); }); -test('buildTargetRequest throws on a stray compaction input item (compact-shim owns the expansion)', async () => { +test('buildTargetPayload throws on a stray compaction input item (compact-shim owns the expansion)', async () => { // The compact-shim expands its own shim-encoded compaction items inline // before reaching this translator and round-trips foreign compactions // back to the upstream as raw items. Either way the translator should @@ -918,7 +928,7 @@ test('buildTargetRequest throws on a stray compaction input item (compact-shim o ); }); -test('buildTargetRequest attaches ephemeral cache breakpoints to system, last function tool, and last message block', async () => { +test('buildTargetPayload attaches ephemeral cache breakpoints to system, last function tool, and last message block', async () => { const result = await buildWithTestSidecar({ model: 'claude-test', input: [ @@ -953,7 +963,7 @@ test('buildTargetRequest attaches ephemeral cache breakpoints to system, last fu assertEquals(lastBlock.cache_control, { type: 'ephemeral' }); }); -test('buildTargetRequest extracts flat text.format json_schema into output_config.format and drops OpenAI-only fields', async () => { +test('buildTargetPayload extracts flat text.format json_schema into output_config.format and drops OpenAI-only fields', async () => { const schema = { type: 'object', properties: { x: { type: 'string' } }, required: ['x'], additionalProperties: false }; const result = await buildWithTestSidecar({ model: 'claude-test', @@ -974,7 +984,7 @@ test('buildTargetRequest extracts flat text.format json_schema into output_confi assertEquals(result.target.output_config, { format: { type: 'json_schema', schema } }); }); -test('buildTargetRequest merges reasoning.effort with structured-output format on a single output_config', async () => { +test('buildTargetPayload merges reasoning.effort with structured-output format on a single output_config', async () => { const schema = { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'], additionalProperties: false }; const result = await buildWithTestSidecar({ model: 'claude-test', @@ -996,7 +1006,7 @@ test('buildTargetRequest merges reasoning.effort with structured-output format o assertEquals(result.target.output_config, { effort: 'high', format: { type: 'json_schema', schema } }); }); -test('buildTargetRequest drops text.format json_object (no Anthropic equivalent)', async () => { +test('buildTargetPayload drops text.format json_object (no Anthropic equivalent)', async () => { const result = await buildWithTestSidecar({ model: 'claude-test', input: [{ type: 'message', role: 'user', content: 'hi' }], @@ -1016,7 +1026,7 @@ test('buildTargetRequest drops text.format json_object (no Anthropic equivalent) assertFalse('output_config' in result.target); }); -test('buildTargetRequest hoists leading role:"system" to top-level system field', async () => { +test('buildTargetPayload hoists leading role:"system" to top-level system field', async () => { const result = await buildWithTestSidecar( { model: 'claude-test', @@ -1035,7 +1045,6 @@ test('buildTargetRequest hoists leading role:"system" to top-level system field' store: false, parallel_tool_calls: true, }, - { loadRemoteImage: stubRemoteImageLoader(null) }, ); assertEquals(result.target.system, [{ type: 'text', text: 'be terse', cache_control: { type: 'ephemeral' } }]); @@ -1043,7 +1052,7 @@ test('buildTargetRequest hoists leading role:"system" to top-level system field' assertEquals(result.target.messages[0].role, 'user'); }); -test('buildTargetRequest keeps non-leading role:"system" inline', async () => { +test('buildTargetPayload keeps non-leading role:"system" inline', async () => { const result = await buildWithTestSidecar( { model: 'claude-test', @@ -1063,7 +1072,6 @@ test('buildTargetRequest keeps non-leading role:"system" inline', async () => { store: false, parallel_tool_calls: true, }, - { loadRemoteImage: stubRemoteImageLoader(null) }, ); assertEquals(result.target.messages.length, 3); @@ -1073,7 +1081,7 @@ test('buildTargetRequest keeps non-leading role:"system" inline', async () => { assertFalse('system' in result.target); }); -test('buildTargetRequest hoists leading role:"developer" to top-level system field', async () => { +test('buildTargetPayload hoists leading role:"developer" to top-level system field', async () => { const result = await buildWithTestSidecar( { model: 'claude-test', @@ -1092,7 +1100,6 @@ test('buildTargetRequest hoists leading role:"developer" to top-level system fie store: false, parallel_tool_calls: true, }, - { loadRemoteImage: stubRemoteImageLoader(null) }, ); assertEquals(result.target.messages.length, 1); @@ -1100,7 +1107,7 @@ test('buildTargetRequest hoists leading role:"developer" to top-level system fie assertEquals(result.target.system, [{ type: 'text', text: 'dev rule', cache_control: { type: 'ephemeral' } }]); }); -test('buildTargetRequest preserves payload.instructions and leading system as separate blocks; non-leading stays inline', async () => { +test('buildTargetPayload preserves payload.instructions and leading system as separate blocks; non-leading stays inline', async () => { const result = await buildWithTestSidecar( { model: 'claude-test', @@ -1121,7 +1128,6 @@ test('buildTargetRequest preserves payload.instructions and leading system as se store: false, parallel_tool_calls: true, }, - { loadRemoteImage: stubRemoteImageLoader(null) }, ); assertEquals(result.target.system, [ @@ -1134,7 +1140,7 @@ test('buildTargetRequest preserves payload.instructions and leading system as se assertEquals(result.target.messages[2].role, 'user'); }); -test('buildTargetRequest throws when a system input message contains an image part', async () => { +test('buildTargetPayload throws when a system input message contains an image part', async () => { await assertRejects( () => buildWithTestSidecar( @@ -1162,14 +1168,13 @@ test('buildTargetRequest throws when a system input message contains an image pa store: false, parallel_tool_calls: true, }, - { loadRemoteImage: stubRemoteImageLoader(null) }, ), Error, "Invalid 'input_image' content part in system message", ); }); -test('buildTargetRequest throws when a non-leading developer input message contains an image part', async () => { +test('buildTargetPayload throws when a non-leading developer input message contains an image part', async () => { await assertRejects( () => buildWithTestSidecar( @@ -1197,7 +1202,6 @@ test('buildTargetRequest throws when a non-leading developer input message conta store: false, parallel_tool_calls: true, }, - { loadRemoteImage: stubRemoteImageLoader(null) }, ), Error, "Invalid 'input_image' content part in developer message", @@ -1207,3 +1211,17 @@ test('buildTargetRequest throws when a non-leading developer input message conta // (Native native↔native only — OpenAI Responses no longer carries thinking-mode // extension fields; alias overlays land on the Anthropic Messages target payload at the wire // call via `applyRulesToUpstreamAnthropicMessages`.) + +test('keeps an empty assistant message as the turn that separates two client runs', async () => { + // Dropping it would leave two adjacent user messages, which this wire merges + // into one turn, losing the boundary the client stated. + const result = await buildWithTestSidecar({ + model: 'gpt-test', + input: [ + { type: 'message', role: 'user', content: 'one' }, + { type: 'message', role: 'assistant', content: [] }, + { type: 'message', role: 'user', content: 'two' }, + ], + }); + assertEquals(result.target.messages.map(message => message.role), ['user', 'assistant', 'user']); +}); diff --git a/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-events_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-events_test.ts new file mode 100644 index 0000000000..326a0b6419 --- /dev/null +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-events_test.ts @@ -0,0 +1,7 @@ +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { testOutputTranslation } from '../fixtures.ts'; + +testOutputTranslation('openai-responses', 'openai-chat-completions', (frames, codec) => { + const [source, target] = pairs.responsesToChat; + return buildSourceEventsFor(source, target, emptyPayload.responses, { sidecarCodec: codec }, frames); +}); diff --git a/packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts similarity index 80% rename from packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/request_test.ts rename to packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts index 853c1cc749..e6e73b61ea 100644 --- a/packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts @@ -1,15 +1,34 @@ import { test } from 'vitest'; -import { buildTargetRequest as buildTargetRequestWithSidecar } from '../../../../src/protocols/openai-responses/via-openai-chat-completions/request.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { buildTargetPayloadFor, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsFunctionTool, OpenAIChatCompletionsToolMessage } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesRequestPayloadEx, OpenAIResponsesInputMultiAgentCallOutputItem, OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; -const buildWithTestSidecar = (payload: Parameters[0]) => - buildTargetRequestWithSidecar(payload, createTestSidecarCodec()); +const buildWithTestSidecar = async (payload: OpenAIResponsesRequestPayloadEx) => { + const streamed = { ...payload, stream: true }; + return { + target: await buildTargetPayloadFor(pairs.responsesToChat[0], pairs.responsesToChat[1], streamed, { sidecarCodec: createTestSidecarCodec() }), + }; +}; + +test('preserves a refusal-only assistant turn between client turns', async () => { + const { target } = await buildWithTestSidecar({ + model: 'model', input: [ + { type: 'message', role: 'user', content: 'Q' }, + { type: 'message', role: 'assistant', content: [{ type: 'refusal', refusal: 'Cannot help.' }] }, + { type: 'message', role: 'user', content: 'Why?' }, + ], + }); + assertEquals(target.messages, [ + { role: 'user', content: 'Q' }, + { role: 'assistant', content: '', refusal: 'Cannot help.' }, + { role: 'user', content: 'Why?' }, + ]); +}); -test('buildTargetRequest accepts an implicit message discriminator', async () => { +test('buildTargetPayload accepts an implicit message discriminator', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ role: 'system', content: 'rules' }], @@ -20,7 +39,7 @@ test('buildTargetRequest accepts an implicit message discriminator', async () => ]); }); -test('buildTargetRequest rejects an agent_message that no interceptor lowered', async () => { +test('buildTargetPayload rejects an agent_message that no interceptor lowered', async () => { await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', @@ -32,11 +51,11 @@ test('buildTargetRequest rejects an agent_message that no interceptor lowered', }], }), Error, - "Invalid input item type 'agent_message'.", + 'Invalid input item: {"type":"agent_message"', ); }); -test('buildTargetRequest merges adjacent assistant reasoning text and tool calls', async () => { +test('buildTargetPayload merges adjacent assistant reasoning text and tool calls', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [ @@ -88,7 +107,7 @@ test('buildTargetRequest merges adjacent assistant reasoning text and tool calls }); assertEquals(result.target.model, 'gpt-test'); - assertEquals(result.target.max_tokens, 256); + assertEquals(result.target.max_completion_tokens, 256); assertEquals(result.target.metadata, { trace_id: 'trace_123' }); assertEquals(result.target.store, false); assertEquals(result.target.parallel_tool_calls, true); @@ -129,7 +148,7 @@ test('buildTargetRequest merges adjacent assistant reasoning text and tool calls ]); }); -test('buildTargetRequest preserves explicit null prompt cache and safety fields', async () => { +test('buildTargetPayload preserves explicit null prompt cache and safety fields', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: 'hello', @@ -143,7 +162,7 @@ test('buildTargetRequest preserves explicit null prompt cache and safety fields' assertEquals(result.target.safety_identifier, null); }); -test('buildTargetRequest omits response_format when OpenAI Responses text.format is absent', async () => { +test('buildTargetPayload omits response_format when OpenAI Responses text.format is absent', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: 'Hi', @@ -153,7 +172,7 @@ test('buildTargetRequest omits response_format when OpenAI Responses text.format assertEquals('response_format' in result.target, false); }); -test('buildTargetRequest omits a null text format from the Chat response-format field', async () => { +test('buildTargetPayload omits a null text format from the Chat response-format field', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: 'Hi', @@ -163,7 +182,7 @@ test('buildTargetRequest omits a null text format from the Chat response-format assertEquals(result.target.response_format, undefined); }); -test('buildTargetRequest reshapes flat json_schema text format into OpenAI Chat Completions shape', async () => { +test('buildTargetPayload reshapes flat json_schema text format into OpenAI Chat Completions shape', async () => { const schema = { type: 'object', properties: { ok: { type: 'boolean' } }, @@ -192,7 +211,7 @@ test('buildTargetRequest reshapes flat json_schema text format into OpenAI Chat }); }); -test('buildTargetRequest passes through plain text format without wrapping', async () => { +test('buildTargetPayload passes through plain text format without wrapping', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: 'Hi', @@ -202,7 +221,7 @@ test('buildTargetRequest passes through plain text format without wrapping', asy assertEquals(result.target.response_format, { type: 'text' }); }); -test('buildTargetRequest does not double-wrap an already-wrapped json_schema', async () => { +test('buildTargetPayload does not double-wrap an already-wrapped json_schema', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: 'Hi', @@ -220,7 +239,7 @@ test('buildTargetRequest does not double-wrap an already-wrapped json_schema', a }); }); -test.each(['web_search_preview', 'file_search', 'image_generation', 'local_shell'])('buildTargetRequest rejects unsupported tool type %s', async type => { +test.each(['web_search_preview', 'file_search', 'image_generation', 'local_shell'])('buildTargetPayload rejects unsupported tool type %s', async type => { await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', @@ -228,11 +247,11 @@ test.each(['web_search_preview', 'file_search', 'image_generation', 'local_shell tools: [{ type } as unknown as OpenAIResponsesTool], }), Error, - `Cannot translate OpenAI Responses tool type '${type}' to OpenAI Chat Completions.`, + `Cannot translate OpenAI Responses tool type '${type}'.`, ); }); -test('buildTargetRequest omits parameters and strict for a schema-less function tool', async () => { +test('buildTargetPayload omits parameters and strict for a schema-less function tool', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: 'Hi' }], @@ -244,23 +263,23 @@ test('buildTargetRequest omits parameters and strict for a schema-less function assertEquals(Object.keys((result.target.tools![0] as OpenAIChatCompletionsFunctionTool).function), ['name']); }); -test.each(['web_search', 'file_search'])('buildTargetRequest rejects unsupported string tool choice %s', async toolChoice => { +test.each(['web_search', 'file_search'])('buildTargetPayload rejects unsupported string tool choice %s', async toolChoice => { await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', input: 'Hi', tool_choice: toolChoice as OpenAIResponsesToolChoice }), Error, - `Cannot translate OpenAI Responses tool choice '${toolChoice}' to OpenAI Chat Completions.`, + `Cannot translate OpenAI Responses tool choice '${toolChoice}'.`, ); }); -test.each(['web_search_preview', 'file_search'])('buildTargetRequest rejects unsupported object tool choice type %s', async type => { +test.each(['web_search_preview', 'file_search'])('buildTargetPayload rejects unsupported object tool choice type %s', async type => { await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', input: 'Hi', tool_choice: { type } as unknown as OpenAIResponsesToolChoice }), Error, - `Cannot translate OpenAI Responses tool choice type '${type}' to OpenAI Chat Completions.`, + `Cannot translate OpenAI Responses tool choice type '${type}'.`, ); }); -test('buildTargetRequest keeps function tool_choice', async () => { +test('buildTargetPayload keeps function tool_choice', async () => { const resultWithFunctionChoice = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: 'Hi' }], @@ -268,7 +287,7 @@ test('buildTargetRequest keeps function tool_choice', async () => { temperature: null, top_p: null, max_output_tokens: null, - tools: null, + tools: [{ type: 'function', name: 'get_weather', parameters: { type: 'object', properties: {} } }], tool_choice: { type: 'function' as const, name: 'get_weather', @@ -286,7 +305,7 @@ test('buildTargetRequest keeps function tool_choice', async () => { }); }); -test.each(['auto', 'required', 'none'] as const)('buildTargetRequest keeps supported string tool choice %s', async toolChoice => { +test.each(['auto', 'none'] as const)('buildTargetPayload omits orphan string tool choice %s', async toolChoice => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: 'Hi' }], @@ -303,10 +322,30 @@ test.each(['auto', 'required', 'none'] as const)('buildTargetRequest keeps suppo text: null, }); - assertEquals(result.target.tool_choice, toolChoice); + assertEquals(result.target.tool_choice, undefined); +}); + +test('buildTargetPayload omits a required tool choice that has no declaration to demand', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + input: [{ type: 'message', role: 'user', content: 'Hi' }], + instructions: null, + temperature: null, + top_p: null, + max_output_tokens: null, + tools: null, + tool_choice: 'required', + metadata: null, + stream: null, + store: null, + parallel_tool_calls: null, + text: null, + }); + + assertEquals(result.target.tool_choice, undefined); }); -test('buildTargetRequest wraps custom tools as single-string function tools and records their names', async () => { +test('buildTargetPayload wraps custom tools as single-string function tools and records their names', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: 'hi', @@ -330,7 +369,6 @@ test('buildTargetRequest wraps custom tools as single-string function tools and text: null, }); - assertEquals(result.customToolNames.has('apply_patch'), true); assertEquals(result.target.tools, [ { type: 'function', @@ -359,7 +397,7 @@ test.each([ { name: 'string', output: 'ok', expected: 'ok' }, { name: 'text array', output: [{ type: 'input_text' as const, text: 'first\n' }, { type: 'input_text' as const, text: 'second' }], expected: 'first\nsecond' }, { name: 'empty array', output: [], expected: '' }, -])('buildTargetRequest projects custom_tool_call history with $name output into wrapped tool_calls shape', async ({ output, expected }) => { +])('buildTargetPayload projects custom_tool_call history with $name output into wrapped tool_calls shape', async ({ output, expected }) => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [ @@ -415,15 +453,15 @@ test.each([ { name: 'multi_agent_call_output', input: [{ type: 'multi_agent_call_output', action: 'spawn_agent', call_id: 'call_1', output: [] as OpenAIResponsesInputMultiAgentCallOutputItem['output'] }] }, { name: 'context_compaction', input: [{ type: 'context_compaction', encrypted_content: 'opaque' }] }, { name: 'item_reference', input: [{ type: 'item_reference', id: 'msg_1' }] }, -] as const)('buildTargetRequest rejects OpenAI-Responses-only $name input', async ({ name, input }) => { +] as const)('buildTargetPayload rejects OpenAI-Responses-only $name input', async ({ name, input }) => { await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', input: [...input] }), Error, - `Invalid input item type '${name}'`, + `Invalid input item: {"type":"${name}"`, ); }); -test('buildTargetRequest wires OpenAI Responses tooling guards', async () => { +test('buildTargetPayload wires OpenAI Responses tooling guards', async () => { await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', @@ -439,23 +477,28 @@ test('buildTargetRequest wires OpenAI Responses tooling guards', async () => { ); }); -test('buildTargetRequest accepts null tool_choice', async () => { +test('buildTargetPayload accepts null tool_choice', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: 'hi', tool_choice: null }); assertEquals(result.target.tool_choice, undefined); }); -test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetRequest rejects input_file in %s', async type => { +test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetPayload rejects input_file in %s', async type => { await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', - input: [{ type, call_id: 'call_1', output: [{ type: 'input_file', file_id: 'file_1' }] }], + input: [ + ...(type === 'function_call_output' + ? [{ type: 'function_call' as const, call_id: 'call_1', name: 'lookup', arguments: '{}' }] + : [{ type: 'custom_tool_call' as const, call_id: 'call_1', name: 'lookup', input: '{}' }]), + { type, call_id: 'call_1', output: [{ type: 'input_file', file_id: 'file_1' }] }, + ], }), Error, - 'input_file tool output', + 'file tool output', ); }); -test('buildTargetRequest rejects file assistant content', async () => { +test('buildTargetPayload rejects file assistant content', async () => { await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', @@ -466,7 +509,7 @@ test('buildTargetRequest rejects file assistant content', async () => { ); }); -test('buildTargetRequest rejects image assistant content', async () => { +test('buildTargetPayload rejects image assistant content', async () => { await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', @@ -477,7 +520,7 @@ test('buildTargetRequest rejects image assistant content', async () => { ); }); -test('buildTargetRequest rejects file_id-only images', async () => { +test('buildTargetPayload rejects file_id-only images', async () => { await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', @@ -488,7 +531,7 @@ test('buildTargetRequest rejects file_id-only images', async () => { ); }); -test('buildTargetRequest forwards image details OpenAI Chat Completions does not publish', async () => { +test('buildTargetPayload forwards image details OpenAI Chat Completions does not publish', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ @@ -507,7 +550,7 @@ test('buildTargetRequest forwards image details OpenAI Chat Completions does not ]); }); -test('buildTargetRequest omits image detail when the client sends none', async () => { +test('buildTargetPayload omits image detail when the client sends none', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: [{ type: 'input_image', image_url: 'data:image/png;base64,AQID' }] }], @@ -518,7 +561,7 @@ test('buildTargetRequest omits image detail when the client sends none', async ( ]); }); -test('buildTargetRequest omits image detail when the client sends null', async () => { +test('buildTargetPayload omits image detail when the client sends null', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: [{ type: 'input_image', image_url: 'data:image/png;base64,AQID', detail: null }] }], @@ -527,7 +570,7 @@ test('buildTargetRequest omits image detail when the client sends null', async ( assertEquals(result.target.messages[0].content, [{ type: 'image_url', image_url: { url: 'data:image/png;base64,AQID' } }]); }); -test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetRequest omits image detail on lifted %s images', async type => { +test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetPayload omits image detail on lifted %s images', async type => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [ @@ -548,7 +591,7 @@ test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTa ]); }); -test('buildTargetRequest throws on a stray web_search_call input item (shim owns the reverse path)', async () => { +test('buildTargetPayload throws on a stray web_search_call input item (shim owns the reverse path)', async () => { // The OpenAI Responses web-search shim rewrites web_search_call input items into // upstream function_call + function_call_output pairs before this // translator runs. Reaching the translator with a raw web_search_call @@ -578,11 +621,11 @@ test('buildTargetRequest throws on a stray web_search_call input item (shim owns parallel_tool_calls: true, }), Error, - "Invalid input item type 'web_search_call'", + 'Invalid input item: {"type":"web_search_call"', ); }); -test('buildTargetRequest throws on a stray compaction_trigger input item (compact-shim owns the strip)', async () => { +test('buildTargetPayload throws on a stray compaction_trigger input item (compact-shim owns the strip)', async () => { // The compact-shim is structurally required on non-openai-responses targets and // strips compaction_trigger items before reaching this translator. // Reaching here with one in input means the shim disengaged; the @@ -606,11 +649,11 @@ test('buildTargetRequest throws on a stray compaction_trigger input item (compac parallel_tool_calls: true, }), Error, - "Invalid input item type 'compaction_trigger'", + 'Invalid input item: {"type":"compaction_trigger"', ); }); -test('buildTargetRequest throws on a stray compaction input item (compact-shim owns the expansion)', async () => { +test('buildTargetPayload throws on a stray compaction input item (compact-shim owns the expansion)', async () => { // The compact-shim expands its own shim-encoded compaction items inline // before reaching this translator and round-trips foreign compactions // back to the upstream as raw items. Either way the translator should @@ -634,11 +677,11 @@ test('buildTargetRequest throws on a stray compaction input item (compact-shim o parallel_tool_calls: true, }), Error, - "Invalid input item type 'compaction'", + 'Invalid input item: {"type":"compaction"', ); }); -test('buildTargetRequest lifts tool-output images into a following user message', async () => { +test('buildTargetPayload lifts tool-output images into a following user message', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [ @@ -685,7 +728,7 @@ test('buildTargetRequest lifts tool-output images into a following user message' ]); }); -test('buildTargetRequest keeps grouped tool results contiguous before lifted images', async () => { +test('buildTargetPayload keeps grouped tool results contiguous before lifted images', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [ @@ -735,7 +778,7 @@ test('buildTargetRequest keeps grouped tool results contiguous before lifted ima ]); }); -test('buildTargetRequest places lifted images before a later source message', async () => { +test('buildTargetPayload places lifted images before a later source message', async () => { for (const trailing of [ { type: 'message' as const, role: 'user' as const, content: 'new user turn' }, { type: 'message' as const, role: 'system' as const, content: 'new system turn' }, @@ -760,7 +803,7 @@ test('buildTargetRequest places lifted images before a later source message', as // ── Native field forwarding ── -test('buildTargetRequest maps text.verbosity onto verbosity', async () => { +test('buildTargetPayload maps text.verbosity onto verbosity', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: 'hi' }], @@ -770,7 +813,7 @@ test('buildTargetRequest maps text.verbosity onto verbosity', async () => { assertEquals(result.target.verbosity, 'low'); }); -test('buildTargetRequest co-emits reasoning.effort onto reasoning_effort and service_tier verbatim', async () => { +test('buildTargetPayload co-emits reasoning.effort onto reasoning_effort and service_tier verbatim', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: 'hi' }], @@ -782,7 +825,7 @@ test('buildTargetRequest co-emits reasoning.effort onto reasoning_effort and ser assertEquals(result.target.service_tier, 'priority'); }); -test('buildTargetRequest forwards empty reasoning.effort verbatim', async () => { +test('buildTargetPayload forwards empty reasoning.effort verbatim', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: 'hi' }], @@ -792,7 +835,7 @@ test('buildTargetRequest forwards empty reasoning.effort verbatim', async () => assertEquals(result.target.reasoning_effort, ''); }); -test('buildTargetRequest drops reasoning.summary (OpenAI Chat Completions has no slot)', async () => { +test('buildTargetPayload drops reasoning.summary (OpenAI Chat Completions has no slot)', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: 'hi' }], @@ -807,3 +850,18 @@ test.each(['system', 'developer'] as const)('preserves %s text part boundaries', const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role, content: [{ type: 'input_text', text: 'a' }, { type: 'input_text', text: 'b' }] }] }); assertEquals(result.target.messages, [{ role, content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }] }]); }); + +test.each(['system', 'developer'] as const)('collapses an empty %s message to the empty string', async role => { + // An empty part array is not a legal Chat body; the empty string states the + // same message in the spelling this wire accepts. + const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role, content: '' }] }); + assertEquals(result.target.messages, [{ role, content: '' }]); +}); + +test('carries Responses penalties into the Chat request', async () => { + const { target } = await buildWithTestSidecar({ + model: 'm', input: 'hello', presence_penalty: 0.6, frequency_penalty: -0.4, + }); + assertEquals(target.presence_penalty, 0.6); + assertEquals(target.frequency_penalty, -0.4); +}); diff --git a/packages/translate/__tests__/protocols/public-boundary_test.ts b/packages/translate/__tests__/protocols/public-boundary_test.ts index 511e9c6bee..95fef1f306 100644 --- a/packages/translate/__tests__/protocols/public-boundary_test.ts +++ b/packages/translate/__tests__/protocols/public-boundary_test.ts @@ -9,11 +9,11 @@ import { routeOpenAIResponsesViaOpenAIChatCompletions, } from '@floway-dev/translate'; -const options = { model: 'resolved-model', sidecarCodec: createTestSidecarCodec('public-boundary') }; +const options = { 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: [], + model: 'source-model', stream: true, messages: [], response_format: { type: 'json_schema', json_schema: { name: 'shape' } }, }, options); expect(failure).toEqual({ type: 'input-error', message: expect.stringContaining('without a schema') }); @@ -27,33 +27,30 @@ test('public translators return ordinary input-failure data before preparing a t test('public operations preserve unexpected errors and their causes', async () => { const cause = new Error('transport'); - const failure = new Error('image loader', { cause }); + const failure = new Error('request read', { 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); + model: 'source-model', get messages(): never { throw failure; }, + }, { ...options, fallbackMaxOutputTokens: 32 })).rejects.toBe(failure); expect(() => canonicalizeOpenAIResponsesPayload({ get model() { throw failure; } })).toThrow(failure); expect(failure.cause).toBe(cause); }); -test('Messages translation retains the injected remote-image loader', async () => { - const urls: string[] = []; +test('Messages translation preserves URL media in its native request', async () => { const trip = await routeOpenAIChatCompletionsViaAnthropicMessages({ - model: 'source-model', messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://example.com/image.png' } }] }], + model: 'source-model', stream: true, 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.messages[0].content).toEqual([{ type: 'image', source: { type: 'url', url: 'https://example.com/image.png' }, 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); +test('Gemini translation retains the model in the formal source request', async () => { + const trip = await routeGeminiGenerateContentViaOpenAIChatCompletions({ model: 'resolved-model', 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/round-trip-receive_test.ts b/packages/translate/__tests__/protocols/round-trip-receive_test.ts index e9f67a5a5d..08a79979f7 100644 --- a/packages/translate/__tests__/protocols/round-trip-receive_test.ts +++ b/packages/translate/__tests__/protocols/round-trip-receive_test.ts @@ -1,32 +1,32 @@ 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'; import { computeOpenAIChatCompletionsTurnDigest } from '../../src/protocols/openai-chat-completions/as-source/round-trip.ts'; import { computeOpenAIResponsesTurnDigest } from '../../src/protocols/openai-responses/as-source/round-trip.ts'; import { hashRoundTripContent } from '../../src/round-trip/hash.ts'; -import type { InputAssistantTurnFor, AssistantTurnProtocol } from '../../src/round-trip/input-assistant-turn.ts'; +import type { AssistantTurnProtocol, InputAssistantTurnFor } from '../../src/round-trip/input-assistant-turn.ts'; import type { ReferenceableInputAssistantTurnFor } from '../../src/round-trip/reference/assistant-turn-types.ts'; import { ContentReference } from '../../src/round-trip/reference/types.ts'; import { encodeRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; import { replayMaterialsByType } from '../../src/round-trip/turn-digest.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { expectTrip } from '../trip.ts'; import type { AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; -import type { GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; +import type { GeminiGenerateContentRequest } 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 { 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 context = { sidecarCodec: createTestSidecarCodec('test-codec-scope'), fallbackMaxOutputTokens: undefined }; const chat = (carrier: string, text = 'answer'): OpenAIChatCompletionsPayloadEx => { const message: OpenAIChatCompletionsAssistantMessageEx = { role: 'assistant', content: text, reasoning_details: [{ type: 'reasoning.encrypted', data: carrier }] }; - return { model: 'source', messages: [message] }; + return { model: 'source', stream: true, messages: [message] }; }; -const messages = (carrier: string): AnthropicMessagesPayload => ({ model: 'source', max_tokens: 16, messages: [{ role: 'assistant', content: [{ type: 'text', text: 'answer' }, { type: 'redacted_thinking', data: carrier }] }] }); -const responses = (carrier: string): OpenAIResponsesRequestPayloadEx => ({ model: 'source', input: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'answer' }] }, { type: 'reasoning', id: 'carrier', summary: [], encrypted_content: carrier }] }); -const gemini = (carrier: string): GeminiGenerateContentPayload => ({ contents: [{ role: 'model', parts: [{ text: 'answer', thoughtSignature: carrier }] }] }); +const messages = (carrier: string): AnthropicMessagesPayload => ({ model: 'source', max_tokens: 16, stream: true, messages: [{ role: 'assistant', content: [{ type: 'text', text: 'answer' }, { type: 'redacted_thinking', data: carrier }] }] }); +const responses = (carrier: string): OpenAIResponsesRequestPayloadEx => ({ model: 'source', stream: true, input: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'answer' }] }, { type: 'reasoning', id: 'carrier', summary: [], encrypted_content: carrier }] }); +const gemini = (carrier: string): GeminiGenerateContentRequest => ({ model: 'model', contents: [{ role: 'model', parts: [{ text: 'answer', thoughtSignature: carrier }] }] }); const turnDigest = async (source: AssistantTurnProtocol) => source === 'openaiChatCompletions' ? await computeOpenAIChatCompletionsTurnDigest({ role: 'assistant', content: 'answer' }) @@ -44,21 +44,21 @@ const nativeTurn = (target: NativeProtocol, text = 'restored'): InputAssistantTu : [{ type: 'message', role: 'assistant', content: text }]; const cases = [ - { 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)) }, + { source: 'openaiChatCompletions', target: 'anthropicMessages', run: (carrier: string) => routeOpenAIChatCompletionsViaAnthropicMessages(chat(carrier), context) }, + { source: 'openaiChatCompletions', target: 'openaiResponses', run: (carrier: string) => routeOpenAIChatCompletionsViaOpenAIResponses(chat(carrier), context) }, + { source: 'anthropicMessages', target: 'openaiChatCompletions', run: (carrier: string) => routeAnthropicMessagesViaOpenAIChatCompletions(messages(carrier), context) }, + { source: 'anthropicMessages', target: 'openaiResponses', run: (carrier: string) => routeAnthropicMessagesViaOpenAIResponses(messages(carrier), context) }, + { source: 'openaiResponses', target: 'anthropicMessages', run: (carrier: string) => routeOpenAIResponsesViaAnthropicMessages(responses(carrier), context) }, + { source: 'openaiResponses', target: 'openaiChatCompletions', run: (carrier: string) => routeOpenAIResponsesViaOpenAIChatCompletions(responses(carrier), context) }, + { source: 'geminiGenerateContent', target: 'anthropicMessages', run: (carrier: string) => routeGeminiGenerateContentViaAnthropicMessages(gemini(carrier), context) }, + { source: 'geminiGenerateContent', target: 'openaiChatCompletions', run: (carrier: string) => routeGeminiGenerateContentViaOpenAIChatCompletions(gemini(carrier), context) }, + { source: 'geminiGenerateContent', target: 'openaiResponses', run: (carrier: string) => routeGeminiGenerateContentViaOpenAIResponses(gemini(carrier), context) }, ] as const; test.each(cases)('receives recognized $source history via $target', async ({ source, target, run }) => { const native = nativeTurn(target); const carrier = await encodeRoundTripSidecar(createTestSidecarCodec(), { v: 1, source, target, targetTurn: native, sourceTurnDigest: await turnDigest(source) }); - const trip = await run(carrier); + const trip = expectTrip(await run(carrier)); expect('input' in trip.target ? trip.target.input : trip.target.messages).toEqual(target === 'openaiResponses' ? native : [native]); }); @@ -72,7 +72,7 @@ test.each(cases)('hydrates $source text references before restoring $target hist const carrier = await encodeRoundTripSidecar(createTestSidecarCodec(), { v: 1, source, target, targetTurn, referencedContentHashes: [await hashRoundTripContent('answer')], sourceTurnDigest: await turnDigest(source), }); - const trip = await run(carrier); + const trip = expectTrip(await run(carrier)); const native = nativeTurn(target, 'ans'); expect('input' in trip.target ? trip.target.input : trip.target.messages).toEqual(target === 'openaiResponses' ? native : [native]); }); @@ -89,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 requireTrip(routeGeminiGenerateContentViaAnthropicMessages({ contents: [{ role: 'model', parts: [{ functionCall: { name: 'lookup', args }, thoughtSignature: carrier }] }] }, context)); + const trip = expectTrip(await routeGeminiGenerateContentViaAnthropicMessages({ model: 'model', 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 }] }]); }); @@ -107,7 +107,7 @@ test('exposes an authenticated missing reference as an internal error after repl test.each(cases)('leaves a terminal sidecar unrecognized by the injected decoder ordinary on $source via $target', async ({ run }) => { const carrier = 'unrecognized sidecar handle'; - const trip = await run(carrier); + const trip = expectTrip(await run(carrier)); expect(JSON.stringify('input' in trip.target ? trip.target.input : trip.target.messages)).toContain('answer'); }); @@ -117,38 +117,40 @@ 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 requireTrip(routeOpenAIChatCompletionsViaAnthropicMessages(chat(carrier, text), context)); + const trip = expectTrip(await routeOpenAIChatCompletionsViaAnthropicMessages(chat(carrier, text), context)); expect(trip.target.messages).toEqual([{ role: 'assistant', content: [{ type: 'text', text, cache_control: { type: 'ephemeral' } }] }]); }); test.each([{ input: 'text' }, { input: null }, { input: [1] }])('preserves ordinary Messages tool input $input', async ({ input }) => { const payload: AnthropicMessagesPayload = { - model: 'source', max_tokens: 16, + model: 'source', max_tokens: 16, stream: true, messages: [{ role: 'assistant', content: [{ type: 'tool_use', id: 'call', name: 'echo', input }] }], }; - const chatTrip = await requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)); + const chatTrip = expectTrip(await routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)); expect(chatTrip.target.messages).toMatchObject([ { role: 'assistant', tool_calls: [{ id: 'call', function: { name: 'echo', arguments: JSON.stringify(input) } }] }, ]); - const responsesTrip = await requireTrip(routeAnthropicMessagesViaOpenAIResponses(payload, context)); + const responsesTrip = expectTrip(await routeAnthropicMessagesViaOpenAIResponses(payload, context)); expect(responsesTrip.target.input).toMatchObject([ { type: 'function_call', call_id: 'call', name: 'echo', arguments: JSON.stringify(input) }, ]); }); 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 requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(payload, context)); + const payload: GeminiGenerateContentRequest = { ...JSON.parse('{"contents":[{"role":"model","parts":[{"functionCall":{"name":"lookup","args":null}}]}]}'), model: 'model' }; + const chatTrip = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions(payload, context)); expect(chatTrip.target.messages).toMatchObject([ { role: 'assistant', tool_calls: [{ function: { name: 'lookup', arguments: 'null' } }] }, ]); - const responsesTrip = await requireTrip(routeGeminiGenerateContentViaOpenAIResponses(payload, context)); + const responsesTrip = expectTrip(await routeGeminiGenerateContentViaOpenAIResponses(payload, context)); expect(responsesTrip.target.input).toMatchObject([ { type: 'function_call', name: 'lookup', arguments: 'null' }, ]); - const messagesTrip = await requireTrip(routeGeminiGenerateContentViaAnthropicMessages(payload, context)); + const messagesTrip = expectTrip(await routeGeminiGenerateContentViaAnthropicMessages(payload, context)); + // Messages needs an argument object, so a `null` argument list keeps its raw + // text rather than becoming `input: null`. expect(messagesTrip.target.messages).toMatchObject([ - { role: 'assistant', content: [{ type: 'tool_use', name: 'lookup', input: null }] }, + { role: 'assistant', content: [{ type: 'tool_use', name: 'lookup', input: { raw_arguments: 'null' } }] }, ]); }); @@ -161,7 +163,7 @@ test('exposes a recognized malformed assistant envelope as an internal error', a targetTurn: { role: 'user', content: [] }, sourceTurnDigest: await turnDigest('openaiChatCompletions'), }); - await expect(requireTrip(routeOpenAIChatCompletionsViaAnthropicMessages(chat(carrier), context))).rejects.toThrow('Messages assistant turn is invalid'); + await expect(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 () => { @@ -177,9 +179,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 requireTrip(routeOpenAIResponsesViaAnthropicMessages({ model: 'source', input: [call, reasoning] }, context)); + const replayed = expectTrip(await routeOpenAIResponsesViaAnthropicMessages({ model: 'source', stream: true, input: [call, reasoning] }, context)); expect(replayed.target.messages).toEqual([native]); - const changed = await requireTrip(routeOpenAIResponsesViaAnthropicMessages({ model: 'source', input: [{ ...call, status: 'completed' }, reasoning] }, context)); + const changed = expectTrip(await routeOpenAIResponsesViaAnthropicMessages({ model: 'source', stream: true, 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 cc0ca51478..2b84a7ca13 100644 --- a/packages/translate/__tests__/protocols/routes_test.ts +++ b/packages/translate/__tests__/protocols/routes_test.ts @@ -1,7 +1,5 @@ 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 { findAnthropicMessagesSidecarOccurrences } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; import { findGeminiGenerateContentSidecarOccurrences } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; @@ -13,20 +11,24 @@ import { findOpenAIResponsesSidecarOccurrences } from '../../src/protocols/opena import { toOpenAIResponsesInputAssistantTurn } from '../../src/protocols/openai-responses/as-target/assistant-turn.ts'; import { decodeRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; import { requireJSONObject } from '../../src/shared/json/object.ts'; +import type { TranslateInputError } from '../../src/types.ts'; +import { collect, iterate } from '../iterables.ts'; +import { fixtureFrames, type nativeResult } from './fixtures.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; -import { collect, iterate } from '../shared/iterables.ts'; +import { expectTrip } from '../trip.ts'; import { collectAnthropicMessagesProtocolEventsToResult, type AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; -import { collectGeminiGenerateContentProtocolEventsToResult, type GeminiGenerateContentContent, type GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; +import { collectGeminiGenerateContentProtocolEventsToResult, type GeminiGenerateContentContent, type GeminiGenerateContentRequest } from '@floway-dev/protocols/gemini-generate-content'; 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 { routeAnthropicMessagesViaOpenAIChatCompletions, routeAnthropicMessagesViaOpenAIResponses, routeGeminiGenerateContentViaAnthropicMessages, routeGeminiGenerateContentViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIResponses, routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses, routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; +import { routeAnthropicMessagesViaOpenAIChatCompletions, routeAnthropicMessagesViaOpenAIResponses, routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses, routeGeminiGenerateContentViaAnthropicMessages, routeGeminiGenerateContentViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIResponses, routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; const schema = { type: 'object' as const, properties: { x: { type: 'string' } } }; const formatSchema = { type: 'object', properties: { y: { type: 'string' } } }; const responses: CanonicalOpenAIResponsesPayload = { model: 'm', + stream: true, input: [ { type: 'reasoning', id: 'rs1', summary: [{ type: 'summary_text', text: 'thought' }] }, { type: 'message', role: 'user', content: 'hi' }, @@ -37,6 +39,7 @@ const responses: CanonicalOpenAIResponsesPayload = { }; const chat: OpenAIChatCompletionsPayload = { model: 'm', + stream: true, messages: [ { role: 'assistant', content: 'hello', reasoning_text: 'thought' } as OpenAIChatCompletionsAssistantMessageEx, { role: 'user', content: 'hi' }, @@ -48,14 +51,15 @@ const chat: OpenAIChatCompletionsPayload = { tool_choice: 'auto', }; const anthropic: AnthropicMessagesPayload = { - model: 'm', max_tokens: 16, + model: 'm', max_tokens: 16, stream: true, messages: [{ role: 'user', content: 'hi' }], tools: [{ name: 'f', input_schema: schema }], output_config: { format: { type: 'json_schema', schema: formatSchema } }, stop_sequences: ['END'], metadata: { user_id: 'u', extension: 'retained' } as AnthropicMessagesPayload['metadata'], }; -const gemini: GeminiGenerateContentPayload = { +const gemini: GeminiGenerateContentRequest = { + model: 'model', contents: [ { role: 'model', parts: [{ functionCall: { name: 'f', args: { nested: { value: 'x' } } } }] }, { role: 'user', parts: [{ text: 'hi' }] }, @@ -76,15 +80,15 @@ const objectsIn = (value: unknown): Set => { }; const translations: Array<{ name: string; source: unknown; translate: () => Promise }> = [ - { 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 }, + { name: 'Responses to Chat Completions', source: responses, translate: async () => (expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions(responses, { sidecarCodec: createTestSidecarCodec('test-codec-scope') }))).target }, + { name: 'Responses to Anthropic Messages', source: responses, translate: async () => (expectTrip(await routeOpenAIResponsesViaAnthropicMessages(responses, { sidecarCodec: createTestSidecarCodec('test-codec-scope') }))).target }, + { name: 'Chat Completions to Responses', source: chat, translate: async () => (expectTrip(await routeOpenAIChatCompletionsViaOpenAIResponses(chat, { sidecarCodec: createTestSidecarCodec('test-codec-scope') }))).target }, + { name: 'Chat Completions to Anthropic Messages', source: chat, translate: async () => (expectTrip(await routeOpenAIChatCompletionsViaAnthropicMessages(chat, { sidecarCodec: createTestSidecarCodec('test-codec-scope') }))).target }, + { name: 'Anthropic Messages to Responses', source: anthropic, translate: async () => (expectTrip(await routeAnthropicMessagesViaOpenAIResponses(anthropic, { sidecarCodec: createTestSidecarCodec('test-codec-scope') }))).target }, + { name: 'Anthropic Messages to Chat Completions', source: anthropic, translate: async () => (expectTrip(await routeAnthropicMessagesViaOpenAIChatCompletions(anthropic, { sidecarCodec: createTestSidecarCodec('test-codec-scope') }))).target }, + { name: 'Gemini to Responses', source: gemini, translate: async () => (expectTrip(await routeGeminiGenerateContentViaOpenAIResponses(gemini, { sidecarCodec: createTestSidecarCodec('test-codec-scope') }))).target }, + { name: 'Gemini to Chat Completions', source: gemini, translate: async () => (expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions(gemini, { sidecarCodec: createTestSidecarCodec('test-codec-scope') }))).target }, + { name: 'Gemini to Anthropic Messages', source: gemini, translate: async () => (expectTrip(await routeGeminiGenerateContentViaAnthropicMessages(gemini, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), fallbackMaxOutputTokens: 16 }))).target }, ]; test.each(translations)('$name owns its target payload without retaining source or prior target objects', async ({ source, translate }) => { @@ -106,16 +110,16 @@ test.each(translations)('$name owns its target payload without retaining source type Protocol = 'openai-chat-completions' | 'openai-responses' | 'anthropic-messages' | 'gemini-generate-content'; 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 => 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 })) }, +const returnedTrips: { a: Protocol; b: Protocol; trip: (codecScope: string) => Promise) => AsyncIterable }> }[] = [ + { a: 'openai-responses', b: 'openai-chat-completions', trip: codecScope => routeOpenAIResponsesViaOpenAIChatCompletions(responses, { sidecarCodec: createTestSidecarCodec(codecScope) }) }, + { a: 'openai-responses', b: 'anthropic-messages', trip: codecScope => routeOpenAIResponsesViaAnthropicMessages(responses, { sidecarCodec: createTestSidecarCodec(codecScope) }) }, + { a: 'openai-chat-completions', b: 'openai-responses', trip: codecScope => routeOpenAIChatCompletionsViaOpenAIResponses(chat, { sidecarCodec: createTestSidecarCodec(codecScope) }) }, + { a: 'openai-chat-completions', b: 'anthropic-messages', trip: codecScope => routeOpenAIChatCompletionsViaAnthropicMessages(chat, { sidecarCodec: createTestSidecarCodec(codecScope) }) }, + { a: 'anthropic-messages', b: 'openai-responses', trip: codecScope => routeAnthropicMessagesViaOpenAIResponses(anthropic, { sidecarCodec: createTestSidecarCodec(codecScope) }) }, + { a: 'anthropic-messages', b: 'openai-chat-completions', trip: codecScope => routeAnthropicMessagesViaOpenAIChatCompletions(anthropic, { sidecarCodec: createTestSidecarCodec(codecScope) }) }, + { a: 'gemini-generate-content', b: 'openai-responses', trip: codecScope => routeGeminiGenerateContentViaOpenAIResponses(gemini, { sidecarCodec: createTestSidecarCodec(codecScope) }) }, + { a: 'gemini-generate-content', b: 'openai-chat-completions', trip: codecScope => routeGeminiGenerateContentViaOpenAIChatCompletions(gemini, { sidecarCodec: createTestSidecarCodec(codecScope) }) }, + { a: 'gemini-generate-content', b: 'anthropic-messages', trip: codecScope => routeGeminiGenerateContentViaAnthropicMessages(gemini, { sidecarCodec: createTestSidecarCodec(codecScope), fallbackMaxOutputTokens: 16 }) }, ] as const; const protocolKeys = { @@ -216,25 +220,46 @@ type TargetPayload = const replayAssistantTurn = async (assistant: AssistantTurn, target: Protocol): Promise => { if (assistant.protocol === 'openai-responses') { - 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 }; + const request = { ...responses, input: [...assistant.turn, { type: 'message' as const, role: 'user' as const, content: 'next question' }] }; + if (target === 'openai-chat-completions') { + return { protocol: 'openai-chat-completions', payload: (expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions(request, { sidecarCodec: createTestSidecarCodec(codecScope) }))).target }; + } + if (target === 'anthropic-messages') { + return { protocol: 'anthropic-messages', payload: (expectTrip(await routeOpenAIResponsesViaAnthropicMessages(request, { sidecarCodec: createTestSidecarCodec(codecScope) }))).target }; + } } if (assistant.protocol === 'openai-chat-completions') { - 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 }; + const request = { ...chat, messages: [assistant.turn, { role: 'user' as const, content: 'next question' }] }; + if (target === 'openai-responses') { + return { protocol: 'openai-responses', payload: (expectTrip(await routeOpenAIChatCompletionsViaOpenAIResponses(request, { sidecarCodec: createTestSidecarCodec(codecScope) }))).target }; + } + if (target === 'anthropic-messages') { + return { protocol: 'anthropic-messages', payload: (expectTrip(await routeOpenAIChatCompletionsViaAnthropicMessages(request, { sidecarCodec: createTestSidecarCodec(codecScope) }))).target }; + } } if (assistant.protocol === 'anthropic-messages') { - 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 }; + const request = { ...anthropic, messages: [assistant.turn, { role: 'user' as const, content: 'next question' }] }; + if (target === 'openai-responses') { + return { protocol: 'openai-responses', payload: (expectTrip(await routeAnthropicMessagesViaOpenAIResponses(request, { sidecarCodec: createTestSidecarCodec(codecScope) }))).target }; + } + if (target === 'openai-chat-completions') { + return { protocol: 'openai-chat-completions', payload: (expectTrip(await routeAnthropicMessagesViaOpenAIChatCompletions(request, { sidecarCodec: createTestSidecarCodec(codecScope) }))).target }; + } } if (assistant.protocol === 'gemini-generate-content') { - 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 }; + const request = { ...gemini, contents: [...assistant.turn, { role: 'user' as const, parts: [{ text: 'next question' }] }] }; + if (target === 'openai-responses') { + return { protocol: 'openai-responses', payload: (expectTrip(await routeGeminiGenerateContentViaOpenAIResponses(request, { sidecarCodec: createTestSidecarCodec(codecScope) }))).target }; + } + if (target === 'openai-chat-completions') { + return { protocol: 'openai-chat-completions', payload: (expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions(request, { sidecarCodec: createTestSidecarCodec(codecScope) }))).target }; + } + if (target === 'anthropic-messages') { + return { protocol: 'anthropic-messages', payload: (expectTrip(await routeGeminiGenerateContentViaAnthropicMessages(request, { sidecarCodec: createTestSidecarCodec(codecScope), fallbackMaxOutputTokens: 16 }))).target }; + } } throw new Error(`Unsupported route: ${assistant.protocol} via ${target}`); }; - const targetAssistantTurn = (target: TargetPayload) => { if (target.protocol === 'openai-responses') return partitionOpenAIResponsesTurns(target.payload.input).find(turn => turn.role === 'assistant')?.items; if (target.protocol === 'openai-chat-completions') return partitionOpenAIChatCompletionsTurns(target.payload.messages).find(turn => turn.role === 'assistant')?.items[0]; @@ -243,7 +268,7 @@ const targetAssistantTurn = (target: TargetPayload) => { test.each(returnedTrips)('$a via $b returns generated content and a decodable sidecar through its public TranslateTrip', async ({ a, b, trip }) => { const codec = createTestSidecarCodec(codecScope); - const translation = await trip(codecScope); + const translation = expectTrip(await trip(codecScope)); const fixture = { text: ['return ', 'path'], thinking: ['upstream thought'], @@ -272,3 +297,27 @@ test.each(returnedTrips)('$a via $b returns generated content and a decodable si assertEquals(assistant, before); assertEquals(targetAssistantTurn(await replayAssistantTurn(moved, b)), sidecar.targetTurn); }); + +test('a Responses client sees its own declarations echoed on the response object', async () => { + // The upstream states no tools of its own, so the response objects carry the + // declarations the client asked with. A client that reads this field expects + // its groups back, not the flattened names the request was written with. + const declaration = { type: 'namespace' as const, name: 'public', description: '', tools: [{ type: 'function' as const, name: 'lookup', parameters: schema }] }; + const payload: CanonicalOpenAIResponsesPayload = { + model: 'm', + stream: true, + input: [{ type: 'message', role: 'user', content: 'hi' }], + tools: [declaration], + tool_choice: 'auto', + }; + const trip = expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions(payload, { sidecarCodec: createTestSidecarCodec('test-codec-scope') })); + const frames = await collect(trip.events(iterate(fixtureFrames('openai-chat-completions', { + text: ['ok'], thinking: [], signature: '', tools: [], + })))); + + const created = frames.find(frame => frame.type === 'event' && frame.event.type === 'response.created'); + assert(created?.type === 'event'); + const createdResponse = 'response' in created.event ? created.event.response : undefined; + assertEquals(createdResponse?.tools, [declaration]); + assertEquals(createdResponse?.tool_choice, 'auto'); +}); diff --git a/packages/translate/__tests__/protocols/streams_test.ts b/packages/translate/__tests__/protocols/streams_test.ts index e2527e4bb1..7e5a32ff6f 100644 --- a/packages/translate/__tests__/protocols/streams_test.ts +++ b/packages/translate/__tests__/protocols/streams_test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { writeAnthropicMessagesEvents, writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; +import { writeAnthropicMessagesEvents, writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './events.ts'; import type { IRJSONObject } from '../../src/ir/common/json.ts'; import { createIRBuilder } from '../../src/ir/output/builder.ts'; import type { IROutputItem } from '../../src/ir/output/types.ts'; @@ -10,7 +10,7 @@ import { usageFromIR as responsesUsageFromIR } from '../../src/protocols/openai- import { usageToIR as responsesUsageToIR } from '../../src/protocols/openai-responses/as-target/usage.ts'; import { TextCoordinateIndex } from '../../src/shared/text-coordinate-index.ts'; import { collectIR, completeIR } from '../ir/output/helpers.ts'; -import { collect, events, iterate } from '../shared/iterables.ts'; +import { collect, events, iterate } from '../iterables.ts'; import { reassembleAnthropicMessagesEvents, type AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; import { reassembleGeminiGenerateContentEvents } from '@floway-dev/protocols/gemini-generate-content'; @@ -73,7 +73,7 @@ const translateChatFrames = async (nativeFrames: readonly any[]) => { }; }; -describe('IR streaming', () => { +describe('output IR streaming', () => { it.each([['ChatCompletions', chat], ['Responses', responses], ['Messages', messages]] as const)('normalizes %s without flattening source strings', async (_name, source) => { const value = await collectIR(source()); expect(value.usage?.input_tokens_inclusive).toBe(11); @@ -363,7 +363,7 @@ describe('IR streaming', () => { expect(result.output.map(item => item.type)).toEqual(['message', 'image_generation_call', 'message', 'reasoning']); }); - it('marks ChatCompletions length termination incomplete and exposes IR errors', async () => { + it('marks ChatCompletions length termination incomplete and exposes output IR errors', async () => { const frames = await collect(readOpenAIChatCompletionsEvents(iterate([chatFrame([{ index: 0, delta: { content: 'text' }, finish_reason: 'length' }]), doneFrame()]))); expect(frames.find(record => record.type === 'finish')).toMatchObject({ status: 'incomplete' }); await expect(collectIR(iterate([{ type: 'error', error: { message: 'failure' } }]))).rejects.toThrow(); @@ -435,7 +435,7 @@ describe('IR streaming', () => { await expect(collect(writeOpenAIChatCompletionsEvents(iterate(frames), options))).rejects.toThrow(); }); - it('uses the same lifecycle validation when collecting IR', async () => { + it('uses the same lifecycle validation when collecting output IR', async () => { for (const records of [[{ type: 'finish', status: 'completed' }], [{ type: 'start', id: 's', model: 'm' }, { type: 'start', id: 's', model: 'm' }, { type: 'finish', status: 'completed' }]]) { await expect(collectIR(iterate(records as any))).rejects.toThrow(); } @@ -481,8 +481,8 @@ describe('IR streaming', () => { ['Responses', writeOpenAIResponsesEvents], ['Messages', writeAnthropicMessagesEvents], ['GenerateContent', writeGeminiGenerateContentEvents], - ] as const)('%s rejects IR start without a model', async (_name, adapter) => { - await expect(collect(adapter(iterate([{ type: 'start', id: 'source' }] as any), options))).rejects.toThrow('requires an IR model'); + ] as const)('%s rejects output IR start without a model', async (_name, adapter) => { + await expect(collect(adapter(iterate([{ type: 'start', id: 'source' }] as any), options))).rejects.toThrow('requires an output IR model'); }); }); diff --git a/packages/translate/__tests__/protocols/unrecognized-assistant-turn-warning_test.ts b/packages/translate/__tests__/protocols/unrecognized-assistant-turn-warning_test.ts deleted file mode 100644 index cebc92905e..0000000000 --- a/packages/translate/__tests__/protocols/unrecognized-assistant-turn-warning_test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { afterEach, expect, test, vi } from 'vitest'; - -import { buildTargetRequest as messagesViaChat } from '../../src/protocols/anthropic-messages/via-openai-chat-completions/request.ts'; -import { buildTargetRequest as messagesViaResponses } from '../../src/protocols/anthropic-messages/via-openai-responses/request.ts'; -import { buildTargetRequest as geminiViaMessages } from '../../src/protocols/gemini-generate-content/via-anthropic-messages/request.ts'; -import { buildTargetRequest as geminiViaChat } from '../../src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts'; -import { buildTargetRequest as geminiViaResponses } from '../../src/protocols/gemini-generate-content/via-openai-responses/request.ts'; -import { buildTargetRequest as chatViaMessages } from '../../src/protocols/openai-chat-completions/via-anthropic-messages/request.ts'; -import { buildTargetRequest as chatViaResponses } from '../../src/protocols/openai-chat-completions/via-openai-responses/request.ts'; -import { buildTargetRequest as responsesViaMessages } from '../../src/protocols/openai-responses/via-anthropic-messages/request.ts'; -import { buildTargetRequest as responsesViaChat } from '../../src/protocols/openai-responses/via-openai-chat-completions/request.ts'; -import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; -import type { OpenAIChatCompletionsAssistantMessageEx } from '@floway-dev/protocols/openai-chat-completions'; - -const codec = () => createTestSidecarCodec(); -const loadRemoteImage = () => Promise.resolve(null); -const foreignChatAssistant = (): OpenAIChatCompletionsAssistantMessageEx => ({ role: 'assistant', content: 'answer', reasoning: 'trace' }); -const chatViaMessagesOptions: Parameters[2] = { loadRemoteImage, fallbackMaxOutputTokens: undefined }; -const responsesViaMessagesOptions: Parameters[2] = { loadRemoteImage, fallbackMaxOutputTokens: undefined }; -const geminiViaMessagesOptions: Parameters[3] = { fallbackMaxOutputTokens: undefined }; - -const cases: { source: string; target: string; translate: () => Promise }[] = [ - { - source: 'openaiChatCompletions', - target: 'anthropicMessages', - translate: () => chatViaMessages({ model: 'model', messages: [foreignChatAssistant()] }, codec(), chatViaMessagesOptions), - }, - { - source: 'openaiChatCompletions', - target: 'openaiResponses', - translate: () => chatViaResponses({ model: 'model', messages: [foreignChatAssistant()] }, codec()), - }, - { - source: 'openaiResponses', - target: 'anthropicMessages', - translate: () => responsesViaMessages({ model: 'model', input: [{ type: 'reasoning', summary: [], encrypted_content: 'foreign' }] }, codec(), responsesViaMessagesOptions), - }, - { - source: 'openaiResponses', - target: 'openaiChatCompletions', - translate: () => responsesViaChat({ model: 'model', input: [{ type: 'reasoning', summary: [], encrypted_content: 'foreign' }] }, codec()), - }, - { - source: 'anthropicMessages', - target: 'openaiChatCompletions', - translate: () => messagesViaChat({ model: 'model', max_tokens: 1, messages: [{ role: 'assistant', content: [{ type: 'redacted_thinking', data: 'foreign' }] }] }, codec()), - }, - { - source: 'anthropicMessages', - target: 'openaiResponses', - translate: () => messagesViaResponses({ model: 'model', max_tokens: 1, messages: [{ role: 'assistant', content: [{ type: 'redacted_thinking', data: 'foreign' }] }] }, codec()), - }, - { - source: 'geminiGenerateContent', - target: 'anthropicMessages', - translate: () => geminiViaMessages({ contents: [{ role: 'model', parts: [{ thoughtSignature: 'foreign' }] }] }, 'model', codec(), geminiViaMessagesOptions), - }, - { - source: 'geminiGenerateContent', - target: 'openaiChatCompletions', - translate: () => geminiViaChat({ contents: [{ role: 'model', parts: [{ thoughtSignature: 'foreign' }] }] }, 'model', codec()), - }, - { - source: 'geminiGenerateContent', - target: 'openaiResponses', - translate: () => geminiViaResponses({ contents: [{ role: 'model', parts: [{ thoughtSignature: 'foreign' }] }] }, 'model', codec()), - }, -]; - -const ordinaryCases: { name: string; translate: () => Promise }[] = [ - { - name: 'ChatCompletions content', - translate: () => chatViaResponses({ model: 'model', messages: [{ role: 'assistant', content: 'answer' }] }, codec()), - }, - { - name: 'Responses readable reasoning', - translate: () => responsesViaChat({ model: 'model', input: [{ type: 'reasoning', summary: [{ type: 'summary_text', text: 'trace' }] }] }, codec()), - }, - { - name: 'Messages readable thinking', - translate: () => messagesViaResponses({ model: 'model', max_tokens: 1, messages: [{ role: 'assistant', content: [{ type: 'thinking', thinking: 'trace', signature: '' }] }] }, codec()), - }, - { - name: 'GenerateContent unsigned text', - translate: () => geminiViaChat({ contents: [{ role: 'model', parts: [{ text: 'answer' }] }] }, 'model', codec()), - }, -]; - -afterEach(() => { vi.restoreAllMocks(); }); - -test.each(cases)('$source via $target warns when bare translation drops foreign reasoning', async ({ source, target, translate }) => { - const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await translate(); - - expect(warning).toHaveBeenCalledTimes(1); - expect(warning).toHaveBeenCalledWith( - 'Dropping reasoning from an unidentifiable foreign assistant turn', - { source, target }, - ); -}); - -test.each(ordinaryCases)('$name does not warn during bare translation', async ({ translate }) => { - const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await translate(); - - expect(warning).not.toHaveBeenCalled(); -}); diff --git a/packages/translate/__tests__/protocols/usage_test.ts b/packages/translate/__tests__/protocols/usage_test.ts index 8ddcc21aaa..365d5673e8 100644 --- a/packages/translate/__tests__/protocols/usage_test.ts +++ b/packages/translate/__tests__/protocols/usage_test.ts @@ -1,16 +1,15 @@ import { expect, test } from 'vitest'; +import { writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './events.ts'; +import { iterate } from '../iterables.ts'; import { fixtureFrames } from './fixtures.ts'; -import { writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; import { usageFromIR as messagesUsageFromIR } from '../../src/protocols/anthropic-messages/as-source/usage.ts'; import { usageToIR as messagesUsageToIR } from '../../src/protocols/anthropic-messages/as-target/usage.ts'; import { usageFromIR } from '../../src/protocols/gemini-generate-content/as-source/usage.ts'; import { usageFromIR as chatUsageFromIR } from '../../src/protocols/openai-chat-completions/as-source/usage.ts'; import { usageToIR as chatUsageToIR } from '../../src/protocols/openai-chat-completions/as-target/usage.ts'; import { usageFromIR as responsesUsageFromIR } from '../../src/protocols/openai-responses/as-source/usage.ts'; -import { usageToIR as responsesUsageToIR } from '../../src/protocols/openai-responses/as-target/usage.ts'; -import { collectIR } from '../ir/output/helpers.ts'; -import { iterate } from '../shared/iterables.ts'; +import { usageToIR as responsesUsageToIR } from '../../src/protocols/openai-responses/as-target/usage.ts';import { collectIR } from '../ir/output/helpers.ts'; import { eventFrame } from '@floway-dev/protocols/common'; const initialUsage = { input_tokens: 17, output_tokens: 2, total_tokens: 19, input_tokens_details: { cached_tokens: 3, cache_write_tokens: 4 } }; diff --git a/packages/translate/__tests__/request/call-ids_test.ts b/packages/translate/__tests__/request/call-ids_test.ts new file mode 100644 index 0000000000..e166d880fd --- /dev/null +++ b/packages/translate/__tests__/request/call-ids_test.ts @@ -0,0 +1,58 @@ +import { test } from 'vitest'; + +import type { RestoredAssistantTurn } from '../../src/composition/reading.ts'; +import type { IRInputTurn } from '../../src/ir/input/types.ts'; +import { assignCallIds, type PendingCall } from '../../src/request/call-ids.ts'; +import { TranslateInputException } from '../../src/shared/input-error.ts'; +import { assertEquals, assertThrows } from '@floway-dev/test-utils'; + +const call = (name: string, callId?: string): IRInputTurn => ({ + kind: 'assistant', + items: [{ type: 'function_call', name, arguments: '{}', ...(callId === undefined ? {} : { call_id: callId }) }], +}); +const result = (name?: string, callId?: string): IRInputTurn => ({ + kind: 'client', + items: [{ type: 'tool_result', ...(name === undefined ? {} : { name }), ...(callId === undefined ? {} : { call_id: callId }), output: '' }], +}); +const restored = (calls: readonly PendingCall[]): RestoredAssistantTurn => ({ kind: 'restored', callIdentities: [], turn: calls }); +const restoredCalls = (turn: readonly PendingCall[]): readonly PendingCall[] => turn; + +const lastResult = (turns: ReturnType) => { + const piece = turns.at(-1); + if (piece?.kind !== 'client') throw new Error('expected a client turn'); + const item = piece.items.at(-1); + if (item?.type !== 'tool_result') throw new Error('expected a tool result'); + return item; +}; + +test('an id-less result answers the oldest call of its name', () => { + assertEquals(lastResult(assignCallIds([call('read', 'a'), call('read', 'b'), result('read')], () => [])).call_id, 'a'); +}); + +test('an id-less result with no name answers the oldest pending call', () => { + assertEquals(lastResult(assignCallIds([call('read', 'a'), call('write', 'b'), result()], () => [])).call_id, 'a'); +}); + +test('an id-less result answers a call a sidecar restored', () => { + assertEquals(lastResult(assignCallIds([restored([{ name: 'read', callId: 'native-1' }]), result()], restoredCalls)).call_id, 'native-1'); +}); + +test('a result naming a call nothing awaits is refused rather than answered with an invented id', () => { + // The call it names is a turn this side never saw — a trimmed history, say. + // Answering an oldest pending call instead would pair it with the wrong one, + // and inventing an id would state a call no assistant turn ever made. + assertThrows(() => assignCallIds([call('read', 'a'), result('write')], () => []), TranslateInputException); + assertThrows(() => assignCallIds([call('read', 'a'), result(undefined, 'invented')], () => []), TranslateInputException); +}); + +test('a stated result releases its call so a later id-less result cannot take it', () => { + assertEquals(lastResult(assignCallIds([call('read', 'a'), call('read', 'b'), result(undefined, 'a'), result('read')], () => [])).call_id, 'b'); +}); + +test('an invented id does not collide with one the conversation states', () => { + // A source may state any string as a real id, including one this step would + // otherwise invent, and two calls sharing an id cannot be told apart. + const ids = assignCallIds([call('read'), call('read', 'read_call_1')], () => []) + .flatMap(turn => turn.kind === 'assistant' ? turn.items.flatMap(item => item.type === 'function_call' ? [item.call_id] : []) : []); + assertEquals(new Set(ids).size, ids.length); +}); diff --git a/packages/translate/__tests__/request/tool-names_test.ts b/packages/translate/__tests__/request/tool-names_test.ts new file mode 100644 index 0000000000..6ba67d6ffd --- /dev/null +++ b/packages/translate/__tests__/request/tool-names_test.ts @@ -0,0 +1,114 @@ +import { test } from 'vitest'; + +import type { IRInputRequestHeader, IRInputTool, IRInputTurn } from '../../src/ir/input/types.ts'; +import { allocateToolNames, type NativeCallNames } from '../../src/request/tool-names.ts'; +import { TranslateInputException } from '../../src/shared/input-error.ts'; +import { assertThrows, assertEquals } from '@floway-dev/test-utils'; + +const native: NativeCallNames = { ids: turn => turn, rename: turn => turn }; + +const request = (tools: readonly IRInputTool[]): IRInputRequestHeader => ({ settings: { model: 'm', stream: true }, tools }); + +test('a grouped declaration keeps its pair when its namespace contains the flatten separator', () => { + // Keep the full callable identity beside its allocated wire name; never reconstruct it by parsing that spelling. + const flattened = allocateToolNames(request([{ type: 'function', namespace: 'a.b', name: 'read', parameters: { type: 'object' } }]), [], native); + + const flatName = flattened.context.originalToolNames.keys().next().value; + assertEquals(typeof flatName, 'string'); + assertEquals(flattened.context.originalToolNames.get(flatName!), { namespace: 'a.b', name: 'read' }); +}); + +test('a grouped declaration keeps its pair when its name contains the flatten separator', () => { + const flattened = allocateToolNames(request([{ type: 'function', namespace: 'files', name: 'read.one', parameters: { type: 'object' } }]), [], native); + + const flatName = flattened.context.originalToolNames.keys().next().value; + assertEquals(typeof flatName, 'string'); + assertEquals(flattened.context.originalToolNames.get(flatName!), { namespace: 'files', name: 'read.one' }); +}); + +test('custom declarations are reported under the flat names this wire writes', () => { + const flattened = allocateToolNames(request([{ type: 'custom', namespace: 'files', name: 'edit' }]), [], native); + + // The reader that recognises these calls back sees the wire name, not the + // client's, so the set has to be stated after flattening. + assertEquals([...flattened.context.customToolNames], [...flattened.context.originalToolNames.keys()]); + assertEquals(flattened.request.tools?.[0]?.namespace, undefined); +}); + +test('a call naming an undeclared group still gets a flat spelling and its pair', () => { + const turns: IRInputTurn[] = [{ + kind: 'assistant', + items: [{ type: 'function_call', call_id: 'c1', namespace: 'ghost', name: 'run', arguments: '{}' }], + }]; + const flattened = allocateToolNames(request([]), turns, native); + + const assistantTurn = flattened.turns[0]; + assertEquals(assistantTurn.kind, 'assistant'); + const item = assistantTurn.kind === 'assistant' ? assistantTurn.items[0] : undefined; + assertEquals(item?.type, 'function_call'); + const flatName = item?.type === 'function_call' ? item.name : undefined; + assertEquals(typeof flatName, 'string'); + assertEquals(flattened.context.originalToolNames.get(flatName!), { namespace: 'ghost', name: 'run' }); +}); + +test('a selector naming a group the request never declared is a caller error', () => { + // A selector must name a callable allocated from the request and conversation; an unmatched grouped identity is a caller input error. + assertThrows( + () => allocateToolNames({ + settings: { model: 'm', stream: true }, + tools: [{ type: 'function', namespace: 'files', name: 'read', parameters: { type: 'object' } }], + tool_choice: { type: 'function', namespace: 'ghost', name: 'run' }, + }, [], native), + TranslateInputException, + 'undeclared grouped tool', + ); +}); + +test('distinct structured identities with the same dotted spelling get distinct wire names', () => { + const flattened = allocateToolNames(request([ + { type: 'function', namespace: 'a', name: 'b.c', parameters: { type: 'object' } }, + { type: 'function', namespace: 'a.b', name: 'c', parameters: { type: 'object' } }, + ]), [], native); + assertEquals(flattened.request.tools?.map(tool => tool.name), ['a_b_c', 'a_b_c_2']); + assertEquals([...flattened.context.originalToolNames.values()], [{ namespace: 'a', name: 'b.c' }, { namespace: 'a.b', name: 'c' }]); +}); + +test('a selector naming a group this request does not declare is refused', () => { + // No declaration or history call allocates this grouped identity, so the selector cannot name an upstream callable. + assertThrows( + () => allocateToolNames({ settings: { model: 'm', stream: true }, tools: [{ type: 'function', name: 'web.run', parameters: { type: 'object' } }], tool_choice: { type: 'function', name: 'run', namespace: 'web' } }, [], native), + TranslateInputException, + ); +}); + +test('a plain selector passes through untouched', () => { + const flattened = allocateToolNames({ settings: { model: 'm', stream: true }, tools: [{ type: 'function', name: 'plain', parameters: { type: 'object' } }], tool_choice: { type: 'function', name: 'plain' } }, [], native); + assertEquals(flattened.request.tool_choice, { type: 'function', name: 'plain' }); +}); + +test.each([undefined, 'files'])('allocates distinct names per callable kind in namespace %s independently of selection', namespace => { + const tools: IRInputTool[] = [ + { type: 'function', namespace, name: 'edit', parameters: { type: 'object' } }, + { type: 'custom', namespace, name: 'edit' }, + { type: 'function', name: 'edit_2', parameters: { type: 'object' } }, + ]; + const turns: IRInputTurn[] = [{ + kind: 'assistant', items: [ + { type: 'function_call', namespace, name: 'edit', call_id: 'f', arguments: '{}' }, + { type: 'custom_tool_call', namespace, name: 'edit', call_id: 'c', input: 'raw' }, + { type: 'custom_tool_call', namespace, name: 'edit', call_id: 'c2', input: 'again' }, + ], + }]; + const baseline = allocateToolNames({ ...request(tools), tool_choice: 'auto' }, turns, native); + const selected = allocateToolNames({ ...request(tools), tool_choice: { type: 'allowed_tools', mode: 'required', tools: [{ type: 'custom', namespace, name: 'edit' }] } }, turns, native); + const first = namespace === undefined ? 'edit' : 'files_edit'; + const second = namespace === undefined ? 'edit_3' : 'files_edit_2'; + assertEquals(baseline.request.tools?.map(tool => tool.name), [first, second, 'edit_2']); + assertEquals(selected.request.tools, baseline.request.tools); + assertEquals(selected.turns, baseline.turns); + assertEquals(selected.request.tool_choice, { type: 'allowed_tools', mode: 'required', tools: [{ type: 'custom', name: second }] }); + const assistant = selected.turns[0]; + assertEquals(assistant.kind === 'assistant' ? assistant.items.map(item => 'name' in item ? item.name : undefined) : [], [first, second, second]); + assertEquals([...selected.context.customToolNames], [second]); + assertEquals(selected.context.originalToolNames.get(second), { name: 'edit', ...(namespace === undefined ? {} : { namespace }) }); +}); diff --git a/packages/translate/__tests__/round-trip/protocols_test.ts b/packages/translate/__tests__/round-trip/protocols_test.ts index a889465ef1..a4638533a7 100644 --- a/packages/translate/__tests__/round-trip/protocols_test.ts +++ b/packages/translate/__tests__/round-trip/protocols_test.ts @@ -1,13 +1,14 @@ import { describe, expect, it } from 'vitest'; import { partitionAnthropicMessagesTurns } from '../../src/protocols/anthropic-messages/as-source/conversation-turns.ts'; -import { computeAnthropicMessagesTurnDigest, createAnthropicMessagesSidecarBlock, findAnthropicMessagesSidecarOccurrences, verifyAnthropicMessagesTurnDigest } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; +import { computeAnthropicMessagesTurnDigest, createAnthropicMessagesSidecarCarrier, findAnthropicMessagesSidecarOccurrences, verifyAnthropicMessagesTurnDigest } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; import { partitionGeminiGenerateContentTurns } from '../../src/protocols/gemini-generate-content/as-source/conversation-turns.ts'; -import { computeGeminiGenerateContentTurnDigest, createGeminiGenerateContentSidecarPart, findGeminiGenerateContentSidecarOccurrences, verifyGeminiGenerateContentTurnDigest } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; +import { computeGeminiGenerateContentTurnDigest, createGeminiGenerateContentSidecarCarrier, findGeminiGenerateContentSidecarOccurrences, verifyGeminiGenerateContentTurnDigest } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; import { partitionOpenAIChatCompletionsTurns } from '../../src/protocols/openai-chat-completions/as-source/conversation-turns.ts'; import { computeOpenAIChatCompletionsTurnDigest, createOpenAIChatCompletionsSidecarDetails, findOpenAIChatCompletionsSidecarOccurrences, verifyOpenAIChatCompletionsTurnDigest } from '../../src/protocols/openai-chat-completions/as-source/round-trip.ts'; import { partitionOpenAIResponsesTurns } from '../../src/protocols/openai-responses/as-source/conversation-turns.ts'; -import { computeOpenAIResponsesTurnDigest, createOpenAIResponsesSidecarItem, findOpenAIResponsesSidecarOccurrences, verifyOpenAIResponsesTurnDigest } from '../../src/protocols/openai-responses/as-source/round-trip.ts'; +import { computeOpenAIResponsesTurnDigest, createOpenAIResponsesSidecarCarrier, findOpenAIResponsesSidecarOccurrences, verifyOpenAIResponsesTurnDigest } from '../../src/protocols/openai-responses/as-source/round-trip.ts'; +import { toOpenAIResponsesInputAssistantTurn } from '../../src/protocols/openai-responses/as-target/assistant-turn.ts'; import type { SidecarOccurrence } from '../../src/round-trip/resolve.ts'; import type { AnyRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; import type { AnthropicMessagesAssistantContentBlock, AnthropicMessagesAssistantMessage, AnthropicMessagesMessage } from '@floway-dev/protocols/anthropic-messages'; @@ -44,7 +45,7 @@ const messagesTurn = (): AnthropicMessagesAssistantMessage[] => [{ { type: 'text', text: 'answer', citations: null }, { type: 'thinking', thinking: 'reasoning', signature: 'native-message-signature' }, { type: 'tool_use', id: 'call-a', name: 'lookup', input: { q: 'a' } }, - createAnthropicMessagesSidecarBlock('opaque-sidecar'), + createAnthropicMessagesSidecarCarrier('opaque-sidecar'), ], }]; @@ -57,7 +58,7 @@ const geminiTurn = (): GeminiGenerateContentContent[] => [ { functionCall: { id: 'call-b', name: 'lookup', args: { q: 'b' } } }, ], }, - { role: 'model', parts: [createGeminiGenerateContentSidecarPart('opaque-sidecar')] }, + { role: 'model', parts: [createGeminiGenerateContentSidecarCarrier('opaque-sidecar')] }, ]; const fixtureSidecar = (candidates: SidecarOccurrence[], data = 'opaque-sidecar') => { @@ -116,15 +117,15 @@ describe('assistant-turn sidecar protocols', () => { it('extracts Messages redacted blocks while preserving ordinary thinking and the input', async () => { const blocks: AnthropicMessagesAssistantContentBlock[] = [{ type: 'text', text: 'answer', citations: null }, { type: 'thinking', thinking: 'thought', signature: 'native' }]; const ordinary: AnthropicMessagesAssistantMessage[] = [{ role: 'assistant', content: blocks }]; - const turn: AnthropicMessagesAssistantMessage[] = [{ ...ordinary[0], content: [...blocks, createAnthropicMessagesSidecarBlock('opaque')] }]; + const turn: AnthropicMessagesAssistantMessage[] = [{ ...ordinary[0], content: [...blocks, createAnthropicMessagesSidecarCarrier('opaque')] }]; const before = structuredClone(turn); const inspection = fixtureSidecar(findAnthropicMessagesSidecarOccurrences(turn), 'opaque'); expect(inspection).toEqual({ sidecar: 'opaque', turn: ordinary }); expect(turn).toEqual(before); const check = await computeAnthropicMessagesTurnDigest(ordinary); expect(await verifyAnthropicMessagesTurnDigest(inspection.turn, check, 1)).toBe(true); - expect(await verifyMessagesTurn([{ ...ordinary[0], content: [...blocks, createAnthropicMessagesSidecarBlock('different')] }], check, 'different')).toBe(true); - expect(await verifyMessagesTurn([{ role: 'assistant', content: [{ type: 'text', text: 'edited answer', citations: null }, ...blocks.slice(1), createAnthropicMessagesSidecarBlock('opaque')] }], check, 'opaque')).toBe(false); + expect(await verifyMessagesTurn([{ ...ordinary[0], content: [...blocks, createAnthropicMessagesSidecarCarrier('different')] }], check, 'different')).toBe(true); + expect(await verifyMessagesTurn([{ role: 'assistant', content: [{ type: 'text', text: 'edited answer', citations: null }, ...blocks.slice(1), createAnthropicMessagesSidecarCarrier('opaque')] }], check, 'opaque')).toBe(false); }); it('extracts Responses encrypted items without retaining carrier metadata or changing ordinary reasoning', async () => { @@ -133,7 +134,7 @@ describe('assistant-turn sidecar protocols', () => { { type: 'message', id: 'message', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: 'answer', annotations: [] }] }, reasoning, ]; - const carrier = { ...createOpenAIResponsesSidecarItem('carrier', 'opaque'), status: 'incomplete' }; + const carrier = { ...createOpenAIResponsesSidecarCarrier('carrier', 'opaque'), status: 'incomplete' }; const turn = [...ordinary, carrier]; const before = structuredClone(turn); const inspection = fixtureSidecar(findOpenAIResponsesSidecarOccurrences(turn), 'opaque'); @@ -166,10 +167,10 @@ describe('assistant-turn sidecar protocols', () => { it('extracts Gemini signatures from attached bodies and removes standalone carriers without mutating input', async () => { const functionCall = { id: 'call', name: 'lookup', args: { q: 'a' } }; - const ordinary: GeminiGenerateContentContent[] = [{ role: 'model', parts: [{ text: 'answer', thoughtSignature: 'on-text' }, { functionCall, thoughtSignature: 'on-call' }, { text: 'native thought', thought: true }, createGeminiGenerateContentSidecarPart('standalone-part')] }]; + const ordinary: GeminiGenerateContentContent[] = [{ role: 'model', parts: [{ text: 'answer', thoughtSignature: 'on-text' }, { functionCall, thoughtSignature: 'on-call' }, { text: 'native thought', thought: true }, createGeminiGenerateContentSidecarCarrier('standalone-part')] }]; const turn: GeminiGenerateContentContent[] = [ - { role: 'model', parts: [{ text: 'answer', thoughtSignature: 'on-text' }, { functionCall, thoughtSignature: 'on-call' }, { text: 'native thought', thought: true }, createGeminiGenerateContentSidecarPart('standalone-part')] }, - { role: 'model', parts: [createGeminiGenerateContentSidecarPart('standalone-content')] }, + { role: 'model', parts: [{ text: 'answer', thoughtSignature: 'on-text' }, { functionCall, thoughtSignature: 'on-call' }, { text: 'native thought', thought: true }, createGeminiGenerateContentSidecarCarrier('standalone-part')] }, + { role: 'model', parts: [createGeminiGenerateContentSidecarCarrier('standalone-content')] }, ]; const before = structuredClone(turn); const inspection = fixtureSidecar(findGeminiGenerateContentSidecarOccurrences(turn), 'standalone-content'); @@ -204,7 +205,7 @@ describe('assistant-turn sidecar protocols', () => { ]; const turns = partitionOpenAIChatCompletionsTurns(messages); expect(turns.map(({ role, items }) => [role, items.length])).toEqual([ - ['bare', 1], ['assistant', 1], ['bare', 1], ['assistant', 1], + ['client', 1], ['assistant', 1], ['client', 1], ['assistant', 1], ]); expect(turns.map(({ items }) => items).flat()).toEqual(messages); }); @@ -259,7 +260,7 @@ describe('assistant-turn sidecar protocols', () => { ]; const turns = partitionOpenAIResponsesTurns(input); expect(turns.map(({ role, items }) => [role, items.length])).toEqual([ - ['assistant', 2], ['bare', 1], ['assistant', 1], ['bare', 1], + ['assistant', 2], ['client', 1], ['assistant', 1], ['client', 1], ]); expect(turns.flatMap(({ items }) => items)).toEqual(input); }); @@ -328,7 +329,7 @@ describe('assistant-turn sidecar protocols', () => { const custom: Extract = { type: 'custom_tool_call', id: 'custom-record', call_id: 'call-custom', name: 'format', input: 'plain input', }; - const carrier = createOpenAIResponsesSidecarItem('rs-sidecar', 'opaque-sidecar'); + const carrier = createOpenAIResponsesSidecarCarrier('rs-sidecar', 'opaque-sidecar'); const check = await computeOpenAIResponsesTurnDigest([reasoning, custom]); expect(await verifyResponsesTurn([{ ...reasoning, id: 'different' }, { ...custom, id: 'different' }, carrier], check)).toBe(true); for (const changed of [ @@ -386,13 +387,40 @@ describe('assistant-turn sidecar protocols', () => { expect(await verifyOpenAIResponsesTurnDigest(cases[2].original, statuslessCheck, 1)).toBe(false); }); - it('preserves the Responses sidecar shape and checks generated image replay', async () => { - expect(createOpenAIResponsesSidecarItem('rs_sidecar', 'opaque-sidecar')).toEqual({ + it('builds canonical Responses history items', async () => { + expect(createOpenAIResponsesSidecarCarrier('rs_sidecar', 'opaque-sidecar')).toEqual({ type: 'reasoning', id: 'rs_sidecar', summary: [], encrypted_content: 'opaque-sidecar', }); const imageCall: Extract = { type: 'image_generation_call', id: 'ig-data', result: 'image-base64', status: 'completed', action: 'generate', output_format: 'png', }; + const messageWithSDKFields = { + type: 'message', id: 'message-a', role: 'assistant', status: 'completed', phase: 'final_answer', created_by: 'sdk', + content: [{ type: 'output_text', text: 'answer', annotations: [{ type: 'url_citation', url: 'https://example.test', title: 'source', start_index: 0, end_index: 6 }], logprobs: [], parsed: { answer: true } }], + } satisfies Extract & { created_by: string; content: (Extract['content'][number], { type: 'output_text' }> & { parsed: { answer: boolean } })[] }; + const functionCallWithSDKFields = { + type: 'function_call', id: 'function-a', call_id: 'call-a', name: 'lookup', arguments: '{"q":"a"}', parsed_arguments: { q: 'a' }, status: 'completed', created_by: 'sdk', + } satisfies Extract & { parsed_arguments: { q: string }; created_by: string }; + const outputHistory: OpenAIResponsesOutputItemEx[] = [ + messageWithSDKFields, + functionCallWithSDKFields, + { type: 'shell_call_output', id: 'shell-output', call_id: 'shell-call', output: [{ stdout: 'ok', stderr: '', outcome: { type: 'exit', exit_code: 0 }, created_by: 'sdk' }], status: 'completed', created_by: 'sdk' }, + { type: 'computer_call_output', call_id: 'computer-call', output: { type: 'computer_screenshot', file_id: 'screenshot' }, status: 'failed', created_by: 'sdk' }, + { type: 'custom_tool_call_output', call_id: 'custom-incomplete', output: 'incomplete', status: 'incomplete' }, + { type: 'custom_tool_call_output', id: 'custom-completed', call_id: 'custom-completed-call', output: 'complete', status: 'completed' }, + { type: 'additional_tools', id: 'additional-tools-developer', role: 'developer', tools: [] }, + { type: 'image_generation_call', id: 'ig-null', result: null, status: 'completed' }, + imageCall, + ]; + expect(toOpenAIResponsesInputAssistantTurn(outputHistory)).toEqual([ + { type: 'message', id: 'message-a', role: 'assistant', status: 'completed', phase: 'final_answer', content: [{ type: 'output_text', text: 'answer', annotations: [{ type: 'url_citation', url: 'https://example.test', title: 'source', start_index: 0, end_index: 6 }], logprobs: [] }] }, + { type: 'function_call', id: 'function-a', call_id: 'call-a', name: 'lookup', arguments: '{"q":"a"}', status: 'completed' }, + { type: 'shell_call_output', id: 'shell-output', call_id: 'shell-call', output: [{ stdout: 'ok', stderr: '', outcome: { type: 'exit', exit_code: 0 } }], status: 'completed' }, + { type: 'custom_tool_call_output', id: 'custom-completed', call_id: 'custom-completed-call', output: 'complete' }, + { type: 'additional_tools', id: 'additional-tools-developer', role: 'developer', tools: [] }, + { type: 'image_generation_call', id: 'ig-null', result: null, status: 'completed' }, + imageCall, + ]); const imageTurn: OpenAIResponsesOutputItemEx[] = [ imageCall, { type: 'reasoning', id: 'rs-image', summary: [], encrypted_content: 'opaque-sidecar' }, @@ -467,7 +495,7 @@ describe('assistant-turn sidecar protocols', () => { { type: 'text', text: 'answer', citations: [textCitation] }, { type: 'thinking', thinking: 'reasoning', signature: 'native-message-signature' }, { type: 'tool_use', id: 'call-a', name: 'lookup', input: { q: 'a' } }, - createAnthropicMessagesSidecarBlock('opaque-sidecar'), + createAnthropicMessagesSidecarCarrier('opaque-sidecar'), ] satisfies AnthropicMessagesAssistantContentBlock[]; const citedTurn: AnthropicMessagesAssistantMessage[] = [{ role: 'assistant', content: responseContent }]; const citedCheck = await computeAnthropicMessagesTurnDigest(fixtureSidecar(findAnthropicMessagesSidecarOccurrences(citedTurn)).turn); @@ -485,7 +513,7 @@ describe('assistant-turn sidecar protocols', () => { { role: 'user', parts: [{ text: 'question' }] }, { role: 'model', parts: [{ text: 'next turn' }] }, ]); - expect(turns.map(({ role, items }) => [role, items.length])).toEqual([['assistant', 2], ['bare', 1], ['assistant', 1]]); + expect(turns.map(({ role, items }) => [role, items.length])).toEqual([['assistant', 2], ['client', 1], ['assistant', 1]]); const check = await computeGeminiGenerateContentTurnDigest(fixtureSidecar(findGeminiGenerateContentSidecarOccurrences(turn)).turn); expect(await verifyGeminiTurn([{ ...turn[0], parts: [turn[0].parts![1], turn[0].parts![0], turn[0].parts![3], turn[0].parts![2]] }, turn[1]], check)).toBe(true); const moved = [ @@ -497,7 +525,7 @@ describe('assistant-turn sidecar protocols', () => { turn[0].parts![3], ], }, - { role: 'model', parts: [createGeminiGenerateContentSidecarPart('opaque-sidecar')] }, + { role: 'model', parts: [createGeminiGenerateContentSidecarCarrier('opaque-sidecar')] }, ]; expect(await verifyGeminiTurn(moved, check)).toBe(true); expect(await verifyGeminiTurn([ @@ -525,7 +553,7 @@ describe('assistant-turn sidecar protocols', () => { it('checks emitted Gemini media and transcript while excluding part metadata', async () => { const part = { inlineData: { mimeType: 'audio/wav', data: 'audio-a' }, audioTranscription: { text: 'transcript-a' } }; const secondPart = { inlineData: { mimeType: 'image/png', data: 'image-b' } }; - const carrier = createGeminiGenerateContentSidecarPart('opaque-sidecar'); + const carrier = createGeminiGenerateContentSidecarCarrier('opaque-sidecar'); const turn = [{ role: 'model' as const, parts: [part, secondPart, carrier] }]; const check = await computeGeminiGenerateContentTurnDigest(fixtureSidecar(findGeminiGenerateContentSidecarOccurrences(turn)).turn); expect(await verifyGeminiTurn([{ diff --git a/packages/translate/__tests__/round-trip/resolve_test.ts b/packages/translate/__tests__/round-trip/resolve_test.ts index b09d957ea9..7c9a8ca720 100644 --- a/packages/translate/__tests__/round-trip/resolve_test.ts +++ b/packages/translate/__tests__/round-trip/resolve_test.ts @@ -13,9 +13,7 @@ describe('assistant-turn replay boundary', () => { 'anthropicMessages', { role: 'assistant', content: 'answer' }, createTestSidecarCodec(), - findOpenAIChatCompletionsSidecarOccurrences, - verifyOpenAIChatCompletionsTurnDigest, - () => [], + { inspect: findOpenAIChatCompletionsSidecarOccurrences, verify: verifyOpenAIChatCompletionsTurnDigest, collectCandidates: () => [] }, )).resolves.toBeUndefined(); }); @@ -44,10 +42,8 @@ describe('assistant-turn replay boundary', () => { 'anthropicMessages', sourceTurn, codec, - findOpenAIChatCompletionsSidecarOccurrences, - verify, - () => [], - )).resolves.toEqual({ turn: targetTurn }); + { inspect: findOpenAIChatCompletionsSidecarOccurrences, verify, collectCandidates: () => [] }, + )).resolves.toEqual(targetTurn); expect(verify).toHaveBeenCalledExactlyOnceWith({ role: 'assistant', content: 'answer', reasoning_details: [] }, expect.any(Object), 1); }); @@ -65,7 +61,7 @@ describe('assistant-turn replay boundary', () => { }); const turn = { ...source, reasoning_details: [...earlier, ...createOpenAIChatCompletionsSidecarDetails(data)] }; const verify = vi.fn(verifyOpenAIChatCompletionsTurnDigest); - await expect(roundTripAssistantTurn('openaiChatCompletions', 'openaiChatCompletions', turn, codec, findOpenAIChatCompletionsSidecarOccurrences, verify, () => [])).resolves.toEqual({ turn: targetTurn }); + await expect(roundTripAssistantTurn('openaiChatCompletions', 'openaiChatCompletions', turn, codec, { inspect: findOpenAIChatCompletionsSidecarOccurrences, verify, collectCandidates: () => [] })).resolves.toEqual(targetTurn); expect(verify).toHaveBeenCalledExactlyOnceWith(source, expect.any(Object), 1); expect(turn.reasoning_details[0].data).toBe(foreign); }); @@ -82,7 +78,7 @@ describe('assistant-turn replay boundary', () => { }); const turn = { ...source, reasoning_details: [...createOpenAIChatCompletionsSidecarDetails(data), ...createOpenAIChatCompletionsSidecarDetails(foreign)] }; const verify = vi.fn(verifyOpenAIChatCompletionsTurnDigest); - await expect(roundTripAssistantTurn('openaiChatCompletions', 'openaiChatCompletions', turn, codec, findOpenAIChatCompletionsSidecarOccurrences, verify, () => [])).resolves.toEqual({ turn: source }); + await expect(roundTripAssistantTurn('openaiChatCompletions', 'openaiChatCompletions', turn, codec, { inspect: findOpenAIChatCompletionsSidecarOccurrences, verify, collectCandidates: () => [] })).resolves.toEqual(source); expect(verify).toHaveBeenCalledExactlyOnceWith(source, expect.any(Object), 1); }); }); diff --git a/packages/translate/__tests__/round-trip/sidecar-candidates_test.ts b/packages/translate/__tests__/round-trip/sidecar-candidates_test.ts index b7aa48caec..0c87fd2ce4 100644 --- a/packages/translate/__tests__/round-trip/sidecar-candidates_test.ts +++ b/packages/translate/__tests__/round-trip/sidecar-candidates_test.ts @@ -25,7 +25,7 @@ const sourceCase = ( const restored = { role: 'assistant' as const, content: 'native target answer' }; const owned = await encodeRoundTripSidecar(codec, { v: 1, source, target: 'openaiChatCompletions', targetTurn: restored, sourceTurnDigest: check }); const checked = vi.fn(verify); - const run = (turn: Turn) => roundTripAssistantTurn(source, 'openaiChatCompletions', turn, codec, inspect, checked, () => []); + const run = (turn: Turn) => roundTripAssistantTurn(source, 'openaiChatCompletions', turn, codec, { inspect, verify: checked, collectCandidates: () => [] }); if (scenario === 'foreign') { const before = structuredClone(ordinary); await expect(run(ordinary)).resolves.toBeUndefined(); @@ -34,7 +34,7 @@ const sourceCase = ( } else if (scenario === 'middle') { const turn = makeTurn(['foreign-before', owned, 'foreign-after']); const before = structuredClone(turn); - await expect(run(turn)).resolves.toEqual({ turn: restored }); + await expect(run(turn)).resolves.toEqual(restored); expect(checked).toHaveBeenCalledExactlyOnceWith(ordinary, check, 1); expect(turn).toEqual(before); } else if (scenario === 'wrong-direction') { @@ -42,7 +42,7 @@ const sourceCase = ( const foreign = await encodeRoundTripSidecar(codec, { v: 1, source: wrongSource, target: 'openaiChatCompletions', targetTurn: restored, sourceTurnDigest: check }); const history = makeTurn([foreign]); const own = await encodeRoundTripSidecar(codec, { v: 1, source, target: 'openaiChatCompletions', targetTurn: restored, sourceTurnDigest: await compute(history) }); - await expect(run(makeTurn([own, foreign]))).resolves.toEqual({ turn: restored }); + await expect(run(makeTurn([own, foreign]))).resolves.toEqual(restored); expect(checked).toHaveBeenCalledExactlyOnceWith(history, expect.any(Object), 1); } else if (scenario === 'malformed') { // Deliberately bypass the typed encoder to exercise authenticated envelope validation. diff --git a/packages/translate/__tests__/round-trip/test-helpers.ts b/packages/translate/__tests__/round-trip/test-helpers.ts index a2ec099d9b..33ae8a8e1d 100644 --- a/packages/translate/__tests__/round-trip/test-helpers.ts +++ b/packages/translate/__tests__/round-trip/test-helpers.ts @@ -1,4 +1,3 @@ -import { flattenNamespaceTools } from '../../src/protocols/openai-responses/as-source/namespace-tools.ts'; import type { InputAssistantTurnByProtocol, InputAssistantTurnFor } from '../../src/round-trip/input-assistant-turn.ts'; import type { RoundTripReader } from '../../src/round-trip/reader.ts'; import type { InputAssistantTurnStateFor } from '../../src/round-trip/reference/assistant-turn-types.ts'; @@ -36,5 +35,3 @@ export const captureRoundTripWriter = flattenNamespaceTools({ model: 'test', input: [], tools: [] }).names; diff --git a/packages/translate/__tests__/shared/api-error_test.ts b/packages/translate/__tests__/shared/api-error_test.ts index e3e90446b5..e3b929258c 100644 --- a/packages/translate/__tests__/shared/api-error_test.ts +++ b/packages/translate/__tests__/shared/api-error_test.ts @@ -7,7 +7,7 @@ import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; const createTrip = async () => { const trip = await routeOpenAIChatCompletionsViaOpenAIResponses({ model: 'm', stream: true, messages: [{ role: 'user', content: 'hello' }], - }, { model: 'm', sidecarCodec: createTestSidecarCodec() }); + }, { sidecarCodec: createTestSidecarCodec() }); if (trip.type !== 'trip') throw new Error(trip.message); return trip; }; diff --git a/packages/translate/__tests__/protocols/openai-responses/common/custom-tool-wrap_test.ts b/packages/translate/__tests__/shared/custom-tool_test.ts similarity index 74% rename from packages/translate/__tests__/protocols/openai-responses/common/custom-tool-wrap_test.ts rename to packages/translate/__tests__/shared/custom-tool_test.ts index 52ed8b3906..46b8855e55 100644 --- a/packages/translate/__tests__/protocols/openai-responses/common/custom-tool-wrap_test.ts +++ b/packages/translate/__tests__/shared/custom-tool_test.ts @@ -1,10 +1,8 @@ import { test, vi } from 'vitest'; -import { buildCustomToolInputSchema, unwrapCustomToolInput } from '../../../../src/protocols/openai-responses/common/custom-tool-wrap.ts'; +import { buildCustomToolInputSchema, unwrapCustomToolInput } from '../../src/shared/custom-tool.ts'; import { assertEquals } from '@floway-dev/test-utils'; -// ── buildCustomToolInputSchema ── - test('buildCustomToolInputSchema returns plain single-string schema when format is absent', () => { assertEquals(buildCustomToolInputSchema(), { type: 'object', @@ -14,17 +12,8 @@ test('buildCustomToolInputSchema returns plain single-string schema when format }); }); -test('buildCustomToolInputSchema returns plain single-string schema when format has no definition', () => { - assertEquals(buildCustomToolInputSchema({ type: 'grammar', syntax: 'lark' }), { - type: 'object', - additionalProperties: false, - required: ['input'], - properties: { input: { type: 'string' } }, - }); -}); - test('buildCustomToolInputSchema returns plain single-string schema when format.definition is empty', () => { - assertEquals(buildCustomToolInputSchema({ definition: '' }), { + assertEquals(buildCustomToolInputSchema({ type: 'grammar', syntax: 'lark', definition: '' }), { type: 'object', additionalProperties: false, required: ['input'], @@ -50,17 +39,8 @@ test('buildCustomToolInputSchema preserves regex syntax in the description', () }); }); -test('buildCustomToolInputSchema uses a generic label for an unknown syntax', () => { - assertEquals(buildCustomToolInputSchema({ definition: 'constraint' }), { - type: 'object', - additionalProperties: false, - required: ['input'], - properties: { input: { type: 'string', description: 'Grammar: constraint' } }, - }); -}); - test('buildCustomToolInputSchema preserves an open-string syntax label', () => { - assertEquals(buildCustomToolInputSchema({ syntax: 'peg', definition: 'start <- value' }), { + assertEquals(buildCustomToolInputSchema({ type: 'grammar', syntax: 'peg', definition: 'start <- value' }), { type: 'object', additionalProperties: false, required: ['input'], @@ -68,17 +48,6 @@ test('buildCustomToolInputSchema preserves an open-string syntax label', () => { }); }); -test('buildCustomToolInputSchema ignores non-string definition values', () => { - assertEquals(buildCustomToolInputSchema({ definition: 42 as unknown as string }), { - type: 'object', - additionalProperties: false, - required: ['input'], - properties: { input: { type: 'string' } }, - }); -}); - -// ── unwrapCustomToolInput ── - test('unwrapCustomToolInput returns empty string for empty input', () => { assertEquals(unwrapCustomToolInput(''), ''); }); diff --git a/packages/translate/__tests__/shared/json/completion_test.ts b/packages/translate/__tests__/shared/json/completion_test.ts new file mode 100644 index 0000000000..572ee41fb1 --- /dev/null +++ b/packages/translate/__tests__/shared/json/completion_test.ts @@ -0,0 +1,17 @@ +import { expect, test, vi } from 'vitest'; + +import { parseJSONObjectUnlessTruncated } from '../../../src/shared/json/completion.ts'; + +test('truncated JSON completion does not invoke the parser', () => { + const parser = vi.fn(() => ({ repaired: true })); + expect(parseJSONObjectUnlessTruncated('{"value":', 'truncated', parser)).toEqual({ completion: 'truncated' }); + expect(parser).not.toHaveBeenCalled(); +}); + +test('complete JSON completion uses repair and still requires an object', () => { + expect(parseJSONObjectUnlessTruncated('{"value":', 'complete', text => JSON.parse(`${text}1}`))).toEqual({ + completion: 'complete', + value: { value: 1 }, + }); + expect(() => parseJSONObjectUnlessTruncated('[]', 'complete')).toThrow('Tool arguments require a JSON object'); +}); diff --git a/packages/translate/__tests__/shared/reasoning-budget_test.ts b/packages/translate/__tests__/shared/reasoning-budget_test.ts new file mode 100644 index 0000000000..e8f58aa243 --- /dev/null +++ b/packages/translate/__tests__/shared/reasoning-budget_test.ts @@ -0,0 +1,15 @@ +import { test } from 'vitest'; + +import { reasoningEffortForBudget } from '../../src/shared/reasoning-budget.ts'; +import { assertEquals } from '@floway-dev/test-utils'; + +test.each([ + [0, 'none'], + [1, 'low'], + [2048, 'low'], + [2049, 'medium'], + [8192, 'medium'], + [8193, 'high'], +] as const)('budget %i approximates effort %s on a target without a budget slot', (budget, effort) => { + assertEquals(reasoningEffortForBudget(budget), effort); +}); diff --git a/packages/translate/__tests__/trip.ts b/packages/translate/__tests__/trip.ts new file mode 100644 index 0000000000..4e1e661922 --- /dev/null +++ b/packages/translate/__tests__/trip.ts @@ -0,0 +1,6 @@ +import type { TranslateInputError } from '../src/types.ts'; + +export const expectTrip = (result: Trip | TranslateInputError): Trip => { + if (result.type === 'input-error') throw new Error(`Expected a translation trip: ${result.message}`); + return result; +}; diff --git a/packages/translate/src/composition/index.ts b/packages/translate/src/composition/index.ts new file mode 100644 index 0000000000..f31dc2558b --- /dev/null +++ b/packages/translate/src/composition/index.ts @@ -0,0 +1,85 @@ +import { resolveReading } from './reading.ts'; +import type { SourceReading, RestoredAssistantTurn } from './reading.ts'; +import type { IRInputRequestHeader, IRInputTurn } from '../ir/input/types.ts'; +import type { IRRecord } from '../ir/output/records.ts'; +import { createRoundTripCoordinator } from '../round-trip/coordinator.ts'; +import type { AssistantTurnProtocol, InputAssistantTurnFor } from '../round-trip/input-assistant-turn.ts'; +import type { RoundTripReader } from '../round-trip/reader.ts'; +import type { SidecarRecognition } from '../round-trip/resolve.ts'; +import { roundTripAssistantTurn } from '../round-trip/resolve.ts'; +import type { SourceAssistantTurnFor } from '../round-trip/source-assistant-turn.ts'; +import type { RoundTripWriter } from '../round-trip/writer.ts'; +import { TranslateInputException } from '../shared/input-error.ts'; +import type { ApiErrorTranslator, TranslateOptions, Translator } from '../types.ts'; +import type { ProtocolFrame } from '@floway-dev/protocols/common'; + +export interface SourceProtocol { + readonly name: Name; + readonly read: (payload: Payload) => SourceReading>; + readonly sidecarRecognition: SidecarRecognition>; + readonly error: ApiErrorTranslator; + /** + * States the returned stream to this protocol's client. + * + * It is handed the payload the client sent, because what the client sees + * stated back is what it asked for, and the payload is the only place a + * spelling the reader normalized away — a declaration group, say — survives. + */ + readonly writeEvents: ( + payload: Payload, + records: AsyncIterable, + roundTrip: RoundTripWriter, + ) => AsyncIterable>; +} + +export interface TargetProtocol { + readonly name: Name; + /** + * Writes the input IR as this protocol's request. + * + * A resolved turn arrives as the structure this protocol states natively, so + * whatever the writer needs to know about it — which calls it answers, say — + * it reads from there rather than being told separately. + */ + readonly write: ( + request: IRInputRequestHeader, + turns: readonly (IRInputTurn | RestoredAssistantTurn>)[], + options: TranslateOptions, + ) => { readonly payload: Payload; readonly context: Context }; + /** Where request writing allocates names, this context preserves their logical identities for event reading. */ + readonly readEvents: ( + frames: AsyncIterable>, + context: Context, + roundTripReader: RoundTripReader, + ) => AsyncIterable; +} + +/** Compose request and event pipelines with shared replay coordination. */ +export const composeTranslation = < + SrcName extends AssistantTurnProtocol, SrcPayload, SrcEvent, + TgtName extends AssistantTurnProtocol, TgtPayload extends { model: string }, TgtEvent, Context, +>( + source: SourceProtocol, + target: TargetProtocol, +): Translator => async (payload, options) => { + try { + const reading = source.read(payload); + const { payload: targetPayload, context } = target.write( + reading.request, await resolveReading(reading, turn => roundTripAssistantTurn( + source.name, target.name, turn.assistantTurn, options.sidecarCodec, source.sidecarRecognition, + )), options, + ); + return { + type: 'trip', + target: targetPayload, + error: source.error, + events: frames => { + const coordinator = createRoundTripCoordinator(source.name, target.name, options.sidecarCodec); + return source.writeEvents(payload, target.readEvents(frames, context, coordinator.reader), coordinator.writer); + }, + }; + } catch (error) { + if (error instanceof TranslateInputException) return error.failure; + throw error; + } +}; diff --git a/packages/translate/src/composition/reading.ts b/packages/translate/src/composition/reading.ts new file mode 100644 index 0000000000..ca2cc34c05 --- /dev/null +++ b/packages/translate/src/composition/reading.ts @@ -0,0 +1,68 @@ +import type { IRInputRequestHeader, IRInputToolSelector, IRInputTurn } from '../ir/input/types.ts'; + +export interface SourceCallIdentity extends IRInputToolSelector { + readonly call_id?: string; +} + +export interface RestoredAssistantTurn { + readonly kind: 'restored'; + readonly turn: Turn; + readonly callIdentities: readonly SourceCallIdentity[]; +} + +/** Native turns authenticate sidecars; naming facts are read separately while restored turns bypass lazy IR conversion. */ +export type SourceTurnSlot = + | { readonly kind: 'client'; readonly toIRInputTurns: () => IRInputTurn[] } + | { + readonly kind: 'assistant'; + readonly assistantTurn: AssistantTurn; + readonly readCallIdentities: () => readonly SourceCallIdentity[]; + readonly toIRInputTurns: () => IRInputTurn[]; + }; + +/** + * A request read into what a writer consumes. + * + * The request's own prompt field opens `turns` when the protocol has one. It is + * a client turn rather than a field of its own, which is what lets a writer + * place it by position the same way it places every other turn. + */ +export interface SourceReading { + readonly request: IRInputRequestHeader; + readonly turns: readonly SourceTurnSlot[]; +} + +export const resolveReading = async ( + reading: SourceReading, + resolve: (turn: Extract, { kind: 'assistant' }>) => Promise, +): Promise<(IRInputTurn | RestoredAssistantTurn)[]> => { + const turns: (IRInputTurn | RestoredAssistantTurn)[] = []; + const append = (input: readonly IRInputTurn[]): void => { + for (const turn of input) { + const previous = turns.at(-1); + // Prompts retain their own boundary for target-side hoisting. Other + // adjacent client pieces belong to the same turn, including server + // results followed by the client's next message. + if (previous?.kind === 'client' && turn.kind === 'client' + && !previous.items.some(item => item.type === 'prompt') + && !turn.items.some(item => item.type === 'prompt')) { + turns[turns.length - 1] = { kind: 'client', items: [...previous.items, ...turn.items] }; + } else { + turns.push(turn); + } + } + }; + for (const turn of reading.turns) { + if (turn.kind === 'client') { + append(turn.toIRInputTurns()); + continue; + } + const result = await resolve(turn); + if (result === undefined) { + append(turn.toIRInputTurns()); + continue; + } + turns.push({ kind: 'restored', turn: result, callIdentities: turn.readCallIdentities() }); + } + return turns; +}; diff --git a/packages/translate/src/index.ts b/packages/translate/src/index.ts index 01a9936e9e..3b4190b364 100644 --- a/packages/translate/src/index.ts +++ b/packages/translate/src/index.ts @@ -1,6 +1,8 @@ -export type { RemoteImageData, RemoteImageLoader, TranslateTrip, TranslateInputError, TranslateOptions, Translator } from './types.ts'; -export { SidecarTaggedValue, type SidecarCodec } from './round-trip/codec.ts'; +export type { SidecarCodec } from './round-trip/codec.ts'; +export type { TranslateTrip, TranslateInputError, TranslateOptions } from './types.ts'; +export { SidecarTaggedValue } from './round-trip/codec.ts'; export { canonicalizeOpenAIResponsesPayload } from './protocols/openai-responses/canonicalize-payload.ts'; + export { routeAnthropicMessagesViaOpenAIChatCompletions, routeAnthropicMessagesViaOpenAIResponses, diff --git a/packages/translate/src/ir/input/media.ts b/packages/translate/src/ir/input/media.ts new file mode 100644 index 0000000000..c5f9cfa49b --- /dev/null +++ b/packages/translate/src/ir/input/media.ts @@ -0,0 +1,34 @@ +import type { IRInputMedia } from './types.ts'; +import { TranslateInputException } from '../../shared/input-error.ts'; + +// RFC 2397 data URLs may include media-type parameters before ;base64. +// https://www.rfc-editor.org/rfc/rfc2397#section-2 +const DATA_URL = /^data:([^,]*),([\s\S]*)$/; +const BASE64_MARKER = ';base64'; + +/** + * Supported base64 data URLs become inline media; ordinary URLs remain references. + * Undefined identifies a data URL with an unsupported encoding. + */ +export const mediaFromURL = (url: string): IRInputMedia | undefined => { + const dataUrl = DATA_URL.exec(url); + if (dataUrl === null) return { source_kind: 'url', url }; + const header = dataUrl[1]!; + const body = dataUrl[2]!; + if (!header.endsWith(BASE64_MARKER)) return undefined; + const mediaType = header.slice(0, -BASE64_MARKER.length).split(';')[0]!; + const essence = mediaType.trim(); + return { source_kind: 'base64', ...(essence === '' ? {} : { media_type: essence }), data: body }; +}; + +/** + * The media a URL states, refusing one whose `data:` form this cannot decode. + * + * A caller that only knows the value is a URL has nothing to fall back on: the + * text of a data URL is not the payload it describes, so carrying it as one + * would send something the upstream cannot read. + */ +export const mediaOrThrow = (media: IRInputMedia | undefined, url: string): IRInputMedia => { + if (media === undefined) throw new TranslateInputException(`Cannot decode a data URL: ${url.slice(0, 32)}`); + return media; +}; diff --git a/packages/translate/src/ir/input/streaming.ts b/packages/translate/src/ir/input/streaming.ts new file mode 100644 index 0000000000..f61113cc6a --- /dev/null +++ b/packages/translate/src/ir/input/streaming.ts @@ -0,0 +1,7 @@ +import type { IRInputSettings } from './types.ts'; + +/** Translation trips consume upstream streams. A false stream setting violates the request boundary's contract. */ +export const requireStreaming = (settings: IRInputSettings): true => { + if (settings.stream !== true) throw new Error('A translation target requires a streaming request, but the IR states a non-streaming one.'); + return true; +}; diff --git a/packages/translate/src/ir/input/types.ts b/packages/translate/src/ir/input/types.ts new file mode 100644 index 0000000000..d94b343592 --- /dev/null +++ b/packages/translate/src/ir/input/types.ts @@ -0,0 +1,269 @@ +// Input Fit IR carries concepts translatable by at least one protocol pair. +// Authenticated native replay bypasses it; vendor-native residuals remain in protocol readers and sidecars. +// Requests arrive complete, so input IR is an immutable value rather than an operation stream. +// Target writers project the fields their wire can represent. +export interface IRInputSettings { + readonly model: string; + // The return path is a stream, so the request path asks the upstream for one + // and a target writer rejects an IR that does not say `true`. + readonly stream: boolean; + readonly temperature?: number; + readonly top_p?: number; + readonly top_k?: number; + readonly stop?: readonly string[]; + readonly seed?: number; + // Preserve the visible-output ceiling independently of the cap that includes reasoning. + readonly max_visible_output_tokens?: number | null; + readonly max_output_tokens?: number | null; + readonly output_format?: IRInputOutputFormat; + // Readers normalize metadata to string pairs; target writers do not coerce it again. + readonly metadata?: Readonly>; + readonly store?: boolean; + // Preserve service-tier strings; each target owns vocabulary mapping. + readonly service_tier?: string; + readonly parallel_tool_calls?: boolean; + // Keep effort as an open string, independently of numeric budget. Protocol readers and writers own their thinking-mode mapping. + readonly reasoning_effort?: string; + // Budget and effort remain independent; a source may cap reasoning tokens without naming an effort level. + readonly reasoning_budget?: number; + // Dynamic budgeting represents model-selected reasoning limits: a negative GenerateContent budget or adaptive Messages thinking. + readonly reasoning_budget_dynamic?: boolean; + // Preserve the summary scale while normalizing boolean source preferences at their reader. + readonly reasoning_summary?: string; + readonly verbosity?: string; + // Preserve an explicit nullable cache key exactly as supplied, independently of field omission. + readonly prompt_cache_key?: string | null; + // Retention and breakpoint lifetime are independent controls. + readonly prompt_cache_retention?: string | null; + readonly prompt_cache_options?: IRInputPromptCacheOptions | null; + // Preserve an explicit nullable safety identifier exactly as supplied. + readonly safety_identifier?: string | null; + readonly user?: string | null; + readonly logprobs?: boolean | null; + readonly top_logprobs?: number | null; + readonly choice_count?: number; + readonly presence_penalty?: number | null; + readonly frequency_penalty?: number | null; +} + +export interface IRInputPromptCacheOptions { + readonly mode?: string; + readonly ttl?: string; +} + +// Structured output. `strict` is three-state: absent means the source did not +// say, which is not the same as `false`. +export interface IRInputOutputFormat { + readonly type: 'text' | 'json_object' | 'json_schema'; + readonly name?: string; + readonly description?: string; + readonly schema?: Readonly>; + readonly strict?: boolean; +} + +// One request's own fields, from the source's wire to the target's. The +// conversation is not part of it: turns travel separately as turns, because a +// writer needs them interleaved with the assistant turns a sidecar restored. +export interface IRInputRequestHeader { + readonly settings: IRInputSettings; + // Absent and empty are the same thing: a request that declares no tool. + readonly tools?: readonly IRInputTool[]; + readonly tool_choice?: IRInputToolChoice; +} + +// A callable the model may invoke. Hosted tools (web search, code execution, +// file search, computer use) have no cross-vendor meaning and stay in the +// native reader, matching the Fit rule for hosted history. +// +// Messages expresses both arms as one declaration shape; the distinction +// survives only where the target distinguishes it. +export type IRInputTool = IRInputFunctionTool | IRInputCustomTool; + +export interface IRInputFunctionTool extends IRInputToolSelector { + readonly type: 'function'; + readonly description?: string; + // Preserve omitted source schemas; each target owns any schema its wire requires. + readonly parameters?: Readonly>; + // Preserve strict as true, false or source-unspecified; each target applies its wire requirement. + readonly strict?: boolean; +} + +// A freeform tool: the model returns the string the declaration describes. +// Responses has a native arm for it; Chat Completions and Messages wrap it in a +// single-`input`-string function schema. +export interface IRInputCustomTool extends IRInputToolSelector { + readonly type: 'custom'; + readonly description?: string; + readonly format?: IRInputCustomToolFormat; +} + +export type IRInputCustomToolFormat = + | { readonly type: 'text' } + | { readonly type: 'grammar'; readonly definition: string; readonly syntax: string }; + +// What the model may call, and how freely. `allowed_tools` states a mode with a +// restricted callable set, so it stays a distinct arm rather than being flattened +// into that mode plus a filtered declaration list: widening or narrowing the +// declaration list changes what the model can see, not what it may choose. +export type IRInputToolChoice = + | 'auto' + | 'none' + | 'required' + | IRInputToolSelector + | { readonly type: 'allowed_tools'; readonly mode: 'auto' | 'required'; readonly tools: readonly IRInputToolSelector[] }; + +// A selector names one declared callable by kind and its logical identity, which +// is what every protocol's selector arm states. A protocol that groups its +// declarations states the group here too. +export interface IRInputToolSelector { + readonly type: 'function' | 'custom'; + readonly name: string; + readonly namespace?: string; +} + +// A turn groups one conversation branch. Assistant messages keep their own +// boundaries; adjacent client messages join except at prompts, whose boundaries +// let targets place them inline or hoist them. Items stay ordered while each +// target chooses its native message grouping. +export type IRInputTurn = IRInputClientTurn | IRInputAssistantTurn; + +// Client turns contain all nonassistant input, including prompts and tool results. +// Kind identifies the conversation branch; item role identifies the speaker. +export interface IRInputClientTurn { + readonly kind: 'client'; + readonly items: readonly IRInputClientItem[]; +} + +export interface IRInputAssistantTurn { + readonly kind: 'assistant'; + readonly items: readonly IRInputAssistantItem[]; +} + +// Items stay flat and ordered because protocols disagree about where a message +// boundary sits: Messages keeps tool results inside a user message, Chat +// Completions gives them a `tool` message, Responses makes them an input item, +// and GenerateContent makes them a part of a user content. Keeping a source +// message's own parts together while tool results and tool calls remain +// separate items lets every writer reproduce its own grouping without this IR +// prejudging one protocol's shape. +export type IRInputClientItem = IRInputPromptItem | IRInputUserMessageItem | IRInputToolResultItem; + +export type IRInputAssistantItem = + | IRInputAssistantMessageItem + | IRInputReasoningItem + | IRInputFunctionCallItem + | IRInputCustomToolCallItem; + +// Prompt position represents both top-level instructions and inline system/developer messages. +// Each target chooses which leading prompts it can hoist. +export interface IRInputPromptItem { + readonly type: 'prompt'; + readonly role: 'system' | 'developer'; + // Prompt parts are text-only; source readers reject unsupported prompt media. + readonly content: readonly IRInputTextPart[]; +} + +export interface IRInputUserMessageItem { + readonly type: 'message'; + readonly content: readonly IRInputUserContentPart[]; +} + +// Assistant IR projects text and refusals. Source readers reject assistant media that this model cannot represent. +export interface IRInputAssistantMessageItem { + readonly type: 'message'; + readonly content: readonly IRInputAssistantContentPart[]; +} + +export type IRInputUserContentPart = + | IRInputTextPart + | IRInputImagePart + | IRInputFilePart + | IRInputAudioPart; + +export type IRInputAssistantContentPart = IRInputTextPart | IRInputRefusalPart; + +export interface IRInputTextPart { + readonly type: 'text'; + readonly text: string; +} + +// A refusal is model output, so only assistant history carries one. No protocol +// accepts it in client-produced content. +export interface IRInputRefusalPart { + readonly type: 'refusal'; + readonly refusal: string; +} + +export interface IRInputImagePart { + readonly type: 'image'; + readonly image: IRInputMedia; + // Preserve the open-string image detail value; each target owns its supported projection. + readonly detail?: string | null; +} + +export interface IRInputFilePart { + readonly type: 'file'; + readonly file: IRInputMedia; + readonly filename?: string | null; +} + +export interface IRInputAudioPart { + readonly type: 'audio'; + readonly audio: IRInputAudio; +} + +// Chat Completions names a two-value `format` enum and GenerateContent names a +// MIME type; the MIME type is the neutral spelling, so readers normalize into +// it and the Chat writer derives its enum. +export interface IRInputAudio { + readonly media_type: string; + readonly data: string; +} + +// Preserve URL and file references exactly as stated. Inline media retains its supplied MIME type; +// bare base64 remains untyped. Target writers choose the wire representation. +export type IRInputMedia = + | { readonly source_kind: 'url'; readonly url: string } + | { readonly source_kind: 'base64'; readonly media_type?: string; readonly data: string } + | { readonly source_kind: 'file'; readonly file_id: string }; + +// Only readable reasoning enters IR; opaque signed replay state remains in authenticated sidecars. +// Summary and content stay separate so each target can project the fields it supports. +export interface IRInputReasoningItem { + readonly type: 'reasoning'; + readonly id?: string; + readonly summary?: readonly string[]; + readonly content?: readonly string[]; +} + +export interface IRInputFunctionCallItem extends Pick { + readonly type: 'function_call'; + // Call IDs may be absent in input IR; target request preparation supplies those required by its wire. + readonly call_id?: string; + // Keep argument JSON as raw text, including malformed input. Each target parses at its own boundary. + // Absence means the source supplied no argument object and remains distinct from explicit null or {}. + readonly arguments?: string; +} + +export interface IRInputCustomToolCallItem extends Pick { + readonly type: 'custom_tool_call'; + readonly call_id?: string; + readonly input: string; +} + +export interface IRInputToolResultItem { + readonly type: 'tool_result'; + // Target allocation pairs an id-less result with a pending call before writing. + readonly call_id?: string; + // The name is supplied when the source correlates results by function name. + readonly name?: string; + // String output remains opaque, including serialized structured results. Content-part arrays preserve + // source multimodal structure until the target chooses its representation. + readonly output: string | readonly IRInputToolResultContentPart[]; + // Record the call kind from source history so target writers can choose custom or function results without inferring it again. + readonly tool_kind?: 'function' | 'custom'; + // Messages is_error maps to the incomplete result state on Responses. + readonly is_error?: boolean; +} + +export type IRInputToolResultContentPart = IRInputTextPart | IRInputImagePart | IRInputFilePart; diff --git a/packages/translate/src/ir/output/schema.ts b/packages/translate/src/ir/output/schema.ts index 034e3fd3ea..68296ef7e2 100644 --- a/packages/translate/src/ir/output/schema.ts +++ b/packages/translate/src/ir/output/schema.ts @@ -69,8 +69,8 @@ const item = union('type', { summary: array(appendString), content: array(appendString), }, { optional: ['summary', 'content'] }), - function_call: object({ call_id: absentString, name: string, arguments: appendString }, { optional: ['call_id'] }), - custom_tool_call: object({ call_id: absentString, name: string, input: appendString }, { optional: ['call_id'] }), + function_call: object({ call_id: absentString, name: string, namespace: string, arguments: appendString }, { optional: ['call_id', 'namespace'] }), + custom_tool_call: object({ call_id: absentString, name: string, namespace: string, input: appendString }, { optional: ['call_id', 'namespace'] }), }, 'item'); const logprobs = nullable(array(union('scope', { diff --git a/packages/translate/src/ir/output/types.ts b/packages/translate/src/ir/output/types.ts index e1991f9c86..88a64797bb 100644 --- a/packages/translate/src/ir/output/types.ts +++ b/packages/translate/src/ir/output/types.ts @@ -63,6 +63,7 @@ type IROutputFunctionCallItem = { readonly type: 'function_call'; // All readonly call_id?: string | IRAbsent; // All readonly name: string; // All + readonly namespace?: string; // ChatCompletions, Responses, Messages readonly arguments: string; // All }; @@ -71,6 +72,7 @@ type IROutputCustomToolCallItem = { readonly type: 'custom_tool_call'; // ChatCompletions, Responses, Messages readonly call_id?: string | IRAbsent; // ChatCompletions, Responses, Messages readonly name: string; // ChatCompletions, Responses, Messages + readonly namespace?: string; // ChatCompletions, Responses, Messages readonly input: string; // ChatCompletions, Responses, Messages }; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source.ts b/packages/translate/src/protocols/anthropic-messages/as-source.ts new file mode 100644 index 0000000000..b6f76bafbc --- /dev/null +++ b/packages/translate/src/protocols/anthropic-messages/as-source.ts @@ -0,0 +1,21 @@ +import { restateAnthropicMessagesError } from './as-source/error-body.ts'; +import { writeAnthropicMessagesEvents } from './as-source/events.ts'; +import { readAnthropicMessagesRequest } from './as-source/input-to.ts'; +import { collectAnthropicMessagesReplayContents, findAnthropicMessagesSidecarOccurrences, verifyAnthropicMessagesTurnDigest } from './as-source/round-trip.ts'; +import type { SourceProtocol } from '../../composition/index.ts'; +import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; + +/** Messages sidecars ride in redacted_thinking blocks and authenticate the native run of assistant messages. */ +export const anthropicMessagesSourceProtocol: SourceProtocol< + 'anthropicMessages', AnthropicMessagesPayload, AnthropicMessagesStreamEventEx +> = { + name: 'anthropicMessages', + read: readAnthropicMessagesRequest, + sidecarRecognition: { + inspect: findAnthropicMessagesSidecarOccurrences, + verify: verifyAnthropicMessagesTurnDigest, + collectCandidates: collectAnthropicMessagesReplayContents, + }, + error: restateAnthropicMessagesError, + writeEvents: (_payload, records, roundTrip) => writeAnthropicMessagesEvents(records, { roundTrip }), +}; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/client-tools.ts b/packages/translate/src/protocols/anthropic-messages/as-source/client-tools.ts deleted file mode 100644 index 3734aba569..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/as-source/client-tools.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { AnthropicMessagesClientTool, AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; - -export const filterAnthropicMessagesClientTools = (tools: AnthropicMessagesPayload['tools'] | undefined): AnthropicMessagesClientTool[] | undefined => { - const clientTools = tools?.filter((tool): tool is AnthropicMessagesClientTool => tool.type === undefined || tool.type === 'custom'); - return clientTools?.length ? clientTools : undefined; -}; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/events.ts b/packages/translate/src/protocols/anthropic-messages/as-source/events.ts index f514ce3550..f63e216b8c 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/events.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/events.ts @@ -1,13 +1,13 @@ -import { collectAnthropicMessagesTextPlacements, computeAnthropicMessagesTurnDigest, createAnthropicMessagesSidecarBlock } from './round-trip.ts'; +import { collectAnthropicMessagesTextPlacements, computeAnthropicMessagesTurnDigest, createAnthropicMessagesSidecarCarrier } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; import type { IRJSONObject } from '../../../ir/common/json.ts'; import { IRAbsent } from '../../../ir/output/absent.ts'; import type { IRCollector } from '../../../ir/output/collector.ts'; -import type { IRErrorPayload } from '../../../ir/output/error.ts'; +import { type IRErrorPayload } from '../../../ir/output/error.ts'; import { isIRItemPath, isIRPartPath, irPathKey } from '../../../ir/output/operation-paths.ts'; -import type { IRPath, IRRecord } from '../../../ir/output/records.ts'; +import { type IRPath, type IRRecord } from '../../../ir/output/records.ts'; import { consumeIRRecords } from '../../../ir/output/stream.ts'; -import type { IROutput } from '../../../ir/output/types.ts'; +import { type IROutput } from '../../../ir/output/types.ts'; import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; import { createIRUnitQueue } from '../../../ir/output/unit-queue.ts'; import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; @@ -16,7 +16,8 @@ import { appendEmittedField, createEmittedFieldState, type EmittedFieldState, ty import { errorDetailsOf } from '../../../shared/api-error.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; -import { parseJSONObject, parseJSONObjectAtCompletion, requireJSONObject } from '../../../shared/json/object.ts'; +import { parseJSONObjectUnlessTruncated } from '../../../shared/json/completion.ts'; +import { parseJSONObject, requireJSONObject, isJSONRecord } from '../../../shared/json/object.ts'; import type { AnthropicMessagesInputAssistantTurnState, AnthropicMessagesInputAssistantTurn } from '../as-target/assistant-turn.ts'; import { generateAnthropicId, @@ -31,7 +32,6 @@ import { } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, type EventFrame } from '@floway-dev/protocols/common'; -// https://platform.claude.com/docs/en/api/errors#http-errors const MESSAGES_ERROR_TYPES = [ 'invalid_request_error', 'authentication_error', @@ -42,6 +42,7 @@ const MESSAGES_ERROR_TYPES = [ 'overloaded_error', ]; +/** HTTP and SSE share the Messages error object. Context overflow uses the shared prompt-too-long compatibility message. */ const messagesError = (error: IRErrorPayload, contextExceeded: boolean): AnthropicMessagesErrorEventEx['error'] => { const type = contextExceeded ? 'invalid_request_error' @@ -54,6 +55,7 @@ const messagesError = (error: IRErrorPayload, contextExceeded: boolean): Anthrop export const anthropicMessagesErrorFromIR = (error: IRErrorPayload): AnthropicMessagesErrorEventEx['error'] => messagesError(error, isContextExceededError(error)); +/** Classify overflow once so its compatibility message and our chosen 400 mapping stay consistent. */ export const anthropicMessagesErrorEnvelope = (error: IRErrorPayload): { readonly body: unknown; readonly status: number | undefined } => { const contextExceeded = isContextExceededError(error); return { @@ -71,11 +73,8 @@ interface MessagesUnit { pendingText?: Map; } -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - const unitKind = (value: unknown): MessagesUnit['kind'] | 'custom_tool_call' | undefined => { - if (!isRecord(value)) return undefined; + if (!isJSONRecord(value)) return undefined; return value.type === 'text' || value.type === 'refusal' || value.type === 'audio' @@ -140,7 +139,7 @@ export const writeAnthropicMessagesEvents = async function* ( return value; }; - // Queues the content blocks that the IR structures initialized by a record will become. + // Queues the content blocks that the output IR structures initialized by a record will become. const registerUnits = (record: IRRecord, state: IROutput): void => { if (record.type === 'write' && record.kind === 'initialize') { let path: IRPath | undefined; @@ -176,7 +175,7 @@ export const writeAnthropicMessagesEvents = async function* ( unit.pendingText?.delete(key); }; - // Opens the waiting units in IR order, until one still lacks what its start event needs. + // Opens the waiting units in output IR order, until one still lacks what its start event needs. const openPendingUnits = (state: IROutput, collector: IRCollector): AnthropicMessagesStreamEventEx[] => { const pendingEvents: AnthropicMessagesStreamEventEx[] = []; while (pending.head !== undefined) { @@ -245,7 +244,7 @@ export const writeAnthropicMessagesEvents = async function* ( if (record.type === 'error') { yield emit({ type: 'error', error: anthropicMessagesErrorFromIR(record.error) }); return; } if (record.type === 'write' && record.path[0] === 'model' && typeof record.value === 'string') metadata.model = record.value; if (record.type === 'start') { - if (state.model === undefined) throw new TypeError('Messages output requires an IR model before start'); + if (state.model === undefined) throw new TypeError('Messages output requires an output IR model before start'); const id = options.id ?? record.id; metadata = { id: id === undefined || id === '' ? generateAnthropicId('msg') : id, model: state.model }; yield emit({ @@ -275,7 +274,7 @@ export const writeAnthropicMessagesEvents = async function* ( : record.finish_reason === 'tool_calls' ? 'tool_use' : record.finish_reason === 'content_filter' ? 'refusal' : 'end_turn'; for (const item of state.choices[0].items) if (item.type === 'function_call') { - parseJSONObjectAtCompletion(item.arguments, record.finish_reason === 'length' ? 'truncated' : 'complete', options.parseToolArguments); + parseJSONObjectUnlessTruncated(item.arguments, record.finish_reason === 'length' ? 'truncated' : 'complete', options.parseToolArguments); } continue; } @@ -334,7 +333,7 @@ export const writeAnthropicMessagesEvents = async function* ( serializedInput = serialized; if (serialized !== '') yield blockDelta(index, { type: 'input_json_delta', partial_json: serialized }); } else { - const parsed = parseJSONObjectAtCompletion(item.arguments, record.type === 'item_end' && record.status === 'incomplete' ? 'truncated' : 'complete'); + const parsed = parseJSONObjectUnlessTruncated(item.arguments, record.type === 'item_end' && record.status === 'incomplete' ? 'truncated' : 'complete'); if (parsed.completion === 'complete') serializedInput = JSON.stringify(parsed.value); } } @@ -376,7 +375,7 @@ export const writeAnthropicMessagesEvents = async function* ( const assistantState: AnthropicMessagesInputAssistantTurnState = { content: contentState }; const sourceTurnDigest = await computeAnthropicMessagesTurnDigest([targetTurn]); const data = await options.roundTrip.encodeSidecar(0, sourceTurnDigest, targetTurn, assistantState, collectAnthropicMessagesTextPlacements([targetTurn])); - const carrier = createAnthropicMessagesSidecarBlock(data); + const carrier = createAnthropicMessagesSidecarCarrier(data); yield emit({ type: 'content_block_start', index: nextBlockIndex, content_block: carrier }); yield emit({ type: 'content_block_stop', index: nextBlockIndex }); if (pendingBlockStop !== undefined) { diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts new file mode 100644 index 0000000000..29b5e95b9c --- /dev/null +++ b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts @@ -0,0 +1,451 @@ +import { klona } from 'klona/json'; + +import { partitionAnthropicMessagesTurns, type AnthropicMessagesConversationTurn } from './conversation-turns.ts'; +import type { AnthropicMessagesSourceAssistantTurn } from './round-trip.ts'; +import type { SourceReading, SourceTurnSlot } from '../../../composition/reading.ts'; +import { mediaFromURL, mediaOrThrow } from '../../../ir/input/media.ts'; +import type { + IRInputAssistantContentPart, + IRInputRequestHeader, + IRInputSettings, + IRInputTool, + IRInputToolChoice, + IRInputOutputFormat, + IRInputAssistantItem, + IRInputMedia, + IRInputTextPart, + IRInputToolResultContentPart, + IRInputTurn, + IRInputUserContentPart, + IRInputClientItem, +} from '../../../ir/input/types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import type { + AnthropicMessagesAssistantInputContentBlock, + AnthropicMessagesClientTool, + AnthropicMessagesImageBlock, + AnthropicMessagesMetadataEx, + AnthropicMessagesMessage, + AnthropicMessagesPayload, + AnthropicMessagesSearchResultBlock, + AnthropicMessagesTextBlockParam, + AnthropicMessagesToolResultBlock, + AnthropicMessagesUserContentBlock, +} from '@floway-dev/protocols/anthropic-messages'; +import { FAST_SERVICE_TIER } from '@floway-dev/protocols/common'; + +const imageMedia = (source: AnthropicMessagesImageBlock['source']): IRInputMedia => { + switch (source.type) { + case 'base64': return { source_kind: 'base64', media_type: source.media_type, data: source.data }; + case 'url': return mediaOrThrow(mediaFromURL(source.url), source.url); + case 'file': return { source_kind: 'file', file_id: source.file_id }; + } +}; + +const promptPartsToIR = (content: string | readonly AnthropicMessagesTextBlockParam[]): IRInputTextPart[] => typeof content === 'string' + ? content === '' ? [] : [{ type: 'text', text: content }] + : content.map(block => { + if (block.type !== 'text') throw new TranslateInputException('Only text system content is supported.'); + return { type: 'text', text: block.text }; + }); + +const base64Of = (text: string): string => { + const bytes = new TextEncoder().encode(text); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +}; + +const documentMedia = (block: Extract): IRInputMedia => { + const source = block.source; + switch (source.type) { + case 'base64': return { source_kind: 'base64', media_type: source.media_type, data: source.data }; + case 'url': return { source_kind: 'url', url: source.url }; + case 'file': return { source_kind: 'file', file_id: source.file_id }; + // A `text` document source carries the document's bytes as plain text, while + // every target that accepts a file expects base64. Encoding here keeps the + // IR's media arms uniform. + case 'text': return { source_kind: 'base64', media_type: source.media_type, data: base64Of(source.data) }; + case 'content': + throw new TranslateInputException('Cannot translate an inline content document; supply a base64, URL, text, or file source.'); + } +}; + +interface MessagePosition { + readonly messageIdx: number; + readonly blockIdx: number; +} + +const userContentPart = ( + block: Exclude, + at: MessagePosition, +): IRInputUserContentPart => { + switch (block.type) { + case 'text': return { type: 'text', text: block.text }; + case 'image': return { type: 'image', image: imageMedia(block.source) }; + case 'document': return { type: 'file', file: documentMedia(block) }; + default: throw new TranslateInputException(`messages.${at.messageIdx}.content.${at.blockIdx}.type: '${(block as { type: string }).type}' content blocks are not supported in user content.`); + } +}; + +const toolResultPart = (block: AnthropicMessagesToolResultBlock['content']): IRInputToolResultContentPart[] => { + if (block === undefined) return []; + if (typeof block === 'string') return [{ type: 'text', text: block }]; + const parts: IRInputToolResultContentPart[] = []; + const searchResults: AnthropicMessagesSearchResultBlock[] = []; + // Only consecutive search results share an array, so text and images retain + // their positions while an all-search result still becomes one JSON value. + const flushSearchResults = (): void => { + if (searchResults.length === 0) return; + parts.push({ type: 'text', text: JSON.stringify(searchResults) }); + searchResults.length = 0; + }; + for (const part of block) { + if (part.type === 'search_result') { + searchResults.push(part); + continue; + } + flushSearchResults(); + if (part.type === 'text') parts.push({ type: 'text', text: part.text }); + else if (part.type === 'image') parts.push({ type: 'image', image: imageMedia(part.source) }); + else throw new TranslateInputException(`'${(part as { type: string }).type}' tool result content blocks are not supported.`); + } + flushSearchResults(); + return parts; +}; + +const clientItems = ( + message: Exclude, + messageIdx: number, +): IRInputClientItem[] => { + if (message.role === 'system') { + if (typeof message.content !== 'string' && message.content.some(block => block.type !== 'text')) { + throw new TranslateInputException('Only text system content is supported.'); + } + return [{ type: 'prompt', role: 'system', content: promptPartsToIR(message.content as string | readonly AnthropicMessagesTextBlockParam[]) }]; + } + if (typeof message.content === 'string') { + return [{ type: 'message', content: message.content === '' ? [] : [{ type: 'text', text: message.content }] }]; + } + + const items: IRInputClientItem[] = []; + const pending: IRInputUserContentPart[] = []; + const flush = (): void => { + if (pending.length === 0) return; + items.push({ type: 'message', content: [...pending] }); + pending.length = 0; + }; + + // Messages keeps tool results inline with user content. Flat input IR items keep + // the chronology and let each target regroup. + for (const [blockIdx, block] of message.content.entries()) { + if (block.type === 'tool_result') { + flush(); + const output = toolResultPart(block.content); + items.push({ + type: 'tool_result', + call_id: block.tool_use_id, + output: output.length === 0 ? '' : output.length === 1 && output[0].type === 'text' ? output[0].text : output, + ...(block.is_error === true ? { is_error: true } : {}), + }); + continue; + } + pending.push(userContentPart(block, { messageIdx, blockIdx })); + } + flush(); + return items; +}; + +// A server tool runs on Anthropic's own backend and its result is written into +// the assistant message by the API, so the client never answers it. Every target +// we write states one call and its result as a separate call/result pair anyway +// (Chat's `tool` message follows its `tool_calls`), so the pair has to become a +// turn boundary here. +// +// https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool +type ServerToolResultBlock = Extract; +const SERVER_TOOL_RESULT_TYPES: ReadonlySet = new Set([ + 'web_search_tool_result', + 'web_fetch_tool_result', + 'code_execution_tool_result', + 'bash_code_execution_tool_result', + 'text_editor_code_execution_tool_result', + 'tool_search_tool_result', +]); + +const isServerToolResult = (block: AnthropicMessagesAssistantInputContentBlock): block is ServerToolResultBlock => + SERVER_TOOL_RESULT_TYPES.has(block.type); + +interface AnthropicMessagesAssistantSegments { + // One entry per run of assistant items, each followed by the tool results the + // server wrote into the same message. SourceReading the pairs in order reproduces + // the block sequence: an assistant turn, then the client turn that answers it. + readonly segments: readonly { + readonly assistant: readonly IRInputAssistantItem[]; + readonly serverResults: readonly IRInputClientItem[]; + }[]; +} + +const assistantSegments = (message: Extract, messageIdx: number): AnthropicMessagesAssistantSegments => { + if (typeof message.content === 'string') { + // An explicitly empty assistant message still occupies a turn of its own; + // dropping it would let two client turns merge across where it stood. + return { segments: [{ assistant: [{ type: 'message', content: [{ type: 'text', text: message.content }] }], serverResults: [] }] }; + } + + const segments: { assistant: IRInputAssistantItem[]; serverResults: IRInputClientItem[] }[] = []; + let assistant: IRInputAssistantItem[] = []; + let serverResults: IRInputClientItem[] = []; + const pending: IRInputAssistantContentPart[] = []; + const flushText = (): void => { + if (pending.length === 0) return; + assistant.push({ type: 'message', content: [...pending] }); + pending.length = 0; + }; + // A result closes the run it answers, so the pair becomes one segment. + const flushSegment = (): void => { + flushText(); + if (assistant.length === 0 && serverResults.length === 0) return; + segments.push({ assistant, serverResults }); + assistant = []; + serverResults = []; + }; + + for (const [blockIdx, block] of message.content.entries()) { + if (isServerToolResult(block)) { + flushText(); + const result = block; + serverResults.push({ + type: 'tool_result', + call_id: result.tool_use_id, + output: JSON.stringify(result.content), + }); + flushSegment(); + continue; + } + switch (block.type) { + case 'text': + pending.push({ type: 'text', text: block.text }); + break; + case 'thinking': + flushText(); + if (block.thinking !== '') assistant.push({ type: 'reasoning', summary: [block.thinking] }); + break; + case 'redacted_thinking': + // The redacted payload is an opaque, vendor-signed blob; the sidecar is + // the only place that can carry it back to the same vendor. + break; + // A client tool and a server tool are the same fact to every target: a call + // the rest of the conversation answers. Only the client answers a client + // call, which is what the next real user turn carries. + case 'tool_use': + case 'server_tool_use': + flushText(); + assistant.push({ type: 'function_call', call_id: block.id, name: block.name, arguments: JSON.stringify(block.input) }); + break; + // Hosted results other than the ones above have no plain-tool spelling and + // no cross-vendor counterpart. + default: + throw new TranslateInputException(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' assistant content blocks are not supported on this model`); + } + } + flushSegment(); + return { segments }; +}; + +const anthropicMessagesSystemToIR = (system: AnthropicMessagesPayload['system']): IRInputTurn | undefined => { + if (system === undefined) return undefined; + const content = promptPartsToIR(system); + return content.length === 0 ? undefined : { kind: 'client', items: [{ type: 'prompt', role: 'system', content }] }; +}; + +const anthropicMessagesTurnToIR = ( + turn: AnthropicMessagesConversationTurn, + firstMessageIndex = 0, +): IRInputTurn[] => { + if (turn.role === 'assistant') { + const turns: IRInputTurn[] = []; + for (const [index, message] of turn.items.entries()) { + for (const segment of assistantSegments(message, firstMessageIndex + index).segments) { + // The pair a server tool states is one call and the result written into + // the same message, so the result becomes the client turn that answers + // it before the next run of assistant content begins. + if (segment.assistant.length > 0) turns.push({ kind: 'assistant', items: segment.assistant }); + if (segment.serverResults.length > 0) turns.push({ kind: 'client', items: segment.serverResults }); + } + } + return turns; + } + return [{ + kind: 'client', + items: turn.items.flatMap((message, index) => { + if (message.role !== 'user' && message.role !== 'system') { + throw new TranslateInputException(`messages.${firstMessageIndex + index}.role: role '${(message as { role: string }).role}' is not supported on this model`); + } + return clientItems(message, firstMessageIndex + index); + }), + }]; +}; + +// A Messages client declaration carries a JSON Schema under `input_schema` and +// states both callable kinds with the same shape, so the arm is the neutral one +// every target can express. +const toolToIR = (tool: AnthropicMessagesClientTool): IRInputTool => { + return { + type: 'function', + name: tool.name, + ...(tool.description === undefined ? {} : { description: tool.description }), + parameters: klona(tool.input_schema), + ...(tool.strict === undefined ? {} : { strict: tool.strict }), + }; +}; + +const toolChoiceToIR = (choice: NonNullable, declarations: readonly IRInputTool[]): IRInputToolChoice => { + switch (choice.type) { + case 'auto': return 'auto'; + case 'any': return 'required'; + case 'none': return 'none'; + default: { + // A selector names a declaration the same request supplies; a request that + // points at nothing cannot be translated into any target's vocabulary. + const name = choice.name; + if (!declarations.some(tool => tool.name === name)) { + throw new TranslateInputException(`Anthropic Messages tool_choice names an undeclared tool: ${String(name)}`); + } + return { type: 'function', name }; + } + } +}; + +const anthropicMessagesToolsToIR = (payload: AnthropicMessagesPayload): Pick => { + // A hosted declaration runs on the vendor's own infrastructure and has no + // meaning in another protocol, so only the callable arms are read. + const declarations = (payload.tools ?? []) + .filter((tool): tool is AnthropicMessagesClientTool => tool.type === undefined || tool.type === 'custom') + .map(toolToIR); + // A selector is validated against the declarations either way: dropping one + // that names nothing would widen what the model may call. + const toolChoice = payload.tool_choice == null ? undefined : toolChoiceToIR(payload.tool_choice, declarations); + return { + ...(declarations.length === 0 ? {} : { tools: declarations }), + ...(toolChoice === undefined ? {} : { tool_choice: toolChoice }), + }; +}; + +/** + * Messages states one ceiling, `max_tokens`, and its structured-output schema + * lives in `output_config.format`. `top_k` is also a GenerateContent sampling + * knob. + */ +const anthropicMessagesSettingsToIR = (payload: AnthropicMessagesPayload): IRInputSettings => { + const format = payload.output_config?.format; + // Messages' structured-output schema is already strict by contract, so the + // fact is stated here rather than left for a target to invent. It states no + // name, while the OpenAI wires require one; this is the established spelling. + // https://platform.claude.com/docs/en/build-with-claude/structured-outputs + const outputFormat: IRInputOutputFormat | undefined = format?.type === 'json_schema' + ? { type: 'json_schema', name: 'messages_response', schema: klona(format.schema), strict: true } + : undefined; + + const reasoningEffort = reasoningEffortToIR(payload); + const serviceTier = serviceTierToIR(payload); + const metadata = metadataToIR(payload.metadata); + return { + model: payload.model, + stream: payload.stream === true, + ...(payload.temperature == null ? {} : { temperature: payload.temperature }), + ...(payload.top_p == null ? {} : { top_p: payload.top_p }), + ...(payload.top_k == null ? {} : { top_k: payload.top_k }), + ...(payload.stop_sequences == null ? {} : { stop: [...payload.stop_sequences] }), + ...(payload.max_tokens == null ? {} : { max_output_tokens: payload.max_tokens }), + ...(outputFormat === undefined ? {} : { output_format: outputFormat }), + ...(serviceTier === undefined ? {} : { service_tier: serviceTier }), + ...(metadata === undefined ? {} : { metadata }), + ...(payload.tool_choice !== undefined && 'disable_parallel_tool_use' in payload.tool_choice && payload.tool_choice.disable_parallel_tool_use != null + ? { parallel_tool_calls: !payload.tool_choice.disable_parallel_tool_use } + : {}), + ...(reasoningEffort === undefined ? {} : { reasoning_effort: reasoningEffort }), + ...(payload.thinking?.type === 'enabled' ? { reasoning_budget: payload.thinking.budget_tokens } : {}), + ...(payload.thinking?.type === 'adaptive' ? { reasoning_budget_dynamic: true } : {}), + }; +}; + +// Messages admits any JSON value as a metadata tag while the IR states string +// pairs, so a value with no string spelling is a caller error rather than a +// silent drop. `user_id: null` is Messages' way of stating no value at all. +const metadataToIR = (metadata: AnthropicMessagesMetadataEx | undefined): Record | undefined => { + if (metadata === undefined) return undefined; + const entries = Object.entries(metadata).flatMap(([key, value]): [string, string][] => { + if (value == null) return []; + if (typeof value !== 'string') throw new TranslateInputException(`Cannot translate Anthropic Messages metadata field '${key}' to a string metadata value.`); + return [[key, value]]; + }); + return entries.length === 0 ? undefined : Object.fromEntries(entries); +}; + +// Messages states the accelerated lane as `speed` and the general lane as +// `service_tier`; every other protocol names both with the one tier string, so +// `speed` wins when a request states both. +// https://docs.claude.com/en/build-with-claude/fast-mode +const serviceTierToIR = (payload: AnthropicMessagesPayload): string | undefined => { + if (payload.speed === 'fast') return FAST_SERVICE_TIER; + // `standard` is the default lane, which a target already assumes. + if (payload.speed === 'standard') return undefined; + return payload.service_tier ?? undefined; +}; + +// A level stated outright wins over the one a thinking mode implies, because the +// caller wrote it explicitly while the mode only bounds it. +const reasoningEffortToIR = (payload: AnthropicMessagesPayload): string | undefined => { + if (payload.output_config?.effort != null) return payload.output_config.effort; + if (payload.thinking?.type === 'disabled') return 'none'; + if (payload.thinking?.type === 'adaptive') return 'medium'; + return undefined; +}; + +/** + * A Messages request read into the turns a writer consumes. + * + * Messages states its prompt in a top-level field and its conversation in + * `messages`; both enter the IR, and the request-wide fields come from the same + * payload. + * + * A server tool's result sits inside the assistant message that called it, and + * an assistant turn can therefore yield both assistant and client turns. The + * lazy conversion preserves those boundaries until composition resolves the + * sidecar path and joins adjacent client turns. + */ +export const readAnthropicMessagesRequest = (payload: AnthropicMessagesPayload): SourceReading => { + const request: IRInputRequestHeader = { + settings: anthropicMessagesSettingsToIR(payload), + ...anthropicMessagesToolsToIR(payload), + }; + + const turns: SourceTurnSlot[] = []; + let messageIndex = 0; + + const prompt = anthropicMessagesSystemToIR(payload.system); + if (prompt !== undefined) turns.push({ kind: 'client', toIRInputTurns: () => [prompt] }); + + for (const turn of partitionAnthropicMessagesTurns(payload.messages)) { + const firstMessageIndex = messageIndex; + messageIndex += turn.items.length; + if (turn.role === 'assistant') { + turns.push({ + kind: 'assistant', + assistantTurn: turn.items, + readCallIdentities: () => turn.items.flatMap(message => typeof message.content === 'string' ? [] : message.content.flatMap(block => block.type === 'tool_use' || block.type === 'server_tool_use' + ? [{ type: 'function' as const, name: block.name, call_id: block.id }] + : [])), + toIRInputTurns: () => anthropicMessagesTurnToIR(turn, firstMessageIndex), + }); + continue; + } + turns.push({ kind: 'client', toIRInputTurns: () => anthropicMessagesTurnToIR(turn, firstMessageIndex) }); + } + + return { request, turns }; +}; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts b/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts index 0dc665976b..6c73e88a71 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts @@ -2,7 +2,7 @@ import type { IRJSONObject, IRJSONValue } from '../../../ir/common/json.ts'; import type { ReplayContent } from '../../../round-trip/reference/types.ts'; import type { SidecarOccurrence } from '../../../round-trip/resolve.ts'; import { collectTextGroups, collectTextPlacementIndex, type TextContribution, type TextPlacementIndex } from '../../../round-trip/text-groups.ts'; -import { createTurnDigestComputer, replayMaterialsByType, createTurnDigestVerifier } from '../../../round-trip/turn-digest.ts'; +import { replayMaterialsByType, createTurnDigestComputer, createTurnDigestVerifier } from '../../../round-trip/turn-digest.ts'; import { isPlainJSONObject, requireJSONObject } from '../../../shared/json/object.ts'; import type { AnthropicMessagesAssistantContentBlock, AnthropicMessagesAssistantMessage, AnthropicMessagesRedactedThinkingBlock } from '@floway-dev/protocols/anthropic-messages'; @@ -68,7 +68,7 @@ export const collectAnthropicMessagesReplayContents = (turn: AnthropicMessagesSo export const verifyAnthropicMessagesTurnDigest = createTurnDigestVerifier(messagesTurnDigestMaterialV1); -export const createAnthropicMessagesSidecarBlock = (data: string): AnthropicMessagesRedactedThinkingBlock => ({ type: 'redacted_thinking', data }); +export const createAnthropicMessagesSidecarCarrier = (data: string): AnthropicMessagesRedactedThinkingBlock => ({ type: 'redacted_thinking', data }); export const collectAnthropicMessagesTextPlacements = (turn: AnthropicMessagesSourceAssistantTurn): TextPlacementIndex => { const views: TextPlacementIndex = new Map(); diff --git a/packages/translate/src/protocols/anthropic-messages/as-target.ts b/packages/translate/src/protocols/anthropic-messages/as-target.ts new file mode 100644 index 0000000000..6246af177d --- /dev/null +++ b/packages/translate/src/protocols/anthropic-messages/as-target.ts @@ -0,0 +1,45 @@ +import { readAnthropicMessagesEvents } from './as-target/events.ts'; +import { anthropicMessagesPayloadFromSegments } from './as-target/segmented-request.ts'; +import type { TargetProtocol } from '../../composition/index.ts'; +import type { RestoredAssistantTurn } from '../../composition/reading.ts'; +import type { IRInputRequestHeader, IRInputTurn } from '../../ir/input/types.ts'; +import { assignCallIds, type PendingCall } from '../../request/call-ids.ts'; +import { allocateToolNames, type ToolNameContext } from '../../request/tool-names.ts'; +import type { InputAssistantTurnFor } from '../../round-trip/input-assistant-turn.ts'; +import type { TranslateOptions } from '../../types.ts'; +import type { AnthropicMessagesMessage, AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; + +const callsOf = (turn: Extract): readonly PendingCall[] => + Array.isArray(turn.content) + ? turn.content.flatMap(block => block.type === 'tool_use' || block.type === 'server_tool_use' ? [{ callId: block.id, name: block.name }] : []) + : []; + +/** Read pending call IDs from restored Messages tool_use blocks before writing later results. */ +export const anthropicMessagesTargetProtocol: TargetProtocol< + 'anthropicMessages', AnthropicMessagesPayload, AnthropicMessagesStreamEventEx, ToolNameContext +> = { + name: 'anthropicMessages', + write: (request: IRInputRequestHeader, turns: readonly (IRInputTurn | RestoredAssistantTurn>)[], options: TranslateOptions) => { + // This wire addresses a callable by one bare name, so every grouped + // declaration and call is stated under the flat spelling it computes. + const flattened = allocateToolNames(request, turns, { + ids: turn => typeof turn.content === 'string' ? [] : turn.content.flatMap(block => block.type === 'tool_use' ? [block.id] : []), + rename: (turn, names) => { + let index = 0; + return { + ...turn, + content: typeof turn.content === 'string' ? turn.content : turn.content.map(block => block.type === 'tool_use' ? { ...block, name: names[index++]! } : block), + }; + }, + }); + return { + payload: anthropicMessagesPayloadFromSegments(flattened.request, assignCallIds(flattened.turns, callsOf), { + fallbackMaxOutputTokens: options.fallbackMaxOutputTokens, + }), + // Both facts come from the same flattening that produced the request, so + // the reader sees exactly the names the upstream was asked with. + context: flattened.context, + }; + }, + readEvents: (frames, context, roundTripReader) => readAnthropicMessagesEvents(frames, { ...context, roundTrip: roundTripReader }), +}; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/cache-breakpoints.ts b/packages/translate/src/protocols/anthropic-messages/as-target/cache-breakpoints.ts index d84cf445d6..153e4ecdc5 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/cache-breakpoints.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/cache-breakpoints.ts @@ -1,24 +1,7 @@ -// Anthropic's prompt cache is opt-in: a request with no cache_control -// breakpoint anywhere returns cache_read_input_tokens=0 regardless of how -// stable the prompt prefix is. Source APIs without an explicit cache_control -// concept — Codex on the OpenAI Responses wire (opaque prompt_cache_key) and plain -// OpenAI Chat Completions (no caching field) — silently drop the field through -// translation, so every codex-through-anthropic-messages or chat-through-anthropic-messages turn -// is a cache miss while native Anthropic Messages clients hit cache fine because they -// inject breakpoints themselves. -// -// Place breakpoints in the translated payload. Up to 4 are allowed per -// request; we use 3 and leave one for downstream additions: -// -// - system block when system text is non-empty -// - last function-tool definition (caches the system+tools prefix, the most -// stable per-turn chunk) -// - last cacheable block of the latest message that has one (caches -// conversation history through the latest turn so subsequent turns build on -// a longer cached prefix) -// -// Reference: -// - https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching +// Translated Messages requests add up to three cache breakpoints: system text, the last custom tool, +// and the latest cacheable message block. Leave the fourth allowed slot for downstream additions. +// Prompt-cache keys are a separate mechanism. +// https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching import type { AnthropicMessagesCacheControl, diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/events.ts b/packages/translate/src/protocols/anthropic-messages/as-target/events.ts index 1dc62b6aa4..32ac4ecbd4 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/events.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/events.ts @@ -1,12 +1,13 @@ import type { AnthropicMessagesInputAssistantTurnState } from './assistant-turn.ts'; import { type AnthropicMessagesInputCounts, usageToIR } from './usage.ts'; import { createIRBuilder, type IRItemHandle, type IRPartHandle } from '../../../ir/output/builder.ts'; -import type { IRRecord } from '../../../ir/output/records.ts'; +import { type IRRecord } from '../../../ir/output/records.ts'; import type { IROutputSourceCitation } from '../../../ir/output/types.ts'; +import type { ToolNameContext } from '../../../request/tool-names.ts'; import type { RoundTripReader } from '../../../round-trip/reader.ts'; import { appendReadField, createReadFieldState, type ReadFieldState, type ReadFieldStates } from '../../../round-trip/reference/reference-builder.ts'; +import { unwrapCustomToolInput } from '../../../shared/custom-tool.ts'; import { requireJSONObject } from '../../../shared/json/object.ts'; -import { unwrapCustomToolInput } from '../../openai-responses/common/custom-tool-wrap.ts'; import { type AnthropicMessagesAssistantContentBlock, type AnthropicMessagesStreamEventEx, @@ -35,7 +36,7 @@ const messagesCitationToIR = (citation: AnthropicMessagesTextCitation): IROutput } as IROutputSourceCitation; }; -// Blocks that open no IR item; their content is not translated. +// Blocks that open no output IR item; their content is not translated. const UNTRANSLATED_BLOCK_TYPES: readonly string[] = [ 'redacted_thinking', 'server_tool_use', @@ -63,11 +64,13 @@ interface MessagesBlockState { export const readAnthropicMessagesEvents = async function* ( frames: AsyncIterable>, - options: { customToolNames?: ReadonlySet; roundTrip: RoundTripReader<'anthropicMessages'> }, + options: Partial & { + roundTrip: RoundTripReader<'anthropicMessages'>; + }, ): AsyncGenerator { - const ir = createIRBuilder(); - const blocks = new Map(); - const blockStates = new Map(); + const builder = createIRBuilder(); + const openBlocks = new Map(); + const blockFieldStates = new Map(); const nativeAccumulator = createAnthropicMessagesEventAccumulator(); let started = false; let finishReason: 'stop' | 'length' | 'tool_calls' | 'content_filter' = 'stop'; @@ -75,22 +78,22 @@ export const readAnthropicMessagesEvents = async function* ( const inputCounts: AnthropicMessagesInputCounts = {}; const assignUsage = (usage: Partial | null | undefined): void => { - ir.usage(usageToIR(usage, inputCounts)); + builder.usage(usageToIR(usage, inputCounts)); const tier = usage?.speed === 'fast' ? 'fast' : usage?.service_tier; - if (typeof tier === 'string') ir.assign(['service_tier'], tier); + if (typeof tier === 'string') builder.assign(['service_tier'], tier); }; const openBlock = (index: number, block: AnthropicMessagesAssistantContentBlock): void => { - if (blocks.has(index)) throw new Error(`Duplicate Messages content_block_start at index ${index}`); + if (openBlocks.has(index)) throw new Error(`Duplicate Messages content_block_start at index ${index}`); let item: IRItemHandle | undefined; let part: IRPartHandle | undefined; let annotationCount: number | undefined; let custom = false; let textField: ReadFieldState<'text'> | undefined; if (block.type === 'fallback') { - ir.assign(['model'], block.to.model); + builder.assign(['model'], block.to.model); } else if (block.type === 'text') { - item = ir.item(0, { type: 'message', content: [] }); + item = builder.item(0, { type: 'message', content: [] }); const annotations = block.citations === undefined ? undefined : block.citations === null ? null : block.citations.flatMap(citation => { const converted = messagesCitationToIR(citation); return converted === undefined ? [] : [converted]; @@ -102,16 +105,19 @@ export const readAnthropicMessagesEvents = async function* ( part.append('text', block.text, [appendReadField(options.roundTrip.builder, textField, block.text)]); } } else if (block.type === 'thinking') { - item = ir.item(0, { type: 'reasoning', summary: [''] }); + item = builder.item(0, { type: 'reasoning', summary: [''] }); if (block.thinking !== '') { textField = createReadFieldState('text'); item.append(['summary', 0], block.thinking, [appendReadField(options.roundTrip.builder, textField, block.thinking)]); } } else if (block.type === 'tool_use') { custom = options.customToolNames?.has(block.name) === true; - item = ir.item(0, custom - ? { type: 'custom_tool_call', call_id: block.id, name: block.name, input: '' } - : { type: 'function_call', call_id: block.id, name: block.name, arguments: '' }); + // Restore allocated names, including bare names renamed to avoid collisions. + const identity = options.originalToolNames?.get(block.name); + const naming = identity ?? { name: block.name }; + item = builder.item(0, custom + ? { type: 'custom_tool_call', call_id: block.id, ...naming, input: '' } + : { type: 'function_call', call_id: block.id, ...naming, arguments: '' }); } else if (!UNTRANSLATED_BLOCK_TYPES.includes(block.type)) { throw new TypeError(`Cannot translate Messages content block ${block.type}`); } @@ -119,8 +125,8 @@ export const readAnthropicMessagesEvents = async function* ( item, part, custom, annotationCount, wrappedInput: '', textField, fieldState: textField === undefined ? undefined : block.type === 'text' ? { text: textField } : { thinking: textField }, }; - blocks.set(index, state); - blockStates.set(index, state); + openBlocks.set(index, state); + blockFieldStates.set(index, state); }; for await (const frame of frames) { @@ -132,10 +138,10 @@ export const readAnthropicMessagesEvents = async function* ( if (started) throw new Error('Duplicate Messages message_start'); if (typeof e.message.id !== 'string') throw new TypeError('Messages message_start requires an id'); if (typeof e.message.model !== 'string') throw new TypeError('Messages message_start requires a model'); - ir.choice(0); - ir.assign(['model'], e.message.model); + builder.choice(0); + builder.assign(['model'], e.message.model); assignUsage(e.message.usage); - ir.event({ type: 'start', id: e.message.id }); + builder.event({ type: 'start', id: e.message.id }); started = true; break; case 'content_block_start': @@ -143,7 +149,7 @@ export const readAnthropicMessagesEvents = async function* ( openBlock(e.index, e.content_block); break; case 'content_block_delta': { - const block = blocks.get(e.index); + const block = openBlocks.get(e.index); if (block === undefined) throw new Error('Messages delta arrived before content_block_start'); const d = e.delta; if (block.item === undefined) break; @@ -154,9 +160,9 @@ export const readAnthropicMessagesEvents = async function* ( block.textField = createReadFieldState('text'); block.fieldState = { text: block.textField }; } - const partId = appendReadField(options.roundTrip.builder, block.textField, d.text); - if (block.part === undefined) block.item.append(['content', 0, 'text'], d.text, [partId]); - else block.part.append('text', d.text, [partId]); + const fragmentId = appendReadField(options.roundTrip.builder, block.textField, d.text); + if (block.part === undefined) block.item.append(['content', 0, 'text'], d.text, [fragmentId]); + else block.part.append('text', d.text, [fragmentId]); } break; case 'thinking_delta': @@ -175,9 +181,9 @@ export const readAnthropicMessagesEvents = async function* ( block.jsonField = createReadFieldState('json'); block.fieldState = { input: block.jsonField }; } - const partId = appendReadField(options.roundTrip.builder, block.jsonField, d.partial_json); + const fragmentId = appendReadField(options.roundTrip.builder, block.jsonField, d.partial_json); if (block.custom) block.wrappedInput += d.partial_json; - else block.item.append('arguments', d.partial_json, [partId]); + else block.item.append('arguments', d.partial_json, [fragmentId]); } break; case 'citations_delta': { @@ -198,9 +204,9 @@ export const readAnthropicMessagesEvents = async function* ( break; } case 'content_block_stop': { - const block = blocks.get(e.index); + const block = openBlocks.get(e.index); if (block === undefined) throw new Error('Messages content_block_stop arrived before content_block_start'); - blocks.delete(e.index); + openBlocks.delete(e.index); if (block.item !== undefined) { if (block.custom) { const input = unwrapCustomToolInput(block.wrappedInput); @@ -215,7 +221,7 @@ export const readAnthropicMessagesEvents = async function* ( assignUsage(e.usage); if (e.delta.stop_reason === 'compaction') throw new TypeError('Cannot translate Messages compaction stop'); if (e.delta.stop_reason === 'refusal') { - ir.choice(0).assign('refusal_details', { + builder.choice(0).assign('refusal_details', { category: e.delta.stop_details?.category ?? null, explanation: e.delta.stop_details?.explanation ?? null, }); @@ -229,29 +235,29 @@ export const readAnthropicMessagesEvents = async function* ( break; case 'message_stop': { if (!started) throw new Error('Messages message_stop arrived before message_start'); - if (blocks.size > 0) throw new Error('Messages message_stop arrived with open content blocks'); + if (openBlocks.size > 0) throw new Error('Messages message_stop arrived with open content blocks'); const nativeResult = nativeAccumulator.result(); const contentFields: NonNullable['content']> = []; contentFields.length = nativeResult.content.length; - [...blockStates.entries()].toSorted(([left], [right]) => left - right).forEach(([, block], index) => { + [...blockFieldStates.entries()].toSorted(([left], [right]) => left - right).forEach(([, block], index) => { if (block.fieldState !== undefined) contentFields[index] = block.fieldState; }); const content = nativeResult.content.map(block => block.type === 'tool_use' ? { ...block, input: requireJSONObject(block.input) } : block); options.roundTrip.onAssistantTurn(0, { role: 'assistant', content }, { content: contentFields }); - ir.choice(0).end(finishReason); - ir.event({ type: 'finish', status: finishReason === 'length' ? 'incomplete' : 'completed' }); + builder.choice(0).end(finishReason); + builder.event({ type: 'finish', status: finishReason === 'length' ? 'incomplete' : 'completed' }); finished = true; break; } case 'error': - ir.event({ type: 'error', error: e.error }); - yield* ir.drain(); + builder.event({ type: 'error', error: e.error }); + yield* builder.drain(); return; case 'ping': - ir.event({ type: 'ping' }); + builder.event({ type: 'ping' }); break; } - yield* ir.drain(); + yield* builder.drain(); if (finished) return; } throw new Error('Messages stream ended without message_stop'); diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts new file mode 100644 index 0000000000..8788bb0d2e --- /dev/null +++ b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts @@ -0,0 +1,337 @@ +import { requireStreaming } from '../../../ir/input/streaming.ts'; +import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputMedia, IRInputTextPart, IRInputToolResultContentPart, IRInputClientItem } from '../../../ir/input/types.ts'; +import type { IRInputAssistantItemWithCallIds, IRInputTurnWithCallIds } from '../../../request/call-ids.ts'; +import { buildCustomToolInputSchema } from '../../../shared/custom-tool.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import { type TranslateOptions } from '../../../types.ts'; +import type { + AnthropicMessagesAssistantInputContentBlock, + AnthropicMessagesClientTool, + AnthropicMessagesImageBlock, + AnthropicMessagesImageSource, + AnthropicMessagesMessage, + AnthropicMessagesPayload, + AnthropicMessagesTextBlockParam, + AnthropicMessagesToolResultContentBlock, + AnthropicMessagesUserContentBlock, +} from '@floway-dev/protocols/anthropic-messages'; +import { ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS } from '@floway-dev/protocols/anthropic-messages'; +import { isFastServiceTier, parseJSONWithRawNumbers } from '@floway-dev/protocols/common'; + +const imageSource = (media: IRInputMedia): AnthropicMessagesImageSource => { + switch (media.source_kind) { + case 'url': { + // Messages remote images require HTTP(S) URLs; invalid schemes are translation input errors. + const scheme = media.url.slice(0, media.url.indexOf(':')); + if (scheme !== 'http' && scheme !== 'https') { + throw new TranslateInputException(`Messages image input requires an http or https URL, got '${media.url}'.`); + } + return { type: 'url', url: media.url }; + } + case 'file': return { type: 'file', file_id: media.file_id }; + case 'base64': + if (media.media_type === undefined) throw new TranslateInputException('Anthropic Messages image input requires a media type.'); + return { type: 'base64', media_type: media.media_type as Extract['media_type'], data: media.data }; + } +}; + +const imageBlock = (media: IRInputMedia): AnthropicMessagesImageBlock => ({ type: 'image', source: imageSource(media) }); + +/** Messages text documents carry decoded characters; inline IR media carries base64 bytes. */ +const textOfBase64 = (data: string): string => { + const binary = atob(data); + const bytes = Uint8Array.from(binary, character => character.charCodeAt(0)); + return new TextDecoder().decode(bytes); +}; + +/** + * The wire distinguishes a plain-text document from a binary one and carries the + * binary MIME type as a string, so the caller's media type travels verbatim and + * the selected upstream decides whether it accepts it. Rewriting an unknown type + * to PDF would describe the bytes wrongly. + */ +const documentBlock = (media: IRInputMedia): AnthropicMessagesUserContentBlock => { + switch (media.source_kind) { + case 'url': return { type: 'document', source: { type: 'url', url: media.url } }; + case 'file': return { type: 'document', source: { type: 'file', file_id: media.file_id } }; + case 'base64': { + if (media.media_type === undefined) throw new TranslateInputException('Anthropic Messages document input requires a media type.'); + return media.media_type === 'text/plain' + ? { type: 'document', source: { type: 'text', media_type: 'text/plain', data: textOfBase64(media.data) } } + : { type: 'document', source: { type: 'base64', media_type: media.media_type, data: media.data } }; + } + } +}; + +const systemContent = (content: readonly IRInputTextPart[]): AnthropicMessagesTextBlockParam[] => content.map(part => ({ type: 'text', text: part.text })); + +const userContent = ( + item: Extract, +): AnthropicMessagesUserContentBlock[] => { + const blocks: AnthropicMessagesUserContentBlock[] = []; + for (const part of item.content) { + switch (part.type) { + case 'text': blocks.push({ type: 'text', text: part.text }); break; + case 'image': blocks.push(imageBlock(part.image)); break; + case 'file': + blocks.push(documentBlock(part.file)); + break; + case 'audio': throw new TranslateInputException(`Cannot translate an audio part of MIME type '${part.audio.media_type}' to Anthropic Messages.`); + default: throw new TranslateInputException(`Invalid '${(part as { type: string }).type}' content part.`); + } + } + // Messages' user content is a non-empty block list in the same way. + return blocks.length === 0 ? [{ type: 'text', text: '' }] : blocks; +}; + +const toolResultContent = ( + output: string | readonly IRInputToolResultContentPart[], +): string | AnthropicMessagesToolResultContentBlock[] => { + if (typeof output === 'string') return output; + const blocks: AnthropicMessagesToolResultContentBlock[] = []; + for (const part of output) { + if (part.type === 'text') { blocks.push({ type: 'text', text: part.text }); continue; } + if (part.type === 'file') throw new TranslateInputException('Cannot translate file tool output to Anthropic Messages.'); + blocks.push(imageBlock(part.image)); + } + // Messages spells an empty tool result as the empty string. + return blocks.length === 0 ? '' : blocks; +}; + +const assistantBlocks = (items: readonly IRInputAssistantItemWithCallIds[]): AnthropicMessagesAssistantInputContentBlock[] => { + const blocks = items.flatMap(item => { + switch (item.type) { + case 'message': + // An empty content list still represents an explicitly empty assistant + // message, so it emits one empty text block. + if (item.content.length === 0) return [{ type: 'text', text: '' }]; + return item.content.map((part): AnthropicMessagesAssistantInputContentBlock => part.type === 'text' + ? { type: 'text', text: part.text } + : { type: 'text', text: part.refusal }); + case 'reasoning': { + // Project readable reasoning summaries into Messages thinking blocks; opaque signed replay state uses sidecars. + const thinking = (item.summary ?? []).join(''); + return thinking === '' ? [] : [{ type: 'thinking', thinking, signature: '' }]; + } + case 'function_call': + return [{ + type: 'tool_use' as const, + id: item.call_id, + name: item.name, + // Messages requires input; an absent argument object is written as {}. + input: item.arguments === undefined ? {} : parseArguments(item.arguments), + }] as AnthropicMessagesAssistantInputContentBlock[]; + case 'custom_tool_call': + // Messages has no freeform tool call; the wrapper schema a custom tool is + // declared with takes one `input` string, so the call is projected onto it. + return [{ type: 'tool_use', id: item.call_id, name: item.name, input: { input: item.input } }]; + } + }); + return blocks; +}; + +// Messages needs an object, so a call whose arguments are not an object keeps +// the raw text under `raw_arguments` rather than dropping the history. A JSON +// `null`, an array, and malformed text all take that path: none of them is an +// argument object, and discarding the call would lose what the model said. +const parseArguments = (argumentsJson: string): Record => { + // An out-of-range JSON number such as `1e400` parses to `Infinity` and would + // serialize back as `null`. Parsing with raw-number preservation keeps the + // caller's exact token. + try { + const parsed = parseJSONWithRawNumbers(argumentsJson); + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? parsed as Record + : { raw_arguments: argumentsJson }; + } catch { + return { raw_arguments: argumentsJson }; + } +}; + +const toolFromIR = (tool: IRInputTool): AnthropicMessagesClientTool => { + const schema = tool.type === 'function' + ? tool.parameters ?? { type: 'object', properties: {} } + : buildCustomToolInputSchema(tool.format); + if (schema.type !== 'object') throw new TranslateInputException('Anthropic Messages function tools require an object input schema.'); + return { + // Messages states this kind for a declaration that is not one of its own + // native tool types, which is every declaration this IR carries. + type: 'custom', + name: tool.name, + ...(tool.description === undefined ? {} : { description: tool.description }), + input_schema: { ...schema, type: 'object' }, + ...(tool.type === 'function' && tool.strict !== undefined ? { strict: tool.strict } : {}), + }; +}; + +const toolChoiceFromIR = (choice: IRInputToolChoice, tools: readonly IRInputTool[]): NonNullable => { + if (choice === 'auto') return { type: 'auto' }; + if (choice === 'none') return { type: 'none' }; + if (choice === 'required') return { type: 'any' }; + if (choice.type === 'allowed_tools') { + if (choice.mode === 'required' && choice.tools.length === 1) return { type: 'tool', name: choice.tools[0].name }; + if (choice.mode === 'auto' && choice.tools.length === 0) return { type: 'none' }; + const permitted = new Set(choice.tools.map(tool => `${tool.type}:${tool.name}`)); + if (tools.every(tool => permitted.has(`${tool.type}:${tool.name}`))) return { type: choice.mode === 'required' ? 'any' : 'auto' }; + throw new TranslateInputException(`Anthropic Messages cannot express an allowed_tools ${choice.mode} subset of ${choice.tools.length} tools.`); + } + return { type: 'tool', name: choice.name }; +}; + +const toolChoiceForTools = (choice: IRInputToolChoice | undefined, tools: readonly IRInputTool[] | undefined): IRInputToolChoice | undefined => { + if (tools !== undefined && tools.length > 0) return choice; + if (choice === 'none' || choice === 'auto') return choice; + if (typeof choice === 'object' && choice.type === 'allowed_tools' && choice.mode === 'auto') return 'none'; + return undefined; +}; + +/** + * One segment's messages and the system blocks a leading prompt run lifts. + * + * Messages keeps the system prompt in its own top-level field. A leading system + * run is lifted there, one block per source text part so the boundaries + * survive; later system messages stay inline at their position. + * + * Only the segment opening the whole conversation may hoist. Splitting at a + * restored assistant turn must not promote a later inline prompt merely because + * it opens another segment. + */ +export interface AnthropicMessagesConversation { + readonly messages: AnthropicMessagesMessage[]; + readonly system: AnthropicMessagesTextBlockParam[]; +} + +export const anthropicMessagesConversationFromIR = ( + turns: readonly IRInputTurnWithCallIds[], + leading: boolean, +): AnthropicMessagesConversation => { + const system: AnthropicMessagesTextBlockParam[] = []; + let hoisting = leading; + const messages: AnthropicMessagesMessage[] = []; + // Messages spells a single text block as a bare string, which is the shape a + // client sees for a plain user turn. + const userMessage = (blocks: AnthropicMessagesUserContentBlock[]): AnthropicMessagesMessage => ({ + role: 'user', + content: blocks.length === 1 && blocks[0].type === 'text' ? blocks[0].text : blocks, + }); + // One IR turn becomes one message: the items inside it belong together, and a + // turn boundary is what keeps two runs apart. The IR states that grouping, so + // the writer never re-derives it from item kinds. + const append = (blocks: AnthropicMessagesUserContentBlock[]): void => { + if (blocks.length > 0) messages.push(userMessage(blocks)); + }; + for (const turn of turns) { + if (turn.kind === 'assistant') { + hoisting = false; + const blocks = assistantBlocks(turn.items); + // A turn whose every item was dropped contributes no message, but the user + // turns around it were never adjacent, so a following user item must not + // merge into the previous message. An explicitly empty text item is not + // dropped: `assistantBlocks` emitted one empty block for it. + if (blocks.length === 0) continue; + const content = blocks.length === 1 && blocks[0].type === 'text' && blocks[0].text === '' ? '' : blocks; + messages.push({ role: 'assistant', content }); + continue; + } + const turnBlocks: AnthropicMessagesUserContentBlock[] = []; + for (const item of turn.items) { + if (item.type === 'prompt') { + if (hoisting) { + system.push(...systemContent(item.content)); + continue; + } + // Messages states a system message's content as a non-empty block list + // or a bare string; an empty prompt run is the empty string. + const blocks = systemContent(item.content); + messages.push({ role: 'system', content: blocks.length === 0 ? '' : blocks }); + continue; + } + hoisting = false; + turnBlocks.push(...(item.type === 'tool_result' + ? [{ type: 'tool_result' as const, tool_use_id: item.call_id, content: toolResultContent(item.output), ...(item.is_error === true ? { is_error: true } : {}) }] + : userContent(item))); + } + append(turnBlocks); + } + return { messages, system }; +}; + +/** + * Everything a Messages request states except its conversation. + * + * The conversation is written separately because a trip interleaves it with the + * assistant turns a sidecar already resolved, so only this half is computed once + * per payload. + */ +export const anthropicMessagesHeaderFromIR = ( + request: IRInputRequestHeader, + options: Pick = {}, +): Omit => { + const tools = request.tools; + const choice = toolChoiceForTools(request.tool_choice, tools); + const selected = choice === undefined ? undefined : toolChoiceFromIR(choice, tools ?? []); + const parallel = request.settings.parallel_tool_calls; + const toolChoice = parallel === undefined || selected?.type === 'none' + ? selected + : { ...(selected ?? { type: 'auto' as const }), disable_parallel_tool_use: !parallel }; + return { + ...anthropicMessagesSettingsFromIR(request.settings), + model: request.settings.model, + max_tokens: request.settings.max_output_tokens ?? request.settings.max_visible_output_tokens + ?? options.fallbackMaxOutputTokens ?? ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS, + // A trip guarantees a streaming request; a request that says otherwise + // cannot be carried by this side at all. + stream: requireStreaming(request.settings), + ...(tools === undefined || tools.length === 0 ? {} : { tools: tools.map(toolFromIR) }), + ...(toolChoice === undefined ? {} : { tool_choice: toolChoice }), + }; +}; + +/** + * Messages states one ceiling, `max_tokens`, and its structured-output schema + * lives in `output_config.format`. It accepts neither `seed` nor Chat's + * `max_completion_tokens` spelling, so a source that only stated the latter + * still lands here as `max_tokens`. + */ +export const anthropicMessagesSettingsFromIR = (settings: IRInputSettings): Omit, 'model'> => { + const ceiling = settings.max_output_tokens ?? settings.max_visible_output_tokens; + const format = settings.output_format; + const formatConfig = format?.type === 'json_schema' && format.schema !== undefined + ? { format: { type: 'json_schema' as const, schema: format.schema } } + : undefined; + + // Anthropic splits the single effort axis across two slots: `thinking` and + // `output_config.effort`. A stated budget is the more specific quantity, so it + // decides the thinking slot — zero being this axis's off switch — while the + // level states the effort. With no budget, an effort of `none` moves to the + // off switch, because this scale has no `none` of its own. Whether the target + // honours the off switch is the target's business. + // https://platform.claude.com/docs/en/build-with-claude/effort + const thinking = settings.reasoning_budget_dynamic === true + ? { type: 'adaptive' as const } + : settings.reasoning_budget !== undefined + ? settings.reasoning_budget === 0 + ? { type: 'disabled' as const } + : { type: 'enabled' as const, budget_tokens: settings.reasoning_budget } + : settings.reasoning_effort === 'none' + ? { type: 'disabled' as const } + : undefined; + const effort = settings.reasoning_effort === undefined || settings.reasoning_effort === 'none' ? undefined : settings.reasoning_effort; + const outputConfig = effort === undefined && formatConfig === undefined + ? undefined + : { ...(effort === undefined ? {} : { effort }), ...(formatConfig ?? {}) }; + + return { + ...(ceiling == null ? {} : { max_tokens: ceiling }), + ...(settings.temperature === undefined ? {} : { temperature: settings.temperature }), + ...(settings.top_p === undefined ? {} : { top_p: settings.top_p }), + ...(settings.top_k === undefined ? {} : { top_k: settings.top_k }), + ...(settings.stop === undefined ? {} : { stop_sequences: [...settings.stop] }), + ...(thinking === undefined ? {} : { thinking }), + ...(outputConfig === undefined ? {} : { output_config: outputConfig }), + // Anthropic spells the accelerated lane as `speed: 'fast'`; every other tier + // stays an open string on `service_tier`. + // https://docs.claude.com/en/build-with-claude/fast-mode + ...(settings.service_tier === undefined ? {} : isFastServiceTier(settings.service_tier) ? { speed: 'fast' as const } : { service_tier: settings.service_tier }), + }; +}; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/reasoning-effort.ts b/packages/translate/src/protocols/anthropic-messages/as-target/reasoning-effort.ts deleted file mode 100644 index 3a6903cc02..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/as-target/reasoning-effort.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; - -// Map the gateway's discrete effort axis onto Anthropic Messages' target fields. -// -// Anthropic splits the gateway's single effort axis across two slots: -// `output_config.effort` carries a level, and `thinking.type: 'disabled'` is -// the off switch. Anthropic's effort scale is `low | medium | high | xhigh | -// max` with no `none` member, so `'none'` is the single value that changes -// slot rather than passing through — routing it to `output_config.effort` -// would send a level the upstream does not define, and dropping it would -// leave a thinking-by-default model reasoning. -// https://platform.claude.com/docs/en/build-with-claude/effort -// -// Whether the target honours the off switch is the target's business, and it -// is not uniform across Claude: thinking-always-on models (Fable 5, Mythos 5, -// Mythos Preview) reject `disabled` with a 400, and Opus 5 accepts it only at -// effort `high` or below. We emit the intent either way, exactly as the native -// `/v1/messages` path forwards a client's own `thinking: {type: 'disabled'}`, -// so a translated request and a native one get the same answer from the same -// model. -// https://platform.claude.com/docs/en/build-with-claude/thinking-troubleshooting#supported-models -// -// Every other value is forwarded verbatim; the upstream owns which levels it -// accepts. -export interface AnthropicMessagesReasoningFields { - thinking?: NonNullable; - effort?: string; -} - -export const anthropicMessagesReasoningFieldsFromEffort = (effort: string | null | undefined): AnthropicMessagesReasoningFields => { - if (effort === 'none') return { thinking: { type: 'disabled' } }; - return effort == null ? {} : { effort }; -}; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/remote-images.ts b/packages/translate/src/protocols/anthropic-messages/as-target/remote-images.ts deleted file mode 100644 index 356daaf5ff..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/as-target/remote-images.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { RemoteImageLoader } from '../../../types.ts'; -import type { AnthropicMessagesImageBlock } from '@floway-dev/protocols/anthropic-messages'; -import { encodeBase64, mediaTypeEssence } from '@floway-dev/protocols/common'; - -const ALLOWED_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']); - -const parseDataUrl = (url: string): { mediaType: string; data: string } | null => { - const match = url.match(/^data:([^;]+);base64,(.+)$/); - if (!match) return null; - const mediaType = mediaTypeEssence(match[1]); - return mediaType === null ? null : { mediaType, data: match[2] }; -}; - -const inferMediaTypeFromUrl = (url: string): string | null => { - try { - const path = new URL(url).pathname.toLowerCase(); - if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'image/jpeg'; - if (path.endsWith('.png')) return 'image/png'; - if (path.endsWith('.gif')) return 'image/gif'; - if (path.endsWith('.webp')) return 'image/webp'; - } catch { - return null; - } - - return null; -}; - -const resolveRemoteImage = async (url: string, loadRemoteImage: RemoteImageLoader): Promise => { - const image = await loadRemoteImage(url); - if (!image) return null; - - let mediaType = mediaTypeEssence(image.mediaType) ?? ''; - if (!ALLOWED_IMAGE_TYPES.has(mediaType)) { - mediaType = inferMediaTypeFromUrl(url) ?? ''; - } - if (!ALLOWED_IMAGE_TYPES.has(mediaType)) return null; - - return { - type: 'image', - source: { - type: 'base64', - media_type: mediaType as Extract['media_type'], - data: encodeBase64(image.data), - }, - }; -}; - -export const resolveImageUrlToAnthropicMessagesImage = async (url: string, loadRemoteImage: RemoteImageLoader): Promise => { - const dataUrl = parseDataUrl(url); - - if (dataUrl) { - if (!ALLOWED_IMAGE_TYPES.has(dataUrl.mediaType)) return null; - - return { - type: 'image', - source: { - type: 'base64', - media_type: dataUrl.mediaType as Extract['media_type'], - data: dataUrl.data, - }, - }; - } - - if (!url.startsWith('http://') && !url.startsWith('https://')) return null; - return await resolveRemoteImage(url, loadRemoteImage); -}; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/segmented-request.ts b/packages/translate/src/protocols/anthropic-messages/as-target/segmented-request.ts new file mode 100644 index 0000000000..f2fffd0b52 --- /dev/null +++ b/packages/translate/src/protocols/anthropic-messages/as-target/segmented-request.ts @@ -0,0 +1,44 @@ +import { applyLastMessageCacheBreakpoint, applyLastSystemCacheBreakpoint, applyLastToolCacheBreakpoint } from './cache-breakpoints.ts'; +import { anthropicMessagesConversationFromIR, anthropicMessagesHeaderFromIR } from './input-from.ts'; +import { type RestoredAssistantTurn } from '../../../composition/reading.ts'; +import type { IRInputRequestHeader } from '../../../ir/input/types.ts'; +import type { IRInputTurnWithCallIds } from '../../../request/call-ids.ts'; +import { segmentTurns } from '../../../request/segments.ts'; +import type { TranslateOptions } from '../../../types.ts'; +import type { AnthropicMessagesMessage, AnthropicMessagesPayload, AnthropicMessagesTextBlockParam } from '@floway-dev/protocols/anthropic-messages'; + +/** + * Preserve restored native Messages turns verbatim and compute request-wide fields once. + * Apply cache breakpoints after complete assembly, leaving restored turns untouched. + */ +export const anthropicMessagesPayloadFromSegments = ( + request: IRInputRequestHeader, + turns: readonly (IRInputTurnWithCallIds | RestoredAssistantTurn)[], + options: Pick = {}, +): AnthropicMessagesPayload => { + const system: AnthropicMessagesTextBlockParam[] = []; + const messages: AnthropicMessagesMessage[] = []; + const restored = new Set(); + for (const piece of segmentTurns(turns)) { + if (piece.kind === 'restored') { + messages.push(piece.turn); + restored.add(piece.turn); + continue; + } + // Only a conversation's first segment may lift its leading prompt into the + // top-level `system` slot, and segmentation is what says which that is. + const segment = anthropicMessagesConversationFromIR(piece.turns, piece.leading); + system.push(...segment.system); + messages.push(...segment.messages); + } + + const target: AnthropicMessagesPayload = { + ...anthropicMessagesHeaderFromIR(request, options), + messages, + ...(system.length === 0 ? {} : { system }), + }; + applyLastSystemCacheBreakpoint(system); + applyLastToolCacheBreakpoint(target.tools); + applyLastMessageCacheBreakpoint(messages.filter(message => !restored.has(message))); + return target; +}; 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 deleted file mode 100644 index 1612c98007..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/as-target/tool-input-schema.ts +++ /dev/null @@ -1,7 +0,0 @@ -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 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/via-openai-chat-completions/events.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts deleted file mode 100644 index 2621b137c2..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; -import { readOpenAIChatCompletionsEvents } from '../../openai-chat-completions/as-target/events.ts'; -import { writeAnthropicMessagesEvents } from '../as-source/events.ts'; -import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; - -export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { - const stream = createRoundTripCoordinator('anthropicMessages', 'openaiChatCompletions', codec); - return writeAnthropicMessagesEvents(readOpenAIChatCompletionsEvents(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); -}; 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 deleted file mode 100644 index 1d82a504e3..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/request.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { klona } from 'klona/json'; - -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import { filterAnthropicMessagesClientTools } from '../as-source/client-tools.ts'; -import { partitionAnthropicMessagesTurns } from '../as-source/conversation-turns.ts'; -import { collectAnthropicMessagesReplayContents, findAnthropicMessagesSidecarOccurrences, verifyAnthropicMessagesTurnDigest } from '../as-source/round-trip.ts'; -import type { - AnthropicMessagesAssistantContentBlock, - AnthropicMessagesAssistantMessage, - AnthropicMessagesClientTool, - AnthropicMessagesPayload, - AnthropicMessagesServerToolUseBlockParam, - AnthropicMessagesSystemMessage, - AnthropicMessagesTextBlockParam, - AnthropicMessagesToolResultBlock, - AnthropicMessagesToolUseBlockParam, - AnthropicMessagesUserContentBlock, - AnthropicMessagesUserMessage, -} from '@floway-dev/protocols/anthropic-messages'; -import { FAST_SERVICE_TIER } from '@floway-dev/protocols/common'; -import type { OpenAIChatCompletionsStreamOptionsEx, OpenAIChatCompletionsTextPart, OpenAIChatCompletionsUserContentPart, OpenAIChatCompletionsPayload, OpenAIChatCompletionsMessage, OpenAIChatCompletionsTool, OpenAIChatCompletionsToolCall } from '@floway-dev/protocols/openai-chat-completions'; - -const flattenToolResultContent = (content: AnthropicMessagesToolResultBlock['content']): string => { - if (content === undefined) return ''; - if (typeof content === 'string') return content; - - const textBlocks = content.filter((block): block is AnthropicMessagesTextBlockParam => block.type === 'text'); - if (textBlocks.length === content.length) return textBlocks.map(block => block.text).join('\n\n'); - - return JSON.stringify(content); -}; - -// OpenAI strict-mode JSON Schema validators reject an object schema without an -// explicit `properties` field. Preserve the source schema while satisfying that -// target requirement. -// https://github.com/caozhiyuan/copilot-api/commit/ad57069826843c5d17d7b0e5ef2f75050128893c -const normalizeToolInputSchema = (schema: Record): Record => { - const target = klona(schema); - if (target.type !== 'object' || target.properties !== undefined) return target; - return { ...target, properties: {} }; -}; - -// The Chat Completions effort axis combines Anthropic's separate effort and -// thinking controls; token budgets remain a separate quantity. -const resolveReasoningEffort = (payload: AnthropicMessagesPayload): string | undefined => { - if (payload.output_config?.effort != null) return payload.output_config.effort; - if (payload.thinking?.type === 'disabled') return 'none'; - if (payload.thinking?.type === 'enabled' || payload.thinking?.type === 'adaptive') return 'medium'; - return undefined; -}; - -const toOpenAIChatCompletionsContent = (content: string | AnthropicMessagesUserContentBlock[] | AnthropicMessagesAssistantContentBlock[]): string | OpenAIChatCompletionsUserContentPart[] => { - if (typeof content === 'string') return content; - - if (!content.some(block => block.type === 'image')) { - return content - .filter((block): block is AnthropicMessagesTextBlockParam => block.type === 'text') - .map(block => block.text) - .join('\n\n'); - } - - const parts: OpenAIChatCompletionsUserContentPart[] = []; - - for (const block of content) { - if (block.type === 'text') { - parts.push({ type: 'text', text: block.text }); - continue; - } - - if (block.type === 'image') { - 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}` } }); - } - } - - return parts; -}; - -const toOpenAIChatCompletionsFunctionCall = (block: AnthropicMessagesToolUseBlockParam | AnthropicMessagesServerToolUseBlockParam): OpenAIChatCompletionsToolCall => ({ - id: block.id, - type: 'function', - function: { - name: block.name, - arguments: JSON.stringify(block.input), - }, -}); - -type PendingAssistantMessage = { - textParts: string[]; - toolCalls: OpenAIChatCompletionsToolCall[]; - reasoningText: string[]; -}; - -const flushPendingAssistantMessage = (messages: OpenAIChatCompletionsMessage[], pending: PendingAssistantMessage): void => { - if (pending.textParts.length === 0 && pending.toolCalls.length === 0 && pending.reasoningText.length === 0) { - return; - } - - messages.push({ - role: 'assistant', - content: pending.textParts.join('\n\n') || null, - ...(pending.toolCalls.length > 0 ? { tool_calls: [...pending.toolCalls] } : {}), - ...(pending.reasoningText.length > 0 ? { reasoning_text: pending.reasoningText.join('') } : {}), - }); - - pending.textParts.length = 0; - pending.toolCalls.length = 0; - pending.reasoningText.length = 0; -}; - -const translateAnthropicMessagesUser = (message: AnthropicMessagesUserMessage, messageIdx: number, legacyFunctionNames: Map): OpenAIChatCompletionsMessage[] => { - if (!Array.isArray(message.content)) { - return [ - { - role: 'user', - content: message.content, - }, - ]; - } - - const messages: OpenAIChatCompletionsMessage[] = []; - const pendingUserBlocks: Exclude[] = []; - - const flushPendingUserBlocks = () => { - if (pendingUserBlocks.length === 0) return; - messages.push({ - role: 'user', - content: toOpenAIChatCompletionsContent(pendingUserBlocks), - }); - - pendingUserBlocks.length = 0; - }; - - for (const [blockIdx, block] of message.content.entries()) { - if (block.type === 'tool_result') { - // Preserving source chronology matters more than keeping one OpenAI Chat Completions message, - // so interleaved user content and tool results become alternating messages. - flushPendingUserBlocks(); - const content = flattenToolResultContent(block.content); - const legacyName = legacyFunctionNames.get(block.tool_use_id); - legacyFunctionNames.delete(block.tool_use_id); - messages.push(legacyName === undefined - ? { role: 'tool', tool_call_id: block.tool_use_id, content } - : { role: 'function', name: legacyName, content }); - continue; - } - - if (block.type !== 'text' && block.type !== 'image') { - throw new TranslateInputException(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' content blocks are not supported on this model`); - } - - pendingUserBlocks.push(block); - } - - flushPendingUserBlocks(); - - return messages; -}; - -const translateAnthropicMessagesAssistant = (message: AnthropicMessagesAssistantMessage, messageIdx: number): OpenAIChatCompletionsMessage[] => { - if (!Array.isArray(message.content)) { - return [ - { - role: 'assistant', - content: message.content, - }, - ]; - } - - const messages: OpenAIChatCompletionsMessage[] = []; - const pending: PendingAssistantMessage = { - textParts: [], - toolCalls: [], - reasoningText: [], - }; - - for (const [blockIdx, block] of message.content.entries()) { - switch (block.type) { - case 'text': - pending.textParts.push(block.text); - break; - case 'thinking': - if (block.thinking !== '') pending.reasoningText.push(block.thinking); - break; - case 'redacted_thinking': - warnDroppedUnrecognizedAssistantReasoning('anthropicMessages', 'openaiChatCompletions'); - break; - case 'tool_use': - case 'server_tool_use': - pending.toolCalls.push(toOpenAIChatCompletionsFunctionCall(block)); - break; - case 'web_search_tool_result': - flushPendingAssistantMessage(messages, pending); - messages.push({ - role: 'tool', - tool_call_id: block.tool_use_id, - content: JSON.stringify(block.content), - }); - break; - default: - throw new TranslateInputException(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' assistant content blocks are not supported on this model`); - } - } - - flushPendingAssistantMessage(messages, pending); - return messages; -}; - -// Anthropic Messages system blocks are prompt boundaries; preserve each one -// as a separate OpenAI Chat Completions text part so a CC→Anthropic Messages→CC round trip -// does not silently merge them. Falls back to the simple string form when -// the source is already a single-string field. -const systemContentFromBlocks = (system: string | AnthropicMessagesTextBlockParam[]): string | OpenAIChatCompletionsTextPart[] => - typeof system === 'string' - ? system - : system.map(block => ({ type: 'text', text: block.text })); - -const translateAnthropicMessagesSystem = (message: AnthropicMessagesSystemMessage): OpenAIChatCompletionsMessage[] => [ - { - role: 'system', - content: typeof message.content === 'string' ? message.content : message.content.map(block => { - if (block.type !== 'text') throw new TranslateInputException('Only text is supported in Chat system messages.'); - return { type: 'text', text: block.text }; - }), - }, -]; - -const translateAnthropicMessagesTools = (tools?: AnthropicMessagesClientTool[]): OpenAIChatCompletionsTool[] | undefined => - // Do not hide target-side function-name constraints by renaming tools here; - // the Anthropic Messages source contract has no reverse mapping surface for that. - tools?.map(tool => ({ - type: 'function', - function: { - name: tool.name, - description: tool.description, - parameters: normalizeToolInputSchema(tool.input_schema), - ...(tool.strict !== undefined ? { strict: tool.strict } : {}), - }, - })); - -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 TranslateInputException(`Anthropic Messages tool_choice names an undeclared tool: ${String(toolChoice.name)}`); - return undefined; - } - - switch (toolChoice.type) { - case 'auto': - return 'auto'; - case 'any': - return 'required'; - case 'tool': { - 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': - return 'none'; - } -}; - -export const buildTargetRequest = async ( - payload: AnthropicMessagesPayload, - codec: SidecarCodec, -): Promise => { - const clientTools = filterAnthropicMessagesClientTools(payload.tools); - const legacyFunctionNames = new Map(); - // Pass effort through verbatim; per-upstream enum acceptance (e.g. some - // backends rejecting `xhigh`/`max`) is the target interceptor's concern. - const reasoningEffort = resolveReasoningEffort(payload); - // Anthropic's structured-output schema is already strict. Supply the name - // required by OpenAI and let OpenAI report any schema constraint it rejects. - // https://platform.claude.com/docs/en/build-with-claude/structured-outputs - const format = payload.output_config?.format; - const jsonSchema = format?.type === 'json_schema' - ? { name: 'messages_response', strict: true as const, schema: klona(format.schema) } - : undefined; - const responseFormat = jsonSchema ? { type: 'json_schema' as const, json_schema: jsonSchema } : undefined; - - // Anthropic `speed: 'fast'` selects OpenAI's priority lane. Without an - // explicit speed, preserve Anthropic's service tier as an open string. - // https://docs.claude.com/en/build-with-claude/fast-mode - // https://platform.openai.com/docs/guides/fast-mode - const serviceTier = payload.speed === 'fast' ? FAST_SERVICE_TIER : payload.speed === undefined ? payload.service_tier : undefined; - const messages: OpenAIChatCompletionsMessage[] = payload.system == null || (typeof payload.system === 'string' ? payload.system === '' : payload.system.length === 0) - ? [] - : [{ role: 'system', content: systemContentFromBlocks(payload.system) }]; - let messageIndex = 0; - for (const turn of partitionAnthropicMessagesTurns(payload.messages)) { - if (turn.role === 'assistant') { - const roundTrip = await roundTripAssistantTurn('anthropicMessages', 'openaiChatCompletions', turn.items, codec, findAnthropicMessagesSidecarOccurrences, verifyAnthropicMessagesTurnDigest, collectAnthropicMessagesReplayContents); - if (roundTrip?.turn.function_call != null) { - const modernIds = new Set(roundTrip.turn.tool_calls?.map(call => call.id)); - for (const message of turn.items) if (Array.isArray(message.content)) for (const block of message.content) { - if ((block.type === 'tool_use' || block.type === 'server_tool_use') && !modernIds.has(block.id)) legacyFunctionNames.set(block.id, roundTrip.turn.function_call.name); - } - } - messages.push(...(roundTrip ? [roundTrip.turn] : turn.items.flatMap((message, index) => translateAnthropicMessagesAssistant(message, messageIndex + index)))); - } else for (const [index, message] of turn.items.entries()) { - switch (message.role) { - case 'user': messages.push(...translateAnthropicMessagesUser(message, messageIndex + index, legacyFunctionNames)); break; - case 'system': messages.push(...translateAnthropicMessagesSystem(message)); break; - default: throw new TranslateInputException(`messages.${messageIndex + index}.role: role '${(message as { role: string }).role}' is not supported on this model`); - } - } - messageIndex += turn.items.length; - } - - return { - model: payload.model, - messages, - ...(reasoningEffort !== undefined ? { reasoning_effort: reasoningEffort } : {}), - max_tokens: payload.max_tokens, - stop: klona(payload.stop_sequences), - stream: true, - // Ask the upstream for usage on every streaming chunk, not just the final - // one. `include_usage` is the OpenAI-standard flag; `continuous_usage_stats` - // is the vLLM/SGLang extension that lets this translation emit the real - // `input_tokens` in `message_start` (before any output exists) and - // cumulative `output_tokens` on intermediate `message_delta` events, which - // is the shape the Anthropic Messages SSE contract promises. Upstreams that - // do not implement the extension ignore it, and the translator falls back - // to the final usage-only chunk. Ref: - // https://github.com/vllm-project/vllm/blob/d5f0a6e829faa69d1db289bf62b14dae136c02b2/vllm/entrypoints/generate/base/protocol.py#L241-L243 - stream_options: { include_usage: true, continuous_usage_stats: true } as OpenAIChatCompletionsStreamOptionsEx, - temperature: payload.temperature, - top_p: payload.top_p, - tools: translateAnthropicMessagesTools(clientTools), - tool_choice: translateAnthropicMessagesToolChoice(payload.tool_choice, clientTools), - ...(responseFormat ? { response_format: responseFormat } : {}), - ...(serviceTier !== undefined ? { service_tier: serviceTier } : {}), - }; -}; 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 deleted file mode 100644 index 6bd9c0ade8..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/translate.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { translateToSourceEvents } from './events.ts'; -import { buildTargetRequest } from './request.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { Translator } from '../../../types.ts'; -import { restateAnthropicMessagesError } from '../as-source/error-body.ts'; -import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; - -export const translate: Translator< - AnthropicMessagesPayload, AnthropicMessagesStreamEventEx, OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent -> = async (src, ctx) => { - try { - const target = await buildTargetRequest(src, ctx.sidecarCodec); - return { - type: 'trip', - target, - events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), - error: restateAnthropicMessagesError, - }; - } catch (error) { - if (error instanceof TranslateInputException) return error.failure; - throw error; - } -}; diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts deleted file mode 100644 index a199b410ab..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; -import { readOpenAIResponsesEvents } from '../../openai-responses/as-target/events.ts'; -import { writeAnthropicMessagesEvents } from '../as-source/events.ts'; -import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; - -export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { - const stream = createRoundTripCoordinator('anthropicMessages', 'openaiResponses', codec); - return writeAnthropicMessagesEvents(readOpenAIResponsesEvents(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); -}; diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/reasoning.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/reasoning.ts deleted file mode 100644 index 5202730ab2..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/reasoning.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { AnthropicMessagesRedactedThinkingBlock, AnthropicMessagesThinkingBlock } from '@floway-dev/protocols/anthropic-messages'; -import type { OpenAIResponsesInputReasoning } from '@floway-dev/protocols/openai-responses'; - -type AnthropicMessagesReasoningBlock = AnthropicMessagesThinkingBlock | AnthropicMessagesRedactedThinkingBlock; - -export const anthropicMessagesReasoningBlockToOpenAIResponsesReasoning = (block: AnthropicMessagesReasoningBlock): OpenAIResponsesInputReasoning | null => { - if (block.type !== 'thinking' || block.thinking.length === 0) return null; - return { - type: 'reasoning', - summary: [{ type: 'summary_text', text: block.thinking }], - }; -}; 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 deleted file mode 100644 index 9bad07ebc0..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/request.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { klona } from 'klona/json'; - -import { anthropicMessagesReasoningBlockToOpenAIResponsesReasoning } from './reasoning.ts'; -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import { filterAnthropicMessagesClientTools } from '../as-source/client-tools.ts'; -import { partitionAnthropicMessagesTurns } from '../as-source/conversation-turns.ts'; -import { collectAnthropicMessagesReplayContents, findAnthropicMessagesSidecarOccurrences, verifyAnthropicMessagesTurnDigest } from '../as-source/round-trip.ts'; -import { - type AnthropicMessagesAssistantMessage, - type AnthropicMessagesClientTool, - type AnthropicMessagesPayload, - type AnthropicMessagesMetadataEx, - type AnthropicMessagesServerToolUseBlockParam, - type AnthropicMessagesSystemMessage, - type AnthropicMessagesTextBlockParam, - type AnthropicMessagesToolResultBlock, - type AnthropicMessagesToolUseBlockParam, - type AnthropicMessagesUserContentBlock, - type AnthropicMessagesUserMessage, - type AnthropicMessagesWebSearchToolResultBlockParam, -} from '@floway-dev/protocols/anthropic-messages'; -import { FAST_SERVICE_TIER } from '@floway-dev/protocols/common'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesInputContent, CanonicalOpenAIResponsesInputItem, OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; - -const flattenToolResultContent = (content: AnthropicMessagesToolResultBlock['content']): string => { - if (content === undefined) return ''; - if (typeof content === 'string') return content; - - const textBlocks = content.filter((block): block is AnthropicMessagesTextBlockParam => block.type === 'text'); - if (textBlocks.length === content.length) return textBlocks.map(block => block.text).join('\n\n'); - - return JSON.stringify(content); -}; - -// OpenAI strict-mode JSON Schema validators reject an object schema without an -// explicit `properties` field. Preserve the source schema while satisfying that -// target requirement. -// https://github.com/caozhiyuan/copilot-api/commit/ad57069826843c5d17d7b0e5ef2f75050128893c -const normalizeToolInputSchema = (schema: Record): Record => { - const target = klona(schema); - if (target.type !== 'object' || target.properties !== undefined) return target; - return { ...target, properties: {} }; -}; - -// The Responses effort axis combines Anthropic's separate effort and thinking -// controls; token budgets remain a separate quantity. -const resolveReasoningEffort = (payload: AnthropicMessagesPayload): string | undefined => { - if (payload.output_config?.effort != null) return payload.output_config.effort; - if (payload.thinking?.type === 'disabled') return 'none'; - if (payload.thinking?.type === 'enabled' || payload.thinking?.type === 'adaptive') return 'medium'; - return undefined; -}; - -const flushPendingContent = (pending: OpenAIResponsesInputContent[], input: CanonicalOpenAIResponsesInputItem[], role: 'user' | 'assistant'): void => { - if (pending.length === 0) return; - input.push({ type: 'message', role, content: [...pending] }); - pending.length = 0; -}; - -const translateUserContentBlock = ( - block: Exclude, - messageIdx: number, - blockIdx: number, -): OpenAIResponsesInputContent => { - if (block.type === 'text') return { type: 'input_text', text: block.text }; - if (block.type === 'image') { - if (block.source.type === 'file') return { type: 'input_image', file_id: block.source.file_id }; - if (block.source.type === 'url') return { type: 'input_image', image_url: block.source.url }; - return { - type: 'input_image', - image_url: `data:${block.source.media_type};base64,${block.source.data}`, - }; - } - - 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 => ({ - type: 'function_call', - call_id: block.id, - name: block.name, - arguments: JSON.stringify(block.input), - status: 'completed', -}); - -const toOpenAIResponsesStructuredToolOutput = (block: AnthropicMessagesWebSearchToolResultBlockParam): Extract => ({ - type: 'function_call_output', - call_id: block.tool_use_id, - output: JSON.stringify(block.content), - status: Array.isArray(block.content) ? 'completed' : 'incomplete', -}); - -const translateUserMessage = (message: AnthropicMessagesUserMessage, messageIdx: number): CanonicalOpenAIResponsesInputItem[] => { - if (typeof message.content === 'string') { - return [{ type: 'message', role: 'user', content: message.content }]; - } - - const input: CanonicalOpenAIResponsesInputItem[] = []; - const pendingContent: OpenAIResponsesInputContent[] = []; - - for (const [blockIdx, block] of message.content.entries()) { - if (block.type === 'tool_result') { - // OpenAI Responses can represent alternating user content and tool outputs, so - // preserve Anthropic Messages block chronology instead of moving all tool results to - // the front of the turn. - flushPendingContent(pendingContent, input, 'user'); - input.push({ - type: 'function_call_output', - call_id: block.tool_use_id, - output: flattenToolResultContent(block.content), - status: block.is_error ? 'incomplete' : 'completed', - }); - continue; - } - - pendingContent.push(translateUserContentBlock(block, messageIdx, blockIdx)); - } - - flushPendingContent(pendingContent, input, 'user'); - return input; -}; - -const translateAssistantMessage = (message: AnthropicMessagesAssistantMessage, messageIdx: number): CanonicalOpenAIResponsesInputItem[] => { - if (typeof message.content === 'string') { - return [{ type: 'message', role: 'assistant', content: message.content }]; - } - - const input: CanonicalOpenAIResponsesInputItem[] = []; - const pendingContent: OpenAIResponsesInputContent[] = []; - - for (const [blockIdx, block] of message.content.entries()) { - if (block.type === 'tool_use' || block.type === 'server_tool_use') { - flushPendingContent(pendingContent, input, 'assistant'); - input.push(toOpenAIResponsesFunctionCall(block)); - continue; - } - - if (block.type === 'web_search_tool_result') { - flushPendingContent(pendingContent, input, 'assistant'); - input.push(toOpenAIResponsesStructuredToolOutput(block)); - continue; - } - - if (block.type === 'redacted_thinking') { - warnDroppedUnrecognizedAssistantReasoning('anthropicMessages', 'openaiResponses'); - continue; - } - - if (block.type === 'thinking') { - flushPendingContent(pendingContent, input, 'assistant'); - const reasoning = anthropicMessagesReasoningBlockToOpenAIResponsesReasoning(block); - if (reasoning !== null) input.push(reasoning); - continue; - } - - if (block.type === 'text') { - pendingContent.push({ type: 'output_text', text: block.text }); - continue; - } - - 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'); - return input; -}; - -// Preserve per-block boundaries when the source carries a -// AnthropicMessagesTextBlockParam[]; single-string source stays as string content. -const translateAnthropicMessagesSystem = (message: AnthropicMessagesSystemMessage): CanonicalOpenAIResponsesInputItem[] => [ - { - type: 'message', - role: 'system', - content: typeof message.content === 'string' - ? message.content - : message.content.map(block => { - if (block.type !== 'text') throw new TranslateInputException('Only text system content is supported by this Responses translation.'); - return { type: 'input_text', text: block.text }; - }), - }, -]; - -// OpenAI Responses' `instructions` field is `string | null` — it cannot carry -// multiple text blocks faithfully. When the source `AnthropicMessagesPayload.system` -// is a single string or a single block, the canonical `instructions` slot -// is the right home. When the source is a multi-block array, the canonical -// slot would silently merge the boundaries, so emit a leading input -// system message with multi-part `input_text` content instead and leave -// `instructions` null; both placements act as a system prompt prefix at -// the upstream and the multi-part form preserves the caller-visible block -// structure. -interface SystemPlacement { - readonly instructions: string | null; - readonly prependItems: readonly CanonicalOpenAIResponsesInputItem[]; -} - -const placeAnthropicMessagesSystem = (system: string | AnthropicMessagesTextBlockParam[] | undefined): SystemPlacement => { - if (typeof system === 'string') return { instructions: system, prependItems: [] }; - if (!system || system.length === 0) return { instructions: null, prependItems: [] }; - if (system.length === 1) return { instructions: system[0].text, prependItems: [] }; - return { - instructions: null, - prependItems: [{ - type: 'message', - role: 'system', - content: system.map(block => ({ type: 'input_text', text: block.text })), - }], - }; -}; - -const translateTools = (tools: AnthropicMessagesClientTool[] | undefined): OpenAIResponsesTool[] => { - if (!tools || tools.length === 0) return []; - - return tools.map(tool => ({ - type: 'function', - name: tool.name, - parameters: normalizeToolInputSchema(tool.input_schema), - // OpenAI Responses tools default stricter than Anthropic/OpenAI-Chat-Completions-style function tools, - // so omitted source strictness is made explicit as false. - strict: tool.strict ?? false, - ...(tool.description ? { description: tool.description } : {}), - })); -}; - -// OpenAI Responses upstreams disagree on an orphaned `tool_choice` — one sent without -// tools: OpenAI-backed models ignore it, while xAI-backed ones reject the -// request with `invalid-argument: A tool_choice was set on the request but no -// tools were specified`. Other gateways hit the same wall on both the native -// OpenAI Responses path and the OpenAI Responses → OpenAI Chat Completions path: -// https://github.com/Wei-Shaw/sub2api/issues/4819 -// https://github.com/jlcodes99/cockpit-tools/issues/1727 -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 TranslateInputException(`Anthropic Messages tool_choice names an undeclared tool: ${String(toolChoice.name)}`); - return undefined; - } - - const toolNames = new Set(tools.map(tool => tool.name)); - - switch (toolChoice.type) { - case 'auto': - return 'auto'; - case 'any': - return 'required'; - case 'tool': { - 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': - return 'none'; - } -}; - -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 TranslateInputException(`Cannot translate Anthropic Messages metadata field '${key}' to a Responses string metadata value.`); - return [[key, value]]; -})); - -export const buildTargetRequest = async ( - payload: AnthropicMessagesPayload, - codec: SidecarCodec, -): Promise => { - // Preserve the source `output_config.effort` value as-is, even if the chosen - // OpenAI Responses upstream may reject it. Translation stays pairwise and leaves - // target-side validation to the selected upstream endpoint. - const effort = resolveReasoningEffort(payload); - const reasoning = effort !== undefined ? { effort } : undefined; - const clientTools = filterAnthropicMessagesClientTools(payload.tools); - const { instructions, prependItems } = placeAnthropicMessagesSystem(payload.system); - // Anthropic's structured-output schema is already strict. Supply the name - // required by OpenAI and let OpenAI report any schema constraint it rejects. - // https://platform.claude.com/docs/en/build-with-claude/structured-outputs - const format = payload.output_config?.format; - const jsonSchema = format?.type === 'json_schema' - ? { name: 'messages_response', strict: true as const, schema: klona(format.schema) } - : undefined; - const text = jsonSchema ? { format: { type: 'json_schema' as const, ...jsonSchema } } : undefined; - - // Anthropic `speed: 'fast'` selects OpenAI's priority lane. Without an - // explicit speed, preserve Anthropic's service tier as an open string. - // https://docs.claude.com/en/build-with-claude/fast-mode - // https://platform.openai.com/docs/guides/fast-mode - const serviceTier = payload.speed === 'fast' ? FAST_SERVICE_TIER : payload.speed === undefined ? payload.service_tier : undefined; - const toolChoice = translateToolChoice(payload.tool_choice, clientTools); - const input = [...prependItems]; - let messageIndex = 0; - for (const turn of partitionAnthropicMessagesTurns(payload.messages)) { - if (turn.role === 'assistant') { - const roundTrip = await roundTripAssistantTurn('anthropicMessages', 'openaiResponses', turn.items, codec, findAnthropicMessagesSidecarOccurrences, verifyAnthropicMessagesTurnDigest, collectAnthropicMessagesReplayContents); - input.push(...(roundTrip ? roundTrip.turn : turn.items.flatMap((message, index) => translateAssistantMessage(message, messageIndex + index)))); - } else for (const [index, message] of turn.items.entries()) { - switch (message.role) { - case 'user': input.push(...translateUserMessage(message, messageIndex + index)); break; - case 'system': input.push(...translateAnthropicMessagesSystem(message)); break; - default: throw new TranslateInputException(`messages.${messageIndex + index}.role: role '${(message as { role: string }).role}' is not supported on this model`); - } - } - messageIndex += turn.items.length; - } - - // Keep fallback semantics strict: do not synthesize `temperature: 1`, - // `store: false`, `parallel_tool_calls: true`, `reasoning.summary`, or - // `reasoning.context` when the Anthropic Messages source did not express those knobs. - // The source thinking block carries no reasoning-context mode, so we never - // invent `all_turns` (or any other value) on the target reasoning object. - return { - model: payload.model, - input, - ...(instructions !== null ? { instructions } : {}), - ...(payload.temperature !== undefined ? { temperature: payload.temperature } : {}), - ...(payload.top_p !== undefined ? { top_p: payload.top_p } : {}), - max_output_tokens: payload.max_tokens, - ...(payload.tools !== undefined ? { tools: translateTools(clientTools) } : {}), - ...(toolChoice !== undefined ? { tool_choice: toolChoice } : {}), - ...(payload.metadata ? { metadata: translateMetadata(payload.metadata as AnthropicMessagesMetadataEx) } : {}), - stream: true, - ...(reasoning ? { reasoning } : {}), - ...(text ? { text } : {}), - ...(serviceTier !== undefined ? { service_tier: serviceTier } : {}), - }; -}; 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 deleted file mode 100644 index 83581fc501..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/translate.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { translateToSourceEvents } from './events.ts'; -import { buildTargetRequest } from './request.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { Translator } from '../../../types.ts'; -import { restateAnthropicMessagesError } from '../as-source/error-body.ts'; -import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; - -export const translate: Translator< - AnthropicMessagesPayload, AnthropicMessagesStreamEventEx, CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx -> = async (src, ctx) => { - try { - return { - type: 'trip', - target: await buildTargetRequest(src, ctx.sidecarCodec), - events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), - error: restateAnthropicMessagesError, - }; - } catch (error) { - if (error instanceof TranslateInputException) return error.failure; - throw error; - } -}; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source.ts b/packages/translate/src/protocols/gemini-generate-content/as-source.ts new file mode 100644 index 0000000000..4d44a5d34a --- /dev/null +++ b/packages/translate/src/protocols/gemini-generate-content/as-source.ts @@ -0,0 +1,20 @@ +import { restateGeminiGenerateContentError } from './as-source/error-body.ts'; +import { writeGeminiGenerateContentEvents } from './as-source/events.ts'; +import { readGeminiGenerateContentRequest } from './as-source/input-to.ts'; +import { collectGeminiGenerateContentReplayContents, findGeminiGenerateContentSidecarOccurrences, verifyGeminiGenerateContentTurnDigest } from './as-source/round-trip.ts'; +import type { SourceProtocol } from '../../composition/index.ts'; +import type { GeminiGenerateContentRequest, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; + +export const geminiGenerateContentSourceProtocol: SourceProtocol< + 'geminiGenerateContent', GeminiGenerateContentRequest, GeminiGenerateContentStreamEvent +> = { + name: 'geminiGenerateContent', + read: readGeminiGenerateContentRequest, + sidecarRecognition: { + inspect: findGeminiGenerateContentSidecarOccurrences, + verify: verifyGeminiGenerateContentTurnDigest, + collectCandidates: collectGeminiGenerateContentReplayContents, + }, + error: restateGeminiGenerateContentError, + writeEvents: (_payload, records, roundTrip) => writeGeminiGenerateContentEvents(records, { roundTrip }), +}; 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 deleted file mode 100644 index 004ab1c235..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/content.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { GeminiGenerateContentPartData, 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; - -// OpenAI Chat Completions accepts only WAV and MP3 input audio. -// https://github.com/openai/openai-node/blob/3f8753e0fc60f22c10c925b391810f45c3904af8/src/resources/chat/completions/completions.ts#L1591-L1621 -const GEMINI_GENERATE_CONTENT_AUDIO_FORMATS = { - 'audio/wav': 'wav', - 'audio/mp3': 'mp3', - 'audio/mpeg': 'mp3', -} as const; - -export type GeminiGenerateContentSupportedImageMimeType = (typeof GEMINI_GENERATE_CONTENT_SUPPORTED_IMAGE_MIME_TYPES)[number]; - -export type GeminiGenerateContentInlineData = - | { kind: 'image'; mimeType: GeminiGenerateContentSupportedImageMimeType; data: string } - | { kind: 'pdf'; mimeType: 'application/pdf'; data: string } - | { 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); - -export const geminiGenerateContentVisibleText = (part: GeminiGenerateContentPart): string | null => (part.thought === true ? null : geminiGenerateContentPartText(part)); - -export const geminiGenerateContentText = (content?: GeminiGenerateContentContent): string | null => { - const texts = content?.parts?.map(geminiGenerateContentPartText).filter((text): text is string => text !== null); - - return texts?.length ? texts.join('\n\n') : null; -}; - -export const geminiGenerateContentInlineData = (part: GeminiGenerateContentPart): GeminiGenerateContentInlineData | null => { - const inlineData = part.inlineData; - if (!inlineData) return null; - - if (GEMINI_GENERATE_CONTENT_SUPPORTED_IMAGE_MIME_TYPES.includes(inlineData.mimeType as GeminiGenerateContentSupportedImageMimeType)) { - return { - kind: 'image', - mimeType: inlineData.mimeType as GeminiGenerateContentSupportedImageMimeType, - data: inlineData.data, - }; - } - - if (inlineData.mimeType === 'application/pdf') { - return { kind: 'pdf', mimeType: inlineData.mimeType, data: inlineData.data }; - } - - const audioFormat = GEMINI_GENERATE_CONTENT_AUDIO_FORMATS[inlineData.mimeType as keyof typeof GEMINI_GENERATE_CONTENT_AUDIO_FORMATS]; - if (audioFormat !== undefined) { - return { - kind: 'audio', - mimeType: inlineData.mimeType as keyof typeof GEMINI_GENERATE_CONTENT_AUDIO_FORMATS, - format: audioFormat, - data: inlineData.data, - }; - } - - return { - kind: 'unsupported', - mimeType: inlineData.mimeType, - data: inlineData.data, - }; -}; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/conversation-turns.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/conversation-turns.ts index 5eb01fa6ec..a7bce50c7b 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/conversation-turns.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/conversation-turns.ts @@ -1,11 +1,11 @@ import type { GeminiGenerateContentContent } from '@floway-dev/protocols/gemini-generate-content'; -export type GeminiGenerateContentConversationTurn = { role: 'assistant' | 'bare'; items: GeminiGenerateContentContent[] }; +export type GeminiGenerateContentConversationTurn = { role: 'assistant' | 'client'; items: GeminiGenerateContentContent[] }; export const partitionGeminiGenerateContentTurns = (contents: readonly GeminiGenerateContentContent[]): GeminiGenerateContentConversationTurn[] => { const turns: GeminiGenerateContentConversationTurn[] = []; for (const content of contents) { - const role = content.role === 'model' ? 'assistant' : 'bare'; + const role = content.role === 'model' ? 'assistant' : 'client'; const previous = turns.at(-1); if (previous?.role === role) previous.items.push(content); else turns.push({ role, items: [content] }); diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts index 92d8fb4444..6eead59c5e 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts @@ -1,13 +1,13 @@ -import { collectGeminiGenerateContentTextPlacements, computeGeminiGenerateContentTurnDigest, createGeminiGenerateContentSidecarPart } from './round-trip.ts'; +import { collectGeminiGenerateContentTextPlacements, computeGeminiGenerateContentTurnDigest, createGeminiGenerateContentSidecarCarrier } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; import type { IRJSONObject } from '../../../ir/common/json.ts'; import { IRAbsent } from '../../../ir/output/absent.ts'; import type { IRCollector } from '../../../ir/output/collector.ts'; -import type { IRErrorPayload } from '../../../ir/output/error.ts'; +import { type IRErrorPayload } from '../../../ir/output/error.ts'; import { irItemPath, irPartPath, irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/output/operation-paths.ts'; -import type { IRPath, IRRecord } from '../../../ir/output/records.ts'; +import { type IRPath, type IRRecord } from '../../../ir/output/records.ts'; import { consumeIRRecords } from '../../../ir/output/stream.ts'; -import type { IROutput, IROutputSourceCitation, IROutputTokenLogprob } from '../../../ir/output/types.ts'; +import { type IROutput, type IROutputSourceCitation, type IROutputTokenLogprob } from '../../../ir/output/types.ts'; import { createIRUnitQueue, type IRUnitQueue } from '../../../ir/output/unit-queue.ts'; import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/provenance.ts'; @@ -15,7 +15,8 @@ import { appendEmittedField, createEmittedFieldState, type EmittedFieldStates, t import { errorDetailsOf } from '../../../shared/api-error.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; -import { isCompleteJSONObject, parseJSONObjectAtCompletion } from '../../../shared/json/object.ts'; +import { parseJSONObjectUnlessTruncated } from '../../../shared/json/completion.ts'; +import { isCompleteJSONObject } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import type { GeminiGenerateContentInputAssistantTurnState, GeminiGenerateContentInputAssistantTurn } from '../as-target/assistant-turn.ts'; import { eventFrame, FAST_SERVICE_TIER, type EventFrame } from '@floway-dev/protocols/common'; @@ -29,7 +30,10 @@ import { createGeminiGenerateContentEventAccumulator, } from '@floway-dev/protocols/gemini-generate-content'; -// https://ai.google.dev/api/rest/v1beta/Status +/** + * Use the Google RPC status vocabulary for the numeric error code. + * https://ai.google.dev/api/rest/v1beta/Status + */ const statusNameOf = (status: number): string => { switch (status) { case 400: return 'INVALID_ARGUMENT'; @@ -47,10 +51,13 @@ const statusNameOf = (status: number): string => { } }; +/** + * HTTP failures supply an upstream status; streaming failures derive a code from the error kind. + * Context overflow selects our local 400 code. + */ export const geminiGenerateContentErrorFromIR = (error: IRErrorPayload, httpStatus?: number): GeminiGenerateContentErrorResponse['error'] => { const details = errorDetailsOf(error, ['message']); - // Arbitrary error fields use the source's Struct details container. - // https://protobuf.dev/reference/protobuf/google.protobuf/#struct + // Preserve unmodeled diagnostic fields in a chosen Struct Any inside details. const extension = details === undefined ? {} : { details: [{ '@type': 'type.googleapis.com/google.protobuf.Struct', value: details }] }; const kind = error.code ?? error.type; const code = httpStatus !== undefined && !isContextExceededError(error) ? httpStatus @@ -100,11 +107,11 @@ export const writeGeminiGenerateContentEvents = async function* ( const pendingText = new Map(); const sourceLengths = new Map(); const nativeAccumulator = createGeminiGenerateContentEventAccumulator(); - type GeminiContentState = NonNullable[number]>; - type PartsState = NonNullable; - const partStates = new Map(); + type AssistantContentState = NonNullable[number]>; + type AssistantPartsState = NonNullable; + const partStates = new Map(); - const partsState = (choice: number): PartsState => { + const partsState = (choice: number): AssistantPartsState => { let state = partStates.get(choice); if (state === undefined) { state = []; partStates.set(choice, state); } return state; @@ -213,7 +220,7 @@ export const writeGeminiGenerateContentEvents = async function* ( : undefined; }; - // Queues the units that the IR structures initialized by a record will become. + // Queues the units that the output IR structures initialized by a record will become. const registerUnits = (record: IRRecord, state: IROutput): void => { if (record.type === 'write' && record.kind === 'initialize') { const choice = record.path[1]; @@ -233,7 +240,7 @@ export const writeGeminiGenerateContentEvents = async function* ( } }; - // Opens each choice's waiting units in IR order, until one still lacks what its part needs. + // Opens each choice's waiting units in output IR order, until one still lacks what its part needs. const openPendingUnits = function* (record: IRRecord, state: IROutput, collector: IRCollector): Generator> { const endingChoice = record.type === 'choice_end' ? record : undefined; for (const [choice, queue] of queues) { @@ -295,7 +302,7 @@ export const writeGeminiGenerateContentEvents = async function* ( if (completion === 'truncated') part = undefined; else if (!isCompleteJSONObject(item.arguments) && choiceEnding === undefined && record.type !== 'finish') wait = true; else { - const parsed = parseJSONObjectAtCompletion(item.arguments, completion, options.parseToolArguments); + const parsed = parseJSONObjectUnlessTruncated(item.arguments, completion, options.parseToolArguments); if (parsed.completion === 'complete') { const partIndex = occupyPart(choice, 'other'); part = { @@ -383,7 +390,7 @@ export const writeGeminiGenerateContentEvents = async function* ( if (record.type === 'error') { yield eventFrame({ error: geminiGenerateContentErrorFromIR(record.error) }); return; } if (record.type === 'write' && record.path[0] === 'model' && typeof record.value === 'string') metadata.model = record.value; if (record.type === 'start') { - if (state.model === undefined) throw new TypeError('GenerateContent output requires an IR model before start'); + if (state.model === undefined) throw new TypeError('GenerateContent output requires an output IR model before start'); const id = options.id ?? record.id; metadata = { id: id === undefined || id === '' ? crypto.randomUUID() : id, model: state.model }; started = true; @@ -447,7 +454,7 @@ export const writeGeminiGenerateContentEvents = async function* ( const assistantState: GeminiGenerateContentInputAssistantTurnState = [{ parts: stateParts }]; const sourceTurnDigest = await computeGeminiGenerateContentTurnDigest(targetTurn); const data = await options.roundTrip.encodeSidecar(record.choice, sourceTurnDigest, targetTurn, assistantState, collectGeminiGenerateContentTextPlacements(targetTurn)); - yield emitPart(record.choice, createGeminiGenerateContentSidecarPart(data)); + yield emitPart(record.choice, createGeminiGenerateContentSidecarCarrier(data)); yield emit({ candidates: [terminalCandidate(record, state, collector)] }); } diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts new file mode 100644 index 0000000000..bf5fffff51 --- /dev/null +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts @@ -0,0 +1,396 @@ +import { klona } from 'klona/json'; + +import { partitionGeminiGenerateContentTurns, type GeminiGenerateContentConversationTurn as GeminiGenerateContentTurn } from './conversation-turns.ts'; +import type { GeminiGenerateContentSourceAssistantTurn } from './round-trip.ts'; +import { geminiFunctionParameters, geminiResponseSchema } from './schema.ts'; +import type { SourceReading, SourceTurnSlot } from '../../../composition/reading.ts'; +import type { + IRInputAssistantContentPart, + IRInputRequestHeader, + IRInputSettings, + IRInputTool, + IRInputToolChoice, + IRInputOutputFormat, + IRInputAssistantItem, + IRInputTextPart, + IRInputTurn, + IRInputUserContentPart, + IRInputClientItem, +} from '../../../ir/input/types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import type { GeminiGenerateContentThinkingConfig, GeminiGenerateContentContent, GeminiGenerateContentPart, GeminiGenerateContentPartData, GeminiGenerateContentPayload, GeminiGenerateContentRequest } from '@floway-dev/protocols/gemini-generate-content'; + +// Translate Google's named thinking levels to target effort names; the default +// sentinel leaves effort unset. Preserve all other open-string values verbatim. +// Reference: https://ai.google.dev/api/generate-content#ThinkingLevel +const geminiGenerateContentReasoningEffortToIR = (thinkingConfig?: GeminiGenerateContentThinkingConfig): string | undefined => { + switch (thinkingConfig?.thinkingLevel) { + case 'LOW': return 'low'; + case 'MEDIUM': return 'medium'; + case 'HIGH': return 'high'; + case 'MINIMAL': return 'minimal'; + case 'THINKING_LEVEL_UNSPECIFIED': return undefined; + default: return thinkingConfig?.thinkingLevel; + } +}; + +const inlinePart = (part: GeminiGenerateContentPart): IRInputUserContentPart => { + const inlineData = geminiGenerateContentInlineData(part); + if (inlineData === null) throw new TranslateInputException('Expected inline data.'); + const media = { source_kind: 'base64' as const, media_type: inlineData.mimeType, data: inlineData.data }; + switch (inlineData.kind) { + case 'image': return { type: 'image', image: media }; + case 'pdf': return { type: 'file', file: media }; + case 'audio': return { type: 'audio', audio: { media_type: inlineData.mimeType, data: inlineData.data } }; + // Inline data outside known image/audio kinds becomes a file with its original MIME type. + // The selected upstream owns acceptance. + case 'other': return { type: 'file', file: media }; + } +}; + +const userParts = (content: GeminiGenerateContentContent): IRInputClientItem[] => { + const items: IRInputClientItem[] = []; + const pending: IRInputUserContentPart[] = []; + const flush = (): void => { + if (pending.length === 0) return; + items.push({ type: 'message', content: pending.splice(0) }); + }; + + (content.parts ?? []).forEach(part => { + const kind = geminiGenerateContentPartKind(part); + switch (kind) { + case null: + return; + case 'function_response': { + const response = part.functionResponse!; + flush(); + items.push({ + type: 'tool_result', + // The target matches id-less results against both ordinary and native calls. + name: response.name, + ...(response.id === undefined ? {} : { call_id: response.id }), + output: JSON.stringify(response.response), + }); + return; + } + case 'text': { + const text = geminiGenerateContentPartText(part); + if (text !== null) pending.push({ type: 'text', text }); + return; + } + case 'inline_data': + pending.push(inlinePart(part)); + return; + default: + throw new TranslateInputException(`"${kind}" parts are not supported in user content.`); + } + }); + flush(); + return items; +}; + +// A thought part states no id of its own, so its IR identity comes from the +// position it occupies in the request. +const reasoningId = (contentIndex: number, partIndex: number): string => `gemini_reasoning_${contentIndex}_${partIndex}`; + +const assistantItems = (content: GeminiGenerateContentContent, contentIndex: number): IRInputAssistantItem[] => { + const items: IRInputAssistantItem[] = []; + const pending: IRInputAssistantContentPart[] = []; + const flush = (): void => { + if (pending.length === 0) return; + items.push({ type: 'message', content: pending.splice(0) }); + }; + + const parts = (content.parts ?? []).flatMap(part => { + const kind = geminiGenerateContentPartKind(part); + return kind === null ? [] : [{ kind, part }]; + }); + + parts.forEach(({ kind, part }, partIndex) => { + switch (kind) { + case 'function_call': { + const call = part.functionCall!; + flush(); + // An absent `functionCall.args` stays absent: `JSON.stringify(undefined)` + // is `undefined`, so the field is omitted exactly as before. An explicit + // `null` keeps `null`, because both OpenAI protocols distinguish the two. + items.push({ type: 'function_call', ...(call.id === undefined ? {} : { call_id: call.id }), name: call.name, arguments: JSON.stringify(call.args) }); + return; + } + case 'text': { + const thoughtText = geminiGenerateContentThoughtText(part); + if (thoughtText !== null) { + flush(); + items.push({ type: 'reasoning', id: reasoningId(contentIndex, partIndex), summary: [thoughtText] }); + return; + } + const visible = geminiGenerateContentVisibleText(part); + if (visible !== null) pending.push({ type: 'text', text: visible }); + return; + } + case 'inline_data': + // A model part has no media variant on any target; the current routes + // reject it rather than flattening it. + throw new TranslateInputException('"inline_data" parts are not supported in model content.'); + default: + throw new TranslateInputException(`"${kind}" parts are not supported in model content.`); + } + }); + flush(); + return items; +}; + +const systemParts = (content: GeminiGenerateContentContent | undefined): IRInputTextPart[] => { + const text = geminiGenerateContentText(content); + return text === null ? [] : [{ type: 'text', text }]; +}; + +// One IR turn per source turn. `partitionGeminiGenerateContentTurns` already +// folds consecutive same-role contents into one turn, so handing back one turn +// per content would re-split a grouping this reader had just made — the turn +// boundary the partition draws is the boundary the IR keeps. +const turnToIRTurns = ( + turn: GeminiGenerateContentTurn, + contentIndex: number, +): IRInputTurn[] => turn.role === 'assistant' + ? [{ + kind: 'assistant', + items: turn.items.flatMap((content, index) => (content.parts?.length ?? 0) === 0 + ? [{ type: 'message' as const, content: [{ type: 'text' as const, text: '' }] }] + : assistantItems(content, contentIndex + index)), + }] + : [{ + kind: 'client', + items: turn.items.flatMap(content => { + if (content.role !== 'user' && content.role !== undefined) { + throw new TranslateInputException(`"${(content as { role: string }).role}" is not a supported content role.`); + } + return (content.parts?.length ?? 0) === 0 + ? [{ type: 'message' as const, content: [] }] + : userParts(content); + }), + }]; + +/** A GenerateContent system instruction is the conversation's leading prompt. */ +const geminiGenerateContentSystemToIR = (content: GeminiGenerateContentContent | undefined): IRInputTurn | undefined => { + const parts = systemParts(content); + return parts.length === 0 ? undefined : { kind: 'client', items: [{ type: 'prompt', role: 'system', content: parts }] }; +}; + +// GenerateContent declares functions in groups and narrows them through +// `toolConfig.functionCallingConfig`. The IR states that narrowing as +// `allowed_tools`, keeping the complete declaration list separate from which +// functions the model may call. +// https://github.com/googleapis/googleapis/blob/master/google/ai/generativelanguage/v1beta/content.proto +// (FunctionCallingConfig: `allowed_function_names` only applies to ANY and +// VALIDATED, and limits which functions the model calls.) +const toolsToIR = (payload: GeminiGenerateContentPayload): Pick => { + const declarations = payload.tools?.flatMap(group => group.functionDeclarations ?? []) ?? []; + const config = payload.toolConfig?.functionCallingConfig; + const allowedNames = config?.allowedFunctionNames; + const restrictable = config?.mode === 'ANY' || config?.mode === 'VALIDATED'; + const permitted = restrictable && allowedNames !== undefined && allowedNames.length > 0 ? new Set(allowedNames) : undefined; + + const tools: IRInputTool[] = declarations.map(declaration => { + const parameters = geminiFunctionParameters(declaration); + return { + type: 'function' as const, + name: declaration.name, + ...(declaration.description === undefined ? {} : { description: declaration.description }), + // GenerateContent states a declaration's schema either as its own dialect or + // as JSON Schema; both become the same neutral object here. + ...(parameters === undefined ? {} : { parameters }), + }; + }); + + let toolChoice: IRInputToolChoice | undefined; + if (config?.mode === 'NONE') toolChoice = 'none'; + else if (config?.mode === 'AUTO' || config?.mode === 'VALIDATED' || config?.mode === 'ANY') { + const mode = config.mode === 'ANY' ? 'required' : 'auto'; + toolChoice = permitted === undefined ? mode : { + type: 'allowed_tools', mode, + tools: [...permitted].map(name => ({ type: 'function', name })), + }; + } + + return { + ...(tools.length === 0 ? {} : { tools }), + ...(toolChoice === undefined ? {} : { tool_choice: toolChoice }), + }; +}; + +export const readGeminiGenerateContentRequest = (payload: GeminiGenerateContentRequest): SourceReading => { + let contentIndex = 0; + const prompt = geminiGenerateContentSystemToIR(payload.systemInstruction); + + const turnSlotFor = (turn: GeminiGenerateContentTurn): SourceTurnSlot => { + const at = contentIndex; + contentIndex += turn.items.length; + const toIRInputTurns = () => turnToIRTurns(turn, at); + if (turn.role !== 'assistant') return { kind: 'client', toIRInputTurns }; + return { + kind: 'assistant', assistantTurn: turn.items, toIRInputTurns, + readCallIdentities: () => turn.items.flatMap(content => (content.parts ?? []).flatMap(part => part.functionCall === undefined ? [] : [{ + type: 'function' as const, name: part.functionCall.name, + ...(part.functionCall.id === undefined ? {} : { call_id: part.functionCall.id }), + }])), + }; + }; + + return { + request: { + settings: geminiGenerateContentSettingsToIR(payload), + ...toolsToIR(payload), + }, + turns: [ + ...(prompt === undefined ? [] : [{ kind: 'client' as const, toIRInputTurns: () => [prompt] }]), + ...partitionGeminiGenerateContentTurns(payload.contents ?? []).map(turnSlotFor), + ], + }; +}; + +/** GenerateContent keeps every sampling knob inside `generationConfig`. */ +const geminiGenerateContentSettingsToIR = (payload: GeminiGenerateContentRequest): IRInputSettings => { + const config = payload.generationConfig; + const budget = config?.thinkingConfig?.thinkingBudget; + // A level is what the caller stated, including an empty one: it is an open + // string, and a target that accepts it forwards it verbatim. A budget is a + // different quantity the caller may state instead, so it travels on its own + // field and each target decides what it can do with it. + const statedLevel = geminiGenerateContentReasoningEffortToIR(config?.thinkingConfig); + // A stated level beside a stated budget is the one combination GenerateContent + // refuses outright: the two knobs are mutually exclusive, and no model accepts + // both. Neither can be preferred here, because which one a model understands + // depends on its family, and a tie-break would invert a caller who asked for + // one and got the other. Refusing keeps this reader's answer the one Google + // itself gives. An empty level names no tier, so it is not a statement. + // https://github.com/cline/cline/issues/7957 + if (statedLevel !== undefined && statedLevel !== '' && budget !== undefined) { + throw new TranslateInputException('Unable to submit request because thinking_budget and thinking_level are not supported together.'); + } + // A budget of zero is GenerateContent's off switch, which every target spells + // on the effort axis. + const effort = statedLevel ?? (budget === 0 ? 'none' : undefined); + const schema = config === undefined ? undefined : geminiResponseSchema(config); + const outputFormat: IRInputOutputFormat | undefined = schema !== undefined + ? { type: 'json_schema', schema: klona(schema), name: 'gemini_response' } + : config?.responseMimeType === 'application/json' ? { type: 'json_object' } : undefined; + + return { + model: payload.model, + stream: true, + ...(config?.temperature == null ? {} : { temperature: config.temperature }), + ...(config?.topP == null ? {} : { top_p: config.topP }), + ...(config?.topK == null ? {} : { top_k: config.topK }), + ...(config?.stopSequences == null ? {} : { stop: [...config.stopSequences] }), + ...(config?.seed == null ? {} : { seed: config.seed }), + ...(config?.maxOutputTokens == null ? {} : { max_output_tokens: config.maxOutputTokens }), + ...(outputFormat === undefined ? {} : { output_format: outputFormat }), + ...(config?.candidateCount === undefined ? {} : { choice_count: config.candidateCount }), + ...(config?.presencePenalty === undefined ? {} : { presence_penalty: config.presencePenalty }), + ...(config?.frequencyPenalty === undefined ? {} : { frequency_penalty: config.frequencyPenalty }), + ...(effort === undefined ? {} : { reasoning_effort: effort }), + // A negative budget asks the model to choose its own and states no number. + ...(budget === undefined || budget <= 0 ? {} : { reasoning_budget: budget }), + ...(budget === undefined || budget >= 0 ? {} : { reasoning_budget_dynamic: true }), + // GenerateContent states readable reasoning as a boolean; the neutral + // spelling names how much of it the caller wants. A request that asked for + // no reasoning at all has nothing to summarize. + ...(effort !== 'none' && config?.thinkingConfig?.includeThoughts === true ? { reasoning_summary: 'detailed' } : {}), + }; +}; + +const GEMINI_GENERATE_CONTENT_SUPPORTED_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] as const; + +const GEMINI_GENERATE_CONTENT_AUDIO_MIME_TYPES = ['audio/wav', 'audio/mp3', 'audio/mpeg'] as const; + +type GeminiGenerateContentAudioMimeType = (typeof GEMINI_GENERATE_CONTENT_AUDIO_MIME_TYPES)[number]; + +const isAudioMimeType = (value: string): value is GeminiGenerateContentAudioMimeType => + (GEMINI_GENERATE_CONTENT_AUDIO_MIME_TYPES as readonly string[]).includes(value); + +type GeminiGenerateContentSupportedImageMimeType = (typeof GEMINI_GENERATE_CONTENT_SUPPORTED_IMAGE_MIME_TYPES)[number]; + +type GeminiGenerateContentInlineData = + | { kind: 'image'; mimeType: GeminiGenerateContentSupportedImageMimeType; data: string } + | { kind: 'pdf'; mimeType: 'application/pdf'; data: string } + | { kind: 'audio'; mimeType: GeminiGenerateContentAudioMimeType; data: string } + // Any other MIME type: carried as a file and left for the target to judge. + | { kind: 'other'; mimeType: string; data: string }; + +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. +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)'}.`); +}; + +const geminiGenerateContentPartText = (part: GeminiGenerateContentPart): string | null => (typeof part.text === 'string' ? part.text : null); + +const geminiGenerateContentThoughtText = (part: GeminiGenerateContentPart): string | null => (part.thought === true && typeof part.text === 'string' ? part.text : null); + +const geminiGenerateContentVisibleText = (part: GeminiGenerateContentPart): string | null => (part.thought === true ? null : geminiGenerateContentPartText(part)); + +const geminiGenerateContentText = (content?: GeminiGenerateContentContent): string | null => { + const texts = content?.parts?.map(geminiGenerateContentPartText).filter((text): text is string => text !== null); + + return texts?.length ? texts.join('') : null; +}; + +const geminiGenerateContentInlineData = (part: GeminiGenerateContentPart): GeminiGenerateContentInlineData | null => { + const inlineData = part.inlineData; + if (!inlineData) return null; + + if (GEMINI_GENERATE_CONTENT_SUPPORTED_IMAGE_MIME_TYPES.includes(inlineData.mimeType as GeminiGenerateContentSupportedImageMimeType)) { + return { + kind: 'image', + mimeType: inlineData.mimeType as GeminiGenerateContentSupportedImageMimeType, + data: inlineData.data, + }; + } + + if (inlineData.mimeType === 'application/pdf') { + return { kind: 'pdf', mimeType: inlineData.mimeType, data: inlineData.data }; + } + + if (isAudioMimeType(inlineData.mimeType)) { + return { kind: 'audio', mimeType: inlineData.mimeType, data: inlineData.data }; + } + + return { + kind: 'other', + mimeType: inlineData.mimeType, + data: inlineData.data, + }; +}; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/reasoning.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/reasoning.ts deleted file mode 100644 index b1d22619c7..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/reasoning.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { GeminiGenerateContentThinkingConfig } from '@floway-dev/protocols/gemini-generate-content'; - -// Translate Google's named thinking levels to target effort names; the default -// sentinel leaves effort unset. Preserve all other open-string values verbatim. -// Reference: https://ai.google.dev/api/generate-content#ThinkingLevel -export const geminiGenerateContentThinkingLevelEffort = (thinkingConfig?: GeminiGenerateContentThinkingConfig): string | undefined => { - switch (thinkingConfig?.thinkingLevel) { - case 'LOW': return 'low'; - case 'MEDIUM': return 'medium'; - case 'HIGH': return 'high'; - case 'MINIMAL': return 'minimal'; - case 'THINKING_LEVEL_UNSPECIFIED': return undefined; - default: return thinkingConfig?.thinkingLevel; - } -}; - -// Bucket Gemini generateContent's numeric `thinkingBudget` back onto the discrete -// `reasoning_effort` axis when the target protocol has no numeric budget -// slot (OpenAI Chat Completions, OpenAI Responses). Google publishes per-model -// budget ranges but not effort-name thresholds; the 2048 / 8192 bin -// edges below invert the community convention of using those same -// numbers as the DEFAULT budget per effort tier — treating "medium's -// default is 2048" as "budgets up to and including 2048 read as low -// tier", and "high's default is 8192" as "budgets up to 8192 read as -// medium tier". -// -// References: -// - Google Gemini thinking config: https://ai.google.dev/gemini-api/docs/thinking -// - AutoReview (community): `THINK_BUDGETS = { low: 512, medium: 2048, high: 8192 }` -// https://github.com/krzysztofdudek/AutoReview/blob/9bf2ede3a960f5215645bedba41c591829053f5c/scripts/lib/providers/google.mjs#L4 -// - LiteLLM (community): `DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET = 2048` -// https://github.com/BerriAI/litellm/blob/88e03e548716a45284597edf2b7f47a7e6a66d5f/litellm/constants.py#L184 -export const geminiGenerateContentReasoningEffort = (thinkingConfig?: GeminiGenerateContentThinkingConfig): string | null => { - if (!thinkingConfig) return null; - - if (thinkingConfig.thinkingBudget !== undefined) { - if (thinkingConfig.thinkingBudget === 0) return 'none'; - if (thinkingConfig.thinkingBudget < 0) return null; - if (thinkingConfig.thinkingBudget <= 2048) return 'low'; - if (thinkingConfig.thinkingBudget <= 8192) return 'medium'; - return 'high'; - } - - return geminiGenerateContentThinkingLevelEffort(thinkingConfig) ?? null; -}; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts index b45b63b855..ea5c375b31 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts @@ -12,9 +12,6 @@ const geminiVisibleTextGroup = (part: GeminiGenerateContentPart): TextContributi export type GeminiGenerateContentSourceAssistantTurn = GeminiGenerateContentContent[]; export type GeminiGenerateContentSidecarPart = GeminiGenerateContentPart & { thoughtSignature: string }; -export const hasGeminiGenerateContentThoughtSignature = (content: GeminiGenerateContentContent): boolean => - content.parts?.some(part => part.thoughtSignature !== undefined) ?? false; - const geminiTurnDigestMaterialV1 = (turn: GeminiGenerateContentSourceAssistantTurn): IRJSONObject => { const contents = turn.map(content => { const visible = collectTextGroups(content.parts ?? [], geminiVisibleTextGroup).get('visible'); @@ -72,7 +69,7 @@ export const verifyGeminiGenerateContentTurnDigest = createTurnDigestVerifier(ge // Gemini returns model-side thought signatures as Part metadata. // https://ai.google.dev/gemini-api/docs/generate-content/thought-signatures -export const createGeminiGenerateContentSidecarPart = (data: string): GeminiGenerateContentSidecarPart => ({ thoughtSignature: data }); +export const createGeminiGenerateContentSidecarCarrier = (data: string): GeminiGenerateContentSidecarPart => ({ thoughtSignature: data }); export const collectGeminiGenerateContentTextPlacements = (turn: GeminiGenerateContentSourceAssistantTurn): TextPlacementIndex => { const views: TextPlacementIndex = new Map(); diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/tools.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/tools.ts deleted file mode 100644 index 52f23425a5..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/tools.ts +++ /dev/null @@ -1,72 +0,0 @@ -const geminiGenerateContentToolCallId = (turnIndex: number, partIndex: number): string => `gemini_call_${turnIndex}_${partIndex}`; - -import type { GeminiGenerateContentFunctionCallingConfig, GeminiGenerateContentFunctionDeclaration, GeminiGenerateContentPayload, GeminiGenerateContentPart } from '@floway-dev/protocols/gemini-generate-content'; - -export type GeminiGenerateContentToolCallIds = Record; - -export type GeminiGenerateContentFunctionCall = NonNullable; -export type GeminiGenerateContentFunctionResponse = NonNullable; - -export type GeminiGenerateContentFunctionCallingIntent = { type: 'none' } | { type: 'auto' } | { type: 'any' } | { type: 'named'; name: string }; - -export interface GeminiGenerateContentFunctionCallPart { - call: GeminiGenerateContentFunctionCall; - id: string; -} - -export interface GeminiGenerateContentFunctionResponsePart { - response: GeminiGenerateContentFunctionResponse; - id: string; -} - -export const geminiGenerateContentFunctionCallPart = (part: GeminiGenerateContentPart, ids: GeminiGenerateContentToolCallIds, turnIndex: number, partIndex: number): GeminiGenerateContentFunctionCallPart | null => { - const call = part.functionCall; - if (!call) return null; - - const id = call.id ?? geminiGenerateContentToolCallId(turnIndex, partIndex); - ids[call.name] ??= []; - ids[call.name].push(id); - - return { call, id }; -}; - -export const geminiGenerateContentFunctionResponsePart = (part: GeminiGenerateContentPart, ids: GeminiGenerateContentToolCallIds, turnIndex: number, partIndex: number, remove: 'first' | 'last' = 'first'): GeminiGenerateContentFunctionResponsePart | null => { - const response = part.functionResponse; - if (!response) return null; - - const unmatched = ids[response.name]; - const id = response.id ?? geminiGenerateContentToolCallId(turnIndex, partIndex); - if (response.id !== undefined) { - const index = remove === 'first' ? unmatched?.indexOf(response.id) ?? -1 : unmatched?.lastIndexOf(response.id) ?? -1; - if (index >= 0) unmatched?.splice(index, 1); - return { response, id }; - } - - return { response, id: unmatched?.shift() ?? id }; -}; - -export const geminiGenerateContentFunctionDeclarations = (payload: GeminiGenerateContentPayload, allowedNameMode: 'any' | 'all' | 'none'): GeminiGenerateContentFunctionDeclaration[] => { - const config = payload.toolConfig?.functionCallingConfig; - const allowedFunctionNames = config?.allowedFunctionNames; - const allowedNames = allowedFunctionNames?.length && (allowedNameMode === 'all' || (allowedNameMode === 'any' && config?.mode === 'ANY')) ? new Set(allowedFunctionNames) : null; - - return payload.tools?.flatMap(toolGroup => toolGroup.functionDeclarations?.filter(declaration => allowedNames?.has(declaration.name) ?? true) ?? []) ?? []; -}; - -const geminiGenerateContentSingleAllowedFunctionName = (config?: GeminiGenerateContentFunctionCallingConfig): string | undefined => (config?.allowedFunctionNames?.length === 1 ? config.allowedFunctionNames[0] : undefined); - -export const geminiGenerateContentFunctionCallingIntent = (config?: GeminiGenerateContentFunctionCallingConfig): GeminiGenerateContentFunctionCallingIntent | undefined => { - switch (config?.mode) { - case 'NONE': - return { type: 'none' }; - case 'AUTO': - case 'VALIDATED': - return { type: 'auto' }; - case 'ANY': { - const name = geminiGenerateContentSingleAllowedFunctionName(config); - return name !== undefined ? { type: 'named', name } : { type: 'any' }; - } - default: - return undefined; - } -}; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts deleted file mode 100644 index 00b75e1095..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; -import { readAnthropicMessagesEvents } from '../../anthropic-messages/as-target/events.ts'; -import { writeGeminiGenerateContentEvents } from '../as-source/events.ts'; -import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; - -export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { - const stream = createRoundTripCoordinator('geminiGenerateContent', 'anthropicMessages', codec); - return writeGeminiGenerateContentEvents(readAnthropicMessagesEvents(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); -}; 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 deleted file mode 100644 index 207889476e..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/request.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { klona } from 'klona/json'; - -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslateInputException } from '../../../shared/input-error.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 { partitionGeminiGenerateContentTurns } from '../as-source/conversation-turns.ts'; -import { geminiGenerateContentThinkingLevelEffort } from '../as-source/reasoning.ts'; -import { collectGeminiGenerateContentReplayContents, hasGeminiGenerateContentThoughtSignature, findGeminiGenerateContentSidecarOccurrences, 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 { - ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS, - type AnthropicMessagesAssistantInputContentBlock, - type AnthropicMessagesPayload, - type AnthropicMessagesTextBlockParam, - type AnthropicMessagesTool, - type AnthropicMessagesUserContentBlock, -} from '@floway-dev/protocols/anthropic-messages'; -import type { GeminiGenerateContentContent, GeminiGenerateContentPayload, GeminiGenerateContentGenerationConfig, GeminiGenerateContentPart, GeminiGenerateContentThinkingConfig } from '@floway-dev/protocols/gemini-generate-content'; - -const inlineDataToContentBlock = (part: GeminiGenerateContentPart): AnthropicMessagesUserContentBlock | null => { - const inlineData = geminiGenerateContentInlineData(part); - if (inlineData === null) return null; - - switch (inlineData.kind) { - case 'image': - return { - type: 'image', - source: { - type: 'base64', - media_type: inlineData.mimeType, - data: inlineData.data, - }, - }; - case 'pdf': - return { - type: 'document', - source: { - type: 'base64', - media_type: inlineData.mimeType, - data: inlineData.data, - }, - }; - case 'audio': - case 'unsupported': - throw new TranslateInputException(`Cannot translate Gemini generateContent inlineData MIME type '${inlineData.mimeType}' to Anthropic Messages.`); - } -}; - -const buildUserMessage = (content: GeminiGenerateContentContent, turnIndex: number, unmatchedToolCallIds: GeminiGenerateContentToolCallIds): AnthropicMessagesPayload['messages'][number] | null => { - const blocks: AnthropicMessagesUserContentBlock[] = []; - - (content.parts ?? []).forEach((part, partIndex) => { - const kind = geminiGenerateContentPartKind(part); - switch (kind) { - case null: - return; - case 'function_response': { - const { response, id } = geminiGenerateContentFunctionResponsePart(part, unmatchedToolCallIds, turnIndex, partIndex, 'last')!; - blocks.push({ - type: 'tool_result', - tool_use_id: id, - content: JSON.stringify(response.response), - }); - return; - } - case 'text': { - const text = geminiGenerateContentPartText(part); - if (text !== null) blocks.push({ type: 'text', text }); - return; - } - case 'inline_data': { - const inlineData = inlineDataToContentBlock(part); - if (inlineData) blocks.push(inlineData); - return; - } - default: - throw new TranslateInputException(`"${kind}" parts are not supported in user content.`); - } - }); - - return blocks.length ? { role: 'user', content: blocks } : (content.parts?.length ?? 0) === 0 ? { role: 'user', content: '' } : null; -}; - -const buildAssistantMessage = (content: GeminiGenerateContentContent, turnIndex: number, unmatchedToolCallIds: GeminiGenerateContentToolCallIds): AnthropicMessagesPayload['messages'][number] | null => { - if (hasGeminiGenerateContentThoughtSignature(content)) { - warnDroppedUnrecognizedAssistantReasoning('geminiGenerateContent', 'anthropicMessages'); - } - const blocks: AnthropicMessagesAssistantInputContentBlock[] = []; - const parts = (content.parts ?? []).flatMap(part => { - const kind = geminiGenerateContentPartKind(part); - return kind === null ? [] : [{ kind, part }]; - }); - - parts.forEach(({ kind, part }, partIndex) => { - switch (kind) { - case 'function_call': { - const { call, id } = geminiGenerateContentFunctionCallPart(part, unmatchedToolCallIds, turnIndex, partIndex)!; - blocks.push({ - type: 'tool_use', - id, - name: call.name, - input: klona(call.args), - }); - return; - } - case 'text': { - const thoughtText = geminiGenerateContentThoughtText(part); - if (thoughtText !== null) { - blocks.push({ signature: '', type: 'thinking', thinking: thoughtText }); - return; - } - const text = geminiGenerateContentVisibleText(part); - if (text !== null) { - blocks.push({ type: 'text', text }); - } - return; - } - default: - throw new TranslateInputException(`"${kind}" parts are not supported in model content.`); - } - }); - - return blocks.length ? { role: 'assistant', content: blocks } : (content.parts?.length ?? 0) === 0 ? { role: 'assistant', content: '' } : null; -}; - -interface ThinkingConfigFields { - thinking?: NonNullable; - outputConfig: NonNullable; -} - -const applyThinkingConfig = (thinkingConfig?: GeminiGenerateContentThinkingConfig): ThinkingConfigFields => { - if (!thinkingConfig) return { outputConfig: {} }; - - let thinking: ThinkingConfigFields['thinking']; - if (thinkingConfig.thinkingBudget === -1) { - thinking = { type: 'adaptive' }; - } else if (thinkingConfig.thinkingBudget !== undefined && thinkingConfig.thinkingBudget > 0) { - thinking = { - type: 'enabled', - budget_tokens: thinkingConfig.thinkingBudget, - }; - } else if (thinkingConfig.thinkingBudget === 0) { - thinking = { type: 'disabled' }; - } - - const effort = geminiGenerateContentThinkingLevelEffort(thinkingConfig); - return { - ...(thinking !== undefined ? { thinking } : {}), - outputConfig: effort !== undefined ? { effort } : {}, - }; -}; - -const applyGenerationConfig = (request: AnthropicMessagesPayload, generationConfig: GeminiGenerateContentGenerationConfig | undefined, fallbackMaxOutputTokens: number): NonNullable => { - request.max_tokens = generationConfig?.maxOutputTokens ?? fallbackMaxOutputTokens; - - if (!generationConfig) return {}; - - if (generationConfig.temperature !== undefined) { - request.temperature = generationConfig.temperature; - } - if (generationConfig.topP !== undefined) { - request.top_p = generationConfig.topP; - } - if (generationConfig.topK !== undefined) { - request.top_k = generationConfig.topK; - } - if (generationConfig.stopSequences !== undefined) { - request.stop_sequences = klona(generationConfig.stopSequences); - } - const schema = geminiResponseSchema(generationConfig); - return schema === undefined ? {} : { format: { type: 'json_schema', schema } }; -}; - -const buildTools = (payload: GeminiGenerateContentPayload): AnthropicMessagesTool[] | undefined => { - const tools = geminiGenerateContentFunctionDeclarations(payload, 'all').map(declaration => ({ - type: 'custom' as const, - name: declaration.name, - ...(declaration.description !== undefined ? { description: declaration.description } : {}), - input_schema: anthropicMessagesToolInputSchema(geminiFunctionParameters(declaration) ?? { type: 'object', properties: {} }), - })); - - return tools.length ? tools : undefined; -}; - -export const buildTargetRequest = async ( - payload: GeminiGenerateContentPayload, - model: string, - codec: SidecarCodec, - options: { fallbackMaxOutputTokens: number | undefined }, -): Promise => { - // Gemini generateContent can omit maxOutputTokens, but AnthropicMessagesPayload requires max_tokens. - // Prefer the model's advertised `/models` cap when one is known; otherwise - // fall back to the gateway policy default shared with the other - // `*-via-anthropic-messages` translators. - const fallbackMaxOutputTokens = options.fallbackMaxOutputTokens ?? ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS; - const request: AnthropicMessagesPayload = { - model, - stream: true, - max_tokens: fallbackMaxOutputTokens, - messages: [], - }; - const unmatchedToolCallIds: GeminiGenerateContentToolCallIds = Object.create(null); - - const system = geminiGenerateContentText(payload.systemInstruction); - if (system !== null) { - const systemBlocks: AnthropicMessagesTextBlockParam[] = [{ type: 'text', text: system }]; - applyLastSystemCacheBreakpoint(systemBlocks); - request.system = systemBlocks; - } - - const restoredMessages = new Set(); - let contentIndex = 0; - for (const turn of partitionGeminiGenerateContentTurns(payload.contents ?? [])) { - const roundTrip = turn.role === 'assistant' && await roundTripAssistantTurn('geminiGenerateContent', 'anthropicMessages', turn.items, codec, findGeminiGenerateContentSidecarOccurrences, verifyGeminiGenerateContentTurnDigest, collectGeminiGenerateContentReplayContents); - if (!roundTrip) { - if (turn.role === 'assistant') { - for (const [index, content] of turn.items.entries()) { - const message = buildAssistantMessage(content, contentIndex + index, unmatchedToolCallIds); - if (message) request.messages.push(message); - } - } else for (const [index, content] of turn.items.entries()) { - 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); - } - } else { - request.messages.push(roundTrip.turn); - restoredMessages.add(roundTrip.turn); - if (Array.isArray(roundTrip.turn.content)) for (const block of roundTrip.turn.content) { - if (block.type !== 'tool_use' && block.type !== 'server_tool_use') continue; - unmatchedToolCallIds[block.name] ??= []; - unmatchedToolCallIds[block.name].push(block.id); - } - } - contentIndex += turn.items.length; - } - - const generationOutputConfig = applyGenerationConfig(request, payload.generationConfig, fallbackMaxOutputTokens); - const { thinking, outputConfig: thinkingOutputConfig } = applyThinkingConfig(payload.generationConfig?.thinkingConfig); - const outputConfig = { ...generationOutputConfig, ...thinkingOutputConfig }; - const hasGenerationOutputConfig = Object.keys(generationOutputConfig).length > 0; - const attachOutputConfig = (): void => { - request.output_config = outputConfig; - }; - - // Preserve request-key insertion order: a structured-output format precedes - // `thinking`, while an effort-only `output_config` follows it. - if (hasGenerationOutputConfig) attachOutputConfig(); - if (thinking !== undefined) request.thinking = thinking; - if (!hasGenerationOutputConfig && Object.keys(outputConfig).length > 0) attachOutputConfig(); - - const tools = buildTools(payload); - if (tools) request.tools = tools; - applyLastToolCacheBreakpoint(request.tools); - applyLastMessageCacheBreakpoint(request.messages.filter(message => !restoredMessages.has(message))); - - const intent = geminiGenerateContentFunctionCallingIntent(payload.toolConfig?.functionCallingConfig); - switch (intent?.type) { - case 'none': - request.tool_choice = { type: 'none' }; - break; - case 'auto': - request.tool_choice = { type: 'auto' }; - break; - case 'any': - request.tool_choice = { type: 'any' }; - break; - case 'named': - request.tool_choice = { type: 'tool', name: intent.name }; - break; - } - - return request; -}; 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 deleted file mode 100644 index c2fd71fb32..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/translate.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { translateToSourceEvents } from './events.ts'; -import { buildTargetRequest } from './request.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { Translator } from '../../../types.ts'; -import { restateGeminiGenerateContentError } from '../as-source/error-body.ts'; -import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import type { GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; - -export const translate: Translator< - GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent, AnthropicMessagesPayload, AnthropicMessagesStreamEventEx, - { fallbackMaxOutputTokens?: number } -> = async (src, ctx) => { - try { - return { - type: 'trip', - error: restateGeminiGenerateContentError, - 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/events.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts deleted file mode 100644 index 2c78e08e90..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; -import { readOpenAIChatCompletionsEvents } from '../../openai-chat-completions/as-target/events.ts'; -import { writeGeminiGenerateContentEvents } from '../as-source/events.ts'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; -import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; - -export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { - const stream = createRoundTripCoordinator('geminiGenerateContent', 'openaiChatCompletions', codec); - return writeGeminiGenerateContentEvents(readOpenAIChatCompletionsEvents(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); -}; 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 deleted file mode 100644 index 3e44c5a564..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { klona } from 'klona/json'; - -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import { geminiGenerateContentInlineData, geminiGenerateContentPartKind, 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, findGeminiGenerateContentSidecarOccurrences, 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 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'; - -const inlineDataToContentPart = (part: GeminiGenerateContentPart): OpenAIChatCompletionsUserContentPart | null => { - const inlineData = geminiGenerateContentInlineData(part); - if (inlineData === null) return null; - - switch (inlineData.kind) { - case 'image': - return { - type: 'image_url', - image_url: { url: `data:${inlineData.mimeType};base64,${inlineData.data}` }, - }; - case 'pdf': - return { - type: 'file', - file: { file_data: `data:${inlineData.mimeType};base64,${inlineData.data}` }, - }; - case 'audio': - return { - type: 'input_audio', - input_audio: { data: inlineData.data, format: inlineData.format }, - }; - case 'unsupported': - throw new TranslateInputException(`Cannot translate Gemini generateContent inlineData MIME type '${inlineData.mimeType}' to OpenAI Chat Completions.`); - } -}; - -const textToContentPart = (text: string): OpenAIChatCompletionsUserContentPart => ({ - type: 'text', - text, -}); - -const contentFromParts = (parts: GeminiGenerateContentPart[]): string | OpenAIChatCompletionsUserContentPart[] | null => { - const textParts = parts.map(geminiGenerateContentPartText).filter((text): text is string => text !== null); - const mediaParts = parts.map(inlineDataToContentPart).filter((part): part is OpenAIChatCompletionsUserContentPart => part !== null); - - if (!textParts.length && !mediaParts.length) return null; - if (!mediaParts.length) return textParts.join('\n\n'); - - return parts.flatMap(part => { - const text = geminiGenerateContentPartText(part); - if (text !== null) return [textToContentPart(text)]; - - const media = inlineDataToContentPart(part); - return media ? [media] : []; - }); -}; - -const buildAssistantMessage = (content: GeminiGenerateContentContent, turnIndex: number, unmatchedToolCallIds: GeminiGenerateContentToolCallIds): OpenAIChatCompletionsMessage | null => { - if (hasGeminiGenerateContentThoughtSignature(content)) { - warnDroppedUnrecognizedAssistantReasoning('geminiGenerateContent', 'openaiChatCompletions'); - } - const visibleParts: GeminiGenerateContentPart[] = []; - const thoughtTexts: string[] = []; - const toolCalls: OpenAIChatCompletionsToolCall[] = []; - const parts = (content.parts ?? []).flatMap(part => { - const kind = geminiGenerateContentPartKind(part); - return kind === null ? [] : [{ kind, part }]; - }); - - parts.forEach(({ kind, part }, partIndex) => { - switch (kind) { - case 'function_call': { - const { call, id } = geminiGenerateContentFunctionCallPart(part, unmatchedToolCallIds, turnIndex, partIndex)!; - toolCalls.push({ - id, - type: 'function', - function: { - name: call.name, - arguments: JSON.stringify(call.args), - }, - }); - return; - } - case 'text': { - const thoughtText = geminiGenerateContentThoughtText(part); - if (thoughtText !== null) { - thoughtTexts.push(thoughtText); - return; - } - if (geminiGenerateContentVisibleText(part) !== null) visibleParts.push(part); - return; - } - case 'inline_data': - visibleParts.push(part); - return; - default: - throw new TranslateInputException(`"${kind}" parts are not supported in model 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', - content: textParts.length > 0 ? textParts.join('\n\n') : null, - }; - - if (toolCalls.length) message.tool_calls = toolCalls; - if (thoughtTexts.length) message.reasoning_text = thoughtTexts.join('\n\n'); - - return message.content !== null || message.tool_calls?.length || message.reasoning_text !== undefined ? message : null; -}; - -const buildToolMessage = (part: GeminiGenerateContentPart, turnIndex: number, partIndex: number, unmatchedToolCallIds: GeminiGenerateContentToolCallIds, legacyFunctionNames: Map): OpenAIChatCompletionsMessage => { - const { response, id } = geminiGenerateContentFunctionResponsePart(part, unmatchedToolCallIds, turnIndex, partIndex)!; - const content = JSON.stringify(response.response); - const legacyName = legacyFunctionNames.get(id); - legacyFunctionNames.delete(id); - return legacyName === undefined - ? { role: 'tool', tool_call_id: id, content } - : { role: 'function', name: legacyName, content }; -}; - -const buildUserMessages = (content: GeminiGenerateContentContent, turnIndex: number, unmatchedToolCallIds: GeminiGenerateContentToolCallIds, legacyFunctionNames: Map): OpenAIChatCompletionsMessage[] => { - const messages: OpenAIChatCompletionsMessage[] = []; - let pendingParts: GeminiGenerateContentPart[] = []; - - const flushUserParts = (): void => { - const chatContent = contentFromParts(pendingParts); - pendingParts = []; - if (chatContent === null) return; - - messages.push({ role: 'user', content: chatContent }); - }; - - (content.parts ?? []).forEach((part, partIndex) => { - const kind = geminiGenerateContentPartKind(part); - switch (kind) { - case null: - return; - case 'function_response': - flushUserParts(); - messages.push(buildToolMessage(part, turnIndex, partIndex, unmatchedToolCallIds, legacyFunctionNames)); - return; - case 'text': - case 'inline_data': - pendingParts.push(part); - return; - default: - throw new TranslateInputException(`"${kind}" parts are not supported in user content.`); - } - }); - - flushUserParts(); - return messages; -}; - -const applyGenerationConfig = (request: OpenAIChatCompletionsPayload, generationConfig?: GeminiGenerateContentGenerationConfig): void => { - if (!generationConfig) return; - - if (generationConfig.maxOutputTokens !== undefined) { - request.max_tokens = generationConfig.maxOutputTokens; - } - if (generationConfig.temperature !== undefined) { - request.temperature = generationConfig.temperature; - } - if (generationConfig.topP !== undefined) { - request.top_p = generationConfig.topP; - } - if (generationConfig.stopSequences !== undefined) { - request.stop = klona(generationConfig.stopSequences); - } - if (generationConfig.candidateCount !== undefined) { - request.n = generationConfig.candidateCount; - } - if (generationConfig.presencePenalty !== undefined) { - request.presence_penalty = generationConfig.presencePenalty; - } - if (generationConfig.frequencyPenalty !== undefined) { - request.frequency_penalty = generationConfig.frequencyPenalty; - } - if (generationConfig.seed !== undefined) { - request.seed = generationConfig.seed; - } - - const schema = geminiResponseSchema(generationConfig); - if (schema !== undefined) { - request.response_format = { - type: 'json_schema', - json_schema: { - name: 'gemini_response', - schema, - }, - }; - } else if (generationConfig.responseMimeType === 'application/json') { - request.response_format = { type: 'json_object' }; - } - - const reasoningEffort = geminiGenerateContentReasoningEffort(generationConfig.thinkingConfig); - if (reasoningEffort !== null) request.reasoning_effort = reasoningEffort; -}; - -const buildTools = (payload: GeminiGenerateContentPayload): OpenAIChatCompletionsTool[] | undefined => { - const tools = geminiGenerateContentFunctionDeclarations(payload, 'any').map(declaration => { - const parameters = geminiFunctionParameters(declaration); - return { - type: 'function' as const, - function: { - name: declaration.name, - ...(declaration.description !== undefined ? { description: declaration.description } : {}), - ...(parameters !== undefined ? { parameters } : {}), - }, - }; - }); - - return tools.length ? tools : undefined; -}; - -export const buildTargetRequest = async ( - payload: GeminiGenerateContentPayload, - model: string, - codec: SidecarCodec, -): Promise => { - const request: OpenAIChatCompletionsPayload = { - model, - stream: true, - messages: [], - }; - const unmatchedToolCallIds: GeminiGenerateContentToolCallIds = Object.create(null); - const legacyFunctionNames = new Map(); - - const systemText = geminiGenerateContentText(payload.systemInstruction); - if (systemText !== null) { - request.messages.push({ role: 'system', content: systemText }); - } - - let contentIndex = 0; - for (const turn of partitionGeminiGenerateContentTurns(payload.contents ?? [])) { - const roundTrip = turn.role === 'assistant' && await roundTripAssistantTurn('geminiGenerateContent', 'openaiChatCompletions', turn.items, codec, findGeminiGenerateContentSidecarOccurrences, verifyGeminiGenerateContentTurnDigest, collectGeminiGenerateContentReplayContents); - if (!roundTrip) { - if (turn.role === 'assistant') { - for (const [index, content] of turn.items.entries()) { - if ((content.parts?.length ?? 0) === 0) { request.messages.push({ role: 'assistant', content: '' }); continue; } - const message = buildAssistantMessage(content, contentIndex + index, unmatchedToolCallIds); - if (message) request.messages.push(message); - } - } else for (const [index, content] of turn.items.entries()) { - 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)); - } - } else { - request.messages.push(roundTrip.turn); - if (roundTrip.turn.function_call == null) { - for (const call of roundTrip.turn.tool_calls ?? []) { - const name = call.type === 'function' ? call.function.name : call.custom.name; - unmatchedToolCallIds[name] ??= []; - unmatchedToolCallIds[name].push(call.id); - } - } else { - const modernIds = new Set(roundTrip.turn.tool_calls?.map(call => call.id)); - for (const [index, content] of turn.items.entries()) for (const [partIndex, part] of (content.parts ?? []).entries()) { - const invocation = geminiGenerateContentFunctionCallPart(part, unmatchedToolCallIds, contentIndex + index, partIndex); - if (invocation !== null && !modernIds.has(invocation.id)) legacyFunctionNames.set(invocation.id, roundTrip.turn.function_call.name); - } - } - } - contentIndex += turn.items.length; - } - - applyGenerationConfig(request, payload.generationConfig); - - const tools = buildTools(payload); - if (tools) { - request.tools = tools; - - const intent = geminiGenerateContentFunctionCallingIntent(payload.toolConfig?.functionCallingConfig); - switch (intent?.type) { - case 'none': - request.tool_choice = 'none'; - break; - case 'auto': - request.tool_choice = 'auto'; - break; - case 'any': - request.tool_choice = 'required'; - break; - case 'named': - request.tool_choice = { - type: 'function', - function: { name: intent.name }, - }; - break; - } - } - - return request; -}; 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 deleted file mode 100644 index 5772e145a0..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/translate.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { translateToSourceEvents } from './events.ts'; -import { buildTargetRequest } from './request.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { Translator } from '../../../types.ts'; -import { restateGeminiGenerateContentError } from '../as-source/error-body.ts'; -import type { GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; -import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; - -export const translate: Translator< - GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent, OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent -> = async (src, ctx) => { - try { - const target = await buildTargetRequest(src, ctx.model, ctx.sidecarCodec); - return { type: 'trip', error: restateGeminiGenerateContentError, target, events: frames => translateToSourceEvents(frames, ctx.sidecarCodec) }; - } catch (error) { - if (error instanceof TranslateInputException) return error.failure; - throw error; - } -}; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts deleted file mode 100644 index 2164c0de90..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; -import { readOpenAIResponsesEvents } from '../../openai-responses/as-target/events.ts'; -import { writeGeminiGenerateContentEvents } from '../as-source/events.ts'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; -import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; - -export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { - const stream = createRoundTripCoordinator('geminiGenerateContent', 'openaiResponses', codec); - return writeGeminiGenerateContentEvents(readOpenAIResponsesEvents(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); -}; 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 deleted file mode 100644 index aea808e81a..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/request.ts +++ /dev/null @@ -1,243 +0,0 @@ -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import { geminiGenerateContentInlineData, geminiGenerateContentPartKind, 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, findGeminiGenerateContentSidecarOccurrences, 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 type { GeminiGenerateContentContent, GeminiGenerateContentPayload, GeminiGenerateContentGenerationConfig, GeminiGenerateContentPart } from '@floway-dev/protocols/gemini-generate-content'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesInputContent, CanonicalOpenAIResponsesInputItem, OpenAIResponsesTool } from '@floway-dev/protocols/openai-responses'; - -const geminiGenerateContentReasoningId = (turnIndex: number, partIndex: number): string => `gemini_reasoning_${turnIndex}_${partIndex}`; - -const flushPendingContent = (input: CanonicalOpenAIResponsesInputItem[], pending: OpenAIResponsesInputContent[], role: 'user' | 'assistant'): void => { - if (pending.length === 0) return; - input.push({ type: 'message', role, content: [...pending] }); - pending.length = 0; -}; - -const inlineDataToInputContent = (part: GeminiGenerateContentPart): OpenAIResponsesInputContent | null => { - const inlineData = geminiGenerateContentInlineData(part); - if (inlineData === null) return null; - - switch (inlineData.kind) { - case 'image': - return { - type: 'input_image', - image_url: `data:${inlineData.mimeType};base64,${inlineData.data}`, - }; - case 'pdf': - return { - type: 'input_file', - file_data: `data:${inlineData.mimeType};base64,${inlineData.data}`, - }; - case 'audio': - case 'unsupported': - throw new TranslateInputException(`Cannot translate Gemini generateContent inlineData MIME type '${inlineData.mimeType}' to OpenAI Responses.`); - } -}; - -const buildUserInputItems = (content: GeminiGenerateContentContent, turnIndex: number, unmatchedToolCallIds: GeminiGenerateContentToolCallIds): CanonicalOpenAIResponsesInputItem[] => { - const input: CanonicalOpenAIResponsesInputItem[] = []; - const pendingContent: OpenAIResponsesInputContent[] = []; - - (content.parts ?? []).forEach((part, partIndex) => { - const kind = geminiGenerateContentPartKind(part); - switch (kind) { - case null: - return; - case 'function_response': { - const { response, id } = geminiGenerateContentFunctionResponsePart(part, unmatchedToolCallIds, turnIndex, partIndex)!; - flushPendingContent(input, pendingContent, 'user'); - input.push({ - type: 'function_call_output', - call_id: id, - output: JSON.stringify(response.response), - status: 'completed', - }); - return; - } - case 'text': { - const text = geminiGenerateContentPartText(part); - if (text !== null) pendingContent.push({ type: 'input_text', text }); - return; - } - case 'inline_data': { - const inlineData = inlineDataToInputContent(part); - if (inlineData) pendingContent.push(inlineData); - return; - } - default: - throw new TranslateInputException(`"${kind}" parts are not supported in user content.`); - } - }); - - flushPendingContent(input, pendingContent, 'user'); - return input; -}; - -const buildAssistantInputItems = (content: GeminiGenerateContentContent, turnIndex: number, unmatchedToolCallIds: GeminiGenerateContentToolCallIds): CanonicalOpenAIResponsesInputItem[] => { - if (hasGeminiGenerateContentThoughtSignature(content)) { - warnDroppedUnrecognizedAssistantReasoning('geminiGenerateContent', 'openaiResponses'); - } - const input: CanonicalOpenAIResponsesInputItem[] = []; - const pendingContent: OpenAIResponsesInputContent[] = []; - const parts = (content.parts ?? []).flatMap(part => { - const kind = geminiGenerateContentPartKind(part); - return kind === null ? [] : [{ kind, part }]; - }); - - parts.forEach(({ kind, part }, partIndex) => { - switch (kind) { - case 'function_call': { - const { call, id } = geminiGenerateContentFunctionCallPart(part, unmatchedToolCallIds, turnIndex, partIndex)!; - flushPendingContent(input, pendingContent, 'assistant'); - input.push({ - type: 'function_call', - call_id: id, - name: call.name, - arguments: JSON.stringify(call.args), - status: 'completed', - }); - return; - } - case 'text': { - const thoughtText = geminiGenerateContentThoughtText(part); - if (thoughtText !== null) { - flushPendingContent(input, pendingContent, 'assistant'); - input.push({ - type: 'reasoning', - id: geminiGenerateContentReasoningId(turnIndex, partIndex), - summary: [{ type: 'summary_text', text: thoughtText }], - }); - return; - } - const visible = geminiGenerateContentVisibleText(part); - if (visible !== null) pendingContent.push({ type: 'output_text', text: visible }); - return; - } - default: - throw new TranslateInputException(`"${kind}" parts are not supported in model content.`); - } - }); - - flushPendingContent(input, pendingContent, 'assistant'); - return input; -}; - -const applyGenerationConfig = (request: CanonicalOpenAIResponsesPayload, generationConfig?: GeminiGenerateContentGenerationConfig): void => { - if (!generationConfig) return; - - if (generationConfig.maxOutputTokens !== undefined) { - request.max_output_tokens = generationConfig.maxOutputTokens; - } - if (generationConfig.temperature !== undefined) { - request.temperature = generationConfig.temperature; - } - if (generationConfig.topP !== undefined) { - request.top_p = generationConfig.topP; - } - - const schema = geminiResponseSchema(generationConfig); - if (schema !== undefined) { - request.text = { - ...request.text, - format: { - type: 'json_schema', - name: 'gemini_response', - schema, - }, - }; - } else if (generationConfig.responseMimeType === 'application/json') { - request.text = { ...request.text, format: { type: 'json_object' } }; - } - - const effort = geminiGenerateContentReasoningEffort(generationConfig.thinkingConfig); - if (effort === null) return; - - request.reasoning = { - effort, - ...(effort !== 'none' && generationConfig.thinkingConfig?.includeThoughts === true ? { summary: 'detailed' as const } : {}), - }; -}; - -const buildTools = (payload: GeminiGenerateContentPayload): OpenAIResponsesTool[] | undefined => { - const tools = geminiGenerateContentFunctionDeclarations(payload, 'any').map(declaration => ({ - type: 'function' as const, - name: declaration.name, - ...(declaration.description !== undefined ? { description: declaration.description } : {}), - parameters: geminiFunctionParameters(declaration) ?? { type: 'object', properties: {} }, - strict: false, - })); - - return tools.length ? tools : undefined; -}; - -export const buildTargetRequest = async ( - payload: GeminiGenerateContentPayload, - model: string, - codec: SidecarCodec, -): Promise => { - const input: CanonicalOpenAIResponsesInputItem[] = []; - const request: CanonicalOpenAIResponsesPayload = { - model, - stream: true, - input, - }; - const unmatchedToolCallIds: GeminiGenerateContentToolCallIds = Object.create(null); - - const instructions = geminiGenerateContentText(payload.systemInstruction); - if (instructions !== null) request.instructions = instructions; - - let contentIndex = 0; - for (const turn of partitionGeminiGenerateContentTurns(payload.contents ?? [])) { - const roundTrip = turn.role === 'assistant' && await roundTripAssistantTurn('geminiGenerateContent', 'openaiResponses', turn.items, codec, findGeminiGenerateContentSidecarOccurrences, verifyGeminiGenerateContentTurnDigest, collectGeminiGenerateContentReplayContents); - if (!roundTrip) { - if (turn.role === 'assistant') { - for (const [index, content] of turn.items.entries()) { - if ((content.parts?.length ?? 0) === 0) { input.push({ type: 'message', role: 'assistant', content: '' }); continue; } - 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 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)); - } - } else { - input.push(...roundTrip.turn); - for (const item of roundTrip.turn) { - if (item.type !== 'function_call' && item.type !== 'custom_tool_call') continue; - unmatchedToolCallIds[item.name] ??= []; - unmatchedToolCallIds[item.name].push(item.call_id); - } - } - contentIndex += turn.items.length; - } - - applyGenerationConfig(request, payload.generationConfig); - - const tools = buildTools(payload); - if (tools) { - request.tools = tools; - - const intent = geminiGenerateContentFunctionCallingIntent(payload.toolConfig?.functionCallingConfig); - switch (intent?.type) { - case 'none': - request.tool_choice = 'none'; - break; - case 'auto': - request.tool_choice = 'auto'; - break; - case 'any': - request.tool_choice = 'required'; - break; - case 'named': - request.tool_choice = { type: 'function', name: intent.name }; - break; - } - } - - return request; -}; 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 deleted file mode 100644 index f483efe36f..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/translate.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { translateToSourceEvents } from './events.ts'; -import { buildTargetRequest } from './request.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { Translator } from '../../../types.ts'; -import { restateGeminiGenerateContentError } from '../as-source/error-body.ts'; -import type { GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; - -export const translate: Translator< - GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent, CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx -> = async (src, ctx) => { - try { - return { - type: 'trip', - error: restateGeminiGenerateContentError, - 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/as-source.ts b/packages/translate/src/protocols/openai-chat-completions/as-source.ts new file mode 100644 index 0000000000..f975ffa132 --- /dev/null +++ b/packages/translate/src/protocols/openai-chat-completions/as-source.ts @@ -0,0 +1,24 @@ +import { restateOpenAIChatCompletionsError } from './as-source/error-body.ts'; +import { writeOpenAIChatCompletionsEvents } from './as-source/events.ts'; +import { continuousUsageOf, readOpenAIChatCompletionsRequest } from './as-source/input-to.ts'; +import { collectOpenAIChatCompletionsReplayContents, findOpenAIChatCompletionsSidecarOccurrences, verifyOpenAIChatCompletionsTurnDigest } from './as-source/round-trip.ts'; +import type { SourceProtocol } from '../../composition/index.ts'; +import type { OpenAIChatCompletionsPayloadEx, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; + +/** Chat sidecars authenticate one native assistant message. */ +export const openaiChatCompletionsSourceProtocol: SourceProtocol< + 'openaiChatCompletions', OpenAIChatCompletionsPayloadEx, OpenAIChatCompletionsStreamEvent +> = { + name: 'openaiChatCompletions', + read: readOpenAIChatCompletionsRequest, + sidecarRecognition: { + inspect: findOpenAIChatCompletionsSidecarOccurrences, + verify: verifyOpenAIChatCompletionsTurnDigest, + collectCandidates: collectOpenAIChatCompletionsReplayContents, + }, + error: restateOpenAIChatCompletionsError, + writeEvents: (payload, records, roundTrip) => writeOpenAIChatCompletionsEvents(records, { + continuousUsageStats: continuousUsageOf(payload), + roundTrip, + }), +}; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/conversation-turns.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/conversation-turns.ts index b35746f262..c8bf346e92 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/conversation-turns.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/conversation-turns.ts @@ -3,8 +3,25 @@ import type { OpenAIChatCompletionsAssistantMessage, OpenAIChatCompletionsAssist export type OpenAIChatCompletionsConversationMessage = OpenAIChatCompletionsMessage | OpenAIChatCompletionsAssistantMessageEx; export type OpenAIChatCompletionsConversationTurn = | { role: 'assistant'; items: [OpenAIChatCompletionsAssistantMessage | OpenAIChatCompletionsAssistantMessageEx] } - | { role: 'bare'; items: [Exclude] }; - -export const partitionOpenAIChatCompletionsTurns = (messages: readonly OpenAIChatCompletionsConversationMessage[]): OpenAIChatCompletionsConversationTurn[] => messages.map(message => message.role === 'assistant' - ? { role: 'assistant', items: [message] } - : { role: 'bare', items: [message] }); + | { role: 'client'; items: Exclude[] }; +// Each assistant message forms its own turn; adjacent nonprompt client messages form one client run. +export const partitionOpenAIChatCompletionsTurns = (messages: readonly OpenAIChatCompletionsConversationMessage[]): OpenAIChatCompletionsConversationTurn[] => { + const turns: OpenAIChatCompletionsConversationTurn[] = []; + for (const message of messages) { + const previous = turns.at(-1); + // A system or developer message is a prompt boundary of its own: a target + // that hoists it into a top-level slot, or keeps it inline, needs it to stay + // separate from the client content around it. + if (message.role === 'assistant') { + turns.push({ role: 'assistant', items: [message] }); + continue; + } + if (message.role === 'system' || message.role === 'developer') { + turns.push({ role: 'client', items: [message] }); + continue; + } + if (previous?.role === 'client' && previous.items.every(item => item.role !== 'system' && item.role !== 'developer')) previous.items.push(message); + else turns.push({ role: 'client', items: [message] }); + } + return turns; +}; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts index 5ca16ec901..d8951fb9ed 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts @@ -1,16 +1,17 @@ import { collectOpenAIChatCompletionsTextPlacements, computeOpenAIChatCompletionsTurnDigest, createOpenAIChatCompletionsSidecarDetails } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; import { IRAbsent } from '../../../ir/output/absent.ts'; -import type { IRErrorPayload } from '../../../ir/output/error.ts'; +import { type IRErrorPayload } from '../../../ir/output/error.ts'; import { irItemPath, irPartPath, irPathKey } from '../../../ir/output/operation-paths.ts'; -import type { IRRecord } from '../../../ir/output/records.ts'; +import { type IRRecord } from '../../../ir/output/records.ts'; import { consumeIRRecords } from '../../../ir/output/stream.ts'; -import type { IROutputItem, IROutputMessageItem, IROutputTokenLogprob } from '../../../ir/output/types.ts'; +import { type IROutputItem, type IROutputMessageItem, type IROutputTokenLogprob } from '../../../ir/output/types.ts'; import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/provenance.ts'; import { appendEmittedField, createEmittedFieldState, type EmittedFieldStates, type EmittedFieldState } from '../../../round-trip/reference/reference-builder.ts'; import { errorDetailsOf } from '../../../shared/api-error.ts'; import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; +import { isJSONRecord } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIChatCompletionsInputAssistantTurn, type OpenAIChatCompletionsOutputAssistantTurnState } from '../as-target/assistant-turn.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; @@ -23,6 +24,8 @@ import { createOpenAIChatCompletionsEventAccumulator, } from '@floway-dev/protocols/openai-chat-completions'; +// The payload the wire states for a failed turn: the IR's own fields, with the +// target's extra ones under the extension slot this protocol names. export const openaiChatCompletionsErrorFromIR = (error: IRErrorPayload) => { const details = errorDetailsOf(error, ['message', 'type', 'code', 'param']); return { @@ -35,7 +38,7 @@ export const openaiChatCompletionsErrorFromIR = (error: IRErrorPayload) => { }; interface OpenAIChatCompletionsEventWriterOptions extends SourceEventWriterOptions<'openaiChatCompletions'> { - // Replay identity belongs to the newly emitted target audio, not the source IR content. + // Replay identity belongs to the newly emitted target audio, not the source output IR content. audioMetadata?: (choice: number) => { id: string; expires_at: number }; continuousUsageStats?: boolean; } @@ -44,11 +47,8 @@ const tierFields = (tier?: string): { service_tier?: string } => tier === undefi type IRToolCallItem = Extract; -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - const isIRItem = (value: unknown): value is IROutputItem => { - if (!isRecord(value)) return false; + if (!isJSONRecord(value)) return false; switch (value.type) { case 'message': return Array.isArray(value.content); case 'reasoning': return true; @@ -59,7 +59,7 @@ const isIRItem = (value: unknown): value is IROutputItem => { }; const partType = (value: unknown): IROutputMessageItem['content'][number]['type'] | undefined => { - if (!isRecord(value)) return undefined; + if (!isJSONRecord(value)) return undefined; return value.type === 'text' || value.type === 'refusal' || value.type === 'image' || value.type === 'audio' ? value.type : undefined; }; @@ -70,7 +70,7 @@ type LogprobMetadata = { readonly scope: 'choice' } | { }; const logprobMetadata = (value: unknown): LogprobMetadata | undefined => { - if (!isRecord(value)) return undefined; + if (!isJSONRecord(value)) return undefined; if (value.scope === 'choice') return { scope: 'choice' }; if (value.scope === 'text_part' && typeof value.item_index === 'number' && typeof value.content_index === 'number') { return { scope: 'text_part', item_index: value.item_index, content_index: value.content_index }; @@ -79,7 +79,7 @@ const logprobMetadata = (value: unknown): LogprobMetadata | undefined => { }; const isIRTokenLogprob = (value: unknown): value is IROutputTokenLogprob => - isRecord(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; + isJSONRecord(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; // Emitted deltas carry the assistant extension fields (reasoning, annotations) beside the standard ones. type ChatChoiceChunk = Omit & { delta: OpenAIChatCompletionsAssistantDeltaEx }; @@ -107,6 +107,10 @@ export const writeOpenAIChatCompletionsEvents = async function* ( } return accumulator; }; + // The accumulator sees every frame this writer emits, so this reassembles what + // has gone out so far. A caller that digests the answer must read it after the + // last delta it intends the client to see. + const emittedChoice = (choice: number) => nativeAccumulator(choice).result().choices.find(candidate => candidate.index === choice); const outputStates = new Map>(); const pendingTranscripts = new Map(); let usage: OpenAIChatCompletionsUsageEx | undefined; @@ -188,7 +192,7 @@ export const writeOpenAIChatCompletionsEvents = async function* ( if (record.type === 'write' && record.path[0] === 'model' && typeof record.value === 'string') metadata.model = record.value; if (record.type === 'write' && record.path[0] === 'usage') usage = state.usage === undefined ? undefined : usageFromIR(state.usage); if (record.type === 'start') { - if (state.model === undefined) throw new TypeError('ChatCompletions output requires an IR model before start'); + if (state.model === undefined) throw new TypeError('ChatCompletions output requires an output IR model before start'); const id = options.id ?? record.id; metadata = { id: id === undefined || id === '' ? `chatcmpl-${crypto.randomUUID()}` : id, @@ -368,22 +372,22 @@ export const writeOpenAIChatCompletionsEvents = async function* ( if (record.type === 'choice_end') { const choice = state.choices[record.choice]; - let flattened = ''; + let concatenatedText = ''; let coordinateIndex: TextCoordinateIndex | undefined; const annotations: OpenAIChatCompletionsAnnotation[] = []; choice.items.forEach(item => { if (item.type !== 'message') return; item.content.forEach(part => { if (part.type !== 'text') return; - const base = flattened.length; - flattened += part.text; + const base = concatenatedText.length; + concatenatedText += part.text; coordinateIndex?.append(part.text); for (const annotation of part.annotations ?? []) { if (annotation.source_kind === 'document' || annotation.source == null) continue; if (annotation.output_text_range === undefined) continue; const range = annotation.output_text_range; - coordinateIndex ??= TextCoordinateIndex.utf16ToCodePoints(flattened); - coordinateIndex.assertUTF16Length(flattened.length); + coordinateIndex ??= TextCoordinateIndex.utf16ToCodePoints(concatenatedText); + coordinateIndex.assertUTF16Length(concatenatedText.length); const offsets = coordinateIndex.utf16RangeToCodePoints({ start: base + range.start, end_exclusive: base + range.end_exclusive }); annotations.push({ type: 'url_citation', @@ -397,11 +401,11 @@ export const writeOpenAIChatCompletionsEvents = async function* ( appendEmittedField(options.roundTrip.builder, textState(record.choice, 'refusal'), choice.refusal_details.explanation); yield deltaFrame(record.choice, { refusal: choice.refusal_details.explanation }, tier); } - const summary = nativeAccumulator(record.choice).result().choices.find(choice => choice.index === record.choice)?.message.reasoning_text; + const summary = emittedChoice(record.choice)?.message.reasoning_text; if (typeof summary === 'string' && summary !== '') { yield deltaFrame(record.choice, { reasoning_details: [{ type: 'reasoning.summary', summary, format: 'unknown', index: 0 }] }, tier); } - const nativeChoice = nativeAccumulator(record.choice).result().choices.find(choice => choice.index === record.choice); + const nativeChoice = emittedChoice(record.choice); if (nativeChoice === undefined) throw new Error(`Missing Chat Completions choice ${record.choice} at choice_end`); const sourceTurnDigest = await computeOpenAIChatCompletionsTurnDigest(nativeChoice.message); const input = toOpenAIChatCompletionsInputAssistantTurn(nativeChoice.message, outputState(record.choice)); diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts new file mode 100644 index 0000000000..5cba0ab920 --- /dev/null +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts @@ -0,0 +1,346 @@ +import { klona } from 'klona/json'; + +import { partitionOpenAIChatCompletionsTurns, type OpenAIChatCompletionsConversationMessage, type OpenAIChatCompletionsConversationTurn } from './conversation-turns.ts'; +import type { OpenAIChatCompletionsSourceAssistantTurn } from './round-trip.ts'; +import type { SourceReading } from '../../../composition/reading.ts'; +import { mediaFromURL, mediaOrThrow } from '../../../ir/input/media.ts'; +import type { + IRInputAssistantContentPart, + IRInputAssistantItem, + IRInputRequestHeader, + IRInputSettings, + IRInputTool, + IRInputToolChoice, + IRInputToolSelector, + IRInputOutputFormat, + IRInputTextPart, + IRInputToolResultContentPart, + IRInputToolResultItem, + IRInputTurn, + IRInputUserContentPart, + IRInputClientItem, + +} from '../../../ir/input/types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import type { + OpenAIChatCompletionsNamedToolChoice, + OpenAIChatCompletionsPayload, + OpenAIChatCompletionsTool, + OpenAIChatCompletionsUserContentPart, + OpenAIChatCompletionsPayloadEx, +} from '@floway-dev/protocols/openai-chat-completions'; + +// Normalize Chat audio container names to the input IR's MIME representation. +const AUDIO_MEDIA_TYPES: Readonly> = { wav: 'audio/wav', mp3: 'audio/mp3' }; + +// Explicit empty user and assistant messages retain their turn boundaries. +// Empty prompt text contributes no text to the prompt. +const textPartsToIR = (value: string, options: { keepEmpty?: boolean } = {}): IRInputTextPart[] => value === '' && options.keepEmpty === false ? [] : [{ type: 'text', text: value }]; + +interface OpenAIChatCompletionsInputContext { + readonly callKinds: ReadonlyMap; + readonly legacyIds: readonly (string | undefined)[]; +} + +// A legacy call and result correlate by name and occurrence. Reserve modern +// ids before choosing their synthetic spelling, including ids later in history. +// This inventory also advances across assistant turns restored without the IR. +const openaiChatCompletionsInputContext = (payload: OpenAIChatCompletionsPayload): OpenAIChatCompletionsInputContext => { + const claimed = new Set(payload.messages.flatMap(message => message.role === 'assistant' ? (message.tool_calls ?? []).map(call => call.id) : [])); + const occurrences = new Map(); + const calls = new Map(); + const results = new Map(); + const legacyIds = payload.messages.map(message => { + const name = message.role === 'assistant' ? message.function_call?.name : message.role === 'function' ? message.name : undefined; + if (name === undefined) return undefined; + const counts = message.role === 'assistant' ? calls : results; + const occurrence = counts.get(name) ?? 0; + counts.set(name, occurrence + 1); + let ids = occurrences.get(name); + if (ids === undefined) { ids = []; occurrences.set(name, ids); } + let id = ids[occurrence]; + if (id === undefined) { + const base = `legacy__${name}__${occurrence}`; + id = base; + for (let suffix = 1; claimed.has(id); suffix++) id = `${base}_${suffix}`; + claimed.add(id); + ids[occurrence] = id; + } + return id; + }); + return { callKinds: openaiChatCompletionsCallKinds(payload), legacyIds }; +}; + +const userContentPart = (part: OpenAIChatCompletionsUserContentPart): IRInputUserContentPart => { + switch (part.type) { + case 'text': + return { type: 'text', text: part.text }; + case 'image_url': + return { + type: 'image', + image: mediaOrThrow(mediaFromURL(part.image_url.url), part.image_url.url), + ...(part.image_url.detail === undefined ? {} : { detail: part.image_url.detail }), + }; + case 'input_audio': + return { type: 'audio', audio: { media_type: AUDIO_MEDIA_TYPES[part.input_audio.format] ?? `audio/${part.input_audio.format}`, data: part.input_audio.data } }; + case 'file': { + const filename = part.file.filename === undefined ? {} : { filename: part.file.filename }; + if (part.file.file_id !== undefined) return { type: 'file', file: { source_kind: 'file', file_id: part.file.file_id }, ...filename }; + if (part.file.file_data !== undefined) { + const media = mediaOrThrow(mediaFromURL(part.file.file_data), part.file.file_data); + return { + type: 'file', + file: media.source_kind === 'base64' ? media : { source_kind: 'base64', data: part.file.file_data }, + ...filename, + }; + } + throw new TranslateInputException("A Chat Completions file part requires 'file_id' or 'file_data'."); + } + default: + throw new TranslateInputException(`Invalid '${(part as { type: string }).type}' content part. Only 'text', 'image_url', 'input_audio', and 'file' are supported.`); + } +}; + +// Chat's `tool` content array is transport fragments of one payload. The input IR +// keeps the parts so a target that accepts structured content can use them; a +// target that only carries strings decides how to flatten. +const toolResultContent = (content: string | readonly { text: string }[]): string | readonly IRInputToolResultContentPart[] => typeof content === 'string' + ? content + : content.map(part => ({ type: 'text' as const, text: part.text })); + +const assistantItems = (turn: Extract, context: OpenAIChatCompletionsInputContext, messageIndex: number): IRInputAssistantItem[] => { + const message = turn.items[0]; + + const content: IRInputAssistantContentPart[] = typeof message.content === 'string' + ? textPartsToIR(message.content) + : (message.content ?? []).map(part => part.type === 'text' + ? { type: 'text' as const, text: part.text } + : { type: 'refusal' as const, refusal: part.refusal }); + if (message.refusal !== undefined && message.refusal !== null && !content.some(part => part.type === 'refusal')) { + content.push({ type: 'refusal', refusal: message.refusal }); + } + + // A null content with nothing else to say is an explicitly empty assistant + // message; with tool calls or a refusal it is simply absent. + const legacyCall = message.function_call ?? undefined; + const emptyMessage = message.content == null && message.refusal == null && (message.tool_calls?.length ?? 0) === 0 && legacyCall === undefined; + const items: IRInputAssistantItem[] = emptyMessage + ? [{ type: 'message', content: [{ type: 'text', text: '' }] }] + : content.length > 0 ? [{ type: 'message', content }] : []; + // The legacy single `function_call` field carries a call with no id. It is + // still input a client may send, so it becomes a call with a deterministic + // id shared with the following legacy result of the same name occurrence. + if (legacyCall !== undefined) { + items.push({ + type: 'function_call', + call_id: context.legacyIds[messageIndex]!, + name: legacyCall.name, + arguments: legacyCall.arguments, + }); + } + for (const call of message.tool_calls ?? []) { + items.push(call.type === 'custom' + ? { type: 'custom_tool_call', call_id: call.id, name: call.custom.name, input: call.custom.input } + : { type: 'function_call', call_id: call.id, name: call.function.name, arguments: call.function.arguments }); + } + return items; +}; + +/** + * Call kinds keyed by call ID. A Chat `tool` message does not state which kind + * it answers; only the preceding assistant message's `tool_calls` do. + */ +const openaiChatCompletionsCallKinds = (payload: OpenAIChatCompletionsPayload): ReadonlyMap => { + const kinds = new Map(); + for (const message of payload.messages) { + if (message.role !== 'assistant') continue; + for (const call of message.tool_calls ?? []) kinds.set(call.id, call.type === 'custom' ? 'custom' : 'function'); + } + return kinds; +}; + +const openaiChatCompletionsTurnToIR = ( + turn: OpenAIChatCompletionsConversationTurn, + context: OpenAIChatCompletionsInputContext, + messageIndex: number, +): IRInputTurn => { + if (turn.role === 'assistant') return { kind: 'assistant', items: assistantItems(turn, context, messageIndex) }; + + // Every client message in one turn contributes its items, in order, to the + // same IR turn; a target that groups them differently reads the item list. + return { kind: 'client', items: turn.items.flatMap((message, index) => clientItems(message, context, messageIndex + index)) }; +}; + +const clientItems = (message: Exclude, context: OpenAIChatCompletionsInputContext, messageIndex: number): IRInputClientItem[] => { + switch (message.role) { + case 'system': + case 'developer': + return [{ + type: 'prompt', + role: message.role, + // A non-text part in system/developer content has no representation on + // any target, so it is rejected here rather than dropped silently. + content: typeof message.content === 'string' + ? textPartsToIR(message.content, { keepEmpty: false }) + : (message.content ?? []).map(part => { + if (part.type !== 'text') throw new TranslateInputException(`Invalid '${part.type}' content part in system or developer message. Only 'text' content parts are supported in system messages on this model.`); + return { type: 'text' as const, text: part.text }; + }), + }]; + case 'user': + return [{ + type: 'message', + content: message.content == null ? [{ type: 'text', text: '' }] : typeof message.content === 'string' ? textPartsToIR(message.content) : message.content.map(userContentPart), + }]; + case 'tool': { + if (!message.tool_call_id) throw new TranslateInputException("Missing required field 'tool_call_id' on a 'tool' role message."); + const toolKind = context.callKinds.get(message.tool_call_id); + const result: IRInputToolResultItem = { + type: 'tool_result', + call_id: message.tool_call_id, + output: toolResultContent(message.content), + ...(toolKind === undefined ? {} : { tool_kind: toolKind }), + }; + return [result]; + } + case 'function': + return [{ type: 'tool_result', call_id: context.legacyIds[messageIndex]!, output: message.content == null ? '' : toolResultContent(message.content) }]; + default: + throw new TranslateInputException(`Invalid role '${(message as { role: string }).role}'.`); + } +}; + +// A Chat declaration states a function with a nested definition, a freeform +// custom tool, or a hosted tool this IR does not carry (an interceptor rewrites +// those before translation). Only the two callable arms are representable. +const toolToIR = (tool: OpenAIChatCompletionsTool): IRInputTool => { + if (tool.type === 'custom') { + const format = tool.custom.format; + return { + type: 'custom', + name: tool.custom.name, + ...(tool.custom.description === undefined ? {} : { description: tool.custom.description }), + ...(format === undefined ? {} : { + format: format.type === 'grammar' + ? { type: 'grammar', definition: format.grammar.definition, syntax: format.grammar.syntax } + : { type: 'text' }, + }), + }; + } + return { + type: 'function', + name: tool.function.name, + ...(tool.function.description === undefined ? {} : { description: tool.function.description }), + ...(tool.function.parameters === undefined ? {} : { parameters: klona(tool.function.parameters) }), + ...(tool.function.strict == null ? {} : { strict: tool.function.strict }), + }; +}; + +const selectionToIR = (selection: OpenAIChatCompletionsNamedToolChoice): IRInputToolSelector => selection.type === 'custom' + ? { type: 'custom', name: selection.custom.name } + : { type: 'function', name: selection.function.name }; + +const toolChoiceToIR = (choice: NonNullable): IRInputToolChoice => { + if (typeof choice === 'string') return choice; + if (choice.type === 'allowed_tools') { + return { type: 'allowed_tools', mode: choice.allowed_tools.mode, tools: choice.allowed_tools.tools.map(selectionToIR) }; + } + return selectionToIR(choice); +}; + +const openaiChatCompletionsToolsToIR = (payload: OpenAIChatCompletionsPayload): Pick => { + const declarations = payload.tools ?? payload.functions?.map(fn => ({ type: 'function' as const, function: fn })); + const tools = declarations?.map(toolToIR); + const legacyChoice = payload.function_call; + const choice = payload.tool_choice ?? (typeof legacyChoice === 'string' + ? legacyChoice + : legacyChoice === undefined ? undefined : { type: 'function' as const, function: legacyChoice }); + return { + ...(tools === undefined || tools.length === 0 ? {} : { tools }), + ...(choice == null ? {} : { tool_choice: toolChoiceToIR(choice) }), + }; +}; + +const openaiChatCompletionsRequestToIR = (payload: OpenAIChatCompletionsPayload): IRInputRequestHeader => ({ + settings: openaiChatCompletionsSettingsToIR(payload), + ...openaiChatCompletionsToolsToIR(payload), +}); + +export const continuousUsageOf = (payload: OpenAIChatCompletionsPayloadEx): boolean => + payload.stream_options?.include_usage === true && payload.stream_options?.continuous_usage_stats === true; + +const openaiChatCompletionsSettingsToIR = (payload: OpenAIChatCompletionsPayload): IRInputSettings => { + const format = payload.response_format; + const outputFormat: IRInputOutputFormat | undefined = format === undefined ? undefined + : format.type === 'json_schema' + ? { + type: 'json_schema', + ...(format.json_schema.name === undefined ? {} : { name: format.json_schema.name }), + ...(format.json_schema.description === undefined ? {} : { description: format.json_schema.description }), + ...(format.json_schema.schema === undefined ? {} : { schema: klona(format.json_schema.schema) }), + // The tool-side and format-side `strict` share a spelling; an omitted + // format strictness stays absent so the target applies its own default. + ...(format.json_schema.strict == null ? {} : { strict: format.json_schema.strict }), + } + : { type: format.type }; + + return { + model: payload.model, + stream: payload.stream === true, + ...(payload.temperature == null ? {} : { temperature: payload.temperature }), + ...(payload.top_p == null ? {} : { top_p: payload.top_p }), + ...(payload.stop == null ? {} : { stop: typeof payload.stop === 'string' ? [payload.stop] : [...payload.stop] }), + ...(payload.seed == null ? {} : { seed: payload.seed }), + ...(payload.max_tokens === undefined ? {} : { max_visible_output_tokens: payload.max_tokens }), + ...(payload.max_completion_tokens === undefined ? {} : { max_output_tokens: payload.max_completion_tokens }), + ...(outputFormat === undefined ? {} : { output_format: outputFormat }), + ...(payload.metadata == null ? {} : { metadata: payload.metadata }), + ...(payload.store == null ? {} : { store: payload.store }), + ...(payload.service_tier == null ? {} : { service_tier: payload.service_tier }), + ...(payload.parallel_tool_calls == null ? {} : { parallel_tool_calls: payload.parallel_tool_calls }), + ...(payload.reasoning_effort == null ? {} : { reasoning_effort: payload.reasoning_effort }), + ...(payload.prompt_cache_key === undefined ? {} : { prompt_cache_key: payload.prompt_cache_key }), + ...(payload.prompt_cache_retention === undefined ? {} : { prompt_cache_retention: payload.prompt_cache_retention }), + ...(payload.prompt_cache_options === undefined ? {} : { prompt_cache_options: klona(payload.prompt_cache_options) }), + ...(payload.safety_identifier === undefined ? {} : { safety_identifier: payload.safety_identifier }), + ...(payload.user === undefined ? {} : { user: payload.user }), + ...(payload.logprobs === undefined ? {} : { logprobs: payload.logprobs }), + ...(payload.top_logprobs === undefined ? {} : { top_logprobs: payload.top_logprobs }), + ...(payload.verbosity == null ? {} : { verbosity: payload.verbosity }), + ...(payload.n == null ? {} : { choice_count: payload.n }), + ...(payload.presence_penalty === undefined ? {} : { presence_penalty: payload.presence_penalty }), + ...(payload.frequency_penalty === undefined ? {} : { frequency_penalty: payload.frequency_penalty }), + // `include_usage` alone asks for the final report; the vLLM/SGLang extension + // asks for one on every chunk. Both are the client's own rendering choice, + // so the field states what it asked for and a target never receives it. + // https://github.com/vllm-project/vllm/blob/d5f0a6e829faa69d1db289bf62b14dae136c02b2/vllm/entrypoints/openai/serving_engine.py#L112-L121 + }; +}; + +export const readOpenAIChatCompletionsRequest = (payload: OpenAIChatCompletionsPayload): SourceReading => { + const context = openaiChatCompletionsInputContext(payload); + let messageIndex = 0; + return { + request: openaiChatCompletionsRequestToIR(payload), + turns: partitionOpenAIChatCompletionsTurns(payload.messages).map(turn => { + const at = messageIndex; + messageIndex += turn.items.length; + return turn.role === 'assistant' + ? { + kind: 'assistant' as const, + assistantTurn: turn.items[0], + readCallIdentities: () => { + const message = turn.items[0]; + return [ + ...(message.function_call == null ? [] : [{ type: 'function' as const, name: message.function_call.name, call_id: context.legacyIds[at]! }]), + ...(message.tool_calls ?? []).map(call => call.type === 'custom' + ? { type: 'custom' as const, name: call.custom.name, call_id: call.id } + : { type: 'function' as const, name: call.function.name, call_id: call.id }), + ]; + }, + toIRInputTurns: () => [openaiChatCompletionsTurnToIR(turn, context, at)], + } + : { kind: 'client' as const, toIRInputTurns: () => [openaiChatCompletionsTurnToIR(turn, context, at)] }; + }), + }; +}; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts index 801eb84a42..23c554ca8f 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts @@ -3,6 +3,7 @@ import type { ReplayContent } from '../../../round-trip/reference/types.ts'; import type { SidecarOccurrence } from '../../../round-trip/resolve.ts'; import { collectTextGroups, collectTextPlacementIndex, type TextContribution, type TextPlacementIndex } from '../../../round-trip/text-groups.ts'; import { createTurnDigestComputer, createTurnDigestVerifier } from '../../../round-trip/turn-digest.ts'; +import { isJSONRecord } from '../../../shared/json/object.ts'; import type { OpenAIChatCompletionsAssistantExtensions, OpenAIChatCompletionsAssistantMessage, OpenAIChatCompletionsAssistantOutputMessage, OpenAIChatCompletionsToolCallEx } from '@floway-dev/protocols/openai-chat-completions'; type OpenAIChatCompletionsReplayPart = NonNullable>[number]; @@ -20,14 +21,12 @@ export type OpenAIChatCompletionsSidecarDetail = { type: 'reasoning.encrypted'; const reasoningFields = ['reasoning', 'reasoning_text', 'reasoning_content'] as const; -const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); - const reasoningDetails = (turn: OpenAIChatCompletionsSourceAssistantTurn): unknown[] => Array.isArray(turn.reasoning_details) ? turn.reasoning_details : []; const reasoningDetailsMaterial = (turn: OpenAIChatCompletionsSourceAssistantTurn): IRJSONObject[] => { const materials: IRJSONObject[] = []; for (const value of reasoningDetails(turn)) { - const item = isRecord(value) ? value : undefined; + const item = isJSONRecord(value) ? value : undefined; if (item?.type === 'reasoning.summary' && typeof item.summary === 'string') materials.push({ type: item.type, summary: item.summary }); else if (typeof item?.type === 'string') materials.push({ type: item.type }); else materials.push({}); @@ -60,9 +59,6 @@ const reasoningTextMaterial = (turn: OpenAIChatCompletionsSourceAssistantTurn): return materials; }; -export const hasOpenAIChatCompletionsReasoning = (turn: OpenAIChatCompletionsSourceAssistantTurn): boolean => - reasoningFields.some(field => turn[field] != null) || reasoningDetails(turn).length > 0; - const chatTurnDigestMaterialV1 = (turn: OpenAIChatCompletionsSourceAssistantTurn): IRJSONObject => ({ ...(Object.hasOwn(turn, 'content') && turn.content !== undefined ? { content: messageContentMaterial(turn) } : {}), ...(Object.hasOwn(turn, 'refusal') && turn.refusal !== undefined ? { refusal: turn.refusal } : {}), @@ -76,7 +72,7 @@ const chatTurnDigestMaterialV1 = (turn: OpenAIChatCompletionsSourceAssistantTurn export const findOpenAIChatCompletionsSidecarOccurrences = (turn: OpenAIChatCompletionsSourceAssistantTurn): SidecarOccurrence[] => { const details = reasoningDetails(turn); - return details.flatMap((detail, detailIndex) => !isRecord(detail) || detail.type !== 'reasoning.encrypted' || typeof detail.data !== 'string' ? [] : [{ + return details.flatMap((detail, detailIndex) => !isJSONRecord(detail) || detail.type !== 'reasoning.encrypted' || typeof detail.data !== 'string' ? [] : [{ data: detail.data, withoutSidecar: () => ({ ...turn, reasoning_details: details.filter((_, index) => index !== detailIndex) }), }]); @@ -96,7 +92,7 @@ export const collectOpenAIChatCompletionsReplayContents = (turn: OpenAIChatCompl collectString(turn.refusal); for (const field of reasoningFields) collectString(turn[field]); for (const value of reasoningDetails(turn)) { - if (isRecord(value) && value.type === 'reasoning.summary' && typeof value.summary === 'string') candidates.push(value.summary); + if (isJSONRecord(value) && value.type === 'reasoning.summary' && typeof value.summary === 'string') candidates.push(value.summary); } if (turn.function_call != null) collectString(turn.function_call.arguments); for (const call of turn.tool_calls ?? []) collectString(call.type === 'function' ? call.function.arguments : call.custom.input); diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target.ts b/packages/translate/src/protocols/openai-chat-completions/as-target.ts new file mode 100644 index 0000000000..cf3964b062 --- /dev/null +++ b/packages/translate/src/protocols/openai-chat-completions/as-target.ts @@ -0,0 +1,67 @@ +import { readOpenAIChatCompletionsEvents } from './as-target/events.ts'; +import { chatLegacyCallsOf } from './as-target/legacy-calls.ts'; +import { openaiChatCompletionsPayloadFromSegments } from './as-target/segmented-request.ts'; +import type { TargetProtocol } from '../../composition/index.ts'; +import { assignCallIds, type PendingCall } from '../../request/call-ids.ts'; +import { allocateToolNames, type ToolNameContext } from '../../request/tool-names.ts'; +import type { InputAssistantTurnFor } from '../../round-trip/input-assistant-turn.ts'; +import type { OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; + +type ChatRestoredAssistantTurn = InputAssistantTurnFor<'openaiChatCompletions'>; + +const callsOf = ( + turn: ChatRestoredAssistantTurn, + legacyCallIds: ReadonlyMap, +): readonly PendingCall[] => { + // Legacy calls have no wire ID. Track their results with the resolver's pairing ID; + // the native function result still pairs by function name. + const legacyCall = turn.function_call; + const legacyId = legacyCallIds.get(turn); + const legacy = legacyCall == null || legacyId === undefined + ? [] + : [{ callId: legacyId, name: legacyCall.name }]; + return [ + ...legacy, + ...(turn.tool_calls ?? []).map(call => ({ callId: call.id, name: call.type === 'function' ? call.function.name : call.custom.name })), + ]; +}; + +/** Request name allocation records custom-call kinds and original logical identities for the event reader. */ +export const openaiChatCompletionsTargetProtocol: TargetProtocol< + 'openaiChatCompletions', OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent, ToolNameContext +> = { + name: 'openaiChatCompletions', + // This wire addresses a callable by one bare name, so every grouped + // declaration and call is stated under the flat spelling it computes. + write: (request, turns) => { + const flattened = allocateToolNames(request, turns, { + ids: turn => [ + ...(turn.function_call == null ? [] : [undefined]), + ...(turn.tool_calls ?? []).map(call => call.id), + ], + rename: (turn, names) => { + let index = 0; + return { + ...turn, + ...(turn.function_call == null ? {} : { function_call: { ...turn.function_call, name: names[index++]! } }), + ...(turn.tool_calls === undefined ? {} : { + tool_calls: turn.tool_calls.map(call => call.type === 'function' + ? { ...call, function: { ...call.function, name: names[index++]! } } + : { ...call, custom: { ...call.custom, name: names[index++]! } }), + }), + }; + }, + }); + const legacy = chatLegacyCallsOf(flattened.turns); + return { + payload: openaiChatCompletionsPayloadFromSegments( + flattened.request, + assignCallIds(flattened.turns, turn => callsOf(turn, legacy.callIds)), + legacy.names, + ), + context: flattened.context, + }; + }, + readEvents: (frames, context, roundTripReader) => + readOpenAIChatCompletionsEvents(frames, { ...context, roundTrip: roundTripReader }), +}; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/events.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/events.ts index cdfc556d6b..2c071da2de 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/events.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/events.ts @@ -2,13 +2,14 @@ import { toOpenAIChatCompletionsInputAssistantTurn, type OpenAIChatCompletionsOu import { usageToIR } from './usage.ts'; import { IRAbsent } from '../../../ir/output/absent.ts'; import { createIRBuilder, type IRItemHandle, type IRPartHandle, type IRWriteHandle, type IRRelativePath } from '../../../ir/output/builder.ts'; -import type { IRErrorPayload } from '../../../ir/output/error.ts'; -import type { IRRecord } from '../../../ir/output/records.ts'; -import type { IROutputSourceCitation } from '../../../ir/output/types.ts'; +import { type IRErrorPayload } from '../../../ir/output/error.ts'; +import { type IRRecord } from '../../../ir/output/records.ts'; +import { type IROutputSourceCitation } from '../../../ir/output/types.ts'; +import type { ToolNameContext } from '../../../request/tool-names.ts'; import type { RoundTripReader } from '../../../round-trip/reader.ts'; import { appendReadField, createReadFieldState, type ReadFieldStates, type ReadFieldState, type TextFragmentId } from '../../../round-trip/reference/reference-builder.ts'; +import { unwrapCustomToolInput } from '../../../shared/custom-tool.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; -import { unwrapCustomToolInput } from '../../openai-responses/common/custom-tool-wrap.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import { createOpenAIChatCompletionsEventAccumulator, @@ -27,7 +28,7 @@ interface ChatToolState { id?: string; name: string; pendingInput: string; - pendingTextFragmentIds: TextFragmentId[]; + pendingFragmentIds: TextFragmentId[]; fieldState?: ChatToolFieldState; custom: boolean; wrapped: boolean; @@ -119,11 +120,17 @@ interface ChatChoiceState { export const readOpenAIChatCompletionsEvents = async function* ( frames: AsyncIterable>, - options: { customToolNames?: ReadonlySet; roundTrip: RoundTripReader<'openaiChatCompletions'> }, + options: Partial & { + roundTrip: RoundTripReader<'openaiChatCompletions'>; + }, ): AsyncGenerator { - const ir = createIRBuilder(); + const builder = createIRBuilder(); const choices = new Map(); const nativeAccumulators = new Map>(); + const namingOf = (flatName: string): { readonly name: string; readonly namespace?: string } => { + const identity = options.originalToolNames?.get(flatName); + return identity ?? { name: flatName }; + }; const nativeAccumulator = (choice: number): ReturnType => { let accumulator = nativeAccumulators.get(choice); if (accumulator === undefined) { @@ -161,18 +168,18 @@ export const readOpenAIChatCompletionsEvents = async function* ( text: string, state: ReadFieldState<'text'>, ): void => { - const partId = appendReadField(options.roundTrip.builder, state, text); - handle.append(field, text, [partId]); + const fragmentId = appendReadField(options.roundTrip.builder, state, text); + handle.append(field, text, [fragmentId]); }; const createItem = (index: number, choice: ChatChoiceState, value: unknown): IRItemHandle => { - const item = ir.item(index, value); + const item = builder.item(index, value); choice.items.push(item); return item; }; const start = (chunk: ChatChunk): void => { if (started) return; - ir.event({ + builder.event({ type: 'start', ...(typeof chunk.id === 'string' && chunk.id !== '' ? { id: chunk.id } : {}), ...(typeof chunk.created === 'number' ? { created: chunk.created } : {}), @@ -180,7 +187,7 @@ export const readOpenAIChatCompletionsEvents = async function* ( started = true; }; - const startForContent = (chunk: ChatChunk): void => { + const startIfModelKnown = (chunk: ChatChunk): void => { if (started) return; if (!hasModel) throw new TypeError('ChatCompletions content arrived before a non-empty model'); start(chunk); @@ -189,7 +196,7 @@ export const readOpenAIChatCompletionsEvents = async function* ( const assignUsage = (usage: OpenAIChatCompletionsUsageEx): void => { const converted = usageToIR(usage); if (!started && !hasModel) throw new TypeError('ChatCompletions usage arrived before a non-empty model'); - ir.usage(converted); + builder.usage(converted); }; // Append text to the message part that currently owns the text channel, keeping the @@ -273,7 +280,7 @@ export const readOpenAIChatCompletionsEvents = async function* ( if (tool.wrapped) { const input = unwrapCustomToolInput(tool.pendingInput); if (input !== '') tool.item.append('input', input); - tool.pendingTextFragmentIds = []; + tool.pendingFragmentIds = []; } tool.item.end(status); }; @@ -281,8 +288,8 @@ export const readOpenAIChatCompletionsEvents = async function* ( const finish = (): readonly IRRecord[] => { if (!started) throw new Error('ChatCompletions stream ended without a response'); for (const [index, choice] of choices) if (!choice.ended) throw new Error(`ChatCompletions choice ${index} ended without finish_reason`); - ir.event({ type: 'finish', status: [...choices.values()].some(choice => choice.incomplete) ? 'incomplete' : 'completed' }); - return ir.drain(); + builder.event({ type: 'finish', status: [...choices.values()].some(choice => choice.incomplete) ? 'incomplete' : 'completed' }); + return builder.drain(); }; // The open part of this kind in the choice's message, created on first use. @@ -329,7 +336,7 @@ export const readOpenAIChatCompletionsEvents = async function* ( }; const readLogprobs = (index: number, choice: ChatChoiceState, logprobs: OpenAIChatCompletionsLogprobs): void => { - const choiceHandle = ir.choice(index); + const choiceHandle = builder.choice(index); choiceHandle.ensure('logprobs', []); for (const kind of ['content', 'refusal'] as const) { const tokens = logprobs[kind]; @@ -371,7 +378,7 @@ export const readOpenAIChatCompletionsEvents = async function* ( tool = { name: '', pendingInput: '', - pendingTextFragmentIds: [], + pendingFragmentIds: [], custom: call.type === 'custom' || call.custom !== undefined, wrapped: false, callIdAssigned: false, @@ -387,7 +394,7 @@ export const readOpenAIChatCompletionsEvents = async function* ( // https://github.com/openai/openai-node/blob/7423ac3e9351c46300cd094479cded5551a72eb4/src/lib/ChatCompletionStream.ts#L2104-L2106 if (fn?.name !== undefined && fn.name !== '') tool.name = fn.name; const fragment = fn?.arguments ?? fn?.input; - let textFragmentId: TextFragmentId | undefined; + let fragmentReferenceId: TextFragmentId | undefined; if (typeof fragment === 'string' && fragment !== '') { let field: ReadFieldStates['text']; if (call.index === -1) { @@ -406,35 +413,33 @@ export const readOpenAIChatCompletionsEvents = async function* ( } (choice.fieldState.tool_calls ??= [])[call.index] = tool.fieldState; } - textFragmentId = appendReadField(options.roundTrip.builder, field, fragment); + fragmentReferenceId = appendReadField(options.roundTrip.builder, field, fragment); } tool.wrapped = !tool.custom && options.customToolNames?.has(tool.name) === true; if (tool.item === undefined && fragment !== undefined) { tool.pendingInput += fragment; - if (textFragmentId !== undefined) tool.pendingTextFragmentIds.push(textFragmentId); + if (fragmentReferenceId !== undefined) tool.pendingFragmentIds.push(fragmentReferenceId); } if (tool.item === undefined && tool.name !== '') { - // Waiting here delays no supported target: Messages and Responses need the name when - // the structure opens, while GenerateContent emits only complete calls. Planned - // Interactions support has the same step.start requirement: - // https://ai.google.dev/gemini-api/docs/streaming - // The official ChatCompletions SDK tolerates a late name: + // The name must precede any delta a client can act on, so it waits for the + // field that carries it. The official ChatCompletions SDK tolerates a late + // name: // https://github.com/openai/openai-node/blob/7423ac3e9351c46300cd094479cded5551a72eb4/src/lib/ChatCompletionStream.ts#L2059-L2107 // Clients such as the Vercel AI SDK reject a first tool delta without one: // https://github.com/vercel/ai/blob/ba05943b69ad070558bb5bc912da99ec66d7d263/packages/provider-utils/src/streaming-tool-call-tracker.ts#L356-L395 const custom = tool.custom || tool.wrapped; const callId = call.index === -1 ? IRAbsent : tool.id; tool.item = createItem(index, choice, custom - ? { type: 'custom_tool_call', ...(callId === undefined ? {} : { call_id: callId }), name: tool.name, input: '' } - : { type: 'function_call', ...(callId === undefined ? {} : { call_id: callId }), name: tool.name, arguments: '' }); + ? { type: 'custom_tool_call', ...(callId === undefined ? {} : { call_id: callId }), ...namingOf(tool.name), input: '' } + : { type: 'function_call', ...(callId === undefined ? {} : { call_id: callId }), ...namingOf(tool.name), arguments: '' }); tool.callIdAssigned = callId !== undefined; tool.emittedName = tool.name; if (!tool.wrapped && tool.pendingInput !== '') { - const textFragmentIds = tool.pendingTextFragmentIds; + const textFragmentIds = tool.pendingFragmentIds; tool.item.append(custom ? 'input' : 'arguments', tool.pendingInput, textFragmentIds.length > 0 ? textFragmentIds : undefined); tool.pendingInput = ''; - tool.pendingTextFragmentIds = []; + tool.pendingFragmentIds = []; } } else if (tool.item !== undefined) { if (tool.id !== undefined && !tool.callIdAssigned) { @@ -442,10 +447,14 @@ export const readOpenAIChatCompletionsEvents = async function* ( tool.callIdAssigned = true; } if (tool.name !== tool.emittedName) { - tool.item.assign('name', tool.name); + // A rename has to state the same pair the opening did, or a grouped call + // would end up with a flat name beside the group's namespace. + const naming = namingOf(tool.name); + tool.item.assign('name', naming.name); + if (naming.namespace !== undefined) tool.item.assign('namespace', naming.namespace); tool.emittedName = tool.name; } - if (fragment !== undefined && fragment !== '' && !tool.wrapped) tool.item.append(tool.custom ? 'input' : 'arguments', fragment, textFragmentId === undefined ? undefined : [textFragmentId]); + if (fragment !== undefined && fragment !== '' && !tool.wrapped) tool.item.append(tool.custom ? 'input' : 'arguments', fragment, fragmentReferenceId === undefined ? undefined : [fragmentReferenceId]); if (fragment !== undefined && tool.wrapped) tool.pendingInput += fragment; } }; @@ -463,7 +472,7 @@ export const readOpenAIChatCompletionsEvents = async function* ( .map(([, tool]) => tool.fieldState); const input = toOpenAIChatCompletionsInputAssistantTurn(nativeChoice.message, choice.fieldState); options.roundTrip.onAssistantTurn(index, input.turn, input.state); - ir.choice(index).end(finishReason === 'function_call' + builder.choice(index).end(finishReason === 'function_call' ? 'tool_calls' : finishReason as 'stop' | 'length' | 'tool_calls' | 'content_filter'); choice.ended = true; @@ -489,8 +498,8 @@ export const readOpenAIChatCompletionsEvents = async function* ( choice.reasoningField ??= chatReasoningField(delta); if (!hasContent && entry.finish_reason == null) return; - startForContent(chunk); - ir.choice(index); + startIfModelKnown(chunk); + builder.choice(index); readReasoning(index, choice, delta); readMessage(index, choice, delta); if (entry.logprobs != null) readLogprobs(index, choice, entry.logprobs); @@ -505,13 +514,13 @@ export const readOpenAIChatCompletionsEvents = async function* ( for await (const frame of frames) { if (frame.type === 'done') { yield* finish(); return; } const chunk: ChatChunk = frame.event; - if (chunk.error !== undefined) { ir.event({ type: 'error', error: chunk.error }); yield* ir.drain(); return; } + if (chunk.error !== undefined) { builder.event({ type: 'error', error: chunk.error }); yield* builder.drain(); return; } const model = typeof chunk.model === 'string' && chunk.model !== '' ? chunk.model : undefined; if (model !== undefined) { - ir.assign(['model'], model); + builder.assign(['model'], model); hasModel = true; } - if (typeof chunk.service_tier === 'string') ir.assign(['service_tier'], chunk.service_tier); + if (typeof chunk.service_tier === 'string') builder.assign(['service_tier'], chunk.service_tier); if (chunk.usage != null) assignUsage(chunk.usage); if (!started && model !== undefined) start(chunk); @@ -523,7 +532,7 @@ export const readOpenAIChatCompletionsEvents = async function* ( // Copilot omits object, varies created, and begins with an empty-id prompt frame; redacted probe evidence: // https://github.com/Menci/Floway/pull/594#issuecomment-6129531836 for (const entry of chunk.choices) readChoice(chunk, entry); - yield* ir.drain(); + yield* builder.drain(); } yield* finish(); }; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts new file mode 100644 index 0000000000..82afe195e1 --- /dev/null +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts @@ -0,0 +1,316 @@ +import { requireStreaming } from '../../../ir/input/streaming.ts'; +import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputMedia, IRInputToolResultContentPart, IRInputClientItem } from '../../../ir/input/types.ts'; +import type { IRInputAssistantItemWithCallIds, IRInputTurnWithCallIds, IRInputClientItemWithCallIds } from '../../../request/call-ids.ts'; +import { buildCustomToolInputSchema } from '../../../shared/custom-tool.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import { reasoningEffortForBudget } from '../../../shared/reasoning-budget.ts'; +import type { + OpenAIChatCompletionsPayload, + OpenAIChatCompletionsMessage, + OpenAIChatCompletionsStreamOptionsEx, + OpenAIChatCompletionsToolCall, + OpenAIChatCompletionsTool, + OpenAIChatCompletionsUserContentPart, +} from '@floway-dev/protocols/openai-chat-completions'; + +// Chat Completions names a container while the input IR keeps MIME types. +const audioFormat = (mediaType: string): string => { + if (mediaType === 'audio/wav') return 'wav'; + if (mediaType === 'audio/mpeg' || mediaType === 'audio/mp3') return 'mp3'; + throw new TranslateInputException(`Cannot translate audio MIME type '${mediaType}' to OpenAI Chat Completions.`); +}; + +const dataUrl = (media: Extract): string => `data:${media.media_type ?? 'application/octet-stream'};base64,${media.data}`; + +const imageUrlPart = (media: IRInputMedia, detail?: string | null): OpenAIChatCompletionsUserContentPart => ({ + type: 'image_url', + image_url: { + url: media.source_kind === 'url' ? media.url : media.source_kind === 'base64' ? dataUrl(media) : (() => { throw new TranslateInputException('Cannot translate file_id-only image content to OpenAI Chat Completions.'); })(), + ...(detail == null ? {} : { detail }), + }, +}); + +const filePart = (media: IRInputMedia, filename?: string | null): OpenAIChatCompletionsUserContentPart => { + const name = filename == null ? {} : { filename }; + switch (media.source_kind) { + case 'file': return { type: 'file', file: { file_id: media.file_id, ...name } }; + case 'base64': return { type: 'file', file: { file_data: dataUrl(media), ...name } }; + case 'url': throw new TranslateInputException('Cannot translate a file URL to OpenAI Chat Completions.'); + } +}; + +const userContent = (item: Extract): string | OpenAIChatCompletionsUserContentPart[] => { + // Adjacent text parts join with no separator: the parts are one run of text, + // and a separator would invent characters the caller never wrote. + if (item.content.every(part => part.type === 'text')) return item.content.map(part => part.text).join(''); + return item.content.map(part => { + switch (part.type) { + case 'text': return { type: 'text' as const, text: part.text }; + case 'image': return imageUrlPart(part.image, part.detail); + case 'file': return filePart(part.file, part.filename); + case 'audio': return { type: 'input_audio' as const, input_audio: { data: part.audio.data, format: audioFormat(part.audio.media_type) } }; + } + }); +}; + +/** + * A Chat `tool` message admits only text, while the input IR tool result may carry + * images. The image parts are lifted into a user message that follows the + * result, so a vision target still receives them. + * + * https://github.com/openai/openai-node/blob/61539248cbe04665de68a71e6fd878127ae4db87/src/resources/chat/completions/completions.ts#L1893-L1908 + * https://github.com/vercel/ai/blob/c093ee7458ccd5dada05d8461041e47c24ee55c0/packages/google/src/convert-to-google-messages.ts#L137-L180 + */ +export const openaiChatCompletionsToolResultContent = ( + callId: string, + output: string | readonly IRInputToolResultContentPart[], +): { readonly content: string; readonly lifted: OpenAIChatCompletionsUserContentPart[] } => { + if (typeof output === 'string') return { content: output, lifted: [] }; + if (output.some(part => part.type === 'file')) throw new TranslateInputException('Cannot translate file tool output to OpenAI Chat Completions.'); + const text = output.flatMap(part => part.type === 'text' ? [part.text] : []).join(''); + const images = output.flatMap(part => part.type === 'image' ? [imageUrlPart(part.image, part.detail)] : []); + return images.length === 0 + ? { content: text, lifted: [] } + : { + content: text === '' ? 'Image output is attached in the following user message.' : text, + lifted: [{ type: 'text', text: `Image output from tool call ${callId}:` }, ...images], + }; +}; + +const assistantMessage = (items: readonly IRInputAssistantItemWithCallIds[]): OpenAIChatCompletionsMessage[] => { + const text: string[] = []; + const refusals: string[] = []; + const reasoning: string[] = []; + const toolCalls: OpenAIChatCompletionsToolCall[] = []; + for (const item of items) { + switch (item.type) { + case 'message': + for (const part of item.content) if (part.type === 'text') text.push(part.text); else refusals.push(part.refusal); + break; + case 'reasoning': + // `reasoning_text` is the extension the gateway already emits for + // translated output; it is a target-only spelling here because no reader + // can identify which alias a Chat source meant. + if (item.summary !== undefined) reasoning.push(item.summary.join('')); + if (item.content !== undefined) reasoning.push(item.content.join('')); + break; + case 'function_call': + toolCalls.push({ + id: item.call_id, + type: 'function', + // Chat requires `arguments`, and the IR states an absent value for a + // call that takes no argument object at all; the empty object is that + // call's argument list written out. + function: { name: item.name, arguments: item.arguments ?? '{}' }, + }); + break; + case 'custom_tool_call': + // Chat Completions must express a freeform tool call as a function call + // whose single argument is `input`; the surrounding route restores the + // custom shape on the response path. + toolCalls.push({ id: item.call_id, type: 'function', function: { name: item.name, arguments: JSON.stringify({ input: item.input }) } }); + break; + } + } + if (text.length === 0 && refusals.length === 0 && toolCalls.length === 0 && reasoning.length === 0) return []; + // A turn carrying reasoning or tool calls with no text spells its content as + // null; a turn with neither is an explicitly empty message and keeps ''. + return [{ + role: 'assistant', + content: text.length === 0 && (toolCalls.length > 0 || reasoning.length > 0) ? null : text.join(''), + ...(refusals.length === 0 ? {} : { refusal: refusals.join('') }), + ...(reasoning.length === 0 ? {} : { reasoning_text: reasoning.join('') }), + ...(toolCalls.length === 0 ? {} : { tool_calls: toolCalls }), + }]; +}; + +const clientMessages = ( + items: readonly IRInputClientItemWithCallIds[], + legacyCallNames: ReadonlyMap, +): OpenAIChatCompletionsMessage[] => { + // Images lifted out of consecutive tool results gather into one user message + // after the run, so a tool-call group stays contiguous. + const messages: OpenAIChatCompletionsMessage[] = []; + let pendingImages: OpenAIChatCompletionsUserContentPart[] = []; + const flushImages = (): void => { + if (pendingImages.length === 0) return; + messages.push({ role: 'user', content: pendingImages }); + pendingImages = []; + }; + for (const item of items) { + if (item.type !== 'tool_result') flushImages(); + messages.push(...openaiChatCompletionsClientItem(item, lifted => pendingImages.push(...lifted), legacyCallNames)); + } + flushImages(); + return messages; +}; + +const openaiChatCompletionsClientItem = ( + item: IRInputClientItemWithCallIds, + lift: (parts: readonly OpenAIChatCompletionsUserContentPart[]) => void, + legacyCallNames: ReadonlyMap, +): OpenAIChatCompletionsMessage[] => { + switch (item.type) { + case 'message': + return [{ role: 'user', content: userContent(item) }]; + case 'prompt': + // A single text part collapses to the string spelling Chat accepts for + // text-only system/developer messages; several parts keep their array so + // block boundaries survive. An empty list is not a legal body on this wire, + // so it becomes the empty string. + return [{ role: item.role, content: item.content.length === 1 ? item.content[0].text : item.content.length === 0 ? '' : item.content.map(part => ({ type: 'text' as const, text: part.text })) }]; + case 'tool_result': { + const projected = openaiChatCompletionsToolResultContent(item.call_id, item.output); + lift(projected.lifted); + // This wire pairs a legacy `function_call` with a `function` message by + // name, and a call that states an id with a `tool` message by that id. + // Answering a legacy call with a `tool` message would name an id no + // assistant turn ever stated. + const legacyCallName = legacyCallNames.get(item.call_id); + return [legacyCallName === undefined + ? { role: 'tool', tool_call_id: item.call_id, content: projected.content } + : { role: 'function', name: legacyCallName, content: projected.content }]; + } + } +}; + +export const openaiChatCompletionsConversationFromIR = ( + turns: readonly IRInputTurnWithCallIds[], + legacyCallNames: ReadonlyMap, +): OpenAIChatCompletionsMessage[] => turns.flatMap(turn => turn.kind === 'assistant' + ? assistantMessage(turn.items) + : clientMessages(turn.items, legacyCallNames)); + +/** + * Writes the settings onto a Chat Completions payload. `max_tokens` is + * deprecated and incompatible with the o-series models, so the ceiling is + * stated as `max_completion_tokens`. + * + * https://github.com/openai/openai-openapi/blob/master/openapi.yaml (max_tokens) + */ +// OpenAI's strict-mode validators reject an object schema without `properties`, +// which is a target-side constraint rather than something the IR states. +const normalizeParameters = (schema: Readonly>): Record => { + const target = { ...schema }; + return target.type === 'object' && target.properties === undefined ? { ...target, properties: {} } : target; +}; + +const toolFromIR = (tool: IRInputTool): OpenAIChatCompletionsTool => { + if (tool.type === 'custom') { + // Chat accepts a freeform declaration natively, but not every upstream that + // speaks the Chat wire accepts one, so a freeform tool is projected onto the + // function shape that states its single raw string. The caller records the + // name so returned function calls can be restored to freeform calls. + return { + type: 'function', + function: { + name: tool.name, + ...(tool.description === undefined ? {} : { description: tool.description }), + parameters: buildCustomToolInputSchema(tool.format), + strict: false, + }, + }; + } + return { + type: 'function', + function: { + name: tool.name, + ...(tool.description === undefined ? {} : { description: tool.description }), + ...(tool.parameters === undefined ? {} : { parameters: normalizeParameters(tool.parameters) }), + // Chat defaults a function tool to non-strict, so an omitted value states + // that default rather than letting the field go missing. + ...(tool.strict === undefined ? {} : { strict: tool.strict }), + }, + }; +}; + +const toolChoiceFromIR = (choice: IRInputToolChoice): NonNullable => { + if (typeof choice === 'string') return choice; + if (choice.type === 'allowed_tools') return { + type: 'allowed_tools', + allowed_tools: { + mode: choice.mode, + tools: choice.tools.map(tool => ({ type: 'function', function: { name: tool.name } })), + }, + }; + // A freeform declaration is written as a function tool, so a selector naming + // it must name the same function. + return { type: 'function', function: { name: choice.name } }; +}; + +/** + * Everything a Chat Completions request states except its messages. + * + * The conversation is written separately because a trip interleaves it with the + * assistant turns a sidecar already resolved, so only this half is computed once + * per payload. + */ +export const openaiChatCompletionsHeaderFromIR = (request: IRInputRequestHeader): Omit => { + const tools = request.tools; + const choice = tools === undefined || tools.length === 0 ? undefined : request.tool_choice; + return { + ...openaiChatCompletionsSettingsFromIR(request.settings), + model: request.settings.model, + // A trip guarantees a streaming request; a request that says otherwise + // cannot be carried by this side at all. + stream: requireStreaming(request.settings), + // A streamed request asks the upstream for usage on every chunk, not only + // the last one. `include_usage` is the OpenAI-standard flag; + // `continuous_usage_stats` is the vLLM/SGLang extension that lets a + // translation state input tokens before any output exists. Upstreams that + // do not implement the extension ignore it. + // https://github.com/vllm-project/vllm/blob/d5f0a6e829faa69d1db289bf62b14dae136c02b2/vllm/entrypoints/generate/base/protocol.py#L241-L243 + stream_options: { include_usage: true, continuous_usage_stats: true } as OpenAIChatCompletionsStreamOptionsEx, + ...(tools === undefined || tools.length === 0 ? {} : { tools: tools.map(toolFromIR) }), + ...(choice === undefined ? {} : { tool_choice: toolChoiceFromIR(choice) }), + }; +}; + +/** A numeric budget approximates effort only when the caller supplied no level. */ +const chatReasoningEffort = (settings: IRInputSettings): string | undefined => + settings.reasoning_effort ?? (settings.reasoning_budget === undefined ? undefined : reasoningEffortForBudget(settings.reasoning_budget)); + +export const openaiChatCompletionsSettingsFromIR = (settings: IRInputSettings): Partial => { + const ceiling = settings.max_output_tokens !== undefined ? settings.max_output_tokens : settings.max_visible_output_tokens; + const format = settings.output_format; + const reasoningEffort = chatReasoningEffort(settings); + + return { + ...(ceiling === undefined ? {} : { max_completion_tokens: ceiling }), + ...(settings.temperature === undefined ? {} : { temperature: settings.temperature }), + ...(settings.top_p === undefined ? {} : { top_p: settings.top_p }), + ...(settings.stop === undefined ? {} : { stop: [...settings.stop] }), + ...(settings.seed === undefined ? {} : { seed: settings.seed }), + ...(settings.metadata === undefined ? {} : { metadata: { ...settings.metadata } }), + ...(settings.store === undefined ? {} : { store: settings.store }), + ...(settings.service_tier === undefined ? {} : { service_tier: settings.service_tier }), + ...(settings.parallel_tool_calls === undefined ? {} : { parallel_tool_calls: settings.parallel_tool_calls }), + ...(reasoningEffort === undefined ? {} : { reasoning_effort: reasoningEffort }), + ...(settings.prompt_cache_key === undefined ? {} : { prompt_cache_key: settings.prompt_cache_key }), + ...(settings.prompt_cache_retention === undefined ? {} : { prompt_cache_retention: settings.prompt_cache_retention }), + ...(settings.prompt_cache_options === undefined ? {} : { prompt_cache_options: settings.prompt_cache_options === null ? null : { ...settings.prompt_cache_options } }), + ...(settings.safety_identifier === undefined ? {} : { safety_identifier: settings.safety_identifier }), + ...(settings.user === undefined ? {} : { user: settings.user }), + ...(settings.logprobs === undefined ? {} : { logprobs: settings.logprobs }), + ...(settings.top_logprobs === undefined ? {} : { top_logprobs: settings.top_logprobs }), + ...(settings.verbosity === undefined ? {} : { verbosity: settings.verbosity }), + ...(settings.choice_count === undefined ? {} : { n: settings.choice_count }), + ...(settings.presence_penalty === undefined ? {} : { presence_penalty: settings.presence_penalty }), + ...(settings.frequency_penalty === undefined ? {} : { frequency_penalty: settings.frequency_penalty }), + ...(format === undefined + ? {} + : format.type === 'json_schema' + ? { + response_format: { + type: 'json_schema' as const, + json_schema: { + name: format.name ?? 'response', + ...(format.description === undefined ? {} : { description: format.description }), + ...(format.schema === undefined ? {} : { schema: format.schema }), + ...(format.strict === undefined ? {} : { strict: format.strict }), + }, + }, + } + : { response_format: { type: format.type } }), + }; +}; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts new file mode 100644 index 0000000000..df47a04549 --- /dev/null +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts @@ -0,0 +1,73 @@ +import type { RestoredAssistantTurn } from '../../../composition/reading.ts'; +import type { IRInputTurn } from '../../../ir/input/types.ts'; +import type { InputAssistantTurnFor } from '../../../round-trip/input-assistant-turn.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; + +type ChatRestoredAssistantTurn = InputAssistantTurnFor<'openaiChatCompletions'>; + +interface ChatLegacyCalls { + /** + * The id the answer to each restored legacy call is written under, keyed by + * that restored turn. Two calls may share a name, so the turn identifies one. + */ + readonly callIds: ReadonlyMap; + /** The legacy call each of those ids answers, by that id. */ + readonly names: ReadonlyMap; +} + +/** Every call id this conversation states, which no legacy answer can name. */ +const statedCallIds = ( + turns: readonly (IRInputTurn | RestoredAssistantTurn)[], +): ReadonlySet => { + const ids = new Set(); + for (const piece of turns) { + if (piece.kind === 'restored') { + for (const call of piece.turn.tool_calls ?? []) ids.add(call.id); + continue; + } + for (const item of piece.items) { + if ((item.type === 'function_call' || item.type === 'custom_tool_call') && item.call_id !== undefined) ids.add(item.call_id); + } + } + return ids; +}; + +/** + * Native legacy function_call has no ID. Match its result within the following client turn by + * eliminating stated modern call IDs; id-less results follow pending-call order. + * Require one unambiguous answer before another round begins. A tail call may remain pending. + * Internal pairing IDs never change the legacy wire's name-based result correlation. + */ +export const chatLegacyCallsOf = ( + turns: readonly (IRInputTurn | RestoredAssistantTurn)[], +): ChatLegacyCalls => { + const stated = statedCallIds(turns); + const callIds = new Map(); + const names = new Map(); + let allocated = 0; + for (const [index, piece] of turns.entries()) { + if (piece.kind !== 'restored') continue; + const name = piece.turn.function_call?.name; + if (name === undefined) continue; + const next = turns[index + 1]; + const results = next?.kind === 'client' + ? next.items.flatMap(item => item.type === 'tool_result' ? [item] : []) + : []; + const unnamed = results.filter(item => item.call_id === undefined || !stated.has(item.call_id)); + if (unnamed.length > 1) { + throw new TranslateInputException(`Cannot translate the answer to the legacy call '${name}': the turn after it states ${unnamed.length} results that name no call of this conversation, so nothing says which one answers it.`); + } + const [answer] = unnamed; + if (answer === undefined) { + if (index < turns.length - 1) { + throw new TranslateInputException(`Cannot translate the legacy call '${name}': the turn after it does not answer it, and a wire that states no id for the call cannot say which later answer means it.`); + } + continue; + } + allocated += 1; + const callId = answer.call_id ?? `legacy_call_${allocated}`; + callIds.set(piece.turn, callId); + names.set(callId, name); + } + return { callIds, names }; +}; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/segmented-request.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/segmented-request.ts new file mode 100644 index 0000000000..b0396704c9 --- /dev/null +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/segmented-request.ts @@ -0,0 +1,26 @@ +import { openaiChatCompletionsConversationFromIR, openaiChatCompletionsHeaderFromIR } from './input-from.ts'; +import { type RestoredAssistantTurn } from '../../../composition/reading.ts'; +import type { IRInputRequestHeader } from '../../../ir/input/types.ts'; +import type { IRInputTurnWithCallIds } from '../../../request/call-ids.ts'; +import { segmentTurns } from '../../../request/segments.ts'; +import type { OpenAIChatCompletionsMessage, OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; + +/** + * Preserve restored native Chat turns verbatim and compute request-wide fields once. + * Chat has no top-level prompt slot, so prompts retain their inline positions. + */ +export const openaiChatCompletionsPayloadFromSegments = ( + request: IRInputRequestHeader, + turns: readonly (IRInputTurnWithCallIds | RestoredAssistantTurn)[], + // The legacy call each result answers, by the id that result carries. Only + // this writer reads it: a legacy call has no id to answer with, so its answer + // is stated in the `function` form this wire pairs with the field. + legacyCallNames: ReadonlyMap, +): OpenAIChatCompletionsPayload => { + const messages: OpenAIChatCompletionsMessage[] = []; + for (const piece of segmentTurns(turns)) { + if (piece.kind === 'restored') { messages.push(piece.turn); continue; } + messages.push(...openaiChatCompletionsConversationFromIR(piece.turns, legacyCallNames)); + } + return { ...openaiChatCompletionsHeaderFromIR(request), messages }; +}; diff --git a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts deleted file mode 100644 index 12b6d1a994..0000000000 --- a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; -import { readAnthropicMessagesEvents } from '../../anthropic-messages/as-target/events.ts'; -import { writeOpenAIChatCompletionsEvents } from '../as-source/events.ts'; -import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsStreamOptionsEx } from '@floway-dev/protocols/openai-chat-completions'; - -export const translateToSourceEvents = ( - frames: AsyncIterable>, - streamOptions: OpenAIChatCompletionsStreamOptionsEx, - codec: SidecarCodec, -): AsyncIterable> => { - const outputOptions = { continuousUsageStats: streamOptions.include_usage === true && streamOptions.continuous_usage_stats === true }; - const stream = createRoundTripCoordinator('openaiChatCompletions', 'anthropicMessages', codec); - return writeOpenAIChatCompletionsEvents(readAnthropicMessagesEvents(frames, { roundTrip: stream.reader }), { ...outputOptions, roundTrip: stream.writer }); -}; 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 deleted file mode 100644 index f39d0f3f41..0000000000 --- a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/request.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { klona } from 'klona/json'; - -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { 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'; -import { resolveImageUrlToAnthropicMessagesImage } from '../../anthropic-messages/as-target/remote-images.ts'; -import { anthropicMessagesToolInputSchema } from '../../anthropic-messages/as-target/tool-input-schema.ts'; -import { partitionOpenAIChatCompletionsTurns } from '../as-source/conversation-turns.ts'; -import { collectOpenAIChatCompletionsReplayContents, hasOpenAIChatCompletionsReasoning, findOpenAIChatCompletionsSidecarOccurrences, verifyOpenAIChatCompletionsTurnDigest } from '../as-source/round-trip.ts'; -import { ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS, type AnthropicMessagesAssistantInputContentBlock, type AnthropicMessagesMessage, type AnthropicMessagesPayload, type AnthropicMessagesTextBlockParam, type AnthropicMessagesUserContentBlock } from '@floway-dev/protocols/anthropic-messages'; -import { isFastServiceTier } from '@floway-dev/protocols/common'; -import type { OpenAIChatCompletionsAssistantMessage, OpenAIChatCompletionsPayload, OpenAIChatCompletionsMessage, OpenAIChatCompletionsTool } from '@floway-dev/protocols/openai-chat-completions'; - -interface BuildTargetRequestOptions { - loadRemoteImage: RemoteImageLoader; - /** - * Preferred cap used when the source payload omits `max_tokens`. Callers in - * the data plane forward the model's advertised `/models` output cap so the - * translated Anthropic Messages request reflects the upstream-known limit rather than - * being silently capped by a target-side default later. - */ - fallbackMaxOutputTokens: number | undefined; -} - -const parseToolArgumentsObject = (argumentsJson: string): Record => { - try { - const parsed = JSON.parse(argumentsJson); - return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : { raw_arguments: argumentsJson }; - } catch { - return { raw_arguments: argumentsJson }; - } -}; - -const anthropicServiceTierFieldsFromOpenAI = (serviceTier: string | null | undefined): Partial => - isFastServiceTier(serviceTier) - ? { speed: 'fast' } - : serviceTier != null - ? { service_tier: serviceTier } - : {}; - -const buildAssistantBlocks = (message: OpenAIChatCompletionsAssistantMessage): AnthropicMessagesAssistantInputContentBlock[] => { - const blocks: AnthropicMessagesAssistantInputContentBlock[] = []; - if (typeof message.content === 'string') { - if (message.content) blocks.push({ type: 'text', text: message.content }); - } else if (Array.isArray(message.content)) { - for (const part of message.content) { - if (part.type === 'text') blocks.push({ type: 'text', text: part.text }); - else if (part.type === 'refusal') blocks.push({ type: 'text', text: part.refusal }); - } - } - - if (message.refusal) blocks.push({ type: 'text', text: message.refusal }); - - for (const toolCall of message.tool_calls ?? []) { - 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, - name: toolCall.function.name, - input: parseToolArgumentsObject(toolCall.function.arguments), - }); - } - - return blocks.length > 0 ? blocks : [{ type: 'text', text: '' }]; -}; - -const appendUserBlocks = (messages: AnthropicMessagesMessage[], blocks: AnthropicMessagesUserContentBlock[]): void => { - const lastMessage = messages[messages.length - 1]; - - if (lastMessage?.role === 'user') { - const existing = Array.isArray(lastMessage.content) ? lastMessage.content : [{ type: 'text' as const, text: lastMessage.content }]; - - lastMessage.content = [...existing, ...blocks]; - return; - } - - messages.push({ - role: 'user', - content: blocks.length === 1 && blocks[0].type === 'text' ? blocks[0].text : blocks, - }); -}; - -const convertUserContent = async (message: OpenAIChatCompletionsMessage, loadRemoteImage: RemoteImageLoader): Promise => { - if (typeof message.content === 'string') { - return [{ type: 'text', text: message.content }]; - } - - if (!Array.isArray(message.content)) { - return [{ type: 'text', text: '' }]; - } - - const resolved = await Promise.all( - message.content.map(part => { - if (part.type === 'text') { - return Promise.resolve({ type: 'text', text: part.text } as AnthropicMessagesUserContentBlock); - } - - if (part.type === 'image_url') { - return resolveImageUrlToAnthropicMessagesImage(part.image_url.url, loadRemoteImage); - } - - throw new TranslateInputException(`Invalid '${(part as { type: string }).type}' content part. Only 'text' and 'image_url' are supported in user content.`); - }), - ); - - const blocks = resolved.filter((block): block is AnthropicMessagesUserContentBlock => block !== null); - - return blocks.length > 0 ? blocks : [{ type: 'text', text: '' }]; -}; - -// Anthropic's Anthropic Messages system field (top-level `AnthropicMessagesPayload.system` and -// inline `AnthropicMessagesSystemMessage.content`) accepts only text. Image parts in -// system / developer messages are rejected here at the translator boundary so -// the caller hits an explicit failure instead of having the image silently -// dropped on the wire. Returns blocks (possibly empty) so the hoist and -// inline call sites share one shape. -const convertSystemContent = (content: OpenAIChatCompletionsMessage['content']): AnthropicMessagesTextBlockParam[] => { - if (typeof content === 'string') { - return content ? [{ type: 'text', text: content }] : []; - } - if (!Array.isArray(content)) return []; - - const blocks: AnthropicMessagesTextBlockParam[] = []; - for (const part of content) { - if (part.type === 'image_url') { - 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 }); - } - } - - return blocks; -}; - -const translateNonAssistantMessages = async ( - messages: Exclude[], - loadRemoteImage: RemoteImageLoader, - result: AnthropicMessagesMessage[], -): Promise => { - for (const message of messages) { - switch (message.role) { - case 'user': - appendUserBlocks(result, await convertUserContent(message, loadRemoteImage)); - break; - case 'tool': - if (!message.tool_call_id) { - throw new TranslateInputException("Missing required field 'tool_call_id' on a 'tool' role message."); - } - - appendUserBlocks(result, [ - { - type: 'tool_result', - tool_use_id: message.tool_call_id, - content: typeof message.content === 'string' ? message.content : '', - }, - ]); - break; - case 'system': - case 'developer': { - // Inline path for non-leading system / developer (the leading prefix - // was hoisted earlier). Anthropic upstreams diverge on inline - // role:'system' here (Bedrock accepts it under placement rules; - // Vertex rejects it outright), so the gateway's - // `rewrite-mid-conv-system-to-user` interceptor flag is the safety - // net for any inline system that would otherwise reach an upstream - // that does not accept it. - const blocks = convertSystemContent(message.content); - result.push({ - role: 'system', - content: blocks.length > 0 ? blocks : '', - }); - break; - } - default: - throw new TranslateInputException(`Invalid role '${message.role}'.`); - } - } -}; - -const translateOpenAIChatCompletionsTools = (tools: OpenAIChatCompletionsTool[]): AnthropicMessagesPayload['tools'] => - tools.map(tool => { - if (tool.type !== 'function') throw new TranslateInputException('Cannot translate custom Chat tools to Anthropic Messages.'); - return { - name: tool.function.name, - description: tool.function.description, - input_schema: anthropicMessagesToolInputSchema(klona(tool.function.parameters) ?? { type: 'object', properties: {} }), - ...(tool.function.strict != null ? { strict: tool.function.strict } : {}), - }; - }); - -const translateOpenAIChatCompletionsToolChoice = (toolChoice: NonNullable): AnthropicMessagesPayload['tool_choice'] => { - if (typeof toolChoice === 'string') return klona(CHAT_TOOL_CHOICES[toolChoice]); - - if (toolChoice.type === 'allowed_tools') return { type: toolChoice.allowed_tools.mode === 'required' ? 'any' : 'auto' }; - if (toolChoice.type !== 'function') throw new TranslateInputException(`Cannot translate ${toolChoice.type} Chat tool choice to Anthropic Messages.`); - return { type: 'tool', name: toolChoice.function.name }; -}; - -const CHAT_TOOL_CHOICES = { - auto: { type: 'auto' }, - none: { type: 'none' }, - required: { type: 'any' }, -} satisfies Record, AnthropicMessagesPayload['tool_choice']>; - -export const buildTargetRequest = async ( - payload: OpenAIChatCompletionsPayload, - codec: SidecarCodec, - options: BuildTargetRequestOptions, -): Promise => { - // Hoist the leading contiguous run of system/developer messages to - // AnthropicMessagesPayload.system, preserving each ContentPart text as its own - // AnthropicMessagesTextBlockParam so part boundaries survive the hoist. Non-leading - // system/developer messages stay inline as AnthropicMessagesSystemMessage at their - // chronological position. - const systemBlocks: AnthropicMessagesTextBlockParam[] = []; - let prefixEnd = 0; - for (const message of payload.messages) { - if (message.role !== 'system' && message.role !== 'developer') break; - systemBlocks.push(...convertSystemContent(message.content)); - prefixEnd++; - } - - const messages: AnthropicMessagesMessage[] = []; - const restoredMessages = new Set(); - for (const turn of partitionOpenAIChatCompletionsTurns(payload.messages.slice(prefixEnd))) { - const roundTrip = turn.role === 'assistant' && await roundTripAssistantTurn('openaiChatCompletions', 'anthropicMessages', turn.items[0], codec, findOpenAIChatCompletionsSidecarOccurrences, verifyOpenAIChatCompletionsTurnDigest, collectOpenAIChatCompletionsReplayContents); - if (!roundTrip) { - if (turn.role === 'assistant') { - const message = turn.items[0]; - if (hasOpenAIChatCompletionsReasoning(message)) warnDroppedUnrecognizedAssistantReasoning('openaiChatCompletions', 'anthropicMessages'); - messages.push({ role: 'assistant', content: buildAssistantBlocks(message) }); - } else await translateNonAssistantMessages(turn.items, options.loadRemoteImage, messages); - continue; - } - messages.push(roundTrip.turn); - restoredMessages.add(roundTrip.turn); - } - - const maxTokens = payload.max_tokens ?? options.fallbackMaxOutputTokens ?? ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS; - const allowedTools = typeof payload.tool_choice === 'object' && payload.tool_choice?.type === 'allowed_tools' - ? new Set(payload.tool_choice.allowed_tools.tools.map(tool => { - if (tool.type !== 'function') throw new TranslateInputException('Cannot translate custom Chat tools to Anthropic Messages.'); - return tool.function.name; - })) - : undefined; - const selectedTools = allowedTools === undefined ? payload.tools : payload.tools?.filter(tool => tool.type === 'function' && allowedTools.has(tool.function.name)); - const tools = selectedTools?.length ? translateOpenAIChatCompletionsTools(selectedTools) : undefined; - applyLastSystemCacheBreakpoint(systemBlocks); - applyLastToolCacheBreakpoint(tools); - applyLastMessageCacheBreakpoint(messages.filter(message => !restoredMessages.has(message))); - - // Merge OpenAI Chat Completions `reasoning_effort` + `response_format` into a single Anthropic Messages - // `output_config` so a chat-source structured-output request survives - // routing through an Anthropic Messages target. `reasoning_effort: 'none'` lands on - // `thinking` instead of `output_config`, and `format` still rides along. - // - // OpenAI Chat Completions nests json_schema details (`response_format = { type: 'json_schema', - // json_schema: { schema } }`); `json_object` / `text` / absent have no - // Anthropic Messages equivalent and drop. - const { thinking, effort: reasoningEffort } = anthropicMessagesReasoningFieldsFromEffort(payload.reasoning_effort); - const responseFormat = payload.response_format; - const jsonSchema = responseFormat?.type === 'json_schema' ? (responseFormat.json_schema as Record | undefined) : undefined; - const formatSchema = - jsonSchema?.schema && typeof jsonSchema.schema === 'object' && !Array.isArray(jsonSchema.schema) ? (jsonSchema.schema as Record) : undefined; - const outputConfig: NonNullable = {}; - if (reasoningEffort !== undefined) outputConfig.effort = reasoningEffort; - if (formatSchema) outputConfig.format = { type: 'json_schema', schema: klona(formatSchema) }; - const hasOutputConfig = Object.keys(outputConfig).length > 0; - - // OpenAI's accelerated request lane maps to Anthropic's `speed: 'fast'`; - // other service-tier values remain open strings. - // https://docs.claude.com/en/build-with-claude/fast-mode - // https://platform.openai.com/docs/guides/fast-mode - const serviceTierFields = anthropicServiceTierFieldsFromOpenAI(payload.service_tier); - - // OpenAI user and generic metadata are not interpreted as Messages user_id. - return { - model: payload.model, - messages, - max_tokens: maxTokens, - ...(systemBlocks.length > 0 ? { system: systemBlocks } : {}), - ...(payload.temperature != null ? { temperature: payload.temperature } : {}), - ...(payload.top_p != null ? { top_p: payload.top_p } : {}), - ...(payload.stop != null - ? { - stop_sequences: Array.isArray(payload.stop) ? klona(payload.stop) : [payload.stop], - } - : {}), - stream: true, - ...(tools ? { tools } : {}), - ...(payload.tool_choice != null ? { tool_choice: translateOpenAIChatCompletionsToolChoice(payload.tool_choice) } : {}), - ...(thinking ? { thinking } : {}), - ...(hasOutputConfig ? { output_config: outputConfig } : {}), - ...serviceTierFields, - }; -}; 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 deleted file mode 100644 index 864f6867c6..0000000000 --- a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/translate.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { translateToSourceEvents } from './events.ts'; -import { buildTargetRequest } from './request.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { RemoteImageLoader, Translator } from '../../../types.ts'; -import { restateOpenAIChatCompletionsError } from '../as-source/error-body.ts'; -import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; - -export const translate: Translator< - OpenAIChatCompletionsPayloadEx, OpenAIChatCompletionsStreamEvent, AnthropicMessagesPayload, AnthropicMessagesStreamEventEx, - { fallbackMaxOutputTokens?: number; loadRemoteImage: RemoteImageLoader } -> = async (src, ctx) => { - try { - const streamOptions = { ...src.stream_options }; - return { - type: 'trip', - error: restateOpenAIChatCompletionsError, - 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 deleted file mode 100644 index b4ce07eb24..0000000000 --- a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/content.ts +++ /dev/null @@ -1,39 +0,0 @@ -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'; - -// OpenAI Chat Completions and OpenAI Responses text arrays are transport fragments of one message, not -// paragraph blocks. Preserve the existing no-separator flattening. -const contentPartText = (part: OpenAIChatCompletionsUserContentPart | OpenAIResponsesInputContent): string | null => (part.type === 'text' || part.type === 'input_text' || part.type === 'output_text' ? part.text : null); - -const contentPartsToText = (parts: readonly (OpenAIChatCompletionsUserContentPart | OpenAIResponsesInputContent)[]): string => - parts - .map(contentPartText) - .filter((text): text is string => text !== null) - .join(''); - -export const openaiChatCompletionsContentToText = (content: string | OpenAIChatCompletionsUserContentPart[] | null | undefined): string => (typeof content === 'string' ? content : Array.isArray(content) ? contentPartsToText(content) : ''); - -export const openaiChatCompletionsContentToOpenAIResponsesInputContent = (content: string | OpenAIChatCompletionsUserContentPart[] | null | undefined): string | OpenAIResponsesInputContent[] => { - if (typeof content === 'string') return content; - if (!Array.isArray(content) || content.length === 0) return ''; - - return content.map( - (part): OpenAIResponsesInputContent => { - switch (part.type) { - case 'text': - return { type: 'input_text', text: part.text }; - case 'input_audio': - throw new TranslateInputException('Cannot translate input_audio content to OpenAI Responses.'); - case 'file': - return { type: 'input_file', ...part.file }; - case 'image_url': - return { - type: 'input_image', - image_url: part.image_url.url, - ...(part.image_url.detail === undefined ? {} : { detail: part.image_url.detail }), - }; - } - }, - ); -}; diff --git a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts deleted file mode 100644 index 39e386f614..0000000000 --- a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; -import { readOpenAIResponsesEvents } from '../../openai-responses/as-target/events.ts'; -import { writeOpenAIChatCompletionsEvents } from '../as-source/events.ts'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsStreamOptionsEx } from '@floway-dev/protocols/openai-chat-completions'; -import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; - -export const translateToSourceEvents = ( - frames: AsyncIterable>, - streamOptions: OpenAIChatCompletionsStreamOptionsEx, - codec: SidecarCodec, -): AsyncIterable> => { - const outputOptions = { continuousUsageStats: streamOptions.include_usage === true && streamOptions.continuous_usage_stats === true }; - const stream = createRoundTripCoordinator('openaiChatCompletions', 'openaiResponses', codec); - return writeOpenAIChatCompletionsEvents(readOpenAIResponsesEvents(frames, { roundTrip: stream.reader }), { ...outputOptions, roundTrip: stream.writer }); -}; 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 deleted file mode 100644 index b55fef0a4c..0000000000 --- a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/request.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { klona } from 'klona/json'; - -import { openaiChatCompletionsContentToOpenAIResponsesInputContent, openaiChatCompletionsContentToText } from './content.ts'; -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import { partitionOpenAIChatCompletionsTurns } from '../as-source/conversation-turns.ts'; -import { collectOpenAIChatCompletionsReplayContents, hasOpenAIChatCompletionsReasoning, findOpenAIChatCompletionsSidecarOccurrences, verifyOpenAIChatCompletionsTurnDigest } from '../as-source/round-trip.ts'; -import type { OpenAIChatCompletionsAssistantMessage, OpenAIChatCompletionsMessage, OpenAIChatCompletionsPayload, OpenAIChatCompletionsTool } from '@floway-dev/protocols/openai-chat-completions'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesAssistantInputContent, CanonicalOpenAIResponsesInputItem, OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; - -const translateChatTools = (tools?: OpenAIChatCompletionsTool[] | null): OpenAIResponsesTool[] => - tools?.length - ? tools.map(tool => { - if (tool.type === 'custom') return { type: 'custom', ...klona(tool.custom) }; - return { - type: 'function', - name: tool.function.name, - parameters: klona(tool.function.parameters) ?? { type: 'object', properties: {} }, - // OpenAI Chat Completions function tools are non-strict by default while OpenAI Responses function - // tools default strict; make omission explicit to preserve OpenAI Chat Completions semantics. - strict: tool.function.strict ?? false, - ...(tool.function.description ? { description: tool.function.description } : {}), - }; - }) - : []; - -const translateChatToolChoice = (choice: NonNullable): OpenAIResponsesToolChoice => { - if (typeof choice === 'string') return choice; - if (choice.type === 'function') return { type: 'function', name: choice.function.name }; - if (choice.type === 'custom') return { type: 'custom', name: choice.custom.name }; - return { - type: 'allowed_tools', mode: choice.allowed_tools.mode, tools: choice.allowed_tools.tools.map(tool => { - return tool.type === 'function' ? { type: 'function', name: tool.function.name } : { type: 'custom', name: tool.custom.name }; - }), - }; -}; - -const translateAssistantContent = (message: OpenAIChatCompletionsAssistantMessage): OpenAIResponsesAssistantInputContent[] => { - const content: OpenAIResponsesAssistantInputContent[] = []; - let hasRefusalPart = false; - - if (typeof message.content === 'string') { - if (message.content) content.push({ type: 'output_text', text: message.content }); - } else if (Array.isArray(message.content)) { - for (const part of message.content) { - if (part.type === 'text') content.push({ type: 'output_text', text: part.text }); - else if (part.type === 'refusal') { - content.push({ type: 'refusal', refusal: part.refusal }); - hasRefusalPart = true; - } - } - } - - if (message.refusal !== undefined && message.refusal !== null && !hasRefusalPart) { - content.push({ type: 'refusal', refusal: message.refusal }); - } - - return content; -}; - -export const buildTargetRequest = async ( - payload: OpenAIChatCompletionsPayload, - codec: SidecarCodec, -): Promise => { - const instructions: string[] = []; - const input: CanonicalOpenAIResponsesInputItem[] = []; - const customToolCallIds = new Set(); - let hoistSystemPrefix = true; - - const translateAssistantMessage = (message: OpenAIChatCompletionsAssistantMessage): void => { - hoistSystemPrefix = false; - if (hasOpenAIChatCompletionsReasoning(message)) { - warnDroppedUnrecognizedAssistantReasoning('openaiChatCompletions', 'openaiResponses'); - } - const assistantContent = translateAssistantContent(message); - if (message.tool_calls?.length) { - if (assistantContent.length > 0) { - input.push({ - type: 'message', - role: 'assistant', - content: assistantContent, - }); - } - - for (const toolCall of message.tool_calls) { - if (toolCall.type === 'custom') { - customToolCallIds.add(toolCall.id); - input.push({ type: 'custom_tool_call', call_id: toolCall.id, ...toolCall.custom, status: 'completed' }); - continue; - } - input.push({ - type: 'function_call', - call_id: toolCall.id, - name: toolCall.function.name, - arguments: toolCall.function.arguments, - status: 'completed', - }); - } - - return; - } - - input.push({ - type: 'message', - role: 'assistant', - content: assistantContent.length > 0 ? assistantContent : '', - }); - }; - - const translateNonAssistantMessages = (messages: Exclude[]): void => { - for (const message of messages) { - // Only the initial OpenAI Chat Completions `system` prefix maps cleanly to OpenAI Responses - // `instructions`; later `system` and `developer` turns are - // chronology-bearing input items. - if (hoistSystemPrefix && message.role === 'system') { - const text = openaiChatCompletionsContentToText(message.content); - if (text) instructions.push(text); - continue; - } - - hoistSystemPrefix = false; - - if (message.role === 'user') { - input.push({ - type: 'message', - role: 'user', - content: openaiChatCompletionsContentToOpenAIResponsesInputContent(message.content), - }); - continue; - } - - if (message.role === 'system' || message.role === 'developer') { - input.push({ - type: 'message', - role: message.role, - content: openaiChatCompletionsContentToOpenAIResponsesInputContent(message.content), - }); - continue; - } - - if (message.role !== 'tool') { - throw new TranslateInputException(`Invalid role '${(message as { role: string }).role}'.`); - } - - if (!message.tool_call_id) { - throw new TranslateInputException("Missing required field 'tool_call_id' on a 'tool' role message."); - } - - input.push({ - type: customToolCallIds.has(message.tool_call_id) ? 'custom_tool_call_output' : 'function_call_output', - call_id: message.tool_call_id, - output: typeof message.content === 'string' ? message.content : JSON.stringify(message.content), - }); - } - }; - - for (const turn of partitionOpenAIChatCompletionsTurns(payload.messages)) { - const roundTrip = turn.role === 'assistant' && await roundTripAssistantTurn('openaiChatCompletions', 'openaiResponses', turn.items[0], codec, findOpenAIChatCompletionsSidecarOccurrences, verifyOpenAIChatCompletionsTurnDigest, collectOpenAIChatCompletionsReplayContents); - if (!roundTrip) { - if (turn.role === 'assistant') translateAssistantMessage(turn.items[0]); - else translateNonAssistantMessages(turn.items); - continue; - } - hoistSystemPrefix = false; - input.push(...roundTrip.turn); - for (const item of roundTrip.turn) if (item.type === 'custom_tool_call') customToolCallIds.add(item.call_id); - } - - const format = klona(payload.response_format); - 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! } - : format, - }; - - const reasoningEffort = payload.reasoning_effort ?? undefined; - const reasoning = reasoningEffort !== undefined ? { effort: reasoningEffort } : undefined; - - return { - model: payload.model, - input, - ...(instructions.length > 0 ? { instructions: instructions.join('\n\n') } : {}), - ...(payload.temperature !== undefined ? { temperature: payload.temperature } : {}), - ...(payload.top_p !== undefined ? { top_p: payload.top_p } : {}), - ...(payload.max_tokens !== undefined ? { max_output_tokens: payload.max_tokens } : {}), - ...(payload.tools !== undefined ? { tools: translateChatTools(payload.tools) } : {}), - // Omit tool_choice without tools: xAI-backed upstreams reject this shape. - // https://github.com/Wei-Shaw/sub2api/issues/4819 - // https://github.com/jlcodes99/cockpit-tools/issues/1727 - ...(payload.tool_choice != null && payload.tools?.length ? { tool_choice: translateChatToolChoice(payload.tool_choice) } : {}), - // Same-purpose OpenAI fields are normal OpenAI Chat Completions/OpenAI Responses adapter surface; - // provider-specific policy filtering belongs at the target boundary, not in - // pairwise translation. - ...(payload.metadata !== undefined ? { metadata: klona(payload.metadata) } : {}), - stream: true, - // Omitted store inherits the target/account default; explicit false disables it. - // https://developers.openai.com/api/docs/guides/migrate-to-responses - ...(payload.store !== undefined ? { store: payload.store } : {}), - ...(payload.parallel_tool_calls !== undefined ? { parallel_tool_calls: payload.parallel_tool_calls } : {}), - ...(reasoning ? { reasoning } : {}), - ...(responseTextConfig !== undefined ? { text: responseTextConfig } : {}), - ...(payload.prompt_cache_key !== undefined ? { prompt_cache_key: payload.prompt_cache_key } : {}), - ...(payload.safety_identifier !== undefined ? { safety_identifier: payload.safety_identifier } : {}), - ...(payload.service_tier !== undefined ? { service_tier: payload.service_tier } : {}), - }; -}; 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 deleted file mode 100644 index 27fbb53a80..0000000000 --- a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/translate.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { translateToSourceEvents } from './events.ts'; -import { buildTargetRequest } from './request.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { Translator } from '../../../types.ts'; -import { restateOpenAIChatCompletionsError } from '../as-source/error-body.ts'; -import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; - -export const translate: Translator< - OpenAIChatCompletionsPayloadEx, OpenAIChatCompletionsStreamEvent, CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx -> = async (src, ctx) => { - try { - const streamOptions = { ...src.stream_options }; - return { - type: 'trip', - error: restateOpenAIChatCompletionsError, - 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.ts b/packages/translate/src/protocols/openai-responses/as-source.ts new file mode 100644 index 0000000000..1fcc64839c --- /dev/null +++ b/packages/translate/src/protocols/openai-responses/as-source.ts @@ -0,0 +1,34 @@ +import { restateOpenAIResponsesError } from './as-source/error-body.ts'; +import { createRandomOpenAIResponsesResponseId, writeOpenAIResponsesEvents } from './as-source/events.ts'; +import { readOpenAIResponsesRequest } from './as-source/input-to.ts'; +import { collectOpenAIResponsesReplayContents, findOpenAIResponsesSidecarOccurrences, verifyOpenAIResponsesTurnDigest } from './as-source/round-trip.ts'; +import type { SourceProtocol } from '../../composition/index.ts'; +import type { OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; + +/** Preserve grouped callable identity as namespace and name for source event restoration. */ +export const openaiResponsesSourceProtocol: SourceProtocol< + 'openaiResponses', OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx +> = { + name: 'openaiResponses', + read: readOpenAIResponsesRequest, + sidecarRecognition: { + inspect: findOpenAIResponsesSidecarOccurrences, + verify: verifyOpenAIResponsesTurnDigest, + collectCandidates: collectOpenAIResponsesReplayContents, + }, + error: restateOpenAIResponsesError, + writeEvents: (payload, records, roundTrip) => { + // Mint the client-visible translated Responses ID for this event stream. + const responseId = createRandomOpenAIResponsesResponseId(); + // The response objects echo the client's own declarations, which only the + // payload states as the client wrote them: the IR holds a normalized form + // and the upstream states none of this at all. + const { tools, tool_choice: toolChoice } = payload; + return writeOpenAIResponsesEvents(records, { + id: responseId, + roundTrip, + ...(tools == null || tools.length === 0 ? {} : { echoedTools: tools }), + ...(toolChoice == null ? {} : { echoedToolChoice: toolChoice }), + }); + }, +}; 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 62f7249ba1..3663de7967 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,77 +1,19 @@ import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; -const validateCallableKinds = (tools: OpenAIResponsesTool[] | null | undefined): void => { - const kinds = new Map(); - for (const tool of tools ?? []) { - if (tool.type !== 'function' && tool.type !== 'custom') continue; - if ('namespace' in tool && tool.namespace !== undefined) continue; - const prior = kinds.get(tool.name); - if (prior !== undefined && prior !== tool.type) { - throw new TranslateInputException(`Cannot translate distinct callable kinds sharing '${tool.name}'.`); - } - kinds.set(tool.name, tool.type); +/** + * The callable a selector names. + * + * `allowed_tools.tools` is the wire's own `Record` array, so + * this is where a member becomes a typed selector. A member that carries + * anything beyond the three fields a selector may state is not one. + */ +export const requireCallableSelector = (selector: unknown): { readonly type: 'function' | 'custom'; readonly name: string; readonly namespace?: string } => { + if (typeof selector !== 'object' || selector === null + || ((selector as { type?: unknown }).type !== 'function' && (selector as { type?: unknown }).type !== 'custom') + || typeof (selector as { name?: unknown }).name !== 'string' + || ('namespace' in selector && selector.namespace !== undefined && typeof selector.namespace !== 'string') + || Object.keys(selector).some(key => key !== 'type' && key !== 'name' && key !== 'namespace')) { + throw new TranslateInputException('Cannot translate an allowed_tools selector that is not a function or custom tool.'); } -}; - -// OpenAI Chat Completions and Anthropic Messages preserve flat function/custom -// subsets by filtering declarations and translating the mode separately. -// Namespace and hosted selectors must arrive already rewritten as flat -// callables; otherwise translation rejects them so the permitted set cannot -// silently widen. -// https://github.com/openai/openai-node/blob/61539248cbe04665de68a71e6fd878127ae4db87/src/resources/responses/responses.ts#L8246-L8279 -export const restrictAllowedTools = ( - tools: OpenAIResponsesTool[] | null | undefined, - choice: OpenAIResponsesToolChoice | null | undefined, -): { tools: OpenAIResponsesTool[] | null | undefined; choice: OpenAIResponsesToolChoice | null | undefined } => { - if (typeof choice !== 'object' || choice?.type !== 'allowed_tools') { - validateCallableKinds(tools); - return { tools, choice }; - } - if ((choice.mode !== 'auto' && choice.mode !== 'required') || !Array.isArray(choice.tools)) { - throw new TranslateInputException('Cannot translate malformed allowed_tools mode or tools array.'); - } - - const declared = new Map>(); - for (const tool of tools ?? []) { - if (tool.type !== 'function' && tool.type !== 'custom') continue; - if ('namespace' in tool && tool.namespace !== undefined) continue; - let names = declared.get(tool.type); - if (names === undefined) { - names = new Map(); - declared.set(tool.type, names); - } - const entries = names.get(tool.name) ?? []; - entries.push(tool); - names.set(tool.name, entries); - } - const selected = new Set(); - const selectedKinds = new Map(); - for (const selector of choice.tools) { - if (typeof selector !== 'object' || selector === null - || (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 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 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 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 TranslateInputException('Cannot translate required allowed_tools with an empty callable subset.'); - } - const restricted = (tools ?? []).filter(tool => selected.has(tool)); - validateCallableKinds(restricted); - return { - tools: restricted, - choice: selected.size === 0 ? 'none' : choice.mode, - }; + return selector as { readonly type: 'function' | 'custom'; readonly name: string; readonly namespace?: string }; }; diff --git a/packages/translate/src/protocols/openai-responses/as-source/conversation-turns.ts b/packages/translate/src/protocols/openai-responses/as-source/conversation-turns.ts index 17723e3d86..f9eb69fb8f 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/conversation-turns.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/conversation-turns.ts @@ -1,14 +1,26 @@ import { isOpenAIResponsesAssistantInputItem, type CanonicalOpenAIResponsesInputItem } from '@floway-dev/protocols/openai-responses'; -export type OpenAIResponsesConversationTurn = { role: 'assistant' | 'bare'; items: CanonicalOpenAIResponsesInputItem[] }; +export type OpenAIResponsesConversationTurn = { + readonly role: 'assistant' | 'client'; + readonly items: CanonicalOpenAIResponsesInputItem[]; +}; + +// A system or developer message is a prompt boundary of its own: a target that +// hoists it into a top-level slot, or keeps it inline, needs it to stay separate +// from the client content around it. +const isPromptBoundary = (item: CanonicalOpenAIResponsesInputItem): boolean => + item.type === 'message' && (item.role === 'system' || item.role === 'developer'); export const partitionOpenAIResponsesTurns = (input: readonly CanonicalOpenAIResponsesInputItem[]): OpenAIResponsesConversationTurn[] => { - const turns: OpenAIResponsesConversationTurn[] = []; + const turns: { role: 'assistant' | 'client'; items: CanonicalOpenAIResponsesInputItem[] }[] = []; for (const item of input) { - const role = isOpenAIResponsesAssistantInputItem(item) ? 'assistant' : 'bare'; + const role = isOpenAIResponsesAssistantInputItem(item) ? 'assistant' : 'client'; const previous = turns.at(-1); - if (previous?.role === role) previous.items.push(item); - else turns.push({ role, items: [item] }); + if (isPromptBoundary(item) || previous?.role !== role || previous.items.some(isPromptBoundary)) { + turns.push({ role, items: [item] }); + continue; + } + previous.items.push(item); } return turns; }; diff --git a/packages/translate/src/protocols/openai-responses/as-source/events.ts b/packages/translate/src/protocols/openai-responses/as-source/events.ts index 09570290a4..dd1811c958 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/events.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/events.ts @@ -1,14 +1,12 @@ -import { restoreNamespaceOutputItem, type NamespaceToolNames } from './namespace-tools.ts'; -import { createRandomOpenAIResponsesResponseId } from './response-id.ts'; -import { collectOpenAIResponsesTextPlacements, computeOpenAIResponsesTurnDigest, createOpenAIResponsesSidecarItem } from './round-trip.ts'; +import { collectOpenAIResponsesTextPlacements, computeOpenAIResponsesTurnDigest, createOpenAIResponsesSidecarCarrier } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; import { IRAbsent } from '../../../ir/output/absent.ts'; import type { IRCollector } from '../../../ir/output/collector.ts'; -import type { IRErrorPayload } from '../../../ir/output/error.ts'; +import { type IRErrorPayload } from '../../../ir/output/error.ts'; import { irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/output/operation-paths.ts'; -import type { IRPath, IRRecord } from '../../../ir/output/records.ts'; +import { type IRPath, type IRRecord } from '../../../ir/output/records.ts'; import { consumeIRRecords } from '../../../ir/output/stream.ts'; -import type { IROutput, IROutputItem, IROutputMessageItem, IROutputSourceCitation, IROutputTokenLogprob } from '../../../ir/output/types.ts'; +import { type IROutput, type IROutputItem, type IROutputMessageItem, type IROutputSourceCitation, type IROutputTokenLogprob } from '../../../ir/output/types.ts'; import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; import { createIRUnitQueue } from '../../../ir/output/unit-queue.ts'; import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; @@ -16,12 +14,14 @@ import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan import { appendEmittedField, createEmittedFieldState, type EmittedFieldState, type EmittedFieldStates } from '../../../round-trip/reference/reference-builder.ts'; import { errorDetailsOf } from '../../../shared/api-error.ts'; import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; -import { isCompleteJSONObject, parseJSONObjectAtCompletion } from '../../../shared/json/object.ts'; +import { parseJSONObjectUnlessTruncated } from '../../../shared/json/completion.ts'; +import { isCompleteJSONObject, isJSONRecord } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIResponsesInputAssistantTurn, type OpenAIResponsesItemState, type OpenAIResponsesOutputAssistantTurnState } from '../as-target/assistant-turn.ts'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { createRandomOpenAIResponsesItemId, + type CanonicalOpenAIResponsesPayload, type OpenAIResponsesAnnotation, type OpenAIResponsesOutputContentBlock, type OpenAIResponsesOutputItemEx, @@ -31,6 +31,8 @@ import { type OpenAIResponsesStreamEventEx, } from '@floway-dev/protocols/openai-responses'; +export const createRandomOpenAIResponsesResponseId = (): string => `resp_${crypto.randomUUID().replaceAll('-', '').slice(0, 24)}`; + interface ResponsesOutputUnitBase { readonly path: IRPath; readonly parts: IRPath[]; @@ -52,13 +54,17 @@ type ResponsesOutputUnit = ResponsesOutputUnitBase & ( | { readonly kind: 'function_call' | 'custom_tool_call'; toolContentState?: EmittedFieldState<'text'> } ); -type OpenAIResponsesEventWriterOptions = SourceEventWriterOptions<'openaiResponses'> & { namespaceToolNames: NamespaceToolNames }; +interface OpenAIResponsesEventWriterOptions extends SourceEventWriterOptions<'openaiResponses'> { + /** The declarations the client asked with, echoed back on every response object. */ + echoedTools?: NonNullable; + echoedToolChoice?: NonNullable; +} const IMAGE_OUTPUT_FORMATS = { 'image/png': 'png', 'image/jpeg': 'jpeg', 'image/webp': 'webp' } as const; type IRWriteRecord = Extract; type IRToolCallItem = Extract; -// The item an opened unit announces, and the events that bring it up to the content IR already holds. +// The item an opened unit announces, and the events that bring it up to the content output IR already holds. interface OpenedUnit { readonly added: OpenAIResponsesOutputItemEx; readonly followUps: OpenAIResponsesStreamEventEx[]; @@ -70,11 +76,8 @@ type MessagePart = IROutputMessageItem['content'][number]; type ResponsesMessageState = OpenAIResponsesItemState, EmittedFieldStates>; type ResponsesReasoningState = OpenAIResponsesItemState, EmittedFieldStates>; -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - const initializedType = (value: unknown): IROutputItem['type'] | MessagePart['type'] | undefined => { - if (!isRecord(value)) return undefined; + if (!isJSONRecord(value)) return undefined; return value.type === 'message' || value.type === 'reasoning' || value.type === 'function_call' @@ -90,14 +93,14 @@ const initializedType = (value: unknown): IROutputItem['type'] | MessagePart['ty type TextPartLogprobMetadata = { readonly item_index: number; readonly content_index: number }; const textPartLogprobMetadata = (value: unknown): TextPartLogprobMetadata | undefined => { - if (!isRecord(value) || value.scope !== 'text_part') return undefined; + if (!isJSONRecord(value) || value.scope !== 'text_part') return undefined; return typeof value.item_index === 'number' && typeof value.content_index === 'number' ? { item_index: value.item_index, content_index: value.content_index } : undefined; }; const isIRTokenLogprob = (value: unknown): value is IROutputTokenLogprob => - isRecord(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; + isJSONRecord(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; const reasoningPartAdded = (field: ReasoningField, itemId: string, outputIndex: number, index: number): OpenAIResponsesStreamEventEx => field === 'summary' @@ -180,6 +183,15 @@ const textOrRefusalPart = (state: IROutput, path: IRPath): Extract { const details = errorDetailsOf(error, ['message', 'code', 'param']); return { @@ -195,6 +207,7 @@ export const writeOpenAIResponsesEvents = async function* ( records: AsyncIterable, options: OpenAIResponsesEventWriterOptions, ): AsyncGenerator> { + const { echoedTools, echoedToolChoice } = options; let metadata = { id: '', model: '', created: 0 }; let started = false; let sequence = 0; @@ -337,12 +350,22 @@ export const writeOpenAIResponsesEvents = async function* ( }; } if (unit.callId === undefined) throw new TypeError('Responses tool unit has no call ID'); + // A call the request stated grouped is written as that pair; one without a + // group keeps its single name. if (unit.kind === 'custom_tool_call') { - if (item.type !== 'custom_tool_call') throw new TypeError('Responses tool unit has the wrong IR item'); - return { type: 'custom_tool_call', id, call_id: unit.callId, name: item.name, input: item.input, status }; + if (item.type !== 'custom_tool_call') throw new TypeError('Responses tool unit has the wrong output IR item'); + return { + type: 'custom_tool_call', id, call_id: unit.callId, name: item.name, + ...(item.namespace === undefined ? {} : { namespace: item.namespace }), + input: item.input, status, + }; } - if (item.type !== 'function_call') throw new TypeError('Responses tool unit has the wrong IR item'); - return { type: 'function_call', id, call_id: unit.callId, name: item.name, arguments: item.arguments, status }; + if (item.type !== 'function_call') throw new TypeError('Responses tool unit has the wrong output IR item'); + return { + type: 'function_call', id, call_id: unit.callId, name: item.name, + ...(item.namespace === undefined ? {} : { namespace: item.namespace }), + arguments: item.arguments, status, + }; }; const response = (state: IROutput, status: OpenAIResponsesResultEx['status'], usage: OpenAIResponsesResultEx['usage']): OpenAIResponsesResultEx => ({ @@ -356,9 +379,15 @@ export const writeOpenAIResponsesEvents = async function* ( error: null, incomplete_details: status === 'incomplete' ? { reason: finishReason === 'content_filter' ? 'content_filter' : 'max_output_tokens' } : null, ...(state.service_tier === undefined ? {} : { service_tier: state.service_tier }), + // The response echoes the declarations the client asked with, because a + // client that reads this field expects to see its own groups rather than + // the flattened names the request placed on the wire. The upstream states + // none of this, so it comes from the request this trip was built from. + ...(echoedTools === undefined ? {} : { tools: echoedTools }), + ...(echoedToolChoice === undefined ? {} : { tool_choice: echoedToolChoice }), }); - // Creates the output units that the IR structures initialized by a record will become. + // Creates the output units that the output IR structures initialized by a record will become. const registerUnits = (record: IRRecord, state: IROutput): void => { if (record.type === 'write' && record.kind === 'initialize') { if (record.path[0] === 'choices' && record.path[1] !== 0) throw new Error('Responses cannot represent multiple choices'); @@ -499,13 +528,22 @@ export const writeOpenAIResponsesEvents = async function* ( const resolvedCallId = callId === IRAbsent ? createRandomOpenAIResponsesItemId('function_call') : callId; unit.id = id; unit.callId = resolvedCallId; + // The announcement states the same pair the terminal snapshot does: a call + // the request declared grouped is named by its group here too. const added: OpenAIResponsesOutputItemEx = custom - ? { type: 'custom_tool_call', id, call_id: resolvedCallId, name: item.name, input: '', status: 'in_progress' } - : { type: 'function_call', id, call_id: resolvedCallId, name: item.name, arguments: '', status: 'in_progress' }; - const restored = restoreNamespaceOutputItem(added, options.namespaceToolNames, 'in_progress'); + ? { + type: 'custom_tool_call', id, call_id: resolvedCallId, name: item.name, + ...(item.namespace === undefined ? {} : { namespace: item.namespace }), + input: '', status: 'in_progress', + } + : { + type: 'function_call', id, call_id: resolvedCallId, name: item.name, + ...(item.namespace === undefined ? {} : { namespace: item.namespace }), + arguments: '', status: 'in_progress', + }; const content = createEmittedFieldState(options.roundTrip.builder, 'text'); unit.toolContentState = content; - nativeState[outputIndex] = restored.type === 'custom_tool_call' ? { input: content } : { arguments: content }; + nativeState[outputIndex] = added.type === 'custom_tool_call' ? { input: content } : { arguments: content }; const followUps: OpenAIResponsesStreamEventEx[] = []; appendPendingText(unit, [...unit.path, item.type === 'custom_tool_call' ? 'input' : 'arguments'], item.type === 'custom_tool_call' ? item.input : item.arguments); if (item.type === 'custom_tool_call') { @@ -516,7 +554,7 @@ export const writeOpenAIResponsesEvents = async function* ( return { added, followUps }; }; - // Opens the waiting units in IR order, until one still lacks what its opening event needs. + // Opens the waiting units in output IR order, until one still lacks what its opening event needs. const openPendingUnits = (state: IROutput, collector: IRCollector): { events: OpenAIResponsesStreamEventEx[]; justOpened: Set } => { const justOpened = new Set(); const pendingEvents: OpenAIResponsesStreamEventEx[] = []; @@ -634,7 +672,7 @@ export const writeOpenAIResponsesEvents = async function* ( } if (record.type === 'write' && record.path[0] === 'model' && typeof record.value === 'string') metadata.model = record.value; if (record.type === 'start') { - if (state.model === undefined) throw new TypeError('Responses output requires an IR model before start'); + if (state.model === undefined) throw new TypeError('Responses output requires an output IR model before start'); const id = options.id ?? record.id; metadata = { id: id?.startsWith('resp_') ? id : createRandomOpenAIResponsesResponseId(), @@ -655,7 +693,7 @@ export const writeOpenAIResponsesEvents = async function* ( const item = state.choices[0].items[current.path[3] as number]; if (item.type !== 'function_call') throw new TypeError('Responses pending function unit requires a function item'); const outputIndex = requireOutputIndex(current); - parseJSONObjectAtCompletion(item.arguments, finishReason === 'length' ? 'truncated' : 'complete'); + parseJSONObjectUnlessTruncated(item.arguments, finishReason === 'length' ? 'truncated' : 'complete'); yield emit({ type: 'response.function_call_arguments.done', item_id: requireId(current), @@ -775,7 +813,7 @@ export const writeOpenAIResponsesEvents = async function* ( yield emit(reasoningPartDone(field, requireId(current), outputIndex, contentIndex, text)); } } else if (current.kind === 'custom_tool_call') { - if (item.type !== 'custom_tool_call') throw new TypeError('Responses tool unit has the wrong IR item'); + if (item.type !== 'custom_tool_call') throw new TypeError('Responses tool unit has the wrong output IR item'); yield emit({ type: 'response.custom_tool_call_input.done', item_id: requireId(current), @@ -783,7 +821,7 @@ export const writeOpenAIResponsesEvents = async function* ( input: item.input, }); } else if (current.kind === 'function_call') { - if (item.type !== 'function_call') throw new TypeError('Responses tool unit has the wrong IR item'); + if (item.type !== 'function_call') throw new TypeError('Responses tool unit has the wrong output IR item'); yield emit({ type: 'response.function_call_arguments.done', item_id: requireId(current), @@ -818,12 +856,12 @@ export const writeOpenAIResponsesEvents = async function* ( return; } const status = record.status === 'completed' && finishReason === 'content_filter' ? 'incomplete' : record.status; - const nativeOutput = outputUnits.map(unit => restoreNamespaceOutputItem(outputItem(state, unit), options.namespaceToolNames, 'completed')); + const nativeOutput = outputUnits.map(unit => outputItem(state, unit)); nativeState.length = nativeOutput.length; const sourceTurnDigest = await computeOpenAIResponsesTurnDigest(nativeOutput); const input = toOpenAIResponsesInputAssistantTurn(nativeOutput, nativeState); const data = await options.roundTrip.encodeSidecar(0, sourceTurnDigest, input.turn, input.state, collectOpenAIResponsesTextPlacements(input.turn)); - sidecar = { ...createOpenAIResponsesSidecarItem(createRandomOpenAIResponsesItemId('reasoning'), data), status: 'completed' }; + sidecar = { ...createOpenAIResponsesSidecarCarrier(createRandomOpenAIResponsesItemId('reasoning'), data), status: 'completed' }; const outputIndex = outputUnits.length; yield emit({ type: 'response.output_item.added', output_index: outputIndex, item: sidecar }); yield emit({ type: 'response.output_item.done', output_index: outputIndex, item: sidecar }); diff --git a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts new file mode 100644 index 0000000000..d92118176e --- /dev/null +++ b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts @@ -0,0 +1,400 @@ +import { klona } from 'klona/json'; + +import { requireCallableSelector } from './allowed-tools.ts'; +import { partitionOpenAIResponsesTurns, type OpenAIResponsesConversationTurn } from './conversation-turns.ts'; +import { rejectProgramCaller, rejectProgrammaticOpenAIResponsesPayload } from './programmatic-tooling.ts'; +import type { OpenAIResponsesSourceAssistantTurn } from './round-trip.ts'; +import type { SourceReading } from '../../../composition/reading.ts'; +import { mediaFromURL, mediaOrThrow } from '../../../ir/input/media.ts'; +import type { + IRInputAssistantContentPart, + IRInputAssistantItem, + IRInputRequestHeader, + IRInputSettings, + IRInputTool, + IRInputToolChoice, + IRInputToolSelector, + IRInputOutputFormat, + IRInputMedia, + IRInputTextPart, + IRInputToolResultContentPart, + IRInputToolResultItem, + IRInputTurn, + IRInputUserContentPart, + IRInputClientItem, +} from '../../../ir/input/types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import { requireCanonicalOpenAIResponsesPayload } from '../canonicalize-payload.ts'; +import type { + CanonicalOpenAIResponsesInputItem, + CanonicalOpenAIResponsesPayload, + OpenAIResponsesInputContent, + OpenAIResponsesInputMessageEx, + OpenAIResponsesRequestPayloadEx, + OpenAIResponsesTool, +} from '@floway-dev/protocols/openai-responses'; + +const imageMedia = (part: Extract): IRInputMedia => { + if (typeof part.image_url === 'string') return mediaOrThrow(mediaFromURL(part.image_url), part.image_url); + if (typeof part.file_id === 'string') return { source_kind: 'file', file_id: part.file_id }; + throw new TranslateInputException('An OpenAI Responses input_image requires image_url or file_id.'); +}; + +const fileMedia = (part: Extract): IRInputMedia => { + if (typeof part.file_id === 'string') return { source_kind: 'file', file_id: part.file_id }; + if (typeof part.file_url === 'string') return { source_kind: 'url', url: part.file_url }; + if (typeof part.file_data === 'string') { + // `file_data` is either raw base64 or a data URL; a data URL also states the + // media type a target may need. A data URL this cannot decode is not raw + // base64 — labelling it so would send the URL text itself as the payload. + const media = mediaFromURL(part.file_data); + if (media === undefined) throw new TranslateInputException('An OpenAI Responses input_file states a data URL this translation cannot decode.'); + return media.source_kind === 'base64' ? media : { source_kind: 'base64', data: part.file_data }; + } + throw new TranslateInputException('An OpenAI Responses input_file requires file_id, file_url, or file_data.'); +}; + +const userContentPart = (part: OpenAIResponsesInputContent): IRInputUserContentPart => { + switch (part.type) { + case 'input_text': + case 'output_text': + return { type: 'text', text: part.text }; + case 'input_image': { + const image = imageMedia(part); + return { type: 'image', image, ...(part.detail == null ? {} : { detail: part.detail }) }; + } + case 'input_file': + return { type: 'file', file: fileMedia(part), ...(typeof part.filename === 'string' ? { filename: part.filename } : {}) }; + } +}; + +const userContent = (content: string | OpenAIResponsesInputContent[]): IRInputUserContentPart[] => typeof content === 'string' + ? content === '' ? [] : [{ type: 'text', text: content }] + : content.map(userContentPart); + +const promptPartsToIR = (message: OpenAIResponsesInputMessageEx): IRInputTextPart[] => typeof message.content === 'string' + ? message.content === '' ? [] : [{ type: 'text', text: message.content }] + : message.content.map(part => { + if (part.type !== 'input_text' && part.type !== 'output_text') { + throw new TranslateInputException(`Invalid '${part.type}' content part in ${message.role} message. Only 'input_text' content parts are supported in ${message.role} messages on this model.`); + } + return { type: 'text', text: part.text }; + }); + +// String content becomes one text part, including an empty string. Arrays preserve their listed parts; +// an empty list still retains the enclosing assistant message and turn. +const assistantContent = (message: Extract): IRInputAssistantContentPart[] => typeof message.content === 'string' + ? [{ type: 'text', text: message.content }] + : message.content.map(part => { + if (part.type === 'input_text' || part.type === 'output_text') return { type: 'text' as const, text: part.text }; + // An assistant turn replays model output, so a refusal it produced is + // history like any other part. + if (part.type === 'refusal') return { type: 'refusal' as const, refusal: part.refusal }; + throw new TranslateInputException(`Cannot translate ${part.type} assistant content.`); + }); + +const toolResultContent = (output: string | readonly OpenAIResponsesInputContent[]): string | readonly IRInputToolResultContentPart[] => typeof output === 'string' + ? output + : output.map(part => { + if (part.type === 'input_image') return { type: 'image' as const, image: imageMedia(part), ...(part.detail == null ? {} : { detail: part.detail }) }; + if (part.type === 'input_file') return { type: 'file' as const, file: fileMedia(part), ...(typeof part.filename === 'string' ? { filename: part.filename } : {}) }; + return { type: 'text' as const, text: part.text }; + }); + +const unsupportedItem = (item: CanonicalOpenAIResponsesInputItem): never => { + throw new TranslateInputException(`Invalid input item: ${JSON.stringify(item)}`); +}; + +const assistantItems = (items: readonly CanonicalOpenAIResponsesInputItem[]): IRInputAssistantItem[] => items.map((item): IRInputAssistantItem | undefined => { + rejectProgramCaller(item); + switch (item.type) { + case 'message': { + if (item.role !== 'assistant') throw new TranslateInputException(`Invalid assistant message role '${item.role}'.`); + const content = assistantContent(item); + // An explicitly empty assistant message still occupies a turn: dropping it + // would put the client runs on either side next to each other, and every + // target that combines adjacent same-role messages would merge them. + return { type: 'message' as const, content }; + } + case 'reasoning': + { + const summary = item.summary.map(part => part.text); + const content = (item.content ?? []).map(part => part.text); + // No readable text means nothing any target could replay. + if (summary.length === 0 && content.length === 0) return undefined; + return { type: 'reasoning' as const, ...(summary.length === 0 ? {} : { summary }), ...(content.length === 0 ? {} : { content }) }; + } + case 'function_call': + return { type: 'function_call' as const, call_id: item.call_id, name: item.name, ...('namespace' in item && typeof item.namespace === 'string' ? { namespace: item.namespace } : {}), arguments: item.arguments }; + case 'custom_tool_call': + return { type: 'custom_tool_call' as const, call_id: item.call_id, name: item.name, ...('namespace' in item && typeof item.namespace === 'string' ? { namespace: item.namespace } : {}), input: item.input }; + case 'item_reference': + case 'web_search_call': + case 'image_generation_call': + return unsupportedItem(item); + default: + unsupportedItem(item); + } +}).filter((item): item is IRInputAssistantItem => item !== undefined); + +const clientItems = (items: readonly CanonicalOpenAIResponsesInputItem[]): IRInputClientItem[] => items.flatMap((item): IRInputClientItem[] => { + rejectProgramCaller(item); + switch (item.type) { + case 'message': + switch (item.role) { + case 'user': return [{ type: 'message' as const, content: userContent(item.content) }]; + case 'system': + case 'developer': return [{ type: 'prompt' as const, role: item.role, content: promptPartsToIR(item) }]; + default: throw new TranslateInputException(`Invalid role '${item.role}' in input message.`); + } + case 'function_call_output': { + const result: IRInputToolResultItem = { + type: 'tool_result', + call_id: item.call_id, + output: toolResultContent(item.output), + tool_kind: 'function', + ...(item.status === 'incomplete' ? { is_error: true } : {}), + }; + return [result]; + } + case 'custom_tool_call_output': + return [{ + type: 'tool_result' as const, + call_id: item.call_id, + output: toolResultContent(item.output), + tool_kind: 'custom' as const, + ...(item.status === 'incomplete' ? { is_error: true } : {}), + }]; + // A deferred inventory states declarations, which the request's tool list + // already carries; it is not something the model said. + case 'additional_tools': + case 'tool_search_output': + return []; + default: + return unsupportedItem(item); + } +}); + +/** The top-level `instructions` field as its own leading system item. */ +const openaiResponsesInstructionsToIR = (instructions: string | null | undefined): IRInputTurn | undefined => instructions + ? { kind: 'client', items: [{ type: 'prompt', role: 'system', content: [{ type: 'text', text: instructions }] }] } + : undefined; + +const openaiResponsesTurnToIR = (turn: OpenAIResponsesConversationTurn): IRInputTurn[] => { + const items = turn.items; + if (turn.role !== 'assistant') return [{ kind: 'client', items: clientItems(items) }]; + // Every assistant item was dropped (an opaque reasoning carrier, say). The + // turn carried a boundary that must survive, so writers still see it as a + // separator but emit no message for it. + return [{ kind: 'assistant', items: assistantItems(items) }]; +}; + +// Responses declares flat function and custom tools beside the hosted kinds. +// A hosted tool runs on the vendor's own infrastructure and has no meaning in +// another protocol, so a request that carries one cannot enter this IR. +const toolToIR = (tool: Extract, namespace?: string): IRInputTool => { + // A declaration this protocol groups states its group beside the name inside + // it, and every other protocol reads that pair as the callable's identity. + const group = namespace ?? ('namespace' in tool && typeof tool.namespace === 'string' ? tool.namespace : undefined); + const identity = group === undefined ? {} : { namespace: group }; + if (tool.type === 'custom') { + const format = tool.format as { type?: unknown; definition?: unknown; syntax?: unknown } | undefined; + return { + type: 'custom', + name: tool.name, + ...identity, + ...(tool.description == null ? {} : { description: tool.description }), + ...(format === undefined || typeof format.type !== 'string' ? {} : { + format: format.type === 'grammar' && typeof format.definition === 'string' + ? { type: 'grammar', definition: format.definition, syntax: String(format.syntax ?? '') } + : { type: 'text' }, + }), + }; + } + return { + type: 'function', + name: tool.name, + ...identity, + ...(tool.description == null ? {} : { description: tool.description }), + ...(tool.parameters == null ? {} : { parameters: klona(tool.parameters) }), + ...(tool.strict == null ? {} : { strict: tool.strict }), + }; +}; + +const selectionToIR = (selection: IRInputToolSelector, declarations: readonly IRInputTool[]): IRInputToolSelector => { + if (selection.namespace !== undefined) return selection; + if (declarations.some(tool => tool.type === selection.type && tool.namespace === undefined && tool.name === selection.name)) return selection; + const qualified = declarations.filter(tool => tool.type === selection.type && tool.namespace !== undefined + && (selection.name === `${tool.namespace}.${tool.name}` || selection.name === `${tool.namespace}__${tool.name}`)); + const first = qualified[0]; + if (first === undefined) return selection; + if (qualified.some(tool => tool.namespace !== first.namespace || tool.name !== first.name)) { + throw new TranslateInputException(`Cannot translate ambiguous tool selector '${selection.name}'.`); + } + return { type: selection.type, name: first.name, namespace: first.namespace }; +}; + +// A selector may name a whole group, which selects each callable it holds. +const selectorOf = (payload: CanonicalOpenAIResponsesPayload, selector: Record, declarations: readonly IRInputTool[]): IRInputToolSelector[] => { + if (typeof selector !== 'object' || selector?.type !== 'namespace') return [selectionToIR(requireCallableSelector(selector), declarations)]; + const groups = [ + ...(payload.tools ?? []), + ...payload.input.flatMap(item => item.type === 'additional_tools' || item.type === 'tool_search_output' ? item.tools : []), + ].flatMap(tool => tool.type === 'namespace' && tool.name === selector.name ? [tool] : []); + if (groups.length === 0) throw new TranslateInputException(`Cannot translate group selector '${String(selector.name)}' without a matching declaration.`); + return groups.flatMap(group => group.tools.flatMap(child => child.type === 'function' || child.type === 'custom' + ? [{ type: child.type, name: child.name, namespace: group.name } as IRInputToolSelector] + : [])); +}; + +const toolChoiceToIR = (payload: CanonicalOpenAIResponsesPayload, declarations: readonly IRInputTool[]): IRInputToolChoice => { + const choice = payload.tool_choice; + if (choice == null) throw new TranslateInputException('Cannot translate a missing tool choice.'); + if (typeof choice === 'string') { + if (choice === 'auto' || choice === 'none' || choice === 'required') return choice; + // Any other bare string is a hosted selector, which names a callable this + // IR does not carry. + throw new TranslateInputException(`Cannot translate OpenAI Responses tool choice '${choice}'.`); + } + if (choice.type === 'allowed_tools') { + if ((choice.mode !== 'auto' && choice.mode !== 'required') || !Array.isArray(choice.tools)) { + throw new TranslateInputException('Cannot translate malformed allowed_tools mode or tools array.'); + } + const tools = choice.tools.flatMap(selector => selectorOf(payload, selector, declarations)); + for (const selector of tools) { + if (!declarations.some(tool => tool.type === selector.type && tool.namespace === selector.namespace && tool.name === selector.name)) { + throw new TranslateInputException(`Cannot translate allowed_tools selector '${selector.name}' without a matching callable declaration.`); + } + } + if (tools.length === 0 && choice.mode === 'required') { + throw new TranslateInputException('Cannot translate required allowed_tools with an empty callable subset.'); + } + return { type: 'allowed_tools', mode: choice.mode, tools }; + } + if (choice.type === 'function' || choice.type === 'custom') return selectionToIR(choice, declarations); + // A hosted selector names a tool this IR does not carry. + throw new TranslateInputException(`Cannot translate OpenAI Responses tool choice type '${choice.type}'.`); +}; + +// A group is a declaration container, not a callable: this IR carries the +// callables it holds, each stating the group it belongs to, so a target that +// has no groups can still spell it out. +const toolGroupToIR = (tool: OpenAIResponsesTool): IRInputTool[] => { + if (tool.type === 'namespace') { + return tool.tools.flatMap(child => { + if (child.type !== 'function' && child.type !== 'custom') { + throw new TranslateInputException(`Cannot translate non-callable child in namespace '${tool.name}'.`); + } + // A group's own description qualifies each child's, the way one merged + // declaration states it. + const childDescription = child.description ?? undefined; + return [toolToIR( + tool.description === undefined + ? child + : { ...child, description: childDescription === undefined ? tool.description : `${tool.description}\n\n${childDescription}` }, + tool.name, + )]; + }); + } + if (tool.type === 'function' || tool.type === 'custom') return [toolToIR(tool)]; + throw new TranslateInputException(`Cannot translate OpenAI Responses tool type '${tool.type}'.`); +}; + +// A request can defer declarations to an inventory item inside the +// conversation; those are tools like any other, not things the model said. +const deferredInventories = (payload: CanonicalOpenAIResponsesPayload): OpenAIResponsesTool[] => + payload.input.flatMap(item => item.type === 'additional_tools' || item.type === 'tool_search_output' ? item.tools : []); + +const openaiResponsesToolsToIR = (payload: CanonicalOpenAIResponsesPayload): Pick => { + const declared = payload.tools?.flatMap(toolGroupToIR) ?? []; + const deferred = deferredInventories(payload).flatMap(toolGroupToIR); + const tools = declared.length === 0 && deferred.length === 0 ? undefined : [...declared, ...deferred]; + const toolChoice = payload.tool_choice == null ? undefined : toolChoiceToIR(payload, tools ?? []); + return { + ...(tools === undefined ? {} : { tools }), + ...(toolChoice === undefined ? {} : { tool_choice: toolChoice }), + }; +}; + +/** + * Responses states the ceiling once, as `max_output_tokens`. Its `text.format` + * carries the structured-output schema flat, unlike Chat's nested spelling. + */ +const openaiResponsesSettingsToIR = (payload: CanonicalOpenAIResponsesPayload): IRInputSettings => { + const format = payload.text?.format; + const outputFormat: IRInputOutputFormat | undefined = format === undefined || format === null ? undefined + : format.type === 'json_schema' + ? { + // A caller may hand the Chat-Completions-nested spelling to the + // Responses field; its details are still the same schema. + ...('json_schema' in format && typeof format.json_schema === 'object' && format.json_schema !== null + ? format.json_schema as Record + : {}), + type: 'json_schema', + ...('name' in format && typeof format.name === 'string' ? { name: format.name } : {}), + ...('description' in format && typeof format.description === 'string' ? { description: format.description } : {}), + ...('schema' in format && format.schema !== undefined && format.schema !== null && typeof format.schema === 'object' + ? { schema: klona(format.schema) as Record } + : {}), + ...('strict' in format && typeof format.strict === 'boolean' ? { strict: format.strict } : {}), + } + : { type: format.type }; + + return { + model: payload.model, + stream: payload.stream === true, + ...(payload.temperature == null ? {} : { temperature: payload.temperature }), + ...(payload.top_p == null ? {} : { top_p: payload.top_p }), + ...(payload.presence_penalty === undefined ? {} : { presence_penalty: payload.presence_penalty }), + ...(payload.frequency_penalty === undefined ? {} : { frequency_penalty: payload.frequency_penalty }), + ...(payload.max_output_tokens === undefined ? {} : { max_output_tokens: payload.max_output_tokens }), + ...(outputFormat === undefined ? {} : { output_format: outputFormat }), + ...(payload.metadata == null ? {} : { metadata: payload.metadata }), + ...(payload.store == null ? {} : { store: payload.store }), + ...(payload.service_tier == null ? {} : { service_tier: payload.service_tier }), + ...(payload.parallel_tool_calls == null ? {} : { parallel_tool_calls: payload.parallel_tool_calls }), + ...(payload.reasoning?.effort == null ? {} : { reasoning_effort: payload.reasoning.effort }), + ...(payload.reasoning?.summary == null ? {} : { reasoning_summary: payload.reasoning.summary }), + ...(payload.prompt_cache_key === undefined ? {} : { prompt_cache_key: payload.prompt_cache_key }), + ...(payload.prompt_cache_retention === undefined ? {} : { prompt_cache_retention: payload.prompt_cache_retention }), + ...(payload.prompt_cache_options === undefined ? {} : { + prompt_cache_options: { + ...(payload.prompt_cache_options.mode === undefined ? {} : { mode: payload.prompt_cache_options.mode }), + ...(payload.prompt_cache_options.ttl === undefined ? {} : { ttl: payload.prompt_cache_options.ttl }), + }, + }), + ...(payload.safety_identifier === undefined ? {} : { safety_identifier: payload.safety_identifier }), + ...(payload.user === undefined ? {} : { user: payload.user }), + ...(payload.include === undefined ? {} : { logprobs: payload.include === null ? null : payload.include.includes('message.output_text.logprobs') }), + ...(payload.top_logprobs === undefined ? {} : { top_logprobs: payload.top_logprobs }), + ...(payload.text?.verbosity == null ? {} : { verbosity: payload.text.verbosity }), + }; +}; + +export const readOpenAIResponsesRequest = (source: OpenAIResponsesRequestPayloadEx): SourceReading => { + const canonical = requireCanonicalOpenAIResponsesPayload(source); + rejectProgrammaticOpenAIResponsesPayload(canonical); + + const prompt = openaiResponsesInstructionsToIR(canonical.instructions); + return { + request: { + settings: openaiResponsesSettingsToIR(canonical), + ...openaiResponsesToolsToIR(canonical), + }, + turns: [ + ...(prompt === undefined ? [] : [{ kind: 'client' as const, toIRInputTurns: () => [prompt] }]), + ...partitionOpenAIResponsesTurns(canonical.input).map(turn => turn.role === 'assistant' + ? { + kind: 'assistant' as const, + assistantTurn: turn.items, + readCallIdentities: () => turn.items.flatMap(item => item.type === 'function_call' || item.type === 'custom_tool_call' ? [{ + type: item.type === 'function_call' ? 'function' as const : 'custom' as const, + name: item.name, call_id: item.call_id, + ...(item.namespace === undefined ? {} : { namespace: item.namespace }), + }] : []), + toIRInputTurns: () => openaiResponsesTurnToIR(turn), + } + : { kind: 'client' as const, toIRInputTurns: () => openaiResponsesTurnToIR(turn) }), + ], + }; +}; 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 deleted file mode 100644 index 299cedf0af..0000000000 --- a/packages/translate/src/protocols/openai-responses/as-source/namespace-tools.ts +++ /dev/null @@ -1,240 +0,0 @@ -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'; - -export interface NamespaceToolNames { - sourceToTarget: Map; - targetToSource: Map; - sourceTools: CanonicalOpenAIResponsesPayload['tools']; - sourceToolChoice: CanonicalOpenAIResponsesPayload['tool_choice']; - toolsChanged: boolean; - toolChoiceChanged: boolean; -} - -interface CallableIdentity { - namespace: string; - name: string; - type: 'function_call' | 'custom_tool_call'; -} - -type ObjectToolChoice = Exclude; -type NamedToolChoice = Extract; - -const isNamedToolChoice = (value: Record): value is Record & NamedToolChoice => - (value.type === 'function' || value.type === 'custom') - && typeof value.name === 'string' - && (value.namespace === undefined || typeof value.namespace === 'string'); - -export const restoreNamespaceOutputItem = ( - item: OpenAIResponsesOutputItemEx, - names: Pick, - status: 'in_progress' | 'completed', -): OpenAIResponsesOutputItemEx => { - if ((item.type !== 'function_call' && item.type !== 'custom_tool_call') || item.namespace !== undefined) return item; - const identity = names.targetToSource.get(item.name); - if (identity === undefined) return item; - if (item.type === 'function_call') { - if (identity.type === 'function_call') return { ...item, name: identity.name, namespace: identity.namespace }; - const { type: _type, arguments: input, ...rest } = item; - return { ...rest, type: 'custom_tool_call', name: identity.name, namespace: identity.namespace, input }; - } - if (identity.type === 'custom_tool_call') return { ...item, name: identity.name, namespace: identity.namespace }; - const { type: _type, input: arguments_, ...rest } = item; - return { ...rest, type: 'function_call', name: identity.name, namespace: identity.namespace, arguments: arguments_, status: item.status ?? status }; -}; - -// Both translated targets require flat callable names. Reserve names from the -// full inventory and raw history before projecting declarations so an excluded -// historical call cannot acquire a different identity on a later turn. -// https://github.com/lidge-jun/opencodex/blob/e45692f8d8e4dedfb4e9b0217fc245080fb2fba8/src/responses/plaintext-v2-agent-messages.ts#L347-L427 -export const flattenNamespaceTools = (payload: CanonicalOpenAIResponsesPayload): { - payload: CanonicalOpenAIResponsesPayload; - names: NamespaceToolNames; - flattenInput: (input: readonly CanonicalOpenAIResponsesInputItem[]) => CanonicalOpenAIResponsesInputItem[]; -} => { - const inventories = [payload.tools ?? [], ...payload.input.flatMap(item => - item.type === 'additional_tools' || item.type === 'tool_search_output' ? [item.tools] : [])]; - const flatNames = new Set(inventories.flatMap(tools => tools.flatMap(tool => - tool.type === 'function' || tool.type === 'custom' ? [tool.name] : []))); - for (const item of payload.input) { - if ((item.type === 'function_call' || item.type === 'custom_tool_call') && item.namespace === undefined) flatNames.add(item.name); - } - const reserved = new Set(flatNames); - const names: NamespaceToolNames = { - sourceToTarget: new Map(), targetToSource: new Map(), - sourceTools: undefined, sourceToolChoice: undefined, - toolsChanged: false, toolChoiceChanged: false, - }; - const byNamespace = new Map>(); - const nextSuffixes = new Map(); - const allocate = (namespace: string, name: string, kind: 'function' | 'custom'): string => { - const type = kind === 'function' ? 'function_call' : 'custom_tool_call'; - const key = `${namespace}.${name}`; - const existing = names.sourceToTarget.get(key); - if (existing !== undefined) { - const identity = names.targetToSource.get(existing)!; - if (identity.namespace !== namespace || identity.name !== name || identity.type !== type) { - throw new TranslateInputException(`Cannot translate ambiguous namespace tool '${key}'.`); - } - return existing; - } - // OpenAI Chat Completions limits function names to 64 ASCII characters. - // https://github.com/openai/openai-node/blob/61539248cbe04665de68a71e6fd878127ae4db87/src/resources/chat/completions/completions.ts - // Bound sanitization work even for a long shared namespace. - const scope = `${namespace.slice(0, 64)}_`.slice(0, 64); - const base = `${scope}${name.slice(0, 64 - scope.length)}`.replaceAll(/[^a-zA-Z0-9_-]/g, '_'); - for (let suffix = 1; ; suffix++) { - const ending = suffix === 1 ? '' : `_${suffix}`; - const prefix = base.slice(0, 64 - ending.length); - if (suffix > 1) { - // Share cursors only when both the truncated prefix and suffix width match. - const key = `${ending.length}:${prefix}`; - const next = nextSuffixes.get(key); - if (next !== undefined && next > suffix) { suffix = next - 1; continue; } - nextSuffixes.set(key, suffix + 1); - } - const candidate = `${prefix}${ending}`; - if (reserved.has(candidate)) continue; - reserved.add(candidate); - names.sourceToTarget.set(key, candidate); - names.targetToSource.set(candidate, { namespace, name, type }); - return candidate; - } - }; - const tools: OpenAIResponsesTool[] = []; - for (const inventory of inventories) { - for (const tool of inventory) { - if (tool.type !== 'namespace') { - tools.push(tool); - continue; - } - const children = byNamespace.get(tool.name) ?? []; - byNamespace.set(tool.name, children); - for (const child of tool.tools) { - if (child.type !== 'function' && child.type !== 'custom') { - 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 }); - tools.push({ - ...child, name, - ...(tool.description ? { description: child.description ? `${tool.description}\n\n${child.description}` : tool.description } : {}), - }); - } - } - } - const flattenInput = (input: readonly CanonicalOpenAIResponsesInputItem[]) => input.flatMap(item => { - if (item.type === 'additional_tools' || item.type === 'tool_search_output') return []; - if (item.type !== 'function_call' && item.type !== 'custom_tool_call') return [item]; - if (item.namespace === undefined) return [item]; - const { namespace, ...rest } = item; - return [{ ...rest, name: allocate(namespace, item.name, item.type === 'function_call' ? 'function' : 'custom') }]; - }); - const selector = (choice: ObjectToolChoice): ObjectToolChoice => { - if (choice.type !== 'function' && choice.type !== 'custom') return choice; - const namespace = choice.namespace; - 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 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 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; - return { ...rest, name }; - }; - let choice = payload.tool_choice; - if (typeof choice === 'object' && choice !== null) { - if (choice.type === 'allowed_tools' && Array.isArray(choice.tools)) { - const original = choice; - choice = { - ...choice, - tools: choice.tools.flatMap(tool => { - 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 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 TranslateInputException('Cannot translate malformed allowed_tools selector.'); - const selected = selector(tool); - return selected === tool ? [tool] : [{ ...selected }]; - }), - }; - if (choice.tools.length === original.tools.length && choice.tools.every((tool, index) => tool === original.tools[index])) choice = original; - } else choice = selector(choice); - } - names.toolsChanged = inventories.length > 1 || payload.tools?.some(tool => tool.type === 'namespace') === true; - names.toolChoiceChanged = choice !== payload.tool_choice; - names.sourceTools = names.toolsChanged ? payload.tools : undefined; - names.sourceToolChoice = names.toolChoiceChanged ? payload.tool_choice : undefined; - return { payload: { ...payload, ...(payload.tools == null && inventories.length === 1 ? {} : { tools }), ...(choice === undefined ? {} : { tool_choice: choice }) }, names, flattenInput }; -}; - -export const restoreNamespaceEvents = async function* ( - frames: AsyncIterable>, - names: NamespaceToolNames, -): AsyncGenerator> { - const { sourceTools, sourceToolChoice, toolsChanged, toolChoiceChanged } = names; - const items = new Map>(); - for await (const frame of frames) { - if (frame.type !== 'event') { yield frame; continue; } - const event = frame.event; - const identity = 'item_id' in event ? items.get(event.item_id) : undefined; - if (event.type === 'response.output_item.added' || event.type === 'response.output_item.done') { - const item = restoreNamespaceOutputItem(event.item, names, event.type === 'response.output_item.added' ? 'in_progress' : 'completed'); - if ((item.type === 'function_call' || item.type === 'custom_tool_call') && typeof item.id === 'string') items.set(item.id, { name: item.name, type: item.type }); - yield item === event.item ? frame : { ...frame, event: { ...event, item } }; - } else if (event.type === 'response.function_call_arguments.delta' && identity?.type === 'custom_tool_call') { - yield { ...frame, event: { ...event, type: 'response.custom_tool_call_input.delta' } }; - } else if (event.type === 'response.function_call_arguments.done' && identity !== undefined) { - // Function arguments.done requires a bare name, not a namespace. Custom - // input.done has neither field; changing families must not leak a wire name. - // https://github.com/openai/openai-node/blob/61539248cbe04665de68a71e6fd878127ae4db87/src/resources/responses/responses.ts - if (identity.type === 'function_call') { - if ('name' in event && event.name === identity.name) yield frame; - else { - const restoredEvent: OpenAIResponsesStreamEventEx = { ...event, name: identity.name }; - yield { ...frame, event: restoredEvent }; - } - } else { - const { arguments: input, name: _name, ...rest } = event; - yield { ...frame, event: { ...rest, type: 'response.custom_tool_call_input.done', input } }; - } - } else if (event.type === 'response.custom_tool_call_input.delta' && identity?.type === 'function_call') { - yield { ...frame, event: { ...event, type: 'response.function_call_arguments.delta' } }; - } else if (event.type === 'response.custom_tool_call_input.done' && identity?.type === 'function_call') { - const { input: args, ...rest } = event; - const restoredEvent: OpenAIResponsesStreamEventEx = { ...rest, type: 'response.function_call_arguments.done', arguments: args, name: identity.name }; - yield { ...frame, event: restoredEvent }; - } else if ('response' in event && Array.isArray(event.response?.output)) { - const output = event.response.output.map(item => restoreNamespaceOutputItem(item, names, isOpenAIResponsesTerminalEvent(event) ? 'completed' : 'in_progress')); - const outputChanged = output.some((item, index) => item !== event.response.output[index]); - const restoreTools = toolsChanged && event.response.tools !== undefined && event.response.tools !== sourceTools; - const restoreChoice = toolChoiceChanged && event.response.tool_choice !== undefined && event.response.tool_choice !== sourceToolChoice; - if (!outputChanged && !restoreTools && !restoreChoice) { - yield frame; - continue; - } - const response: OpenAIResponsesResultEx = { - ...event.response, - output: outputChanged ? output : event.response.output, - // Preserve absent echoes; only undo fields actually stated by the - // translated result. The outer shim must not observe invented tools. - ...(restoreChoice ? { tool_choice: sourceToolChoice } : {}), - }; - if (restoreTools) { - if (sourceTools == null) delete response.tools; - else response.tools = sourceTools; - } - const restoredEvent: OpenAIResponsesStreamEventEx = { ...event, response }; - yield { ...frame, event: restoredEvent }; - } else yield frame; - } -}; 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 5e17e8a741..cb90b05e74 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 { TranslateInputException } from '../../../shared/input-error.ts'; import type { CanonicalOpenAIResponsesInputItem, OpenAIResponsesPayloadEx } from '@floway-dev/protocols/openai-responses'; -export const rejectProgrammaticOpenAIResponsesPayload = (payload: OpenAIResponsesPayloadEx, target: string): void => { +export const rejectProgrammaticOpenAIResponsesPayload = (payload: OpenAIResponsesPayloadEx): void => { const toolChoice = payload.tool_choice; if (payload.tools?.some(hasProgrammaticCaller) === true || (toolChoice !== null && typeof toolChoice === 'object' && toolChoice.type === 'programmatic_tool_calling')) { - throw new TranslateInputException(`Programmatic OpenAI Responses tooling cannot be translated to ${target}.`); + throw new TranslateInputException('Programmatic OpenAI Responses tooling cannot be translated.'); } if (payload.tools?.some(hasDeferredTool) === true) { - throw new TranslateInputException(`Deferred OpenAI Responses tooling cannot be translated to ${target}.`); + throw new TranslateInputException('Deferred OpenAI Responses tooling cannot be translated.'); } }; @@ -26,14 +26,10 @@ const hasDeferredTool = (tool: unknown): boolean => { return Array.isArray(record.tools) && record.tools.some(hasDeferredTool); }; -const isProgramCaller = (item: CanonicalOpenAIResponsesInputItem): item is CanonicalOpenAIResponsesInputItem & { call_id: string; caller: { type: 'program'; caller_id: string } } => { - if (!('caller' in item)) return false; - const caller = item.caller; - return typeof caller === 'object' && caller !== null && 'type' in caller && caller.type === 'program'; -}; - export const rejectProgramCaller = (item: CanonicalOpenAIResponsesInputItem): void => { - if (isProgramCaller(item)) { - throw new TranslateInputException(`Cannot translate ${item.type} '${item.call_id}' with a program caller.`); + if (!('caller' in item)) return; + const caller = item.caller; + if (typeof caller === 'object' && caller !== null && 'type' in caller && caller.type === 'program') { + throw new TranslateInputException(`Cannot translate ${item.type} '${Reflect.get(item, 'call_id')}' with a program caller.`); } }; diff --git a/packages/translate/src/protocols/openai-responses/as-source/response-id.ts b/packages/translate/src/protocols/openai-responses/as-source/response-id.ts deleted file mode 100644 index 0255cb4dac..0000000000 --- a/packages/translate/src/protocols/openai-responses/as-source/response-id.ts +++ /dev/null @@ -1 +0,0 @@ -export const createRandomOpenAIResponsesResponseId = (): string => `resp_${crypto.randomUUID().replaceAll('-', '').slice(0, 24)}`; diff --git a/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts b/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts index eec8acceca..1a35db7ff5 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts @@ -2,7 +2,7 @@ import type { IRJSONObject, IRJSONValue } from '../../../ir/common/json.ts'; import type { ReplayContent } from '../../../round-trip/reference/types.ts'; import type { SidecarOccurrence } from '../../../round-trip/resolve.ts'; import { collectTextGroups, collectTextPlacementIndex, type TextContribution, type TextPlacementIndex } from '../../../round-trip/text-groups.ts'; -import { createTurnDigestComputer, replayMaterialsByType, createTurnDigestVerifier } from '../../../round-trip/turn-digest.ts'; +import { replayMaterialsByType, createTurnDigestComputer, createTurnDigestVerifier } from '../../../round-trip/turn-digest.ts'; import type { CanonicalOpenAIResponsesInputItem, OpenAIResponsesInputContent, OpenAIResponsesOutputContentBlock, OpenAIResponsesOutputItemEx, OpenAIResponsesOutputReasoning } from '@floway-dev/protocols/openai-responses'; type OpenAIResponsesMessagePart = OpenAIResponsesInputContent | OpenAIResponsesOutputContentBlock; @@ -112,7 +112,7 @@ export const collectOpenAIResponsesReplayContents = (turn: OpenAIResponsesSource export const verifyOpenAIResponsesTurnDigest = createTurnDigestVerifier(responsesTurnDigestMaterialV1); -export const createOpenAIResponsesSidecarItem = (id: string, data: string): OpenAIResponsesSidecarItem => ({ +export const createOpenAIResponsesSidecarCarrier = (id: string, data: string): OpenAIResponsesSidecarItem => ({ type: 'reasoning', id, summary: [], diff --git a/packages/translate/src/protocols/openai-responses/as-target.ts b/packages/translate/src/protocols/openai-responses/as-target.ts new file mode 100644 index 0000000000..33d606ff14 --- /dev/null +++ b/packages/translate/src/protocols/openai-responses/as-target.ts @@ -0,0 +1,24 @@ +import { readOpenAIResponsesEvents } from './as-target/events.ts'; +import { openaiResponsesPayloadFromSegments } from './as-target/segmented-request.ts'; +import type { TargetProtocol } from '../../composition/index.ts'; +import { assignCallIds, type PendingCall } from '../../request/call-ids.ts'; +import type { CanonicalOpenAIResponsesPayload, CanonicalOpenAIResponsesInputItem, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; + +type ResponsesRestoredAssistantTurn = readonly CanonicalOpenAIResponsesInputItem[]; + +const callsOf = (turn: ResponsesRestoredAssistantTurn): readonly PendingCall[] => + turn.flatMap(item => item.type === 'function_call' || item.type === 'custom_tool_call' + ? [{ callId: item.call_id, name: item.name }] + : []); + +/** Restored Responses turns expose their own call IDs, and returned call items carry the IDs needed for event correlation. */ +export const openaiResponsesTargetProtocol: TargetProtocol< + 'openaiResponses', CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx +> = { + name: 'openaiResponses', + write: (request, turns) => ({ + payload: openaiResponsesPayloadFromSegments(request, assignCallIds(turns, callsOf)), + context: undefined, + }), + readEvents: (frames, _context, roundTripReader) => readOpenAIResponsesEvents(frames, { roundTrip: roundTripReader }), +}; diff --git a/packages/translate/src/protocols/openai-responses/as-target/events.ts b/packages/translate/src/protocols/openai-responses/as-target/events.ts index 24ca5559a4..c2592227a4 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/events.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/events.ts @@ -1,7 +1,7 @@ import { toOpenAIResponsesInputAssistantTurn, type OpenAIResponsesItemState, type OpenAIResponsesOutputAssistantTurnState } from './assistant-turn.ts'; import { usageToIR } from './usage.ts'; import { createIRBuilder, type IRItemHandle, type IRPartHandle, type IRRelativePath, type IRWriteHandle } from '../../../ir/output/builder.ts'; -import type { IRRecord } from '../../../ir/output/records.ts'; +import { type IRRecord } from '../../../ir/output/records.ts'; import type { IROutputSourceCitation } from '../../../ir/output/types.ts'; import type { RoundTripReader } from '../../../round-trip/reader.ts'; import { appendReadField, createReadFieldState, invalidateReadField, type ReadFieldStates } from '../../../round-trip/reference/reference-builder.ts'; @@ -24,16 +24,16 @@ type FunctionFieldState = OpenAIResponsesItemState, ReadFieldStates>; type TextFieldStateNode = NonNullable[number]>; -interface WriteTarget { +interface WritePosition { readonly handle: IRWriteHandle; readonly path: IRRelativePath; } -interface ResponsesTextTarget extends WriteTarget { +interface ResponsesTextPosition extends WritePosition { fieldState?: ReadFieldStates['text']; } -interface ResponsesOutputTextPartState extends ResponsesTextTarget { +interface ResponsesOutputTextPartState extends ResponsesTextPosition { readonly type: 'output_text'; readonly handle: IRPartHandle; readonly path: 'text'; @@ -44,7 +44,7 @@ interface ResponsesOutputTextPartState extends ResponsesTextTarget { nextAnnotationOrdinal: number; } -interface ResponsesRefusalPartState extends ResponsesTextTarget { +interface ResponsesRefusalPartState extends ResponsesTextPosition { readonly type: 'refusal'; readonly handle: IRPartHandle; readonly path: 'refusal'; @@ -56,9 +56,12 @@ type ResponsesPartState = ResponsesTextPartState | ResponsesImagePartState; interface ResponsesItemStateBase { readonly outputIndex: number; readonly id: string; + // The declaration group the item opened with, kept so a later snapshot cannot + // silently state a call from a different group under the same id. + readonly namespace?: string; item?: IRItemHandle; readonly parts: Map; - readonly reasoning: Record<'summary' | 'content', ResponsesTextTarget[]>; + readonly reasoning: Record<'summary' | 'content', ResponsesTextPosition[]>; readonly logprobs: Map; closed: boolean; finalSnapshot?: OpenAIResponsesOutputItemEx; @@ -67,8 +70,8 @@ interface ResponsesItemStateBase { type ResponsesItemState = ResponsesItemStateBase & ( | { readonly type: 'message'; fieldState: MessageFieldState } | { readonly type: 'reasoning'; fieldState: ReasoningFieldState } - | { readonly type: 'function_call'; fieldState: FunctionFieldState; toolText?: ResponsesTextTarget } - | { readonly type: 'custom_tool_call'; fieldState: CustomFieldState; toolText?: ResponsesTextTarget } + | { readonly type: 'function_call'; fieldState: FunctionFieldState; toolText?: ResponsesTextPosition } + | { readonly type: 'custom_tool_call'; fieldState: CustomFieldState; toolText?: ResponsesTextPosition } | { readonly type: Exclude; fieldState?: never } ); @@ -119,7 +122,7 @@ const annotationToIR = (coordinateIndex: () => TextCoordinateIndex, annotation: return undefined; }; -// The IR item an output item opens as; other item types have no IR counterpart. +// The output IR item an output item opens as; other item types have no output IR counterpart. const irItemSeed = (item: OpenAIResponsesOutputItemEx): Record | undefined => { switch (item.type) { case 'message': @@ -128,9 +131,9 @@ const irItemSeed = (item: OpenAIResponsesOutputItemEx): Record case 'reasoning': return undefined; case 'function_call': - return { type: 'function_call', ...(item.call_id === undefined ? {} : { call_id: item.call_id }), name: item.name, arguments: '' }; + return { type: 'function_call', ...(item.call_id === undefined ? {} : { call_id: item.call_id }), name: item.name, ...(item.namespace === undefined ? {} : { namespace: item.namespace }), arguments: '' }; case 'custom_tool_call': - return { type: 'custom_tool_call', ...(item.call_id === undefined ? {} : { call_id: item.call_id }), name: item.name, input: '' }; + return { type: 'custom_tool_call', ...(item.call_id === undefined ? {} : { call_id: item.call_id }), name: item.name, ...(item.namespace === undefined ? {} : { namespace: item.namespace }), input: '' }; default: return undefined; } @@ -142,7 +145,7 @@ export const readOpenAIResponsesEvents = async function* ( frames: AsyncIterable>, options: { roundTrip: RoundTripReader<'openaiResponses'> }, ): AsyncGenerator { - const ir = createIRBuilder(); + const builder = createIRBuilder(); const items = new Map(); const outputState: OpenAIResponsesOutputAssistantTurnState = []; let started = false; @@ -160,14 +163,14 @@ export const readOpenAIResponsesEvents = async function* ( if (typeof response.model !== 'string') throw new TypeError('Responses response envelope requires a model'); if (responseIdentity === undefined) { responseIdentity = { id: response.id, model: response.model }; - ir.assign(['model'], response.model); + builder.assign(['model'], response.model); } else { if (response.model !== responseIdentity.model) { throw new TypeError(`Responses response model changed from ${responseIdentity.model} to ${response.model}`); } } - if (typeof response.service_tier === 'string') ir.assign(['service_tier'], response.service_tier); - if (response.usage != null) ir.usage(usageToIR(response.usage)); + if (typeof response.service_tier === 'string') builder.assign(['service_tier'], response.service_tier); + if (response.usage != null) builder.usage(usageToIR(response.usage)); }; const requireOutputIndex = (value: unknown): number => { @@ -183,10 +186,13 @@ export const readOpenAIResponsesEvents = async function* ( const id = itemId(item); if (typeof id !== 'string') throw new TypeError('Responses output items require an id'); const seed = irItemSeed(item); - const handle = seed === undefined ? undefined : ir.item(0, seed); + const handle = seed === undefined ? undefined : builder.item(0, seed); const base: ResponsesItemStateBase = { outputIndex, id, + ...(item.type === 'function_call' || item.type === 'custom_tool_call' + ? item.namespace === undefined ? {} : { namespace: item.namespace } + : {}), item: handle, parts: new Map(), reasoning: { summary: [], content: [] }, @@ -216,9 +222,13 @@ export const readOpenAIResponsesEvents = async function* ( if (item.type !== state.type) throw new TypeError(`Responses output item type changed from ${state.type} to ${item.type}`); const id = itemId(item); if (id !== state.id) throw new TypeError(`Responses output item id changed from ${state.id} to ${String(id)}`); + // The group is part of what names the call, so it may not move between + // snapshots any more than the id may. + const namespace = item.type === 'function_call' || item.type === 'custom_tool_call' ? item.namespace : undefined; + if (namespace !== state.namespace) throw new TypeError(`Responses output item namespace changed from ${String(state.namespace)} to ${String(namespace)}`); }; - const ensureToolText = (state: ResponsesItemState): ResponsesTextTarget => { + const ensureToolText = (state: ResponsesItemState): ResponsesTextPosition => { if (state.item === undefined || (state.type !== 'function_call' && state.type !== 'custom_tool_call')) throw new TypeError('Responses tool text requires a tool item'); if (state.toolText !== undefined) return state.toolText; const fieldState = createReadFieldState('text'); @@ -235,11 +245,11 @@ export const readOpenAIResponsesEvents = async function* ( return state.toolText; }; - const appendTextAtom = (target: ResponsesTextTarget, text: string): void => { + const appendTextAtom = (target: ResponsesTextPosition, text: string): void => { if (target.fieldState === undefined) target.handle.append(target.path, text); else target.handle.append(target.path, text, [appendReadField(options.roundTrip.builder, target.fieldState, text)]); }; - const reconcileAppend = (target: ResponsesTextTarget, snapshot: unknown): string => { + const reconcileAppend = (target: ResponsesTextPosition, snapshot: unknown): string => { const { handle, path } = target; const absolutePath = handle.absolutePath(path); if (typeof snapshot !== 'string') throw new TypeError(`Responses text snapshot must be a string at ${JSON.stringify(absolutePath)}`); @@ -275,7 +285,7 @@ export const readOpenAIResponsesEvents = async function* ( return snapshot; }; - const reconcileAssign = ({ handle, path }: WriteTarget, snapshot: unknown): void => { + const reconcileAssign = ({ handle, path }: WritePosition, snapshot: unknown): void => { if (snapshot === undefined) return; const current = handle.current(path); if (current === undefined) handle.assign(path, snapshot); @@ -284,9 +294,9 @@ export const readOpenAIResponsesEvents = async function* ( } }; - const ensureReasoningText = (state: ResponsesItemState, field: 'summary' | 'content', index: number): ResponsesTextTarget => { + const ensureReasoningText = (state: ResponsesItemState, field: 'summary' | 'content', index: number): ResponsesTextPosition => { if (state.type !== 'reasoning') throw new TypeError('Responses reasoning text requires a reasoning item'); - state.item ??= ir.item(0, { type: 'reasoning' }); + state.item ??= builder.item(0, { type: 'reasoning' }); state.item.ensure(field, []); const targets = state.reasoning[field]; const length = targets.length; @@ -363,7 +373,7 @@ export const readOpenAIResponsesEvents = async function* ( const ensureLogprobGroup = (state: ResponsesItemState, contentIndex: number, part: IRPartHandle): { readonly index: number; length: number } => { let group = state.logprobs.get(contentIndex); if (group !== undefined) return group; - const choice = ir.choice(0); + const choice = builder.choice(0); choice.ensure('logprobs', []); if (state.item === undefined) throw new TypeError('Responses content part requires a message item'); group = { index: nextLogprobGroup++, length: 0 }; @@ -385,7 +395,7 @@ export const readOpenAIResponsesEvents = async function* ( ): void => { if (tokens === undefined || tokens.length === 0) return; const group = ensureLogprobGroup(state, contentIndex, part.handle); - const choice = ir.choice(0); + const choice = builder.choice(0); for (const token of tokens) choice.assign(['logprobs', group.index, 'tokens', group.length++], token); }; @@ -397,7 +407,7 @@ export const readOpenAIResponsesEvents = async function* ( ): void => { if (tokens === undefined || tokens.length === 0) return; const group = ensureLogprobGroup(state, contentIndex, part.handle); - const choice = ir.choice(0); + const choice = builder.choice(0); tokens.forEach((token, index) => reconcileAssign({ handle: choice, path: ['logprobs', group.index, 'tokens', index] }, token)); group.length = Math.max(group.length, tokens.length); }; @@ -544,14 +554,14 @@ export const readOpenAIResponsesEvents = async function* ( if (frame.type === 'done') throw new Error('Responses done arrived without a terminal response'); const e = frame.event; if (e.type === 'error') { - ir.event({ type: 'error', error: errorPayloadFromEvent(e) }); - yield* ir.drain(); + builder.event({ type: 'error', error: errorPayloadFromEvent(e) }); + yield* builder.drain(); return; } if (e.type === 'response.failed' && e.response.error?.code !== 'bio_policy' && e.response.error?.code !== 'cyber_policy') { // `error` is nullable on a Response, so a failure may carry no description. - ir.event({ type: 'error', error: errorPayloadFromWire(e.response.error ?? { message: 'Response failed due to unknown error.' }) }); - yield* ir.drain(); + builder.event({ type: 'error', error: errorPayloadFromWire(e.response.error ?? { message: 'Response failed due to unknown error.' }) }); + yield* builder.drain(); return; } if (!started && !('response' in e)) throw new Error('Responses event arrived before the initial response envelope'); @@ -561,8 +571,8 @@ export const readOpenAIResponsesEvents = async function* ( responseMetadata(response); if (!started) { if (responseIdentity === undefined) throw new Error('Responses metadata did not establish response identity'); - ir.choice(0); - ir.event({ type: 'start', id: responseIdentity.id, ...(response.created_at === undefined ? {} : { created: response.created_at }) }); + builder.choice(0); + builder.event({ type: 'start', id: responseIdentity.id, ...(response.created_at === undefined ? {} : { created: response.created_at }) }); started = true; } if (['response.completed', 'response.incomplete', 'response.failed'].includes(e.type)) { @@ -600,7 +610,7 @@ export const readOpenAIResponsesEvents = async function* ( audioItem.end(); } const policy = response.error?.code === 'cyber_policy' ? 'cyber' : response.error?.code === 'bio_policy' ? 'bio' : undefined; - if (policy !== undefined) ir.choice(0).assign('refusal_details', { category: policy, explanation: response.error?.message }); + if (policy !== undefined) builder.choice(0).assign('refusal_details', { category: policy, explanation: response.error?.message }); outputState.length = terminalOutput.length; const input = toOpenAIResponsesInputAssistantTurn(terminalOutput, outputState); options.roundTrip.onAssistantTurn(0, input.turn, input.state); @@ -608,8 +618,8 @@ export const readOpenAIResponsesEvents = async function* ( const finishReason = policy !== undefined ? 'content_filter' : e.type === 'response.incomplete' ? incompleteReason : hasTools ? 'tool_calls' : 'stop'; - ir.choice(0).end(finishReason); - ir.event({ type: 'finish', status: e.type === 'response.incomplete' ? 'incomplete' : 'completed' }); + builder.choice(0).end(finishReason); + builder.event({ type: 'finish', status: e.type === 'response.incomplete' ? 'incomplete' : 'completed' }); finished = true; } } else if (e.type === 'response.output_item.added') { @@ -622,7 +632,7 @@ export const readOpenAIResponsesEvents = async function* ( } else if (e.type === 'response.audio.delta' || e.type === 'response.audio.transcript.delta') { const field = e.type === 'response.audio.delta' ? 'data' : 'transcript'; if (audioItem === undefined) { - audioItem = ir.item(0, { type: 'message', content: [] }); + audioItem = builder.item(0, { type: 'message', content: [] }); audioPart = audioItem.part({ type: 'audio', audio: {} }); } const part = audioPart; @@ -687,7 +697,7 @@ export const readOpenAIResponsesEvents = async function* ( } } } - yield* ir.drain(); + yield* builder.drain(); if (finished) return; } throw new Error('Responses stream ended without a terminal response'); diff --git a/packages/translate/src/protocols/openai-responses/as-target/input-from.ts b/packages/translate/src/protocols/openai-responses/as-target/input-from.ts new file mode 100644 index 0000000000..dbf22069a7 --- /dev/null +++ b/packages/translate/src/protocols/openai-responses/as-target/input-from.ts @@ -0,0 +1,309 @@ +import { requireStreaming } from '../../../ir/input/streaming.ts'; +import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputOutputFormat, IRInputMedia, IRInputToolResultContentPart, IRInputClientItem, IRInputPromptItem } from '../../../ir/input/types.ts'; +import type { IRInputAssistantItemWithCallIds, IRInputTurnWithCallIds, IRInputClientItemWithCallIds } from '../../../request/call-ids.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import { reasoningEffortForBudget } from '../../../shared/reasoning-budget.ts'; +import type { + CanonicalOpenAIResponsesInputItem, + CanonicalOpenAIResponsesPayload, + OpenAIResponsesInputContent, + OpenAIResponsesToolOutputContent, + OpenAIResponsesTool, +} from '@floway-dev/protocols/openai-responses'; + +const inputContent = (media: IRInputMedia, detail?: string | null): OpenAIResponsesInputContent => { + switch (media.source_kind) { + case 'url': + return { type: 'input_image', image_url: media.url, ...(detail == null ? {} : { detail }) }; + case 'file': + return { type: 'input_image', file_id: media.file_id, ...(detail == null ? {} : { detail }) }; + case 'base64': + return { type: 'input_image', image_url: `data:${media.media_type ?? 'application/octet-stream'};base64,${media.data}`, ...(detail == null ? {} : { detail }) }; + } +}; + +const fileContent = (media: IRInputMedia, filename?: string | null): OpenAIResponsesInputContent => { + const name = filename == null ? {} : { filename }; + switch (media.source_kind) { + case 'url': + return { type: 'input_file', file_url: media.url, ...name }; + case 'file': + return { type: 'input_file', file_id: media.file_id, ...name }; + case 'base64': + // Responses accepts the bytes either raw or as a data URL; a MIME type the + // source stated is carried in the data URL so the target can act on it. + return { + type: 'input_file', + file_data: media.media_type === undefined ? media.data : `data:${media.media_type};base64,${media.data}`, + ...name, + }; + } +}; + +// Responses tool output admits text, images, and files, but not refusals, so it +// is projected from the tool-result parts rather than the message-part union. +const toolResultOutput = (output: string | readonly IRInputToolResultContentPart[]): string | OpenAIResponsesToolOutputContent[] => typeof output === 'string' + ? output + : [...output].map(part => { + if (part.type === 'text') return { type: 'input_text' as const, text: part.text }; + if (part.type === 'image') return inputContent(part.image, part.detail); + return fileContent(part.file, part.filename); + }); + +// Responses accepts a bare string or a part array. The array is the general +// spelling, so it is used unconditionally: the IR states each part and the +// target keeps them, without a writer having to know how the source phrased it. +const userContent = (item: Extract): OpenAIResponsesInputContent[] => item.content.map(part => { + switch (part.type) { + case 'text': return { type: 'input_text' as const, text: part.text }; + case 'image': return inputContent(part.image, part.detail); + case 'file': return fileContent(part.file, part.filename); + case 'audio': throw new TranslateInputException(`Cannot translate an audio part of MIME type '${part.audio.media_type}' to OpenAI Responses.`); + } +}); + +const assistantItems = (items: readonly IRInputAssistantItemWithCallIds[]): CanonicalOpenAIResponsesInputItem[] => items.map(item => { + switch (item.type) { + case 'message': + return { + type: 'message', + role: 'assistant', + // Responses spelling of an empty assistant history message. + content: item.content.length === 0 || item.content.every(part => part.type === 'text' && part.text === '') + ? '' + : item.content.map(part => part.type === 'text' + ? { type: 'output_text' as const, text: part.text } + : { type: 'refusal' as const, refusal: part.refusal }), + }; + case 'reasoning': + return { + type: 'reasoning', + ...(item.id === undefined ? {} : { id: item.id }), + summary: (item.summary ?? []).map(text => ({ type: 'summary_text' as const, text })), + ...(item.content === undefined ? {} : { content: item.content.map(text => ({ type: 'reasoning_text' as const, text })) }), + }; + case 'function_call': + return { + type: 'function_call', + call_id: item.call_id, + name: item.name, + // Responses requires `arguments`, and the IR states an absent value for a + // call that takes no argument object at all; the empty object is that + // call's argument list written out. + arguments: item.arguments ?? '{}', + status: 'completed', + }; + case 'custom_tool_call': + return { type: 'custom_tool_call', call_id: item.call_id, name: item.name, input: item.input, status: 'completed' }; + } +}); + +const clientItems = (items: readonly IRInputClientItemWithCallIds[]): CanonicalOpenAIResponsesInputItem[] => items.map(item => { + switch (item.type) { + case 'prompt': { + // A prompt boundary states its text plainly: one part is that string, and + // several keep their array so the boundaries between them survive. + const content = item.content.length === 1 + ? item.content[0].text + : item.content.map(part => ({ type: 'input_text' as const, text: part.text })); + return { type: 'message', role: item.role, content }; + } + case 'message': { + // One text part is what a plain prompt states, and Responses spells a + // text message as a bare string; several parts keep their array so the + // boundaries between them survive. + if (item.content.length === 1 && item.content[0].type === 'text') { + return { type: 'message', role: 'user', content: item.content[0].text }; + } + return { type: 'message', role: 'user', content: item.content.length === 0 ? '' : userContent(item) }; + } + case 'tool_result': { + const output = toolResultOutput(item.output); + return item.tool_kind === 'custom' + ? { type: 'custom_tool_call_output', call_id: item.call_id, output, ...(item.is_error === undefined ? {} : { status: item.is_error ? 'incomplete' as const : 'completed' as const }) } + : { type: 'function_call_output', call_id: item.call_id, output, ...(item.is_error === true ? { status: 'incomplete' as const } : { status: 'completed' as const }) }; + } + } +}); + +// A leading system or developer run states the request's prompt, which +// Responses keeps in its own top-level slot. Every later system message stays +// inline as an ordinary input item. +const isHoistablePrompt = (entry: IRInputClientItem): entry is IRInputPromptItem => + entry.type === 'prompt'; + +export const openaiResponsesConversationFromIR = ( + turns: readonly IRInputTurnWithCallIds[], + hoistSystem: boolean, +): Pick => { + const instructions: string[] = []; + // A conversation's own leading prompt is the first turn of the first segment. + // Later segments continue an exchange that already started, so their leading + // prompt (if any) stays inline where the caller placed it. + let hoisting = hoistSystem; + + const input = turns.flatMap(item => { + if (item.kind === 'assistant') { + hoisting = false; + return assistantItems(item.items); + } + return item.items.flatMap((entry): CanonicalOpenAIResponsesInputItem[] => { + if (hoisting && isHoistablePrompt(entry)) { + // Responses states the slot as one string. A single text part is one + // string; several parts cannot be merged without losing the boundaries + // the source drew, so the run stays inline with each part its own + // `input_text` block. + const parts = entry.content.filter(part => part.text !== ''); + if (parts.length === 1) { + instructions.push(parts[0].text); + return []; + } + if (parts.length > 1) { + hoisting = false; + return [{ type: 'message', role: entry.role, content: parts.map(part => ({ type: 'input_text' as const, text: part.text })) }]; + } + return []; + } + hoisting = false; + return clientItems([entry]); + }); + }); + + return { instructions: instructions.length === 0 ? undefined : instructions.join('\n\n'), input }; +}; + +const requireSchema = (format: IRInputOutputFormat): Readonly> => { + if (format.schema === undefined) throw new TranslateInputException('Cannot translate json_schema response format without a schema.'); + return format.schema; +}; + +/** + * Responses states the ceiling once and nests no schema details: `text.format` + * is flat. `strict` stays absent when the source did not say, because an + * omitted Responses tool/format strictness is not the same as `false`. + */ +// OpenAI's strict-mode validators reject an object schema that states no +// `properties`, which is a target-side constraint rather than something the IR +// states. +const normalizeParameters = (schema: Readonly> | undefined): Record => { + const target: Record = schema === undefined ? { type: 'object' } : { ...schema }; + return target.type === 'object' && target.properties === undefined ? { ...target, properties: {} } : target; +}; + +const toolFromIR = (tool: IRInputTool): OpenAIResponsesTool => { + if (tool.type === 'custom') { + return { + type: 'custom', + name: tool.name, + ...(tool.description === undefined ? {} : { description: tool.description }), + ...(tool.format === undefined ? {} : { + format: tool.format.type === 'grammar' + ? { type: 'grammar', definition: tool.format.definition, syntax: tool.format.syntax } + : { type: 'text' }, + }), + }; + } + return { + type: 'function', + name: tool.name, + ...(tool.description === undefined ? {} : { description: tool.description }), + parameters: normalizeParameters(tool.parameters), + // A Responses function tool requires `strict`; an omitted value states the + // schema is not enforced rather than letting the field go missing. + strict: tool.strict ?? false, + }; +}; + +const toolChoiceFromIR = (choice: IRInputToolChoice): NonNullable => { + if (typeof choice === 'string') return choice; + // Responses states a narrowed declaration set natively, which is how the IR's + // `allowed_tools` is expressed on this wire. + if (choice.type === 'allowed_tools') { + return { + type: 'allowed_tools', + mode: choice.mode, + tools: choice.tools.map(selection => selection.type === 'custom' + ? { type: 'custom' as const, name: selection.name } + : { type: 'function' as const, name: selection.name }), + }; + } + return { type: choice.type, name: choice.name }; +}; + +/** + * Everything a Responses request states except its conversation. + * + * The conversation is written separately because a trip interleaves it with the + * assistant turns a sidecar already resolved, so only this half is computed once + * per payload. + */ +export const openaiResponsesHeaderFromIR = (request: IRInputRequestHeader): Omit => { + const { settings } = request; + // This wire states a permitted subset natively, so the declarations stay + // whole: narrowing them as well would also narrow what the model may see. + const tools = request.tools; + const choice = tools === undefined || tools.length === 0 ? undefined : request.tool_choice; + return { + ...openaiResponsesSettingsFromIR(settings), + model: settings.model, + // A trip guarantees a streaming request; a request that says otherwise + // cannot be carried by this side at all. + stream: requireStreaming(settings), + ...(tools === undefined || tools.length === 0 ? {} : { tools: tools.map(toolFromIR) }), + ...(choice === undefined ? {} : { tool_choice: toolChoiceFromIR(choice) }), + }; +}; + +export const openaiResponsesSettingsFromIR = (settings: IRInputSettings): Partial => { + const ceiling = settings.max_output_tokens !== undefined ? settings.max_output_tokens : settings.max_visible_output_tokens; + const format = settings.output_format; + const reasoningEffort = settings.reasoning_effort ?? (settings.reasoning_budget === undefined ? undefined : reasoningEffortForBudget(settings.reasoning_budget)); + + return { + ...(ceiling === undefined ? {} : { max_output_tokens: ceiling }), + ...(settings.temperature === undefined ? {} : { temperature: settings.temperature }), + ...(settings.top_p === undefined ? {} : { top_p: settings.top_p }), + ...(settings.presence_penalty === undefined ? {} : { presence_penalty: settings.presence_penalty }), + ...(settings.frequency_penalty === undefined ? {} : { frequency_penalty: settings.frequency_penalty }), + ...(settings.metadata === undefined ? {} : { metadata: { ...settings.metadata } }), + ...(settings.store === undefined ? {} : { store: settings.store }), + ...(settings.service_tier === undefined ? {} : { service_tier: settings.service_tier }), + ...(settings.parallel_tool_calls === undefined ? {} : { parallel_tool_calls: settings.parallel_tool_calls }), + ...(reasoningEffort === undefined && settings.reasoning_summary === undefined ? {} : { + reasoning: { + ...(reasoningEffort === undefined ? {} : { effort: reasoningEffort }), + ...(settings.reasoning_summary === undefined ? {} : { summary: settings.reasoning_summary }), + }, + }), + ...(settings.prompt_cache_key === undefined ? {} : { prompt_cache_key: settings.prompt_cache_key }), + ...(settings.prompt_cache_retention === undefined ? {} : { prompt_cache_retention: settings.prompt_cache_retention }), + ...(settings.prompt_cache_options == null ? {} : { prompt_cache_options: { ...settings.prompt_cache_options } }), + ...(settings.safety_identifier === undefined ? {} : { safety_identifier: settings.safety_identifier }), + ...(settings.user == null ? {} : { user: settings.user }), + // Responses requests token probabilities through its includable output data. + // https://github.com/openai/openai-node/blob/7423ac3e9351c46300cd094479cded5551a72eb4/src/resources/responses/responses.ts + ...(settings.logprobs === undefined ? {} : { include: settings.logprobs === null ? null : settings.logprobs ? ['message.output_text.logprobs'] : [] }), + ...(settings.top_logprobs === undefined ? {} : { top_logprobs: settings.top_logprobs }), + ...(format === undefined && settings.verbosity === undefined + ? {} + : { + text: { + ...(settings.verbosity === undefined ? {} : { verbosity: settings.verbosity }), + ...(format === undefined ? {} : { + format: format.type === 'json_schema' + ? { + type: 'json_schema' as const, + // Responses requires both a name and a schema; the IR makes them + // optional because Chat and Messages spell them differently. A + // source without a schema cannot be expressed here at all. + name: format.name ?? 'response', + schema: requireSchema(format), + ...(format.description === undefined ? {} : { description: format.description }), + ...(format.strict === undefined ? {} : { strict: format.strict }), + } + : { type: format.type }, + }), + }, + }), + }; +}; diff --git a/packages/translate/src/protocols/openai-responses/as-target/segmented-request.ts b/packages/translate/src/protocols/openai-responses/as-target/segmented-request.ts new file mode 100644 index 0000000000..8c85d15f79 --- /dev/null +++ b/packages/translate/src/protocols/openai-responses/as-target/segmented-request.ts @@ -0,0 +1,30 @@ +import { openaiResponsesConversationFromIR, openaiResponsesHeaderFromIR } from './input-from.ts'; +import { type RestoredAssistantTurn } from '../../../composition/reading.ts'; +import type { IRInputRequestHeader } from '../../../ir/input/types.ts'; +import type { IRInputTurnWithCallIds } from '../../../request/call-ids.ts'; +import { segmentTurns } from '../../../request/segments.ts'; +import type { CanonicalOpenAIResponsesInputItem, CanonicalOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; + +/** + * Preserve restored native Responses turns verbatim and compute request-wide fields once. + * Only the first conversation segment may supply top-level instructions. + */ +export const openaiResponsesPayloadFromSegments = ( + request: IRInputRequestHeader, + turns: readonly (IRInputTurnWithCallIds | RestoredAssistantTurn)[], +): CanonicalOpenAIResponsesPayload => { + const input: CanonicalOpenAIResponsesInputItem[] = []; + let instructions: string | undefined; + for (const piece of segmentTurns(turns)) { + if (piece.kind === 'restored') { input.push(...piece.turn); continue; } + const segment = openaiResponsesConversationFromIR(piece.turns, piece.leading); + if (segment.instructions != null) instructions ??= segment.instructions; + input.push(...segment.input); + } + + return { + ...openaiResponsesHeaderFromIR(request), + input, + ...(instructions === undefined ? {} : { instructions }), + }; +}; diff --git a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts deleted file mode 100644 index 1e5b36763b..0000000000 --- a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; -import { readAnthropicMessagesEvents } from '../../anthropic-messages/as-target/events.ts'; -import { writeOpenAIResponsesEvents } from '../as-source/events.ts'; -import type { NamespaceToolNames } from '../as-source/namespace-tools.ts'; -import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; - -export const translateToSourceEvents = (frames: AsyncIterable>, responseId: string, customToolNames: ReadonlySet, codec: SidecarCodec, namespaceToolNames: NamespaceToolNames): AsyncIterable> => { - const stream = createRoundTripCoordinator('openaiResponses', 'anthropicMessages', codec); - return writeOpenAIResponsesEvents(readAnthropicMessagesEvents(frames, { customToolNames, roundTrip: stream.reader }), { id: responseId, namespaceToolNames, roundTrip: stream.writer }); -}; diff --git a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/reasoning.ts b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/reasoning.ts deleted file mode 100644 index 61fb2eccd6..0000000000 --- a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/reasoning.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { AnthropicMessagesThinkingBlock } from '@floway-dev/protocols/anthropic-messages'; -import type { OpenAIResponsesReasoningItem } from '@floway-dev/protocols/openai-responses'; - -export const openaiResponsesReasoningToAnthropicMessagesUpstreamBlock = (item: OpenAIResponsesReasoningItem): AnthropicMessagesThinkingBlock | null => { - const thinking = item.summary?.map(part => part.text).join('') ?? ''; - return thinking.length > 0 ? { type: 'thinking', thinking, signature: '' } : null; -}; 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 deleted file mode 100644 index d1ec043970..0000000000 --- a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts +++ /dev/null @@ -1,441 +0,0 @@ -import { klona } from 'klona/json'; - -import { openaiResponsesReasoningToAnthropicMessagesUpstreamBlock } from './reasoning.ts'; -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import { parseJSONObject } from '../../../shared/json/object.ts'; -import type { 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'; -import { resolveImageUrlToAnthropicMessagesImage } from '../../anthropic-messages/as-target/remote-images.ts'; -import { anthropicMessagesToolInputSchema } from '../../anthropic-messages/as-target/tool-input-schema.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, findOpenAIResponsesSidecarOccurrences, isOpenAIResponsesEncryptedReasoning, verifyOpenAIResponsesTurnDigest } from '../as-source/round-trip.ts'; -import { requireCanonicalOpenAIResponsesPayload } from '../canonicalize-payload.ts'; -import { buildCustomToolInputSchema } from '../common/custom-tool-wrap.ts'; -import { - ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS, - type AnthropicMessagesAssistantContentBlock, - type AnthropicMessagesAssistantInputContentBlock, - type AnthropicMessagesAssistantMessage, - type AnthropicMessagesMessage, - type AnthropicMessagesPayload, - type AnthropicMessagesTextBlockParam, - type AnthropicMessagesTool, - type AnthropicMessagesToolResultBlock, - type AnthropicMessagesToolResultContentBlock, - type AnthropicMessagesUserContentBlock, - type AnthropicMessagesUserMessage, -} from '@floway-dev/protocols/anthropic-messages'; -import { isFastServiceTier } from '@floway-dev/protocols/common'; -import type { - OpenAIResponsesInputContent, - OpenAIResponsesInputImage, - CanonicalOpenAIResponsesInputItem, - OpenAIResponsesInputMessageEx, - CanonicalOpenAIResponsesText, - OpenAIResponsesRequestPayloadEx, - OpenAIResponsesTool, - OpenAIResponsesToolChoice, -} from '@floway-dev/protocols/openai-responses'; - -interface BuildTargetRequestOptions { - loadRemoteImage: RemoteImageLoader; - /** - * Preferred cap used when the source payload omits `max_output_tokens`. - * Callers in the data plane forward the model's advertised `/models` output - * cap so the translated Anthropic Messages request reflects the upstream-known limit - * rather than being silently capped by a target-side default later. - */ - fallbackMaxOutputTokens: number | undefined; -} - -const parseToolArgumentsObject = (argumentsJson: string): Record => { - try { - return parseJSONObject(argumentsJson); - } catch { - return { raw_arguments: argumentsJson }; - } -}; - -const anthropicServiceTierFieldsFromOpenAI = (serviceTier: string | null | undefined): Partial => - isFastServiceTier(serviceTier) - ? { speed: 'fast' } - : serviceTier != null - ? { service_tier: serviceTier } - : {}; - -export interface TargetRequestResult { - target: AnthropicMessagesPayload; - /** - * Names of OpenAI Responses `custom` tools the request translator wrapped as - * single-string function tools. Returned alongside the translated payload so - * the trip's events translator can project wrapped function calls back into - * `custom_tool_call` outputs. - */ - customToolNames: Set; - namespaceToolNames: NamespaceToolNames; -} - -const translateUserMessage = async (message: OpenAIResponsesInputMessageEx, loadRemoteImage: RemoteImageLoader): Promise => { - if (typeof message.content === 'string') { - return { role: 'user', content: message.content }; - } - - const content: AnthropicMessagesUserContentBlock[] = []; - - for (const block of message.content) { - if (block.type === 'input_text') { - content.push({ type: 'text', text: (block as CanonicalOpenAIResponsesText).text }); - continue; - } - - if (block.type === 'input_file') { - throw new TranslateInputException('Cannot translate input_file message content to Anthropic Messages.'); - } - - if (block.type === 'refusal') { - 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 TranslateInputException('Cannot translate file_id-only image content to Anthropic Messages.'); - } - const image = await resolveImageUrlToAnthropicMessagesImage(imageUrl, loadRemoteImage); - if (image) content.push(image); - } - - return { role: 'user', content: content.length > 0 ? content : '' }; -}; - -// Function and custom tool outputs both admit text, image, and file parts. -// Anthropic tool_result content natively carries images, so preserve them -// rather than flattening the output. -// https://github.com/openai/openai-node/blob/cf1b7e1cf7981ef79695c496caf14b6bb492f18e/src/resources/responses/responses.ts#L3610-L3621 -// https://github.com/openai/openai-node/blob/cf1b7e1cf7981ef79695c496caf14b6bb492f18e/src/resources/responses/responses.ts#L3157-L3169 -// https://github.com/anthropics/anthropic-sdk-typescript/blob/3c5d9c0c15bb847a628f3f2876ac09719abe3012/src/resources/messages/messages.ts#L3457-L3475 -const translateToolOutput = async (output: string | OpenAIResponsesInputContent[], loadRemoteImage: RemoteImageLoader): Promise => { - if (typeof output === 'string') return output; - - const blocks: AnthropicMessagesToolResultContentBlock[] = []; - for (const part of output) { - if (part.type === 'input_image') { - if (typeof part.image_url !== 'string') { - 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 TranslateInputException('Cannot translate unavailable or unsupported image tool output to Anthropic Messages.'); - } - blocks.push(image); - } else if (part.type === 'input_file') { - throw new TranslateInputException('Cannot translate input_file tool output to Anthropic Messages.'); - } else { - blocks.push({ type: 'text', text: part.text }); - } - } - - return blocks.length > 0 ? blocks : ''; -}; - -const translateAssistantMessage = (message: OpenAIResponsesInputMessageEx): AnthropicMessagesAssistantMessage => { - if (typeof message.content === 'string') { - return { role: 'assistant', content: message.content }; - } - - const content: AnthropicMessagesAssistantInputContentBlock[] = []; - - for (const block of message.content) { - if (block.type === 'input_image') { - throw new TranslateInputException('Cannot translate input_image assistant content to Anthropic Messages.'); - } - if (block.type === 'input_file') { - throw new TranslateInputException('Cannot translate input_file assistant content to Anthropic Messages.'); - } - if (block.type === 'refusal') { - content.push({ type: 'text', text: block.refusal }); - continue; - } - if (block.type === 'input_text' || block.type === 'output_text') { - content.push({ type: 'text', text: (block as CanonicalOpenAIResponsesText).text }); - } - } - - return { role: 'assistant', content: content.length > 0 ? content : '' }; -}; - -// Anthropic's Anthropic Messages system field (top-level `AnthropicMessagesPayload.system` and -// inline `AnthropicMessagesSystemMessage.content`) accepts only text. Image parts in -// system / developer OpenAI Responses input messages are rejected here at the -// translator boundary so the caller hits an explicit failure instead of -// having the image silently dropped on the wire. -const openaiResponsesSystemBlocks = (message: OpenAIResponsesInputMessageEx): AnthropicMessagesTextBlockParam[] => { - if (typeof message.content === 'string') { - return message.content ? [{ type: 'text', text: message.content }] : []; - } - - const blocks: AnthropicMessagesTextBlockParam[] = []; - for (const block of message.content) { - if (block.type === 'input_image') { - 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 TranslateInputException(`Invalid content block type '${(block as { type: string }).type}' in ${message.role} message.`); - } - blocks.push({ type: 'text', text: block.text }); - } - return blocks; -}; - -const appendAssistantBlock = (messages: AnthropicMessagesMessage[], block: AnthropicMessagesAssistantContentBlock): void => { - const lastMessage = messages[messages.length - 1]; - - if (lastMessage?.role === 'assistant' && Array.isArray(lastMessage.content)) { - lastMessage.content.push(block); - return; - } - - messages.push({ role: 'assistant', content: [block] }); -}; - -const appendUserBlock = (messages: AnthropicMessagesMessage[], block: AnthropicMessagesToolResultBlock): void => { - const lastMessage = messages[messages.length - 1]; - - if (lastMessage?.role === 'user' && Array.isArray(lastMessage.content)) { - lastMessage.content.push(block); - return; - } - - messages.push({ role: 'user', content: [block] }); -}; - -const translateTools = ( - tools: OpenAIResponsesTool[] | null | undefined, - customToolNames: Set, -): AnthropicMessagesTool[] | undefined => { - const out: AnthropicMessagesTool[] = []; - - for (const tool of tools ?? []) { - if (tool.type === 'function') { - out.push({ - name: tool.name, - // OpenAI Responses spells "this tool has no description" as an explicit - // `null`; Anthropic Messages has no such spelling, so the key is dropped. - ...(tool.description == null ? {} : { description: tool.description }), - // Anthropic Messages has no spelling for "no schema" either: `input_schema` is - // required, and forwarding `undefined` makes Anthropic reject the whole - // request with a 400. The empty object schema is Anthropic's own - // spelling for a tool that takes no arguments. - // https://github.com/anthropics/anthropic-sdk-typescript/blob/3b45cd3b69c956ac63384fdb09ce1d8109f3fa80/src/resources/messages/messages.ts#L1845-L1852 - // https://github.com/anthropics/anthropic-sdk-typescript/blob/3b45cd3b69c956ac63384fdb09ce1d8109f3fa80/examples/managed-agents-self-hosted-sandbox-worker.ts#L34-L41 - input_schema: anthropicMessagesToolInputSchema(klona(tool.parameters) ?? { type: 'object', properties: {} }), - ...(tool.strict == null ? {} : { strict: tool.strict }), - }); - continue; - } - if (tool.type === 'custom') { - customToolNames.add(tool.name); - out.push({ - name: tool.name, - description: tool.description, - input_schema: buildCustomToolInputSchema(tool.format), - }); - continue; - } - throw new TranslateInputException(`Cannot translate OpenAI Responses tool type '${tool.type}' to Anthropic Messages.`); - } - return out.length > 0 ? out : undefined; -}; - -const translateToolChoice = ( - toolChoice: OpenAIResponsesToolChoice | null | undefined, -): AnthropicMessagesPayload['tool_choice'] => { - if (!toolChoice) return undefined; - - if (typeof toolChoice === 'string') { - switch (toolChoice) { - case 'auto': - return { type: 'auto' }; - case 'none': - return { type: 'none' }; - case 'required': - return { type: 'any' }; - default: - throw new TranslateInputException(`Cannot translate OpenAI Responses tool choice '${toolChoice}' to Anthropic Messages.`); - } - } - - // Both function and wrapped custom tools land on the target as named tool - // choices since they share the function-tool wire shape after translation. - if (toolChoice.type === 'function' || toolChoice.type === 'custom') { - return toolChoice.name ? { type: 'tool', name: toolChoice.name } : undefined; - } - throw new TranslateInputException(`Cannot translate OpenAI Responses tool choice type '${toolChoice.type}' to Anthropic Messages.`); -}; - -export const buildTargetRequest = async ( - source: OpenAIResponsesRequestPayloadEx, - codec: SidecarCodec, - options: BuildTargetRequestOptions, -): Promise => { - const canonical = requireCanonicalOpenAIResponsesPayload(source); - const { payload, names: namespaceToolNames, flattenInput } = flattenNamespaceTools(canonical); - rejectProgrammaticOpenAIResponsesPayload(payload, 'Anthropic Messages'); - const customToolNames = new Set(); - const allowed = restrictAllowedTools(payload.tools, payload.tool_choice); - const tools = translateTools(allowed.tools, customToolNames); - const hoistedSystemBlocks: AnthropicMessagesTextBlockParam[] = []; - const hoistedItems = new Set(); - for (const item of canonical.input) { - if (item.type === 'additional_tools' || item.type === 'tool_search_output') continue; - if (item.type !== 'message' || (item.role !== 'system' && item.role !== 'developer')) break; - hoistedSystemBlocks.push(...openaiResponsesSystemBlocks(item)); - hoistedItems.add(item); - } - const bareMessages = new Set(); - const translateAssistantTurn = (input: CanonicalOpenAIResponsesInputItem[]): AnthropicMessagesMessage[] => { - const messages: AnthropicMessagesMessage[] = []; - for (const item of flattenInput(input)) { - rejectProgramCaller(item); - switch (item.type) { - case 'message': - if (item.role !== 'assistant') throw new TranslateInputException(`Invalid assistant message role '${item.role}'.`); - messages.push(translateAssistantMessage(item)); - break; - case 'function_call': - appendAssistantBlock(messages, { type: 'tool_use', id: item.call_id, name: item.name, input: parseToolArgumentsObject(item.arguments) }); - break; - case 'custom_tool_call': - appendAssistantBlock(messages, { type: 'tool_use', id: item.call_id, name: item.name, input: { input: item.input } }); - break; - case 'reasoning': { - if (isOpenAIResponsesEncryptedReasoning(item)) { - warnDroppedUnrecognizedAssistantReasoning('openaiResponses', 'anthropicMessages'); - break; - } - const block = openaiResponsesReasoningToAnthropicMessagesUpstreamBlock(item); - if (block) appendAssistantBlock(messages, block); - break; - } - case 'item_reference': - case 'web_search_call': - case 'image_generation_call': - throw new TranslateInputException(`Invalid input item type '${item.type}'.`); - default: - throw new TranslateInputException(`Invalid input item: ${JSON.stringify(item)}`); - } - } - for (const message of messages) bareMessages.add(message); - return messages; - }; - - const translateNonAssistantInput = async (input: CanonicalOpenAIResponsesInputItem[], loadRemoteImage: RemoteImageLoader): Promise => { - const messages: AnthropicMessagesMessage[] = []; - for (const item of flattenInput(input).filter(item => !hoistedItems.has(item))) { - rejectProgramCaller(item); - switch (item.type) { - case 'message': - switch (item.role) { - case 'user': messages.push(await translateUserMessage(item, loadRemoteImage)); break; - case 'system': - case 'developer': { - const blocks = openaiResponsesSystemBlocks(item); - messages.push({ role: 'system', content: blocks.length > 0 ? blocks : '' }); - break; - } - default: throw new TranslateInputException(`Invalid role '${item.role}' in input message.`); - } - break; - case 'function_call_output': - case 'custom_tool_call_output': - appendUserBlock(messages, { - type: 'tool_result', tool_use_id: item.call_id, - content: await translateToolOutput(item.output, loadRemoteImage), - is_error: item.type === 'function_call_output' && item.status === 'incomplete' ? true : undefined, - }); - break; - case 'item_reference': - case 'web_search_call': - case 'image_generation_call': - throw new TranslateInputException(`Invalid input item type '${item.type}'.`); - default: - throw new TranslateInputException(`Invalid input item: ${JSON.stringify(item)}`); - } - } - for (const message of messages) bareMessages.add(message); - return messages; - }; - // `payload.instructions` is the OpenAI Responses canonical system field; leading - // system/developer input items contribute additional blocks immediately - // after it. Each source — the instructions field and each leading input - // message — is preserved as its own AnthropicMessagesTextBlockParam so the boundary - // between "canonical instructions" and "leading input system" survives - // and the downstream prompt cache sees stable per-source segments. - const systemBlocks: AnthropicMessagesTextBlockParam[] = [ - ...(payload.instructions ? [{ type: 'text' as const, text: payload.instructions }] : []), - ...hoistedSystemBlocks, - ]; - const effort = payload.reasoning?.effort; - const maxTokens = payload.max_output_tokens ?? options.fallbackMaxOutputTokens ?? ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS; - applyLastSystemCacheBreakpoint(systemBlocks); - applyLastToolCacheBreakpoint(tools); - - // Merge reasoning effort + structured-output format into a single - // `output_config`. `effort === 'none'` still maps to `thinking: {type: - // 'disabled'}` (Anthropic's native disable shape), but `format` should - // still ride along when present. - // - // OpenAI Responses keeps json_schema details flat (`text.format = { type, schema }`); - // a `text` format or absent config has no Anthropic Messages equivalent and drops. - const openaiResponsesFormat = payload.text?.format; - const formatSchema = - openaiResponsesFormat?.type === 'json_schema' && openaiResponsesFormat.schema && typeof openaiResponsesFormat.schema === 'object' && !Array.isArray(openaiResponsesFormat.schema) - ? (openaiResponsesFormat.schema as Record) - : undefined; - const { thinking, effort: outputConfigEffort } = anthropicMessagesReasoningFieldsFromEffort(effort); - const outputConfig: NonNullable = {}; - if (outputConfigEffort !== undefined) outputConfig.effort = outputConfigEffort; - if (formatSchema) outputConfig.format = { type: 'json_schema', schema: klona(formatSchema) }; - const hasOutputConfig = Object.keys(outputConfig).length > 0; - - // OpenAI's accelerated request lane maps to Anthropic's `speed: 'fast'`; - // other service-tier values remain open strings. - // https://docs.claude.com/en/build-with-claude/fast-mode - // https://platform.openai.com/docs/guides/fast-mode - const serviceTierFields = anthropicServiceTierFieldsFromOpenAI(payload.service_tier); - - // OpenAI Responses `metadata` is intentionally omitted on the Anthropic Messages path; - // not coerced into Anthropic metadata.user_id, prompt-cache, or safety - // semantics. - const target: AnthropicMessagesPayload = { - model: payload.model, - messages: [], - max_tokens: maxTokens, - ...(systemBlocks.length > 0 ? { system: systemBlocks } : {}), - ...(payload.temperature != null ? { temperature: payload.temperature } : {}), - ...(payload.top_p != null ? { top_p: payload.top_p } : {}), - stream: true, - tools, - tool_choice: translateToolChoice(allowed.choice), - ...(thinking ? { thinking } : {}), - ...(hasOutputConfig ? { output_config: outputConfig } : {}), - ...serviceTierFields, - }; - - for (const turn of partitionOpenAIResponsesTurns(canonical.input)) { - if (turn.role !== 'assistant') { target.messages.push(...await translateNonAssistantInput(turn.items, options.loadRemoteImage)); continue; } - const roundTrip = await roundTripAssistantTurn('openaiResponses', 'anthropicMessages', turn.items, codec, findOpenAIResponsesSidecarOccurrences, verifyOpenAIResponsesTurnDigest, collectOpenAIResponsesReplayContents); - target.messages.push(...(roundTrip ? [roundTrip.turn] : translateAssistantTurn(turn.items))); - } - applyLastMessageCacheBreakpoint(target.messages.filter(message => bareMessages.has(message))); - return { target, customToolNames, namespaceToolNames }; -}; 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 deleted file mode 100644 index 4c8618bc35..0000000000 --- a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/translate.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { translateToSourceEvents } from './events.ts'; -import { buildTargetRequest } from './request.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { RemoteImageLoader, Translator } from '../../../types.ts'; -import { restateOpenAIResponsesError } from '../as-source/error-body.ts'; -import { restoreNamespaceEvents } from '../as-source/namespace-tools.ts'; -import { createRandomOpenAIResponsesResponseId } from '../as-source/response-id.ts'; -import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import type { OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; - -export const translate: Translator< - OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx, AnthropicMessagesPayload, AnthropicMessagesStreamEventEx, - { fallbackMaxOutputTokens?: number; loadRemoteImage: RemoteImageLoader } -> = async (src, ctx) => { - 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 { - type: 'trip', - error: restateOpenAIResponsesError, - 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 deleted file mode 100644 index bb6b09a280..0000000000 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/content.ts +++ /dev/null @@ -1,48 +0,0 @@ -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'; - -// OpenAI Chat Completions and OpenAI Responses text arrays are transport fragments of one message, not -// paragraph blocks. Preserve the existing no-separator flattening. -const contentPartText = (part: OpenAIChatCompletionsUserContentPart | OpenAIResponsesAssistantInputContent): string | null => (part.type === 'text' || part.type === 'input_text' || part.type === 'output_text' ? part.text : null); - -const contentPartsToText = (parts: readonly (OpenAIChatCompletionsUserContentPart | OpenAIResponsesAssistantInputContent)[]): string => - parts - .map(contentPartText) - .filter((text): text is string => text !== null) - .join(''); - -export const openaiResponsesContentToText = (content: string | OpenAIResponsesAssistantInputContent[]): string => (typeof content === 'string' ? content : contentPartsToText(content)); - -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 TranslateInputException('Cannot translate input_file content to OpenAI Chat Completions.'); - } - - return content.some(part => part.type === 'input_image') - ? content.map( - (part): OpenAIChatCompletionsUserContentPart => { - if (part.type === 'input_image') { - if (typeof part.image_url !== 'string') { - throw new TranslateInputException('Cannot translate file_id-only image content to OpenAI Chat Completions.'); - } - return { - type: 'image_url', - image_url: { - url: part.image_url, - // Both protocols read an absent `detail` as `auto`, and OpenAI Chat Completions - // Completions has no null member, so an absent or null value - // becomes an omitted key. Anything else is the upstream's to - // accept or reject. - // https://github.com/openai/openai-openapi/blob/db3e53198a66732cfe161339ea63bf36fc0137ad/openapi.yaml#L30795-L30803 - // https://github.com/openai/openai-openapi/blob/db3e53198a66732cfe161339ea63bf36fc0137ad/openapi.yaml#L67946-L67951 - ...(part.detail == null ? {} : { detail: part.detail }), - }, - }; - } - return { type: 'text', text: part.text }; - }, - ) - : contentPartsToText(content); -}; diff --git a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts deleted file mode 100644 index 30ea2a5d62..0000000000 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; -import { readOpenAIChatCompletionsEvents } from '../../openai-chat-completions/as-target/events.ts'; -import { writeOpenAIResponsesEvents } from '../as-source/events.ts'; -import type { NamespaceToolNames } from '../as-source/namespace-tools.ts'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; -import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; - -export const translateToSourceEvents = (frames: AsyncIterable>, customToolNames: ReadonlySet, codec: SidecarCodec, namespaceToolNames: NamespaceToolNames): AsyncIterable> => { - const stream = createRoundTripCoordinator('openaiResponses', 'openaiChatCompletions', codec); - return writeOpenAIResponsesEvents(readOpenAIChatCompletionsEvents(frames, { customToolNames, roundTrip: stream.reader }), { namespaceToolNames, roundTrip: stream.writer }); -}; 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 deleted file mode 100644 index df2444c152..0000000000 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/request.ts +++ /dev/null @@ -1,281 +0,0 @@ -import { klona } from 'klona/json'; - -import { openaiResponsesContentToOpenAIChatCompletionsContent, openaiResponsesContentToText } from './content.ts'; -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslateInputException } from '../../../shared/input-error.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, findOpenAIResponsesSidecarOccurrences, isOpenAIResponsesEncryptedReasoning, verifyOpenAIResponsesTurnDigest } from '../as-source/round-trip.ts'; -import { requireCanonicalOpenAIResponsesPayload } from '../canonicalize-payload.ts'; -import { buildCustomToolInputSchema } from '../common/custom-tool-wrap.ts'; -import type { OpenAIChatCompletionsAssistantMessage, OpenAIChatCompletionsResponseFormat, OpenAIChatCompletionsUserContentPart, OpenAIChatCompletionsPayload, OpenAIChatCompletionsMessage, OpenAIChatCompletionsTool, OpenAIChatCompletionsToolCall } from '@floway-dev/protocols/openai-chat-completions'; -import type { OpenAIResponsesCustomToolCallOutputItem, OpenAIResponsesFunctionCallOutputItem, OpenAIResponsesInputImage, CanonicalOpenAIResponsesText, CanonicalOpenAIResponsesInputItem, OpenAIResponsesPayloadEx, OpenAIResponsesRequestPayloadEx, OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; - -interface AssistantAccumulator { - message: OpenAIChatCompletionsAssistantMessage; - reasoningText?: string; -} - -const ensureAssistant = (assistant: AssistantAccumulator | null): AssistantAccumulator => - assistant ?? { - message: { role: 'assistant', content: null }, - }; - -const appendAssistantText = (assistant: AssistantAccumulator | null, text: string): AssistantAccumulator | null => { - if (!text) return assistant; - - const next = ensureAssistant(assistant); - next.message.content = typeof next.message.content === 'string' ? next.message.content + text : text; - return next; -}; - -const appendAssistantToolCall = ( - assistant: AssistantAccumulator | null, - call: { call_id: string; name: string; arguments: string }, -): AssistantAccumulator => { - const next = ensureAssistant(assistant); - next.message.tool_calls = [ - ...(next.message.tool_calls ?? []), - { - id: call.call_id, - type: 'function', - function: { - name: call.name, - arguments: call.arguments, - }, - } satisfies OpenAIChatCompletionsToolCall, - ]; - return next; -}; - -interface ToolCallOutputProjection { - toolContent: string; - liftedImageContent: OpenAIChatCompletionsUserContentPart[]; -} - -// OpenAI Chat Completions tool messages admit only strings or text parts, while OpenAI Responses tool -// output also admits images. Keep every tool result contiguous with its -// assistant tool-call group, then lift its images into one following user -// message so vision targets receive a legal, usable shape. -// https://github.com/openai/openai-node/blob/61539248cbe04665de68a71e6fd878127ae4db87/src/resources/chat/completions/completions.ts#L1893-L1908 -// https://github.com/vercel/ai/blob/c093ee7458ccd5dada05d8461041e47c24ee55c0/packages/google/src/convert-to-google-messages.ts#L137-L180 -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 TranslateInputException('Cannot translate input_file tool output to OpenAI Chat Completions.'); - } - - const images = item.output.filter((part): part is OpenAIResponsesInputImage => part.type === 'input_image'); - const textParts = item.output.filter((part): part is CanonicalOpenAIResponsesText => - part.type === 'input_text' || part.type === 'output_text'); - if (images.length === 0) { - return { toolContent: openaiResponsesContentToText(textParts), liftedImageContent: [] }; - } - - const lifted = openaiResponsesContentToOpenAIChatCompletionsContent([ - { type: 'input_text', text: `Image output from tool call ${item.call_id}:` }, - ...images, - ]); - if (typeof lifted === 'string') throw new Error('Image tool output projection lost its image content'); - return { - toolContent: openaiResponsesContentToText(textParts) || 'Image output is attached in the following user message.', - liftedImageContent: lifted as OpenAIChatCompletionsUserContentPart[], - }; -}; - -const translateOpenAIResponsesTools = (tools: OpenAIResponsesTool[] | null | undefined, customToolNames: Set): OpenAIChatCompletionsTool[] | undefined => { - // After allowed_tools selection, Chat Completions can represent only flat - // function and custom declarations. Custom tools are wrapped as functions - // and recorded so response events can restore their freeform shape. The - // server-tool shim rewrites hosted web_search declarations to ordinary - // function tools before this translation, so retained shim tools need no - // special handling here. - const out: OpenAIChatCompletionsTool[] = []; - - for (const tool of tools ?? []) { - if (tool.type === 'function') { - out.push({ - type: 'function', - function: { - name: tool.name, - // OpenAI Responses spells "unspecified" as an omitted key or an explicit - // `null`; OpenAI Chat Completions has only the omitted-key spelling. - ...(tool.parameters == null ? {} : { parameters: klona(tool.parameters) }), - ...(tool.strict == null ? {} : { strict: tool.strict }), - ...(tool.description ? { description: tool.description } : {}), - }, - }); - continue; - } - if (tool.type === 'custom') { - customToolNames.add(tool.name); - out.push({ - type: 'function', - function: { - name: tool.name, - parameters: buildCustomToolInputSchema(tool.format), - strict: false, - ...(tool.description ? { description: tool.description } : {}), - }, - }); - continue; - } - throw new TranslateInputException(`Cannot translate OpenAI Responses tool type '${tool.type}' to OpenAI Chat Completions.`); - } - - return out.length > 0 ? out : undefined; -}; - -const translateOpenAIResponsesToolChoice = (choice?: OpenAIResponsesToolChoice | null): OpenAIChatCompletionsPayload['tool_choice'] => { - if (choice == null) return undefined; - if (typeof choice === 'string') { - if (choice === 'auto' || choice === 'none' || choice === 'required') return choice; - 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 TranslateInputException(`Cannot translate OpenAI Responses tool choice type '${choice.type}' to OpenAI Chat Completions.`); - return { type: 'function', function: { name: choice.name } }; -}; - -const buildOpenAIChatCompletionsResponseFormat = (text: OpenAIResponsesPayloadEx['text']): OpenAIChatCompletionsPayload['response_format'] | undefined => { - if (text === undefined) return undefined; - if (text === null) return undefined; - // `text: {}` means no explicit format. Keep it omitted instead of converting - // absence into an explicit OpenAI Chat Completions `response_format: null`. - const format = klona(text.format); - if (!Object.hasOwn(text, 'format') || format === undefined) return undefined; - if (format === null) return undefined; - // OpenAI Responses API uses a flat json_schema shape - // ({ type, name, strict, schema }), while OpenAI Chat Completions wraps the - // schema details under a nested `json_schema` field. Reshape only when - // needed; pass `text`/`json_object` and already-wrapped variants through. - // Without this, OpenAI Chat Completions upstreams reject the request with - // "When response_format type is 'json_schema', the 'json_schema' field - // must be provided". - // References: - // https://platform.openai.com/docs/api-reference/responses/create - // https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format - if (format.type === 'json_schema' && !('json_schema' in format)) { - const { type: _type, ...rest } = format; - return { type: 'json_schema', json_schema: rest as Extract['json_schema'] }; - } - return format as OpenAIChatCompletionsResponseFormat; -}; - -export interface TargetRequestResult { - target: OpenAIChatCompletionsPayload; - namespaceToolNames: NamespaceToolNames; - /** - * Names of OpenAI Responses `custom` tools the request translator wrapped as - * single-string function tools. Returned alongside the translated payload so - * the trip's events translator can project wrapped function calls back into - * `custom_tool_call` outputs. - */ - customToolNames: Set; -} - -export const buildTargetRequest = async (source: OpenAIResponsesRequestPayloadEx, codec: SidecarCodec): Promise => { - const canonical = requireCanonicalOpenAIResponsesPayload(source); - const { payload, names: namespaceToolNames, flattenInput } = flattenNamespaceTools(canonical); - rejectProgrammaticOpenAIResponsesPayload(payload, 'OpenAI Chat Completions'); - const customToolNames = new Set(); - const legacyFunctionNames = new Map(); - const responseFormat = buildOpenAIChatCompletionsResponseFormat(payload.text); - - const translateAssistantTurn = (items: CanonicalOpenAIResponsesInputItem[]): OpenAIChatCompletionsMessage[] => { - let assistant: AssistantAccumulator | null = null; - for (const item of flattenInput(items)) { - rejectProgramCaller(item); - if (item.type === 'reasoning') { - if (isOpenAIResponsesEncryptedReasoning(item)) { warnDroppedUnrecognizedAssistantReasoning('openaiResponses', 'openaiChatCompletions'); continue; } - assistant = ensureAssistant(assistant); - const text = item.summary.map(part => part.text).join(''); - if (text) assistant.reasoningText = (assistant.reasoningText ?? '') + text; - } else if (item.type === 'function_call') assistant = appendAssistantToolCall(assistant, item); - else if (item.type === 'custom_tool_call') assistant = appendAssistantToolCall(assistant, { call_id: item.call_id, name: item.name, arguments: JSON.stringify({ input: item.input }) }); - 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 TranslateInputException(`Cannot translate ${unsupported.type} assistant content to OpenAI Chat Completions.`); - } - assistant = appendAssistantText(assistant, openaiResponsesContentToText(item.content)); - } else throw new TranslateInputException(`Invalid input item type '${item.type}'.`); - } - return assistant === null ? [] : [{ ...assistant.message, ...(assistant.reasoningText === undefined ? {} : { reasoning_text: assistant.reasoningText }) }]; - }; - - const translateNonAssistantInput = (items: CanonicalOpenAIResponsesInputItem[]): OpenAIChatCompletionsMessage[] => { - const messages: OpenAIChatCompletionsMessage[] = []; - const pendingToolOutputImages: OpenAIChatCompletionsUserContentPart[] = []; - const flushToolOutputImages = (): void => { - if (pendingToolOutputImages.length === 0) return; - messages.push({ role: 'user', content: [...pendingToolOutputImages] }); - pendingToolOutputImages.length = 0; - }; - for (const item of flattenInput(items)) { - if (item.type !== 'function_call_output' && item.type !== 'custom_tool_call_output') flushToolOutputImages(); - rejectProgramCaller(item); - if (item.type === 'function_call_output' || item.type === 'custom_tool_call_output') { - const projected = projectToolCallOutput(item); - const legacyName = legacyFunctionNames.get(item.call_id); - legacyFunctionNames.delete(item.call_id); - messages.push(legacyName === undefined - ? { role: 'tool', tool_call_id: item.call_id, content: projected.toolContent } - : { role: 'function', name: legacyName, content: projected.toolContent }); - pendingToolOutputImages.push(...projected.liftedImageContent); - } else if (item.type === 'message' && item.role !== 'assistant') { - 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 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 TranslateInputException(`Invalid input item type '${item.type}'.`); - } - flushToolOutputImages(); - return messages; - }; - - const allowed = restrictAllowedTools(payload.tools, payload.tool_choice); - const tools = translateOpenAIResponsesTools(allowed.tools, customToolNames); - // Same-purpose OpenAI fields pass through directly here, while broader - // OpenAI-Responses-only state such as `previous_response_id` remains native-only. - const target: OpenAIChatCompletionsPayload = { - model: payload.model, - messages: payload.instructions ? [{ role: 'system', content: payload.instructions }] : [], - ...(payload.max_output_tokens !== undefined ? { max_tokens: payload.max_output_tokens } : {}), - stream: true, - ...(payload.temperature !== undefined ? { temperature: payload.temperature } : {}), - ...(payload.top_p !== undefined ? { top_p: payload.top_p } : {}), - ...(payload.metadata !== undefined ? { metadata: klona(payload.metadata) } : {}), - ...(payload.store !== undefined ? { store: payload.store } : {}), - ...(payload.parallel_tool_calls != null ? { parallel_tool_calls: payload.parallel_tool_calls } : {}), - ...(responseFormat !== undefined ? { response_format: responseFormat } : {}), - ...(payload.prompt_cache_key !== undefined ? { prompt_cache_key: payload.prompt_cache_key } : {}), - ...(payload.safety_identifier !== undefined ? { safety_identifier: payload.safety_identifier } : {}), - ...(payload.reasoning?.effort != null ? { reasoning_effort: payload.reasoning.effort } : {}), - ...(payload.text?.verbosity != null ? { verbosity: payload.text.verbosity } : {}), - ...(payload.service_tier !== undefined ? { service_tier: payload.service_tier } : {}), - tools, - tool_choice: translateOpenAIResponsesToolChoice(allowed.choice), - }; - - for (const turn of partitionOpenAIResponsesTurns(canonical.input)) { - if (turn.role !== 'assistant') { target.messages.push(...translateNonAssistantInput(turn.items)); continue; } - const roundTrip = await roundTripAssistantTurn('openaiResponses', 'openaiChatCompletions', turn.items, codec, findOpenAIResponsesSidecarOccurrences, verifyOpenAIResponsesTurnDigest, collectOpenAIResponsesReplayContents); - if (roundTrip?.turn.function_call != null) { - const modernIds = new Set(roundTrip.turn.tool_calls?.map(call => call.id)); - for (const item of turn.items) { - if ((item.type === 'function_call' || item.type === 'custom_tool_call') && !modernIds.has(item.call_id)) legacyFunctionNames.set(item.call_id, roundTrip.turn.function_call.name); - } - } - target.messages.push(...(roundTrip ? [roundTrip.turn] : translateAssistantTurn(turn.items))); - } - return { target, customToolNames, namespaceToolNames }; -}; diff --git a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/translate.ts b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/translate.ts deleted file mode 100644 index b349d18130..0000000000 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/translate.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { translateToSourceEvents } from './events.ts'; -import { buildTargetRequest } from './request.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { Translator } from '../../../types.ts'; -import { restateOpenAIResponsesError } from '../as-source/error-body.ts'; -import { restoreNamespaceEvents } from '../as-source/namespace-tools.ts'; -import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; -import type { OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; - -export const translate: Translator< - OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx, OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent -> = async (src, ctx) => { - try { - const { target, customToolNames, namespaceToolNames } = await buildTargetRequest(src, ctx.sidecarCodec); - return { - type: 'trip', - error: restateOpenAIResponsesError, - target, - events: frames => restoreNamespaceEvents( - translateToSourceEvents(frames, customToolNames, ctx.sidecarCodec, namespaceToolNames), - 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 index d547865e59..451b71d41f 100644 --- a/packages/translate/src/protocols/routes.ts +++ b/packages/translate/src/protocols/routes.ts @@ -1,9 +1,35 @@ -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'; +import { anthropicMessagesSourceProtocol } from './anthropic-messages/as-source.ts'; +import { anthropicMessagesTargetProtocol } from './anthropic-messages/as-target.ts'; +import { composeTranslation } from '../composition/index.ts'; +import { geminiGenerateContentSourceProtocol } from './gemini-generate-content/as-source.ts'; +import { openaiChatCompletionsSourceProtocol } from './openai-chat-completions/as-source.ts'; +import { openaiChatCompletionsTargetProtocol } from './openai-chat-completions/as-target.ts'; +import { openaiResponsesSourceProtocol } from './openai-responses/as-source.ts'; +import { openaiResponsesTargetProtocol } from './openai-responses/as-target.ts'; + +export const routeAnthropicMessagesViaOpenAIChatCompletions = + composeTranslation(anthropicMessagesSourceProtocol, openaiChatCompletionsTargetProtocol); + +export const routeAnthropicMessagesViaOpenAIResponses = + composeTranslation(anthropicMessagesSourceProtocol, openaiResponsesTargetProtocol); + +export const routeGeminiGenerateContentViaAnthropicMessages = + composeTranslation(geminiGenerateContentSourceProtocol, anthropicMessagesTargetProtocol); + +export const routeGeminiGenerateContentViaOpenAIChatCompletions = + composeTranslation(geminiGenerateContentSourceProtocol, openaiChatCompletionsTargetProtocol); + +export const routeGeminiGenerateContentViaOpenAIResponses = + composeTranslation(geminiGenerateContentSourceProtocol, openaiResponsesTargetProtocol); + +export const routeOpenAIChatCompletionsViaAnthropicMessages = + composeTranslation(openaiChatCompletionsSourceProtocol, anthropicMessagesTargetProtocol); + +export const routeOpenAIChatCompletionsViaOpenAIResponses = + composeTranslation(openaiChatCompletionsSourceProtocol, openaiResponsesTargetProtocol); + +export const routeOpenAIResponsesViaAnthropicMessages = + composeTranslation(openaiResponsesSourceProtocol, anthropicMessagesTargetProtocol); + +export const routeOpenAIResponsesViaOpenAIChatCompletions = + composeTranslation(openaiResponsesSourceProtocol, openaiChatCompletionsTargetProtocol); diff --git a/packages/translate/src/request/call-ids.ts b/packages/translate/src/request/call-ids.ts new file mode 100644 index 0000000000..620777c7f7 --- /dev/null +++ b/packages/translate/src/request/call-ids.ts @@ -0,0 +1,97 @@ +import type { RestoredAssistantTurn } from '../composition/reading.ts'; +import type { IRInputAssistantItem, IRInputCustomToolCallItem, IRInputFunctionCallItem, IRInputPromptItem, IRInputToolResultItem, IRInputUserMessageItem, IRInputTurn } from '../ir/input/types.ts'; +import { TranslateInputException } from '../shared/input-error.ts'; + +// Target writers use this shape after allocating missing call IDs; every call and result has an ID. +export type IRInputTurnWithCallIds = IRInputClientTurnWithCallIds | IRInputAssistantTurnWithCallIds; +export interface IRInputClientTurnWithCallIds { + readonly kind: 'client'; + readonly items: readonly IRInputClientItemWithCallIds[]; +} +export interface IRInputAssistantTurnWithCallIds { + readonly kind: 'assistant'; + readonly items: readonly IRInputAssistantItemWithCallIds[]; +} +export type IRInputClientItemWithCallIds = IRInputPromptItem | IRInputUserMessageItem | IRInputToolResultItemWithCallId; +export type IRInputToolResultItemWithCallId = IRInputToolResultItem & { readonly call_id: string }; +export type IRInputAssistantItemWithCallIds = + | Exclude + | (IRInputFunctionCallItem & { readonly call_id: string }) + | (IRInputCustomToolCallItem & { readonly call_id: string }); + +export interface PendingCall { + readonly name: string; + readonly callId: string; +} + +/** + * Allocate missing IDs in conversation order so id-less results answer pending calls. + * Explicit result IDs must refer to a call present in the conversation. Restored native + * turns contribute their pending calls; target writers use the assigned IDs on calls and results. + */ +export const assignCallIds = ( + turns: readonly (IRInputTurn | RestoredAssistantTurn)[], + restoredCalls: (turn: Restored) => readonly PendingCall[], +): readonly (IRInputTurnWithCallIds | RestoredAssistantTurn)[] => { + const pending: PendingCall[] = []; + let allocated = 0; + // Every id this conversation states, read before allocating anything: an id + // this step invents must not collide with one stated later in the same + // conversation, or two calls would share it. + const claimed = new Set(); + for (const piece of turns) { + if (piece.kind === 'restored') { + for (const call of restoredCalls(piece.turn)) if (call.callId !== undefined) claimed.add(call.callId); + } else if (piece.kind === 'assistant') { + for (const item of piece.items) { + if ((item.type === 'function_call' || item.type === 'custom_tool_call') && item.call_id !== undefined) claimed.add(item.call_id); + } + } + } + const allocate = (name: string): string => { + let candidate: string; + do { + allocated += 1; + candidate = `${name}_call_${allocated}`; + } while (claimed.has(candidate)); + claimed.add(candidate); + return candidate; + }; + // A stated answer releases the call it names; an unstated one takes the + // oldest call of its name, or the oldest call at all when the source stated + // no name either. + const answer = (callId: string | undefined, name: string | undefined): PendingCall => { + const index = callId !== undefined + ? pending.findIndex(call => call.callId === callId) + : name === undefined ? 0 : pending.findIndex(call => call.name === name); + if (index < 0) { + throw new TranslateInputException(callId === undefined + ? `Cannot translate a tool result that answers no call${name === undefined ? ' (name: unstated)' : ` (name: ${name})`}.` + : `Cannot translate a tool result answering '${callId}': no call of this conversation states that id.`); + } + const [answered] = pending.splice(index, 1); + if (answered === undefined) throw new TranslateInputException('Cannot translate a tool result that answers no call.'); + return answered; + }; + + return turns.map((piece): IRInputTurnWithCallIds | RestoredAssistantTurn => { + if (piece.kind === 'restored') { + pending.push(...restoredCalls(piece.turn)); + return piece; + } + if (piece.kind === 'client') { + const items: IRInputClientItemWithCallIds[] = piece.items.map((item): IRInputClientItemWithCallIds => { + if (item.type !== 'tool_result') return item; + return { ...item, call_id: answer(item.call_id, item.name).callId }; + }); + return { kind: 'client', items }; + } + const items: IRInputAssistantItemWithCallIds[] = piece.items.map(item => { + if (item.type !== 'function_call' && item.type !== 'custom_tool_call') return item; + const callId = item.call_id ?? allocate(item.name); + pending.push({ name: item.name, callId }); + return { ...item, call_id: callId }; + }); + return { kind: 'assistant', items }; + }); +}; diff --git a/packages/translate/src/request/segments.ts b/packages/translate/src/request/segments.ts new file mode 100644 index 0000000000..7113c5a513 --- /dev/null +++ b/packages/translate/src/request/segments.ts @@ -0,0 +1,40 @@ +import type { IRInputTurnWithCallIds } from './call-ids.ts'; +import type { RestoredAssistantTurn } from '../composition/reading.ts'; + +// A conversation translates in segments split at sidecar-resolved assistant +// turns. Those turns are already native target structure, so a writer emits +// them verbatim instead of reconstructing them from the IR. +// +// Segmentation is what preserves cross-turn behaviour: a writer that folds +// adjacent user turns into one native message must not fold across a resolved +// assistant turn, because that turn sits between them in the output. +export interface Segment { + readonly kind: 'ir'; + readonly turns: readonly IRInputTurnWithCallIds[]; + // Only the first segment of a conversation may hoist a leading system run into + // a protocol's top-level instructions slot. + readonly leading: boolean; +} + +export const segmentTurns = (turns: readonly (IRInputTurnWithCallIds | RestoredAssistantTurn)[]): readonly (Segment | RestoredAssistantTurn)[] => { + const segments: (Segment | RestoredAssistantTurn)[] = []; + let current: IRInputTurnWithCallIds[] = []; + let leading = true; + const flush = (): void => { + if (current.length === 0) return; + segments.push({ kind: 'ir', turns: current, leading }); + current = []; + leading = false; + }; + for (const piece of turns) { + if (piece.kind === 'restored') { + flush(); + leading = false; + segments.push(piece); + continue; + } + current.push(piece); + } + flush(); + return segments; +}; diff --git a/packages/translate/src/request/tool-names.ts b/packages/translate/src/request/tool-names.ts new file mode 100644 index 0000000000..b3520ab133 --- /dev/null +++ b/packages/translate/src/request/tool-names.ts @@ -0,0 +1,171 @@ +import type { RestoredAssistantTurn, SourceCallIdentity } from '../composition/reading.ts'; +import type { IRInputRequestHeader, IRInputTool, IRInputToolChoice, IRInputToolSelector, IRInputTurn } from '../ir/input/types.ts'; +import { TranslateInputException } from '../shared/input-error.ts'; + +/** Records allocated custom-call names and their original identities for the target event reader. */ +export interface ToolNameContext { + readonly customToolNames: ReadonlySet; + readonly originalToolNames: ReadonlyMap>; +} + +const customNamesOf = (request: IRInputRequestHeader): ReadonlySet => + new Set((request.tools ?? []) + .flatMap(tool => tool.type === 'custom' ? [tool.name] : [])); + +const callableKey = ({ type, namespace, name }: IRInputToolSelector): string => JSON.stringify([type, namespace, name]); + +export interface NativeCallNames { + readonly ids: (turn: Turn) => readonly (string | undefined)[]; + readonly rename: (turn: Turn, names: readonly string[]) => Turn; +} + +const nativeCallIdentities = ( + turn: RestoredAssistantTurn, + native: NativeCallNames, +): readonly SourceCallIdentity[] => { + const calls = native.ids(turn.turn); + const remaining = new Set(turn.callIdentities); + const byId = new Map(turn.callIdentities.flatMap(identity => identity.call_id === undefined ? [] : [[identity.call_id, identity] as const])); + const matched = calls.map(callId => { + const identity = callId === undefined ? undefined : byId.get(callId); + if (identity !== undefined) remaining.delete(identity); + return identity; + }); + const unmatched = matched.flatMap((identity, index) => identity === undefined ? [index] : []); + // Legacy Chat names no id. Once modern ids are removed, the one remaining + // source call is its identity; no wire spelling or position invents a match. + if (unmatched.length === 1 && remaining.size === 1) { + const [identity] = remaining; + if (calls[unmatched[0]] === undefined || identity.call_id === undefined) matched[unmatched[0]] = identity; + } + return matched.map(identity => { + if (identity === undefined) throw new Error('Authenticated native call has no unique source identity.'); + return identity; + }); +}; + +/** + * States this target's request and conversation with wireNames callable names. + * + * Declarations and historical calls share one allocation keyed by kind, group, + * and name. Bare names stay unchanged unless two callable kinds share one. The + * grouping itself is dropped, because this wire has nowhere to state it. What the + * returned context carries is what the same spelling decided: which + * declarations were freeform, and which identity each allocated name came from. + */ +export const allocateToolNames = ( + request: IRInputRequestHeader, + inputTurns: readonly (IRInputTurn | RestoredAssistantTurn)[], + native: NativeCallNames, +): { readonly request: IRInputRequestHeader; readonly turns: readonly (IRInputTurn | RestoredAssistantTurn)[]; readonly context: ToolNameContext } => { + const callables = [ + ...(request.tools ?? []), + ...inputTurns.flatMap(turn => turn.kind === 'restored' ? turn.callIdentities : turn.items.flatMap(item => + item.type === 'function_call' || item.type === 'custom_tool_call' + ? [{ type: item.type === 'function_call' ? 'function' as const : 'custom' as const, namespace: item.namespace, name: item.name }] + : [])), + ]; + const reserved = new Set(callables.flatMap(callable => callable.namespace === undefined ? [callable.name] : [])); + const wireNames = assignToolNames(callables, reserved); + const originalToolNames = new Map>(); + for (const callable of callables) { + const wireName = wireNames.get(callableKey(callable))!; + if (callable.namespace !== undefined || wireName !== callable.name) { + originalToolNames.set(wireName, { name: callable.name, ...(callable.namespace === undefined ? {} : { namespace: callable.namespace }) }); + } + } + + const wireNameOf = (callable: IRInputToolSelector): string => { + const wireName = wireNames.get(callableKey(callable)); + if (wireName !== undefined) return wireName; + if (callable.namespace === undefined) return callable.name; + throw new TranslateInputException(`Cannot translate a selector for undeclared grouped tool '${callable.namespace}.${callable.name}'.`); + }; + const allocateChoiceNames = (choice: IRInputToolChoice): IRInputToolChoice => { + if (typeof choice === 'string') return choice; + if (choice.type === 'allowed_tools') return { ...choice, tools: choice.tools.map(allocateSelectorName) }; + return allocateSelectorName(choice); + }; + const allocateSelectorName = (selection: IRInputToolSelector): IRInputToolSelector => { + const { namespace: _namespace, ...rest } = selection; + return { ...rest, name: wireNameOf(selection) }; + }; + const toolChoice = request.tool_choice === undefined ? undefined : allocateChoiceNames(request.tool_choice); + const allocateDeclarationName = (tool: IRInputTool): IRInputTool => { + const { namespace: _namespace, ...rest } = tool; + return { ...rest, name: wireNameOf(tool) }; + }; + const allocatedRequest: IRInputRequestHeader = { + ...request, + ...(request.tools === undefined ? {} : { tools: request.tools.map(allocateDeclarationName) }), + ...(toolChoice === undefined ? {} : { tool_choice: toolChoice }), + }; + return { + request: allocatedRequest, + context: { customToolNames: customNamesOf(allocatedRequest), originalToolNames }, + turns: inputTurns.map((turn): IRInputTurn | RestoredAssistantTurn => { + if (turn.kind === 'restored') { + const names = nativeCallIdentities(turn, native).map(wireNameOf); + return names.length === 0 ? turn : { ...turn, turn: native.rename(turn.turn, names) }; + } + if (turn.kind === 'assistant') { + return { + kind: 'assistant', + items: turn.items.map(item => item.type === 'function_call' || item.type === 'custom_tool_call' + ? { ...item, name: wireNameOf({ type: item.type === 'function_call' ? 'function' : 'custom', namespace: item.namespace, name: item.name }) } + : item), + }; + } + return turn; + }), + }; +}; + +/** + * Assigns names independently of tool choice. Both callable kinds become function + * declarations on these targets, so even ungrouped names may need a suffix. + * Original bare names are reserved before allocation to keep aliases from + * taking a later declaration's own name. + * + * Grouped names are limited to Chat's 64 ASCII characters; collision suffixes + * follow allocation order across the complete declarations and history. + * https://github.com/openai/openai-node/blob/61539248cbe04665de68a71e6fd878127ae4db87/src/resources/chat/completions/completions.ts + */ +const assignToolNames = ( + callables: readonly IRInputToolSelector[], + reserved: ReadonlySet, +): Map => { + const taken = new Set(reserved); + const nextSuffixes = new Map(); + const assigned = new Map(); + const bareNames = new Set(); + for (const callable of callables) { + const { namespace, name } = callable; + const key = callableKey(callable); + if (assigned.has(key)) continue; + if (namespace === undefined && !bareNames.has(name)) { + bareNames.add(name); + assigned.set(key, name); + continue; + } + const scope = namespace === undefined ? '' : `${namespace.slice(0, 64)}_`.slice(0, 64); + const base = namespace === undefined ? name : `${scope}${name.slice(0, 64 - scope.length)}`.replaceAll(/[^a-zA-Z0-9_-]/g, '_'); + for (let suffix = 1; ; suffix++) { + const ending = suffix === 1 ? '' : `_${suffix}`; + const prefix = base.slice(0, 64 - ending.length); + if (suffix > 1) { + // Share cursors only when both the truncated prefix and suffix width match. + const cursor = `${ending.length}:${prefix}`; + const next = nextSuffixes.get(cursor); + if (next !== undefined && next > suffix) { suffix = next - 1; continue; } + nextSuffixes.set(cursor, suffix + 1); + } + const spelling = `${prefix}${ending}`; + if (taken.has(spelling)) continue; + taken.add(spelling); + assigned.set(key, spelling); + break; + } + } + return assigned; +}; diff --git a/packages/translate/src/round-trip/resolve.ts b/packages/translate/src/round-trip/resolve.ts index 8853c3bf33..b625dc1b63 100644 --- a/packages/translate/src/round-trip/resolve.ts +++ b/packages/translate/src/round-trip/resolve.ts @@ -10,35 +10,31 @@ export interface SidecarOccurrence { readonly withoutSidecar: () => Turn; } -export interface RoundTripAssistantTurn { - turn: InputAssistantTurnFor; +export interface SidecarRecognition { + readonly inspect: (turn: Turn) => SidecarOccurrence[]; + readonly verify: (turn: Turn, digest: RoundTripTurnDigest, version: number) => Promise; + readonly collectCandidates: (turn: Turn) => ReplayContent[]; } -export const warnDroppedUnrecognizedAssistantReasoning = ( - source: keyof InputAssistantTurnByProtocol, - target: keyof InputAssistantTurnByProtocol, -): void => { console.warn('Dropping reasoning from an unidentifiable foreign assistant turn', { source, target }); }; - export const roundTripAssistantTurn = async ( source: Source, target: Target, turn: SourceTurn, codec: SidecarCodec, - inspect: (turn: SourceTurn) => SidecarOccurrence[], - verify: (turn: SourceTurn, sourceTurnDigest: RoundTripTurnDigest, version: number) => Promise, - collectCandidates: (turn: SourceTurn) => ReplayContent[], -): Promise | undefined> => { - let selected: { candidate: SidecarOccurrence; sidecar: AnyRoundTripSidecar } | undefined; - for (const candidate of inspect(turn)) { - const sidecar = await decodeRoundTripSidecar(codec, source, candidate.data); + recognition: SidecarRecognition, +): Promise | undefined> => { + const { inspect, verify, collectCandidates } = recognition; + let selected: { carrier: SidecarOccurrence; sidecar: AnyRoundTripSidecar } | undefined; + for (const carrier of inspect(turn)) { + const sidecar = await decodeRoundTripSidecar(codec, source, carrier.data); if (sidecar === undefined) continue; if (selected !== undefined) throw new Error('Assistant turn has multiple recognized round-trip sidecars'); - selected = { candidate, sidecar }; + selected = { carrier, sidecar }; } if (selected === undefined || !hasRoundTripTarget(selected.sidecar, target)) return undefined; - const sourceTurn = selected.candidate.withoutSidecar(); + const sourceTurn = selected.carrier.withoutSidecar(); if (!(await verify(sourceTurn, selected.sidecar.sourceTurnDigest, selected.sidecar.v))) return undefined; const resolved = await restoreAssistantTurn(target, collectCandidates(sourceTurn), selected.sidecar.targetTurn, selected.sidecar.referencedContentHashes); if (!resolved.ok) throw new Error(`Authenticated assistant-turn sidecar restoration failed: ${resolved.reason}`); - return { turn: resolved.value }; + return resolved.value; }; diff --git a/packages/translate/src/round-trip/source-assistant-turn.ts b/packages/translate/src/round-trip/source-assistant-turn.ts new file mode 100644 index 0000000000..952ed62b72 --- /dev/null +++ b/packages/translate/src/round-trip/source-assistant-turn.ts @@ -0,0 +1,18 @@ +import type { AnthropicMessagesSourceAssistantTurn } from '../protocols/anthropic-messages/as-source/round-trip.ts'; +import type { GeminiGenerateContentSourceAssistantTurn } from '../protocols/gemini-generate-content/as-source/round-trip.ts'; +import type { OpenAIChatCompletionsSourceAssistantTurn } from '../protocols/openai-chat-completions/as-source/round-trip.ts'; +import type { OpenAIResponsesSourceAssistantTurn } from '../protocols/openai-responses/as-source/round-trip.ts'; + +/** + * Source sidecars authenticate the source's native assistant structure. + * Target replay uses InputAssistantTurnFor. + */ +interface SourceAssistantTurnByProtocol { + openaiChatCompletions: OpenAIChatCompletionsSourceAssistantTurn; + openaiResponses: OpenAIResponsesSourceAssistantTurn; + anthropicMessages: AnthropicMessagesSourceAssistantTurn; + geminiGenerateContent: GeminiGenerateContentSourceAssistantTurn; +} + +export type SourceAssistantTurnFor = + SourceAssistantTurnByProtocol[Protocol]; diff --git a/packages/translate/src/protocols/openai-responses/common/custom-tool-wrap.ts b/packages/translate/src/shared/custom-tool.ts similarity index 62% rename from packages/translate/src/protocols/openai-responses/common/custom-tool-wrap.ts rename to packages/translate/src/shared/custom-tool.ts index d822649d76..67376fabfc 100644 --- a/packages/translate/src/protocols/openai-responses/common/custom-tool-wrap.ts +++ b/packages/translate/src/shared/custom-tool.ts @@ -1,10 +1,11 @@ +import type { IRInputCustomToolFormat } from '../ir/input/types.ts'; // Translation paths wrap Responses `custom` tools as single-string JSON-schema // function tools. Their grammar definition becomes a syntax-labeled prompt hint; // downstream enforcement remains provider-dependent. // https://github.com/openai/openai-node/blob/7423ac3e9351c46300cd094479cded5551a72eb4/src/resources/responses/responses.ts -export const buildCustomToolInputSchema = (format?: Record): Record & { type: 'object' } => { - const definition = typeof format?.definition === 'string' ? format.definition : undefined; - const sourceSyntax = typeof format?.syntax === 'string' && format.syntax !== '' ? format.syntax : undefined; +export const buildCustomToolInputSchema = (format?: IRInputCustomToolFormat): Record & { type: 'object' } => { + const definition = format?.type === 'grammar' ? format.definition : undefined; + const sourceSyntax = format?.type === 'grammar' && format.syntax !== '' ? format.syntax : undefined; const syntax = sourceSyntax === 'lark' ? 'Lark grammar' : sourceSyntax === 'regex' ? 'Regex' : sourceSyntax === undefined ? 'Grammar' : `Grammar (${sourceSyntax})`; return { type: 'object', @@ -19,8 +20,8 @@ export const buildCustomToolInputSchema = (format?: Record): Re }; }; -// Preserve malformed wrappers as freeform input so the client can decide how -// to handle the tool call without terminating the agent's generation stream. +// Extract custom input from the single-input function wrapper. Preserve malformed wrappers as raw +// freeform input so the client can handle the call without terminating generation. export const unwrapCustomToolInput = (wrappedArguments: string): string => { if (wrappedArguments.length === 0) return ''; let parsed: unknown; @@ -29,7 +30,7 @@ export const unwrapCustomToolInput = (wrappedArguments: string): string => { } catch { return wrappedArguments; } - if (typeof parsed === 'object' && parsed !== null && typeof (parsed as { input?: unknown }).input === 'string') return (parsed as { input: string }).input; + if (typeof parsed === 'object' && parsed !== null && 'input' in parsed && typeof parsed.input === 'string') return parsed.input; console.warn('Custom tool wrapper has no string input; returning the entire JSON value as freeform input.'); return wrappedArguments; }; diff --git a/packages/translate/src/shared/json/completion.ts b/packages/translate/src/shared/json/completion.ts new file mode 100644 index 0000000000..bb15600b62 --- /dev/null +++ b/packages/translate/src/shared/json/completion.ts @@ -0,0 +1,8 @@ +import { parseJSONObject } from './object.ts'; + +export const parseJSONObjectUnlessTruncated = ( + text: string, + completion: 'complete' | 'truncated', + parser?: (text: string) => unknown, +): { readonly completion: 'truncated' } | { readonly completion: 'complete'; readonly value: ReturnType } => + completion === 'truncated' ? { completion } : { completion, value: parseJSONObject(text, parser) }; diff --git a/packages/translate/src/shared/json/object.ts b/packages/translate/src/shared/json/object.ts index 76f05749c7..c70861ee1d 100644 --- a/packages/translate/src/shared/json/object.ts +++ b/packages/translate/src/shared/json/object.ts @@ -2,6 +2,9 @@ import { rawJSON } from './raw-json.ts'; import type { IRJSONObject } from '../../ir/common/json.ts'; import { isJsonObject, parseJSONWithRawNumbers } from '@floway-dev/protocols/common'; +export const isJSONRecord = (value: unknown): value is Readonly> => + typeof value === 'object' && value !== null && !Array.isArray(value); + export const isPlainJSONObject = (value: unknown): value is IRJSONObject => isJsonObject(value) && !rawJSON.isRawJSON(value); export const requireJSONObject = (value: unknown): IRJSONObject => { diff --git a/packages/translate/src/shared/reasoning-budget.ts b/packages/translate/src/shared/reasoning-budget.ts new file mode 100644 index 0000000000..ef82d3cba3 --- /dev/null +++ b/packages/translate/src/shared/reasoning-budget.ts @@ -0,0 +1,14 @@ +/** + * Approximate numeric budgets using community effort-tier defaults as upper bounds. + * The 2048 and 8192 thresholds are our heuristic; zero maps to none. + * Google budget ranges: https://ai.google.dev/gemini-api/docs/thinking + * Community tier defaults: + * https://github.com/krzysztofdudek/AutoReview/blob/9bf2ede3a960f5215645bedba41c591829053f5c/scripts/lib/providers/google.mjs#L4 + * https://github.com/BerriAI/litellm/blob/88e03e548716a45284597edf2b7f47a7e6a66d5f/litellm/constants.py#L184 + */ +export const reasoningEffortForBudget = (budget: number): string => { + if (budget <= 0) return 'none'; + if (budget <= 2048) return 'low'; + if (budget <= 8192) return 'medium'; + return 'high'; +}; diff --git a/packages/translate/src/types.ts b/packages/translate/src/types.ts index 1f565db7c6..22cd767e74 100644 --- a/packages/translate/src/types.ts +++ b/packages/translate/src/types.ts @@ -1,18 +1,12 @@ import type { SidecarCodec } from './round-trip/codec.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; -export interface RemoteImageData { - mediaType: string | null; - data: Uint8Array; -} - -export type RemoteImageLoader = (url: string) => Promise; - -/** Runtime dependencies and target-specific settings for one translation. */ -export type TranslateOptions = { - readonly model: string; +export interface TranslateOptions { readonly sidecarCodec: SidecarCodec; -} & TExtras; + // The upstream's own output limit, when the operator knows it. A target that + // requires a ceiling uses it when the source stated none. + readonly fallbackMaxOutputTokens?: number; +} export type ApiErrorTranslator = (status: number, headers: Headers, body: Uint8Array) => { readonly status: number; @@ -20,11 +14,6 @@ export type ApiErrorTranslator = (status: number, headers: Headers, body: Uint8A readonly body: unknown; } | undefined; -/** - * 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 TranslateTrip { readonly type: 'trip'; readonly target: TargetPayload; @@ -39,7 +28,7 @@ export interface TranslateInputError { readonly code?: string; } -export type Translator = ( +export type Translator = ( payload: SourcePayload, - options: TranslateOptions, + options: TranslateOptions, ) => Promise | TranslateInputError>; From 2cd4023814fbbeb6c40f3ae320ddd27b72f2ac56 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 07:48:21 +0800 Subject: [PATCH 13/66] test: retain replay and error coverage on input IR Preserve sparse namespace provenance coverage with logical IR call kinds. Align restored native call names and Responses error assertions with the final request allocation and prerequisite error contracts. --- .../openai-responses/as-source/events_test.ts | 93 +++++++++++++++++++ ...anthropic-messages-events-protocol_test.ts | 2 +- .../protocols/round-trip-receive_test.ts | 2 +- 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts new file mode 100644 index 0000000000..003e74661c --- /dev/null +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts @@ -0,0 +1,93 @@ +import { expect, test } from 'vitest'; + +import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; +import { writeOpenAIResponsesEvents } from '../../../../src/protocols/openai-responses/as-source/events.ts'; +import { appendReadField, collectEmittedFieldPlacements, createReferenceBuilder, createReadFieldState, createFieldReferencer } from '../../../../src/round-trip/reference/reference-builder.ts'; +import type { RoundTripWriter } from '../../../../src/round-trip/writer.ts'; + +test('collects sparse namespace-restored Responses state from emitted IR atoms', async () => { + const references = createReferenceBuilder(); + const field = (parts: readonly string[]) => { + const state = createReadFieldState('text'); + const textFragmentIds = parts.map(part => appendReadField(references, state, part)); + return { state, textFragmentIds, original: parts.join('') }; + }; + const functionArguments = field(['{"code":', '1}']); + const messageText = field(['A', 'B']); + const refusal = field(['N', 'O']); + const reasoningSummary = field(['S', 'C']); + const reasoningContent = field(['D']); + const customInput = field(['{"q":', '1}']); + let prepared: Parameters['encodeSidecar']> | undefined; + const roundTrip: RoundTripWriter<'openaiResponses'> = { + builder: references, + encodeSidecar: async (...args) => { + prepared = args; + return 'sidecar'; + }, + }; + + const builder = createIRBuilder(); + builder.choice(0); + builder.assign(['model'], 'model'); + builder.event({ type: 'start', id: 'response' }); + + builder.initialize(['choices', 0, 'items', 0], { type: 'custom_tool_call', namespace: 'tools', name: 'script', input: '' }); + builder.append(['choices', 0, 'items', 0, 'input'], '{"code":', [functionArguments.textFragmentIds[0]]); + builder.append(['choices', 0, 'items', 0, 'input'], '1}', [functionArguments.textFragmentIds[1]]); + + builder.initialize(['choices', 0, 'items', 1], { type: 'message', content: [] }); + builder.initialize(['choices', 0, 'items', 1, 'content', 0], { type: 'text', text: '' }); + builder.append(['choices', 0, 'items', 1, 'content', 0, 'text'], 'A', [messageText.textFragmentIds[0]]); + builder.append(['choices', 0, 'items', 1, 'content', 0, 'text'], 'B', [messageText.textFragmentIds[1]]); + builder.initialize(['choices', 0, 'items', 1, 'content', 1], { type: 'refusal', refusal: '' }); + builder.append(['choices', 0, 'items', 1, 'content', 1, 'refusal'], 'N', [refusal.textFragmentIds[0]]); + builder.append(['choices', 0, 'items', 1, 'content', 1, 'refusal'], 'O', [refusal.textFragmentIds[1]]); + + builder.initialize(['choices', 0, 'items', 2], { type: 'reasoning', summary: [''], content: [''] }); + builder.append(['choices', 0, 'items', 2, 'summary', 0], 'S', [reasoningSummary.textFragmentIds[0]]); + builder.append(['choices', 0, 'items', 2, 'summary', 0], 'C', [reasoningSummary.textFragmentIds[1]]); + builder.append(['choices', 0, 'items', 2, 'content', 0], 'D', [reasoningContent.textFragmentIds[0]]); + + builder.initialize(['choices', 0, 'items', 3], { type: 'function_call', namespace: 'tools', call_id: 'lookup', name: 'lookup', arguments: '' }); + builder.append(['choices', 0, 'items', 3, 'arguments'], '{"q":', [customInput.textFragmentIds[0]]); + builder.append(['choices', 0, 'items', 3, 'arguments'], '1}', [customInput.textFragmentIds[1]]); + + builder.assign(['choices', 0, 'items', 0, 'call_id'], 'script'); + builder.event({ type: 'part_end', choice: 0, item: 1, part: 0 }); + builder.event({ type: 'part_end', choice: 0, item: 1, part: 1 }); + builder.event({ type: 'item_end', choice: 0, item: 0 }); + builder.event({ type: 'item_end', choice: 0, item: 1 }); + builder.event({ type: 'item_end', choice: 0, item: 2 }); + builder.event({ type: 'item_end', choice: 0, item: 3 }); + + builder.initialize(['choices', 0, 'items', 4], { type: 'message', content: [] }); + builder.initialize(['choices', 0, 'items', 4, 'content', 0], { type: 'image', image: { data: 'literal', media_type: 'image/png' } }); + builder.event({ type: 'part_end', choice: 0, item: 4, part: 0 }); + builder.event({ type: 'item_end', choice: 0, item: 4 }); + + builder.event({ type: 'choice_end', choice: 0, finish_reason: 'tool_calls' }); + builder.event({ type: 'finish', status: 'completed' }); + + for await (const _frame of writeOpenAIResponsesEvents((async function* () { yield* builder.drain(); })(), { roundTrip })) {} + if (prepared === undefined) throw new Error('Responses sidecar was not prepared'); + const [, , turn, state] = prepared; + + expect(turn).toMatchObject([ + { type: 'custom_tool_call', namespace: 'tools', name: 'script', input: '{"code":1}' }, + { type: 'message', content: [{ type: 'output_text', text: 'AB' }, { type: 'refusal', refusal: 'NO' }] }, + { type: 'reasoning', summary: [{ text: 'SC' }], content: [{ text: 'D' }] }, + { type: 'function_call', namespace: 'tools', name: 'lookup', arguments: '{"q":1}' }, + { type: 'image_generation_call', result: 'literal' }, + ]); + expect(state[0]).toMatchObject({ input: {} }); + expect(state[1]).toMatchObject({ content: [{ text: {} }, { refusal: {} }] }); + expect(state[2]).toMatchObject({ summary: [{ text: {} }], content: [{ text: {} }] }); + expect(state[3]).toMatchObject({ arguments: {} }); + expect(4 in state).toBe(false); + + const finalizer = createFieldReferencer(references, collectEmittedFieldPlacements(turn, state)); + for (const source of [functionArguments, messageText, refusal, reasoningSummary, reasoningContent, customInput]) { + expect(await finalizer.referenceField(source.original, source.state)).not.toBe(source.original); + } +}); diff --git a/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-events-protocol_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-events-protocol_test.ts index 7242e3a216..1050404e2b 100644 --- a/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-events-protocol_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-events-protocol_test.ts @@ -64,7 +64,7 @@ test('translateToSourceEvents translates Anthropic Messages error terminal and s eventFrame({ type: 'error', message: 'upstream overloaded', - code: 'overloaded_error', + provider_specific_fields: { type: 'overloaded_error' }, sequence_number: 0, }), ); diff --git a/packages/translate/__tests__/protocols/round-trip-receive_test.ts b/packages/translate/__tests__/protocols/round-trip-receive_test.ts index 08a79979f7..1153ea3b68 100644 --- a/packages/translate/__tests__/protocols/round-trip-receive_test.ts +++ b/packages/translate/__tests__/protocols/round-trip-receive_test.ts @@ -90,7 +90,7 @@ test('hydrates a native JSON field from a replayed GenerateContent argument cand sourceTurnDigest: await hashRoundTripContent(JSON.stringify(checkMaterial)), }); const trip = expectTrip(await routeGeminiGenerateContentViaAnthropicMessages({ model: 'model', 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 }] }]); + expect(trip.target.messages).toEqual([{ role: 'assistant', content: [{ type: 'tool_use', id: 'native-call', name: 'lookup', input: args }] }]); }); test('exposes an authenticated missing reference as an internal error after replay verification', async () => { From 2e77d7c812747bfd1238f2c8f3b42517afa4b095 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 07:55:17 +0800 Subject: [PATCH 14/66] refactor(translate): complete output IR cleanup Keep output validation diagnostics, type refinements and reviewed comments with their output owner. Preserve configured choice limits and gap filling while narrowing relative paths to strings and explicit path arrays. Give completion-aware JSON parsing its own module and mirrored tests. Reuse the existing structural record guard from the JSON owner in native writers. Retain the reviewed sentinel and write-validation rationale. Validate 625 tests in 43 named files, 68 controlled main/final differential probes, 89 runtime/declaration/test correspondences, 101 scoped lint targets and the translate TypeScript project. Track diagnostic terminology changes explicitly while comparing complete wire, digest and native replay values. --- .../__tests__/ir/output/collector_test.ts | 2 +- .../translate/__tests__/ir/output/helpers.ts | 2 +- .../__tests__/ir/output/part-id_test.ts | 2 +- .../__tests__/ir/output/schema_test.ts | 2 +- .../__tests__/shared/json/completion_test.ts | 17 ++++++++++ .../__tests__/shared/json/object_test.ts | 18 ++--------- packages/translate/src/ir/output/absent.ts | 3 ++ packages/translate/src/ir/output/builder.ts | 7 ++-- packages/translate/src/ir/output/collector.ts | 32 +++++++++---------- packages/translate/src/ir/output/lifecycle.ts | 13 ++++---- .../src/ir/output/operation-paths.ts | 6 ++-- packages/translate/src/ir/output/schema.ts | 10 +++--- packages/translate/src/ir/output/stream.ts | 8 ++--- .../translate/src/ir/output/unit-index.ts | 4 +-- .../translate/src/ir/output/unit-registry.ts | 2 +- .../src/ir/output/value-validation.ts | 28 ++++++++-------- .../anthropic-messages/as-source/events.ts | 12 +++---- .../as-source/events.ts | 5 +-- .../as-source/events.ts | 12 +++---- .../openai-responses/as-source/events.ts | 14 ++++---- .../translate/src/shared/json/completion.ts | 8 +++++ packages/translate/src/shared/json/object.ts | 11 +++---- .../src/shared/text-coordinate-index.ts | 4 +-- 23 files changed, 116 insertions(+), 106 deletions(-) create mode 100644 packages/translate/__tests__/shared/json/completion_test.ts create mode 100644 packages/translate/src/shared/json/completion.ts diff --git a/packages/translate/__tests__/ir/output/collector_test.ts b/packages/translate/__tests__/ir/output/collector_test.ts index 0ad6b217e4..82b0a10cea 100644 --- a/packages/translate/__tests__/ir/output/collector_test.ts +++ b/packages/translate/__tests__/ir/output/collector_test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from 'vitest'; import { IRAbsent } from '../../../src/ir/output/absent.ts'; import { createIRCollector } from '../../../src/ir/output/collector.ts'; -describe('IR accumulator', () => { +describe('output IR accumulator', () => { test('stores plain accumulated state without publication metadata', () => { const collector = createIRCollector(); collector.applyWrite({ type: 'write', kind: 'initialize', path: ['usage'], value: {} }); diff --git a/packages/translate/__tests__/ir/output/helpers.ts b/packages/translate/__tests__/ir/output/helpers.ts index 80bfc12a59..1b598ae405 100644 --- a/packages/translate/__tests__/ir/output/helpers.ts +++ b/packages/translate/__tests__/ir/output/helpers.ts @@ -55,7 +55,7 @@ export const collectIR = async (records: AsyncIterable): Promise { ]); }); -test('retains every original ID when a native buffer is merged into one IR append', () => { +test('retains every original ID when a native buffer is merged into one output IR append', () => { const builder = createIRBuilder(); const part = builder.item(0, { type: 'message', content: [] }).part({ type: 'text', text: '' }); builder.drain(); diff --git a/packages/translate/__tests__/ir/output/schema_test.ts b/packages/translate/__tests__/ir/output/schema_test.ts index 901e737119..2b32283b04 100644 --- a/packages/translate/__tests__/ir/output/schema_test.ts +++ b/packages/translate/__tests__/ir/output/schema_test.ts @@ -14,7 +14,7 @@ const root = { }; const item = (index: number, ...rest: (string | number)[]) => ['choices', 0, 'items', index, ...rest]; -describe('IR schema queries', () => { +describe('output IR schema queries', () => { test.each([ [item(0, 'content', 0, 'text'), true], [item(0, 'content', 1, 'audio', 'data'), true], diff --git a/packages/translate/__tests__/shared/json/completion_test.ts b/packages/translate/__tests__/shared/json/completion_test.ts new file mode 100644 index 0000000000..572ee41fb1 --- /dev/null +++ b/packages/translate/__tests__/shared/json/completion_test.ts @@ -0,0 +1,17 @@ +import { expect, test, vi } from 'vitest'; + +import { parseJSONObjectUnlessTruncated } from '../../../src/shared/json/completion.ts'; + +test('truncated JSON completion does not invoke the parser', () => { + const parser = vi.fn(() => ({ repaired: true })); + expect(parseJSONObjectUnlessTruncated('{"value":', 'truncated', parser)).toEqual({ completion: 'truncated' }); + expect(parser).not.toHaveBeenCalled(); +}); + +test('complete JSON completion uses repair and still requires an object', () => { + expect(parseJSONObjectUnlessTruncated('{"value":', 'complete', text => JSON.parse(`${text}1}`))).toEqual({ + completion: 'complete', + value: { value: 1 }, + }); + expect(() => parseJSONObjectUnlessTruncated('[]', 'complete')).toThrow('Tool arguments require a JSON object'); +}); diff --git a/packages/translate/__tests__/shared/json/object_test.ts b/packages/translate/__tests__/shared/json/object_test.ts index 1da5567f41..8d9ece2940 100644 --- a/packages/translate/__tests__/shared/json/object_test.ts +++ b/packages/translate/__tests__/shared/json/object_test.ts @@ -1,23 +1,9 @@ -import { expect, test, vi } from 'vitest'; +import { expect, test } from 'vitest'; -import { parseJSONObject, parseJSONObjectAtCompletion } from '../../../src/shared/json/object.ts'; +import { parseJSONObject } from '../../../src/shared/json/object.ts'; test('default object parsing preserves number tokens that would serialize differently', () => { const source = '{"rounded":0.10000000000000001,"negativeZero":-0,"canonical":1.25}'; expect(JSON.stringify(parseJSONObject(source))).toBe(source); }); - -test('truncated JSON completion does not invoke the parser', () => { - const parser = vi.fn(() => ({ repaired: true })); - expect(parseJSONObjectAtCompletion('{"value":', 'truncated', parser)).toEqual({ completion: 'truncated' }); - expect(parser).not.toHaveBeenCalled(); -}); - -test('complete JSON completion uses repair and still requires an object', () => { - expect(parseJSONObjectAtCompletion('{"value":', 'complete', text => JSON.parse(`${text}1}`))).toEqual({ - completion: 'complete', - value: { value: 1 }, - }); - expect(() => parseJSONObjectAtCompletion('[]', 'complete')).toThrow('Tool arguments require a JSON object'); -}); diff --git a/packages/translate/src/ir/output/absent.ts b/packages/translate/src/ir/output/absent.ts index 0db8f19542..77c37e4609 100644 --- a/packages/translate/src/ir/output/absent.ts +++ b/packages/translate/src/ir/output/absent.ts @@ -1,3 +1,6 @@ +// A field whose absence is itself a fact: an optional value is now known never +// to arrive, as opposed to not having arrived yet. Only the output stream needs +// this, because a request arrives complete. export const IRAbsent: unique symbol = Symbol('IRAbsent'); export type IRAbsent = typeof IRAbsent; diff --git a/packages/translate/src/ir/output/builder.ts b/packages/translate/src/ir/output/builder.ts index f0c8653261..3264ff985d 100644 --- a/packages/translate/src/ir/output/builder.ts +++ b/packages/translate/src/ir/output/builder.ts @@ -3,7 +3,7 @@ import { irItemPath, irPartPath, irPathKey, irUnitPath } from './operation-paths import type { IREvent, IRPath, IRRecord, IRWrite } from './records.ts'; import type { IROutputUsage, IROutputValue } from './types.ts'; -export type IRRelativePath = string | number | IRPath; +export type IRRelativePath = string | IRPath; export interface IRWriteHandle { readonly path: IRPath; @@ -49,6 +49,9 @@ export const createIRBuilder = (maxChoices = 1) => { for (const [key, child] of Object.entries(value)) remember([...path, Array.isArray(value) ? Number(key) : key], child); }; const write = (value: IRWrite): void => { if (collector.applyWrite(value)) records.push(value); }; + // A handle accepts any caller value; the collector is what decides whether it + // is a legal output IR value, so the write record states what was handed over + // and validation runs on the way in. const assign = (path: IRPath, value: unknown): void => { write({ type: 'write', kind: 'assign', path, value: value as IROutputValue }); remember(path, value); @@ -66,7 +69,7 @@ export const createIRBuilder = (maxChoices = 1) => { }); const event = (value: IREvent): void => { collector.applyEvent(value); records.push(value); }; const drain = (): readonly IRRecord[] => { const drained = records; records = []; return drained; }; - const relativePath = (base: IRPath, path: IRRelativePath): IRPath => [...base, ...Array.isArray(path) ? path : [path]]; + const relativePath = (base: IRPath, path: IRRelativePath): IRPath => [...base, ...typeof path === 'string' ? [path] : path]; const writeHandle = (path: IRPath, index: number): IRWriteHandle => ({ path, index, diff --git a/packages/translate/src/ir/output/collector.ts b/packages/translate/src/ir/output/collector.ts index 94944ee6c5..7f7c594879 100644 --- a/packages/translate/src/ir/output/collector.ts +++ b/packages/translate/src/ir/output/collector.ts @@ -2,7 +2,7 @@ import { IRAbsent } from './absent.ts'; import { createLifecycle } from './lifecycle.ts'; import { irEndedUnit, irPathKey, resolveIRPath, valueAt } from './operation-paths.ts'; import type { IREvent, IRWrite, IRPath } from './records.ts'; -import type { IROutput } from './types.ts'; +import { type IROutput } from './types.ts'; import { assertComplete, walkValue } from './value-validation.ts'; export const createIRCollector = () => { @@ -28,43 +28,43 @@ export const createIRCollector = () => { // Returns whether the write changed the state. const applyWrite = (write: IRWrite): boolean => { - if (write.value === undefined) throw new TypeError('IR writes cannot carry undefined'); + if (write.value === undefined) throw new TypeError('output IR writes cannot carry undefined'); const { path } = write; const resolution = resolveIRPath(path, state); if (resolution !== undefined) lifecycle.assertWritable(resolution.units, path); const key = path.at(-1); - if (key === undefined) throw new TypeError('IR writes require a property path'); + if (key === undefined) throw new TypeError('output IR writes require a property path'); const parentPath = path.slice(0, -1); const parent = valueAt(state, parentPath); - if (parent === undefined) throw new TypeError(`Missing IR path: ${irPathKey(parentPath)}`); - if (typeof parent !== 'object' || parent === null) throw new TypeError('IR write parent must be an object or array'); + if (parent === undefined) throw new TypeError(`Missing output IR path: ${irPathKey(parentPath)}`); + if (typeof parent !== 'object' || parent === null) throw new TypeError('output IR write parent must be an object or array'); const present = Object.hasOwn(parent, key); - if (write.kind === 'initialize' && present) throw new TypeError('IR initialize requires an absent target'); + if (write.kind === 'initialize' && present) throw new TypeError('output IR initialize requires an absent target'); if (Array.isArray(parent)) { - if (typeof key !== 'number' || !Number.isInteger(key) || key < 0 || key > parent.length) throw new TypeError('IR array write requires a contiguous nonnegative index'); - if (!present && key !== parent.length) throw new TypeError('IR array insertion must use the next index'); - if (write.kind === 'initialize' && key !== parent.length) throw new TypeError('IR array initialization must use the next index'); + if (typeof key !== 'number' || !Number.isInteger(key) || key < 0 || key > parent.length) throw new TypeError('output IR array write requires a contiguous nonnegative index'); + if (!present && key !== parent.length) throw new TypeError('output IR array insertion must use the next index'); + if (write.kind === 'initialize' && key !== parent.length) throw new TypeError('output IR array initialization must use the next index'); } const current = (parent as Record)[key]; const node = resolution?.node; if (write.kind === 'append') { - if (node?.kind !== 'string' || node.append !== true) throw new TypeError(`IR append requires an append-target path: ${irPathKey(path)}`); - if (typeof current !== 'string') throw new TypeError('IR append requires a string target'); - if (typeof write.value !== 'string') throw new TypeError('IR append requires a string value'); + if (node?.kind !== 'string' || node.append !== true) throw new TypeError(`output IR append requires an append-target path: ${irPathKey(path)}`); + if (typeof current !== 'string') throw new TypeError('output IR append requires a string target'); + if (typeof write.value !== 'string') throw new TypeError('output IR append requires a string value'); if (write.value === '') return false; } else { - if (node === undefined) throw new TypeError(`IR path is not part of the IR schema: ${irPathKey(path)}`); + if (node === undefined) throw new TypeError(`output IR path is not part of the output IR schema: ${irPathKey(path)}`); if (write.kind === 'initialize') { walkValue(path, node, write.value, (childPath, childNode, child) => { - if (childNode.kind === 'string' && childNode.append === true && child !== '') throw new TypeError(`IR append target must initialize to an empty string: ${irPathKey(childPath)}`); + if (childNode.kind === 'string' && childNode.append === true && child !== '') throw new TypeError(`output IR append target must initialize to an empty string: ${irPathKey(childPath)}`); }, { allowAbsent: true }); } else if (write.value === IRAbsent) { if (node.kind !== 'string' || node.absent !== true) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); if (present) throw new TypeError('IRAbsent may only be assigned to an absent target'); } else { walkValue(path, node, write.value, (childPath, childNode) => { - if (childNode.kind === 'string' && childNode.append === true) throw new TypeError(`IR append target cannot be assigned: ${irPathKey(childPath)}`); + if (childNode.kind === 'string' && childNode.append === true) throw new TypeError(`output IR append target cannot be assigned: ${irPathKey(childPath)}`); }, { allowAbsent: false }); if (present && Object.is(current, write.value)) return false; } @@ -84,7 +84,7 @@ export const createIRCollector = () => { const ended = irEndedUnit(event); if (ended !== undefined) { const resolution = resolveIRPath(ended.path, state); - if (resolution === undefined || valueAt(state, ended.path) === undefined) throw new TypeError(`IR ${ended.kind} does not exist: ${irPathKey(ended.path)}`); + if (resolution === undefined || valueAt(state, ended.path) === undefined) throw new TypeError(`output IR ${ended.kind} does not exist: ${irPathKey(ended.path)}`); lifecycle.closeUnit(resolution.units); assertComplete(ended.path, resolution.node, valueAt(state, ended.path)); } else if (event.type === 'finish') lifecycle.assertFinishable(); diff --git a/packages/translate/src/ir/output/lifecycle.ts b/packages/translate/src/ir/output/lifecycle.ts index 25754c73b4..194fecbf72 100644 --- a/packages/translate/src/ir/output/lifecycle.ts +++ b/packages/translate/src/ir/output/lifecycle.ts @@ -1,4 +1,5 @@ -import { irPathKey, type IRUnit } from './operation-paths.ts'; +import { irPathKey } from './operation-paths.ts'; +import type { IRUnit } from './operation-paths.ts'; import type { IRPath } from './records.ts'; const createCounter = () => { @@ -8,7 +9,7 @@ const createCounter = () => { increment: (key: K): void => { counts.set(key, (counts.get(key) ?? 0) + 1); }, decrement: (key: K): void => { const count = counts.get(key); - if (count === undefined) throw new TypeError(`IR lifecycle counter underflow: ${String(key)}`); + if (count === undefined) throw new TypeError(`output IR lifecycle counter underflow: ${String(key)}`); if (count === 1) counts.delete(key); else counts.set(key, count - 1); }, @@ -22,7 +23,7 @@ export const createLifecycle = () => { const openChildren = createCounter(); const assertWritable = (units: readonly IRUnit[], path: IRPath): void => { - for (const unit of units) if (closed.has(irPathKey(unit.path))) throw new TypeError(`IR write targets a closed ${unit.kind}: ${irPathKey(path)}`); + for (const unit of units) if (closed.has(irPathKey(unit.path))) throw new TypeError(`output IR write targets a closed ${unit.kind}: ${irPathKey(path)}`); }; // `units` ends with the unit that was initialized. @@ -34,14 +35,14 @@ export const createLifecycle = () => { const closeUnit = (units: readonly IRUnit[]): void => { const unit = units[units.length - 1]; const key = irPathKey(unit.path); - if (!open.delete(key)) throw new TypeError(`IR ${unit.kind} is not open: ${key}`); - if (openChildren.get(key) !== 0) throw new TypeError(`IR ${unit.kind} ended with an open unit inside it: ${key}`); + if (!open.delete(key)) throw new TypeError(`output IR ${unit.kind} is not open: ${key}`); + if (openChildren.get(key) !== 0) throw new TypeError(`output IR ${unit.kind} ended with an open unit inside it: ${key}`); closed.add(key); if (units.length > 1) openChildren.decrement(irPathKey(units[units.length - 2].path)); }; const assertFinishable = (): void => { - if (open.size !== 0) throw new TypeError('IR finish arrived with open units'); + if (open.size !== 0) throw new TypeError('output IR finish arrived with open units'); }; return { assertWritable, openUnit, closeUnit, assertFinishable, isOpen: (path: IRPath): boolean => open.has(irPathKey(path)), isClosed: (path: IRPath): boolean => closed.has(irPathKey(path)) }; diff --git a/packages/translate/src/ir/output/operation-paths.ts b/packages/translate/src/ir/output/operation-paths.ts index 07be9869b2..c7460b8f3d 100644 --- a/packages/translate/src/ir/output/operation-paths.ts +++ b/packages/translate/src/ir/output/operation-paths.ts @@ -3,7 +3,7 @@ import { irSchema, type IRObjectNode, type IRSchemaNode, type IRUnionNode, type // Everything here is derived from the schema in schema.ts. A path is resolved against the // schema, and the answers come from the traits of the node it lands on. Where the node -// depends on which variant of a union the IR holds there, the variant is read from `root` +// depends on which variant of a union the output IR holds there, the variant is read from `root` // (the accumulated IR), so `root` must already contain the structure the path goes through. export const irPathKey = (path: IRPath): string => JSON.stringify(path); @@ -120,12 +120,12 @@ export const irUnitPath = (kind: IRUnitKind, ...indices: readonly number[]): IRP let template = unitTemplates.get(kind); if (template === undefined) { template = findUnitTemplate(irSchema, kind, []); - if (template === undefined) throw new TypeError(`The IR schema has no ${kind} unit`); + if (template === undefined) throw new TypeError(`The output IR schema has no ${kind} unit`); unitTemplates.set(kind, template); } let next = 0; const path = template.map(segment => segment === WILDCARD ? indices[next++] : segment); - if (next !== indices.length) throw new TypeError(`IR ${kind} paths take ${next} indices, got ${indices.length}`); + if (next !== indices.length) throw new TypeError(`output IR ${kind} paths take ${next} indices, got ${indices.length}`); return path; }; diff --git a/packages/translate/src/ir/output/schema.ts b/packages/translate/src/ir/output/schema.ts index 034e3fd3ea..b5377c5294 100644 --- a/packages/translate/src/ir/output/schema.ts +++ b/packages/translate/src/ir/output/schema.ts @@ -1,10 +1,10 @@ import type { IRAbsent } from './absent.ts'; import type { IROutput, IROutputSourceCitation, IROutputTokenLogprob } from './types.ts'; -// The structure of the IR, declared once. Every question about a path (is it an append +// The structure of the IROutput, declared once. Every question about a path (is it an append // target, may it hold `IRAbsent`, which lifecycle unit does it belong to) is answered by // walking this schema, never by matching path shapes. The value type inferred from the -// schema is checked against the IR types at the bottom of this file. +// schema is checked against the output IR types at the bottom of this file. // Items, content parts and choices have their own start and end in a stream. export type IRUnitKind = 'choice' | 'item' | 'part'; @@ -34,7 +34,7 @@ export interface IRUnionNode { readonly kind: 'opaque'; readonly phantom?: V } export type IRSchemaNode = IRStringNode | IRNumberNode | IRNullableNode | IRArrayNode | IRObjectNode | IRUnionNode | IROpaqueNode; export interface IRSchemaFields { readonly [key: string]: IRSchemaNode } @@ -118,7 +118,7 @@ type ValueOf = type Mutual = [A] extends [B] ? ([B] extends [A] ? true : false) : false; -// Compile-time proof that the schema and the IR types describe the same values: a field the -// schema declares optional, nullable, appendable or `IRAbsent`-capable differs from the IR type +// Compile-time proof that the schema and the output IR types describe the same values: a field the +// schema declares optional, nullable, appendable or `IRAbsent`-capable differs from the output IR type // exactly when this stops being `true`. export const irSchemaTypeEquality: Mutual, IROutput> = true; diff --git a/packages/translate/src/ir/output/stream.ts b/packages/translate/src/ir/output/stream.ts index e5dce93ac3..5110a47a42 100644 --- a/packages/translate/src/ir/output/stream.ts +++ b/packages/translate/src/ir/output/stream.ts @@ -9,15 +9,15 @@ export const consumeIRRecords = async function* (records: AsyncIterable { let next = 0; const open = (path: IRPath): number => { const key = irPathKey(path); - if (indices.has(key)) throw new TypeError(`IR unit is already open: ${key}`); + if (indices.has(key)) throw new TypeError(`output IR unit is already open: ${key}`); const index = next++; indices.set(key, index); return index; @@ -16,7 +16,7 @@ export const createIRUnitIndexAllocator = () => { const get = (path: IRPath): number => { const key = irPathKey(path); const index = indices.get(key); - if (index === undefined) throw new TypeError(`Unknown open IR unit: ${key}`); + if (index === undefined) throw new TypeError(`Unknown open output IR unit: ${key}`); return index; }; const close = (path: IRPath): void => { diff --git a/packages/translate/src/ir/output/unit-registry.ts b/packages/translate/src/ir/output/unit-registry.ts index 228e553441..32629eaea4 100644 --- a/packages/translate/src/ir/output/unit-registry.ts +++ b/packages/translate/src/ir/output/unit-registry.ts @@ -2,7 +2,7 @@ import { irPathKey, irRecordOwnerPaths } from './operation-paths.ts'; import type { IRPath, IRRecord } from './records.ts'; // Finds the unit that a record belongs to. A writer registers each unit at the path -// where its IR structure was initialized. +// where its output IR structure was initialized. export const createIRUnitRegistry = () => { const units = new Map(); return { diff --git a/packages/translate/src/ir/output/value-validation.ts b/packages/translate/src/ir/output/value-validation.ts index 5cb73c903f..75549562c4 100644 --- a/packages/translate/src/ir/output/value-validation.ts +++ b/packages/translate/src/ir/output/value-validation.ts @@ -2,15 +2,15 @@ import { IRAbsent } from './absent.ts'; import { irPathKey, unionVariantNode } from './operation-paths.ts'; import type { IRPath } from './records.ts'; import type { IRSchemaNode } from './schema.ts'; -import type { IROutputValue } from './types.ts'; +import { type IROutputValue } from './types.ts'; -type IROutputValueVisitor = (path: IRPath, node: IRSchemaNode, value: IROutputValue) => void; +type IRValueVisitor = (path: IRPath, node: IRSchemaNode, value: IROutputValue) => void; const isPlainObject = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); -// Opaque values are replaced as a whole, so only what no IR value may contain is rejected. +// Opaque values are replaced as a whole, so only what no output IR value may contain is rejected. const assertPlainValue = (path: IRPath, value: unknown): void => { - if (value === undefined) throw new TypeError(`IR values cannot contain undefined: ${irPathKey(path)}`); + if (value === undefined) throw new TypeError(`output IR values cannot contain undefined: ${irPathKey(path)}`); if (value === IRAbsent) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); if (typeof value !== 'object' || value === null) return; for (const [key, child] of Object.entries(value)) assertPlainValue([...path, Array.isArray(value) ? Number(key) : key], child); @@ -18,8 +18,8 @@ const assertPlainValue = (path: IRPath, value: unknown): void => { // Walks a value that is about to be written together with the schema node describing it. // `IRAbsent` is accepted where the schema allows it unless `allowAbsent` is off. -export const walkValue = (path: IRPath, node: IRSchemaNode, value: IROutputValue, visitor: IROutputValueVisitor, { allowAbsent }: { allowAbsent: boolean }): void => { - if (value === undefined) throw new TypeError(`IR values cannot contain undefined: ${irPathKey(path)}`); +export const walkValue = (path: IRPath, node: IRSchemaNode, value: IROutputValue, visitor: IRValueVisitor, { allowAbsent }: { readonly allowAbsent: boolean }): void => { + if (value === undefined) throw new TypeError(`output IR values cannot contain undefined: ${irPathKey(path)}`); if (node.kind === 'nullable') { if (value === null) return visitor(path, node, value); node = node.of; @@ -30,23 +30,23 @@ export const walkValue = (path: IRPath, node: IRSchemaNode, value: IROutputValue if (value === IRAbsent) { if (!allowAbsent) throw new TypeError('IRAbsent must be assigned directly to call_id'); if (node.absent !== true) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); - } else if (typeof value !== 'string') throw new TypeError(`IR value must be a string: ${irPathKey(path)}`); + } else if (typeof value !== 'string') throw new TypeError(`output IR value must be a string: ${irPathKey(path)}`); return; case 'number': - if (typeof value !== 'number') throw new TypeError(`IR value must be a number: ${irPathKey(path)}`); + if (typeof value !== 'number') throw new TypeError(`output IR value must be a number: ${irPathKey(path)}`); return; case 'array': - if (!Array.isArray(value)) throw new TypeError(`IR value must be an array: ${irPathKey(path)}`); + if (!Array.isArray(value)) throw new TypeError(`output IR value must be an array: ${irPathKey(path)}`); value.forEach((child, index) => walkValue([...path, index], node.of, child, visitor, { allowAbsent })); return; case 'object': case 'union': { - if (!isPlainObject(value)) throw new TypeError(`IR value must be an object: ${irPathKey(path)}`); + if (!isPlainObject(value)) throw new TypeError(`output IR value must be an object: ${irPathKey(path)}`); const variant = unionVariantNode(node, value); - if (variant?.kind !== 'object') throw new TypeError(`IR value has an unknown variant: ${irPathKey(path)}`); + if (variant?.kind !== 'object') throw new TypeError(`output IR value has an unknown variant: ${irPathKey(path)}`); for (const [key, child] of Object.entries(value)) { const field = node.kind === 'union' && key === node.discriminant ? { kind: 'string' } as const : Object.hasOwn(variant.fields, key) ? variant.fields[key] : undefined; - if (field === undefined) throw new TypeError(`IR value has no field ${key}: ${irPathKey(path)}`); + if (field === undefined) throw new TypeError(`output IR value has no field ${key}: ${irPathKey(path)}`); walkValue([...path, key], field, child, visitor, { allowAbsent }); } return; @@ -66,11 +66,11 @@ export const assertComplete = (path: IRPath, node: IRSchemaNode, value: unknown) else if (actual.kind === 'object') { const present = value as Record; if (actual.requiresAnyOf !== undefined && !actual.requiresAnyOf.some(key => present[key] !== undefined)) { - throw new TypeError(`IR value requires one of ${actual.requiresAnyOf.join(', ')}: ${irPathKey(path)}`); + throw new TypeError(`output IR value requires one of ${actual.requiresAnyOf.join(', ')}: ${irPathKey(path)}`); } for (const [key, field] of Object.entries(actual.fields)) { if (!Object.hasOwn(present, key)) { - if (!actual.optional.includes(key)) throw new TypeError(`IR value requires field ${key}: ${irPathKey(path)}`); + if (!actual.optional.includes(key)) throw new TypeError(`output IR value requires field ${key}: ${irPathKey(path)}`); continue; } assertComplete([...path, key], field, present[key]); diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/events.ts b/packages/translate/src/protocols/anthropic-messages/as-source/events.ts index 7ed73dcdc3..ad3a41b51e 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/events.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/events.ts @@ -15,7 +15,8 @@ import { appendContent, createContentState, type ContentState, type ContentState import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; -import { parseJSONObject, parseJSONObjectAtCompletion, requireJSONObject } from '../../../shared/json/object.ts'; +import { parseJSONObjectUnlessTruncated } from '../../../shared/json/completion.ts'; +import { parseJSONObject, requireJSONObject, isJSONRecord } from '../../../shared/json/object.ts'; import type { AnthropicMessagesAssistantState, AnthropicMessagesInputAssistantTurn } from '../as-target/assistant-turn.ts'; import { generateAnthropicId, @@ -53,11 +54,8 @@ interface MessagesUnit { pendingText?: Map; } -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - const unitKind = (value: unknown): MessagesUnit['kind'] | 'custom_tool_call' | undefined => { - if (!isRecord(value)) return undefined; + if (!isJSONRecord(value)) return undefined; return value.type === 'text' || value.type === 'refusal' || value.type === 'audio' @@ -257,7 +255,7 @@ export const writeAnthropicMessagesEvents = async function* ( : record.finish_reason === 'tool_calls' ? 'tool_use' : record.finish_reason === 'content_filter' ? 'refusal' : 'end_turn'; for (const item of state.choices[0].items) if (item.type === 'function_call') { - parseJSONObjectAtCompletion(item.arguments, record.finish_reason === 'length' ? 'truncated' : 'complete', options.parseToolArguments); + parseJSONObjectUnlessTruncated(item.arguments, record.finish_reason === 'length' ? 'truncated' : 'complete', options.parseToolArguments); } continue; } @@ -316,7 +314,7 @@ export const writeAnthropicMessagesEvents = async function* ( serializedInput = serialized; if (serialized !== '') yield blockDelta(index, { type: 'input_json_delta', partial_json: serialized }); } else { - const parsed = parseJSONObjectAtCompletion(item.arguments, record.type === 'item_end' && record.status === 'incomplete' ? 'truncated' : 'complete'); + const parsed = parseJSONObjectUnlessTruncated(item.arguments, record.type === 'item_end' && record.status === 'incomplete' ? 'truncated' : 'complete'); if (parsed.completion === 'complete') serializedInput = JSON.stringify(parsed.value); } } diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts index d72c8ed9c7..ef1c3a912a 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts @@ -14,7 +14,8 @@ import { appendContent, createContentState, type ContentStates, type ContentStat import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; -import { isCompleteJSONObject, parseJSONObjectAtCompletion } from '../../../shared/json/object.ts'; +import { parseJSONObjectUnlessTruncated } from '../../../shared/json/completion.ts'; +import { isCompleteJSONObject } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import type { GeminiGenerateContentAssistantState, GeminiGenerateContentInputAssistantTurn } from '../as-target/assistant-turn.ts'; import { eventFrame, FAST_SERVICE_TIER, type EventFrame } from '@floway-dev/protocols/common'; @@ -271,7 +272,7 @@ export const writeGeminiGenerateContentEvents = async function* ( if (completion === 'truncated') part = undefined; else if (!isCompleteJSONObject(item.arguments) && choiceEnding === undefined && record.type !== 'finish') wait = true; else { - const parsed = parseJSONObjectAtCompletion(item.arguments, completion, options.parseToolArguments); + const parsed = parseJSONObjectUnlessTruncated(item.arguments, completion, options.parseToolArguments); if (parsed.completion === 'complete') { const partIndex = occupyPart(choice, 'other'); part = { diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts index 2941fd9b60..a18f4bdd4d 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts @@ -10,6 +10,7 @@ import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; import { appendContent, createContentState, type ContentStates, type ContentState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; +import { isJSONRecord } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIChatCompletionsInputAssistantTurn, type OpenAIChatCompletionsOutputAssistantState } from '../as-target/assistant-turn.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; @@ -40,11 +41,8 @@ const tierFields = (tier?: string): { service_tier?: string } => tier === undefi type IRToolCallItem = Extract; -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - const isIRItem = (value: unknown): value is IROutputItem => { - if (!isRecord(value)) return false; + if (!isJSONRecord(value)) return false; switch (value.type) { case 'message': return Array.isArray(value.content); case 'reasoning': return true; @@ -55,7 +53,7 @@ const isIRItem = (value: unknown): value is IROutputItem => { }; const partType = (value: unknown): IROutputMessageItem['content'][number]['type'] | undefined => { - if (!isRecord(value)) return undefined; + if (!isJSONRecord(value)) return undefined; return value.type === 'text' || value.type === 'refusal' || value.type === 'image' || value.type === 'audio' ? value.type : undefined; }; @@ -66,7 +64,7 @@ type LogprobMetadata = { readonly scope: 'choice' } | { }; const logprobMetadata = (value: unknown): LogprobMetadata | undefined => { - if (!isRecord(value)) return undefined; + if (!isJSONRecord(value)) return undefined; if (value.scope === 'choice') return { scope: 'choice' }; if (value.scope === 'text_part' && typeof value.item_index === 'number' && typeof value.content_index === 'number') { return { scope: 'text_part', item_index: value.item_index, content_index: value.content_index }; @@ -75,7 +73,7 @@ const logprobMetadata = (value: unknown): LogprobMetadata | undefined => { }; const isIRTokenLogprob = (value: unknown): value is IROutputTokenLogprob => - isRecord(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; + isJSONRecord(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; // Emitted deltas carry the assistant extension fields (reasoning, annotations) beside the standard ones. type ChatChoiceChunk = Omit & { delta: OpenAIChatCompletionsAssistantDeltaEx }; diff --git a/packages/translate/src/protocols/openai-responses/as-source/events.ts b/packages/translate/src/protocols/openai-responses/as-source/events.ts index 64fc6b19df..a9910775f3 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/events.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/events.ts @@ -14,7 +14,8 @@ import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; import { appendContent, createContentState, type ContentState, type ContentStates } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; -import { isCompleteJSONObject, parseJSONObjectAtCompletion } from '../../../shared/json/object.ts'; +import { parseJSONObjectUnlessTruncated } from '../../../shared/json/completion.ts'; +import { isCompleteJSONObject, isJSONRecord } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIResponsesInputAssistantTurn, type OpenAIResponsesItemState, type OpenAIResponsesOutputAssistantState } from '../as-target/assistant-turn.ts'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; @@ -68,11 +69,8 @@ type MessagePart = IROutputMessageItem['content'][number]; type ResponsesMessageState = OpenAIResponsesItemState, ContentStates>; type ResponsesReasoningState = OpenAIResponsesItemState, ContentStates>; -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - const initializedType = (value: unknown): IROutputItem['type'] | MessagePart['type'] | undefined => { - if (!isRecord(value)) return undefined; + if (!isJSONRecord(value)) return undefined; return value.type === 'message' || value.type === 'reasoning' || value.type === 'function_call' @@ -88,14 +86,14 @@ const initializedType = (value: unknown): IROutputItem['type'] | MessagePart['ty type TextPartLogprobMetadata = { readonly item_index: number; readonly content_index: number }; const textPartLogprobMetadata = (value: unknown): TextPartLogprobMetadata | undefined => { - if (!isRecord(value) || value.scope !== 'text_part') return undefined; + if (!isJSONRecord(value) || value.scope !== 'text_part') return undefined; return typeof value.item_index === 'number' && typeof value.content_index === 'number' ? { item_index: value.item_index, content_index: value.content_index } : undefined; }; const isIRTokenLogprob = (value: unknown): value is IROutputTokenLogprob => - isRecord(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; + isJSONRecord(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; const reasoningPartAdded = (field: ReasoningField, itemId: string, outputIndex: number, index: number): OpenAIResponsesStreamEventEx => field === 'summary' @@ -647,7 +645,7 @@ export const writeOpenAIResponsesEvents = async function* ( const item = state.choices[0].items[current.path[3] as number]; if (item.type !== 'function_call') throw new TypeError('Responses pending function unit requires a function item'); const outputIndex = requireOutputIndex(current); - parseJSONObjectAtCompletion(item.arguments, finishReason === 'length' ? 'truncated' : 'complete'); + parseJSONObjectUnlessTruncated(item.arguments, finishReason === 'length' ? 'truncated' : 'complete'); yield emit({ type: 'response.function_call_arguments.done', item_id: requireId(current), diff --git a/packages/translate/src/shared/json/completion.ts b/packages/translate/src/shared/json/completion.ts new file mode 100644 index 0000000000..bb15600b62 --- /dev/null +++ b/packages/translate/src/shared/json/completion.ts @@ -0,0 +1,8 @@ +import { parseJSONObject } from './object.ts'; + +export const parseJSONObjectUnlessTruncated = ( + text: string, + completion: 'complete' | 'truncated', + parser?: (text: string) => unknown, +): { readonly completion: 'truncated' } | { readonly completion: 'complete'; readonly value: ReturnType } => + completion === 'truncated' ? { completion } : { completion, value: parseJSONObject(text, parser) }; diff --git a/packages/translate/src/shared/json/object.ts b/packages/translate/src/shared/json/object.ts index 76f05749c7..499d7132da 100644 --- a/packages/translate/src/shared/json/object.ts +++ b/packages/translate/src/shared/json/object.ts @@ -2,6 +2,10 @@ import { rawJSON } from './raw-json.ts'; import type { IRJSONObject } from '../../ir/common/json.ts'; import { isJsonObject, parseJSONWithRawNumbers } from '@floway-dev/protocols/common'; +/** Structural record check that also permits raw JSON token objects; isPlainJSONObject excludes those tokens. */ +export const isJSONRecord = (value: unknown): value is Readonly> => + typeof value === 'object' && value !== null && !Array.isArray(value); + export const isPlainJSONObject = (value: unknown): value is IRJSONObject => isJsonObject(value) && !rawJSON.isRawJSON(value); export const requireJSONObject = (value: unknown): IRJSONObject => { @@ -23,10 +27,3 @@ export const isCompleteJSONObject = (text: string): boolean => { throw error; } }; - -export const parseJSONObjectAtCompletion = ( - text: string, - completion: 'complete' | 'truncated', - parser?: (text: string) => unknown, -): { readonly completion: 'truncated' } | { readonly completion: 'complete'; readonly value: ReturnType } => - completion === 'truncated' ? { completion } : { completion, value: parseJSONObject(text, parser) }; diff --git a/packages/translate/src/shared/text-coordinate-index.ts b/packages/translate/src/shared/text-coordinate-index.ts index 74cc96296c..dd8bb9d4e9 100644 --- a/packages/translate/src/shared/text-coordinate-index.ts +++ b/packages/translate/src/shared/text-coordinate-index.ts @@ -141,11 +141,11 @@ export class TextCoordinateIndex { #utf16Range(range: TextRange): readonly [number, number] { if (!Number.isInteger(range.start) || !Number.isInteger(range.end_exclusive) || range.start < 0 || range.end_exclusive < range.start || range.end_exclusive > this.#utf16Length) { - throw new RangeError('IR range is outside text or splits a surrogate pair'); + throw new RangeError('output IR range is outside text or splits a surrogate pair'); } const start = this.#boundaries.get(range.start); const end = this.#boundaries.get(range.end_exclusive); - if (start === INVALID_BOUNDARY || end === INVALID_BOUNDARY) throw new RangeError('IR range is outside text or splits a surrogate pair'); + if (start === INVALID_BOUNDARY || end === INVALID_BOUNDARY) throw new RangeError('output IR range is outside text or splits a surrogate pair'); return [start, end]; } } From fa83fa3756151a7036b70af62b6957fdc1087c98 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:03:02 +0800 Subject: [PATCH 15/66] refactor(translate): give protocol errors dedicated owners Move each source protocol's error converters into as-source/error.ts so HTTP error restatement and SSE output share the focused error owner. Preserve the extracted implementation byte for byte. Align six existing error expectations in three test files with retained upstream details and independent classifications. Validate 376 tests in 22 named files, all 17 changed TypeScript lint targets, and the translate and gateway TypeScript projects. --- .../translate/__tests__/ir/edge-cases_test.ts | 4 +- .../as-source/error-body_test.ts | 2 +- .../events-protocol_test.ts | 2 + .../as-source/error-body_test.ts | 2 +- .../events-protocol_test.ts | 2 +- .../as-source/error-body.ts | 2 +- .../anthropic-messages/as-source/error.ts | 35 +++++++++++++++++ .../anthropic-messages/as-source/sse-to.ts | 38 +------------------ .../as-source/error-body.ts | 2 +- .../as-source/error.ts | 35 +++++++++++++++++ .../as-source/sse-to.ts | 37 +----------------- .../as-source/error-body.ts | 2 +- .../as-source/error.ts | 13 +++++++ .../as-source/sse-to.ts | 15 +------- .../openai-responses/as-source/error-body.ts | 2 +- .../openai-responses/as-source/error.ts | 14 +++++++ .../openai-responses/as-source/sse-to.ts | 15 +------- 17 files changed, 116 insertions(+), 106 deletions(-) create mode 100644 packages/translate/src/protocols/anthropic-messages/as-source/error.ts create mode 100644 packages/translate/src/protocols/gemini-generate-content/as-source/error.ts create mode 100644 packages/translate/src/protocols/openai-chat-completions/as-source/error.ts create mode 100644 packages/translate/src/protocols/openai-responses/as-source/error.ts diff --git a/packages/translate/__tests__/ir/edge-cases_test.ts b/packages/translate/__tests__/ir/edge-cases_test.ts index 652013558f..430d27d911 100644 --- a/packages/translate/__tests__/ir/edge-cases_test.ts +++ b/packages/translate/__tests__/ir/edge-cases_test.ts @@ -32,7 +32,7 @@ for (const [code, category] of [['bio_policy', 'bio'], ['cyber_policy', 'cyber'] test.each(['The model failed.', 'The safety policy service is unavailable.'])('a server error stays an error regardless of message keywords: %s', async message => { const frames = await collect(generateContentViaResponses(iterate([eventFrame({ type: 'response.failed', response: { id: 'resp_failed', model: 'served', output: [], status: 'failed', error: { code: 'server_error', message } } })] as any), createTestSidecarCodec())); - expect(frames).toEqual([eventFrame({ error: { code: 500, status: 'INTERNAL', message } })]); + expect(frames).toEqual([eventFrame({ error: { code: 500, status: 'INTERNAL', message, details: [{ '@type': 'type.googleapis.com/google.protobuf.Struct', value: { code: 'server_error' } }] } })]); }); test('a failed Responses stream without an error description still fails with a message', async () => { @@ -42,7 +42,7 @@ test('a failed Responses stream without an error description still fails with a test('rate limiting becomes a native GenerateContent error', async () => { const frames = await collect(generateContentViaResponses(iterate([eventFrame({ type: 'error', code: 'rate_limit_exceeded', message: 'Try later.' })] as any), createTestSidecarCodec())); - expect(frames).toEqual([eventFrame({ error: { code: 429, status: 'RESOURCE_EXHAUSTED', message: 'Try later.' } })]); + expect(frames).toEqual([eventFrame({ error: { code: 429, status: 'RESOURCE_EXHAUSTED', message: 'Try later.', details: [{ '@type': 'type.googleapis.com/google.protobuf.Struct', value: { code: 'rate_limit_exceeded' } }] } })]); }); test('ChatCompletions ignores empty identity frames and starts when a model appears', async () => { diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-source/error-body_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/error-body_test.ts index 051b7f2b20..ddf1fbf724 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-source/error-body_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-source/error-body_test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; import { restateAnthropicMessagesError } from '../../../../src/protocols/anthropic-messages/as-source/error-body.ts'; -import { anthropicMessagesErrorFromIR } from '../../../../src/protocols/anthropic-messages/as-source/sse-to.ts'; +import { anthropicMessagesErrorFromIR } from '../../../../src/protocols/anthropic-messages/as-source/error.ts'; import { errorPayloadFromWire } from '../../../../src/shared/api-error.ts'; import { buildPromptTooLongBody, PROMPT_TOO_LONG_MESSAGE } from '@floway-dev/protocols/anthropic-messages'; import { assert, assertEquals } from '@floway-dev/test-utils'; diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts index 079e83e658..e3590ff9fa 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts @@ -163,6 +163,7 @@ test('translateToSourceEvents translates OpenAI Responses failed terminal to Ant error: { type: 'api_error', message: 'upstream failed', + provider_specific_fields: { code: 'server_error', type: 'server_error' }, }, } satisfies AnthropicMessagesStreamEventEx), ]); @@ -193,6 +194,7 @@ test('translateToSourceEvents translates OpenAI Responses error terminal to Anth error: { type: 'api_error', message: 'upstream overloaded', + provider_specific_fields: { code: 'overloaded_error' }, }, } satisfies AnthropicMessagesStreamEventEx), ]); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/error-body_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/error-body_test.ts index 2d2cf41214..35e416fad2 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/error-body_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/error-body_test.ts @@ -1,7 +1,7 @@ import { test } from 'vitest'; import { restateGeminiGenerateContentError } from '../../../../src/protocols/gemini-generate-content/as-source/error-body.ts'; -import { geminiGenerateContentErrorFromIR } from '../../../../src/protocols/gemini-generate-content/as-source/sse-to.ts'; +import { geminiGenerateContentErrorFromIR } from '../../../../src/protocols/gemini-generate-content/as-source/error.ts'; import { errorPayloadFromWire } from '../../../../src/shared/api-error.ts'; import { assertEquals } from '@floway-dev/test-utils'; diff --git a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts b/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts index d956a51402..eaa947fd77 100644 --- a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts @@ -60,7 +60,7 @@ test('translateToSourceEvents translates Anthropic Messages error terminal and s eventFrame({ type: 'error', message: 'upstream overloaded', - code: 'overloaded_error', + provider_specific_fields: { type: 'overloaded_error' }, sequence_number: 0, }), ); diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/error-body.ts b/packages/translate/src/protocols/anthropic-messages/as-source/error-body.ts index 270ead5694..68a1a70741 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/error-body.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/error-body.ts @@ -1,4 +1,4 @@ -import { anthropicMessagesErrorEnvelope } from './sse-to.ts'; +import { anthropicMessagesErrorEnvelope } from './error.ts'; import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; import type { ApiErrorTranslator } from '../../../types.ts'; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/error.ts b/packages/translate/src/protocols/anthropic-messages/as-source/error.ts new file mode 100644 index 0000000000..fc45587217 --- /dev/null +++ b/packages/translate/src/protocols/anthropic-messages/as-source/error.ts @@ -0,0 +1,35 @@ +import type { IRErrorPayload } from '../../../ir/types.ts'; +import { errorDetailsOf } from '../../../shared/api-error.ts'; +import { isContextExceededError } from '../../../shared/context-window-error.ts'; +import { PROMPT_TOO_LONG_MESSAGE, type AnthropicMessagesErrorEventEx } from '@floway-dev/protocols/anthropic-messages'; + +// https://platform.claude.com/docs/en/api/errors#http-errors +const MESSAGES_ERROR_TYPES = [ + 'invalid_request_error', + 'authentication_error', + 'permission_error', + 'not_found_error', + 'rate_limit_error', + 'api_error', + 'overloaded_error', +]; + +const messagesError = (error: IRErrorPayload, contextExceeded: boolean): AnthropicMessagesErrorEventEx['error'] => { + const type = contextExceeded + ? 'invalid_request_error' + : error.type !== undefined && MESSAGES_ERROR_TYPES.includes(error.type) ? error.type : 'api_error'; + const details = errorDetailsOf(error, type === error.type ? ['message', 'type'] : ['message']); + const extension = details === undefined ? {} : { provider_specific_fields: details }; + return { type, message: contextExceeded ? PROMPT_TOO_LONG_MESSAGE : error.message, ...extension }; +}; + +export const anthropicMessagesErrorFromIR = (error: IRErrorPayload): AnthropicMessagesErrorEventEx['error'] => + messagesError(error, isContextExceededError(error)); + +export const anthropicMessagesErrorEnvelope = (error: IRErrorPayload): { readonly body: unknown; readonly status: number | undefined } => { + const contextExceeded = isContextExceededError(error); + return { + body: { type: 'error', error: messagesError(error, contextExceeded) }, + status: contextExceeded ? 400 : undefined, + }; +}; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts index 08ac20ae08..d4dc754190 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts @@ -1,62 +1,28 @@ +import { anthropicMessagesErrorFromIR } from './error.ts'; import { collectAnthropicMessagesTextViews, computeAnthropicMessagesTurnDigest, createAnthropicMessagesSidecarCarrier } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; import type { IRCollector } from '../../../ir/collector.ts'; import { isIRItemPath, isIRPartPath, irPathKey } from '../../../ir/operation-paths.ts'; import { createIROrdinalMapper } from '../../../ir/ordinal-mapper.ts'; import { consumeIRRecords, type IRPath, type IRRecord } from '../../../ir/stream.ts'; -import { IRAbsent, type IR, type IRErrorPayload, type IRJSONObject } from '../../../ir/types.ts'; +import { IRAbsent, type IR, type IRJSONObject } from '../../../ir/types.ts'; import { createIRUnitQueue, createIRUnitRegistry } from '../../../ir/unit-queue.ts'; import { appendContent, createContentState, type ContentState, type ContentStates } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; -import { errorDetailsOf } from '../../../shared/api-error.ts'; -import { isContextExceededError } from '../../../shared/context-window-error.ts'; import { parseJSONObject, parseJSONObjectAtCompletion, requireJSONObject } from '../../../shared/json/object.ts'; import type { AnthropicMessagesAssistantState, AnthropicMessagesInputAssistantTurn } from '../as-target/assistant-turn.ts'; import { generateAnthropicId, createAnthropicMessagesEventAccumulator, - PROMPT_TOO_LONG_MESSAGE, type AnthropicMessagesAssistantContentBlock, type AnthropicMessagesContentBlockDeltaEvent, - type AnthropicMessagesErrorEventEx, type AnthropicMessagesResult, type AnthropicMessagesStreamEventEx, type AnthropicMessagesUsage, } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, type EventFrame } from '@floway-dev/protocols/common'; -// https://platform.claude.com/docs/en/api/errors#http-errors -const MESSAGES_ERROR_TYPES = [ - 'invalid_request_error', - 'authentication_error', - 'permission_error', - 'not_found_error', - 'rate_limit_error', - 'api_error', - 'overloaded_error', -]; - -const messagesError = (error: IRErrorPayload, contextExceeded: boolean): AnthropicMessagesErrorEventEx['error'] => { - const type = contextExceeded - ? 'invalid_request_error' - : error.type !== undefined && MESSAGES_ERROR_TYPES.includes(error.type) ? error.type : 'api_error'; - const details = errorDetailsOf(error, type === error.type ? ['message', 'type'] : ['message']); - const extension = details === undefined ? {} : { provider_specific_fields: details }; - return { type, message: contextExceeded ? PROMPT_TOO_LONG_MESSAGE : error.message, ...extension }; -}; - -export const anthropicMessagesErrorFromIR = (error: IRErrorPayload): AnthropicMessagesErrorEventEx['error'] => - messagesError(error, isContextExceededError(error)); - -export const anthropicMessagesErrorEnvelope = (error: IRErrorPayload): { readonly body: unknown; readonly status: number | undefined } => { - const contextExceeded = isContextExceededError(error); - return { - body: { type: 'error', error: messagesError(error, contextExceeded) }, - status: contextExceeded ? 400 : undefined, - }; -}; - interface MessagesUnit { readonly path: IRPath; readonly kind: 'text' | 'refusal' | 'audio' | 'image' | 'reasoning' | 'function_call'; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/error-body.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/error-body.ts index 951d2ee794..fd77080bea 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/error-body.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/error-body.ts @@ -1,4 +1,4 @@ -import { geminiGenerateContentErrorFromIR } from './sse-to.ts'; +import { geminiGenerateContentErrorFromIR } from './error.ts'; import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; import type { ApiErrorTranslator } from '../../../types.ts'; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts new file mode 100644 index 0000000000..7c2b974075 --- /dev/null +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts @@ -0,0 +1,35 @@ +import type { IRErrorPayload } from '../../../ir/types.ts'; +import { errorDetailsOf } from '../../../shared/api-error.ts'; +import { isContextExceededError } from '../../../shared/context-window-error.ts'; +import type { GeminiGenerateContentErrorResponse } from '@floway-dev/protocols/gemini-generate-content'; + +// https://ai.google.dev/api/rest/v1beta/Status +const statusNameOf = (status: number): string => { + switch (status) { + case 400: return 'INVALID_ARGUMENT'; + case 401: return 'UNAUTHENTICATED'; + case 403: return 'PERMISSION_DENIED'; + case 404: return 'NOT_FOUND'; + case 408: return 'DEADLINE_EXCEEDED'; + case 409: return 'ABORTED'; + case 429: return 'RESOURCE_EXHAUSTED'; + case 500: return 'INTERNAL'; + case 501: return 'UNIMPLEMENTED'; + case 503: return 'UNAVAILABLE'; + case 504: return 'DEADLINE_EXCEEDED'; + default: return 'UNKNOWN'; + } +}; + +export const geminiGenerateContentErrorFromIR = (error: IRErrorPayload, httpStatus?: number): GeminiGenerateContentErrorResponse['error'] => { + const details = errorDetailsOf(error, ['message']); + // Arbitrary error fields use the source's Struct details container. + // https://protobuf.dev/reference/protobuf/google.protobuf/#struct + const extension = details === undefined ? {} : { details: [{ '@type': 'type.googleapis.com/google.protobuf.Struct', value: details }] }; + const kind = error.code ?? error.type; + const code = httpStatus !== undefined && !isContextExceededError(error) ? httpStatus + : kind === 'rate_limit_exceeded' || kind === 'rate_limit_error' ? 429 + : kind === 'invalid_prompt' || kind === 'invalid_request_error' || isContextExceededError(error) ? 400 + : kind === 'overloaded_error' ? 503 : 500; + return { code, status: statusNameOf(code), message: error.message, ...extension }; +}; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts index 0c187560f0..e5fd8360b9 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts @@ -1,22 +1,20 @@ +import { geminiGenerateContentErrorFromIR } from './error.ts'; import { collectGeminiGenerateContentTextViews, computeGeminiGenerateContentTurnDigest, createGeminiGenerateContentSidecarCarrier } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; import type { IRCollector } from '../../../ir/collector.ts'; import { irItemPath, irPartPath, irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/operation-paths.ts'; import { consumeIRRecords, type IRPath, type IRRecord } from '../../../ir/stream.ts'; -import { IRAbsent, type IR, type IRErrorPayload, type IRJSONObject, type IRSourceCitation, type IRTokenLogprob } from '../../../ir/types.ts'; +import { IRAbsent, type IR, type IRJSONObject, type IRSourceCitation, type IRTokenLogprob } from '../../../ir/types.ts'; import { createIRUnitQueue, createIRUnitRegistry, type IRUnitQueue } from '../../../ir/unit-queue.ts'; import { appendContent, createContentState, type ContentStates, type ContentState as ReferenceContentState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; -import { errorDetailsOf } from '../../../shared/api-error.ts'; -import { isContextExceededError } from '../../../shared/context-window-error.ts'; import { isCompleteJSONObject, parseJSONObjectAtCompletion } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import type { GeminiGenerateContentAssistantState, GeminiGenerateContentInputAssistantTurn } from '../as-target/assistant-turn.ts'; import { eventFrame, FAST_SERVICE_TIER, type EventFrame } from '@floway-dev/protocols/common'; import { type GeminiGenerateContentCandidate, - type GeminiGenerateContentErrorResponse, type GeminiGenerateContentGroundingChunk, type GeminiGenerateContentPart, type GeminiGenerateContentResult, @@ -24,37 +22,6 @@ import { createGeminiGenerateContentEventAccumulator, } from '@floway-dev/protocols/gemini-generate-content'; -// https://ai.google.dev/api/rest/v1beta/Status -const statusNameOf = (status: number): string => { - switch (status) { - case 400: return 'INVALID_ARGUMENT'; - case 401: return 'UNAUTHENTICATED'; - case 403: return 'PERMISSION_DENIED'; - case 404: return 'NOT_FOUND'; - case 408: return 'DEADLINE_EXCEEDED'; - case 409: return 'ABORTED'; - case 429: return 'RESOURCE_EXHAUSTED'; - case 500: return 'INTERNAL'; - case 501: return 'UNIMPLEMENTED'; - case 503: return 'UNAVAILABLE'; - case 504: return 'DEADLINE_EXCEEDED'; - default: return 'UNKNOWN'; - } -}; - -export const geminiGenerateContentErrorFromIR = (error: IRErrorPayload, httpStatus?: number): GeminiGenerateContentErrorResponse['error'] => { - const details = errorDetailsOf(error, ['message']); - // Arbitrary error fields use the source's Struct details container. - // https://protobuf.dev/reference/protobuf/google.protobuf/#struct - const extension = details === undefined ? {} : { details: [{ '@type': 'type.googleapis.com/google.protobuf.Struct', value: details }] }; - const kind = error.code ?? error.type; - const code = httpStatus !== undefined && !isContextExceededError(error) ? httpStatus - : kind === 'rate_limit_exceeded' || kind === 'rate_limit_error' ? 429 - : kind === 'invalid_prompt' || kind === 'invalid_request_error' || isContextExceededError(error) ? 400 - : kind === 'overloaded_error' ? 503 : 500; - return { code, status: statusNameOf(code), message: error.message, ...extension }; -}; - interface GenerateContentUnit { readonly path: IRPath; readonly kind: 'text' | 'refusal' | 'audio' | 'image' | 'reasoning' | 'function_call'; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/error-body.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/error-body.ts index 7968ecc167..84db14ec20 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/error-body.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/error-body.ts @@ -1,4 +1,4 @@ -import { openaiChatCompletionsErrorFromIR } from './sse-to.ts'; +import { openaiChatCompletionsErrorFromIR } from './error.ts'; import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; import type { ApiErrorTranslator } from '../../../types.ts'; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/error.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/error.ts new file mode 100644 index 0000000000..169b8c35ea --- /dev/null +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/error.ts @@ -0,0 +1,13 @@ +import type { IRErrorPayload } from '../../../ir/types.ts'; +import { errorDetailsOf } from '../../../shared/api-error.ts'; + +export const openaiChatCompletionsErrorFromIR = (error: IRErrorPayload) => { + const details = errorDetailsOf(error, ['message', 'type', 'code', 'param']); + return { + message: error.message, + ...(error.type === undefined ? {} : { type: error.type }), + ...(error.code === undefined ? {} : { code: error.code }), + ...(error.param === undefined ? {} : { param: error.param }), + ...(details === undefined ? {} : { provider_specific_fields: details }), + }; +}; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts index 175311b94b..c1b08e4797 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts @@ -1,13 +1,13 @@ +import { openaiChatCompletionsErrorFromIR } from './error.ts'; import { collectOpenAIChatCompletionsTextViews, computeOpenAIChatCompletionsTurnDigest, createOpenAIChatCompletionsSidecarCarrier } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; import { irItemPath, irPartPath, irPathKey } from '../../../ir/operation-paths.ts'; import { createIROrdinalMapper } from '../../../ir/ordinal-mapper.ts'; import { consumeIRRecords, type IRRecord } from '../../../ir/stream.ts'; -import { IRAbsent, type IRErrorPayload, type IRItem, type IRMessageItem, type IRTokenLogprob } from '../../../ir/types.ts'; +import { IRAbsent, type IRItem, type IRMessageItem, type IRTokenLogprob } from '../../../ir/types.ts'; import { appendContent, createContentState, type ContentStates, type ContentState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; -import { errorDetailsOf } from '../../../shared/api-error.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIChatCompletionsInputAssistantTurn, type OpenAIChatCompletionsOutputAssistantState } from '../as-target/assistant-turn.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; @@ -20,17 +20,6 @@ import { createOpenAIChatCompletionsEventAccumulator, } from '@floway-dev/protocols/openai-chat-completions'; -export const openaiChatCompletionsErrorFromIR = (error: IRErrorPayload) => { - const details = errorDetailsOf(error, ['message', 'type', 'code', 'param']); - return { - message: error.message, - ...(error.type === undefined ? {} : { type: error.type }), - ...(error.code === undefined ? {} : { code: error.code }), - ...(error.param === undefined ? {} : { param: error.param }), - ...(details === undefined ? {} : { provider_specific_fields: details }), - }; -}; - interface OpenAIChatCompletionsSSEOutputOptions extends RoundTripOutputOptions<'openaiChatCompletions'> { // Replay identity belongs to the newly emitted target audio, not the source IR content. audioMetadata?: (choice: number) => { id: string; expires_at: number }; diff --git a/packages/translate/src/protocols/openai-responses/as-source/error-body.ts b/packages/translate/src/protocols/openai-responses/as-source/error-body.ts index 7a0c0f957b..0463d6c678 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/error-body.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/error-body.ts @@ -1,4 +1,4 @@ -import { openaiResponsesErrorEventOf } from './sse-to.ts'; +import { openaiResponsesErrorEventOf } from './error.ts'; import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; import type { ApiErrorTranslator } from '../../../types.ts'; diff --git a/packages/translate/src/protocols/openai-responses/as-source/error.ts b/packages/translate/src/protocols/openai-responses/as-source/error.ts new file mode 100644 index 0000000000..186d6a5900 --- /dev/null +++ b/packages/translate/src/protocols/openai-responses/as-source/error.ts @@ -0,0 +1,14 @@ +import type { IRErrorPayload } from '../../../ir/types.ts'; +import { errorDetailsOf } from '../../../shared/api-error.ts'; +import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; + +export const openaiResponsesErrorEventOf = (error: IRErrorPayload): OpenAIResponsesStreamEventEx => { + const details = errorDetailsOf(error, ['message', 'code', 'param']); + return { + type: 'error', + message: error.message, + ...(error.code === undefined ? {} : { code: error.code }), + ...(error.param === undefined ? {} : { param: error.param }), + ...(details === undefined ? {} : { provider_specific_fields: details }), + }; +}; diff --git a/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts b/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts index a2224ba2cc..0fddc5b6f5 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts @@ -1,3 +1,4 @@ +import { openaiResponsesErrorEventOf } from './error.ts'; import { restoreNamespaceOutputItem, type NamespaceToolNames } from './namespace-tools.ts'; import { createRandomOpenAIResponsesResponseId } from './response-id.ts'; import { collectOpenAIResponsesTextViews, computeOpenAIResponsesTurnDigest, createOpenAIResponsesSidecarCarrier } from './round-trip.ts'; @@ -6,12 +7,11 @@ import type { IRCollector } from '../../../ir/collector.ts'; import { irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/operation-paths.ts'; import { createIROrdinalMapper } from '../../../ir/ordinal-mapper.ts'; import { consumeIRRecords, type IRPath, type IRRecord } from '../../../ir/stream.ts'; -import { IRAbsent, type IR, type IRErrorPayload, type IRItem, type IRMessageItem, type IRSourceCitation, type IRTokenLogprob } from '../../../ir/types.ts'; +import { IRAbsent, type IR, type IRItem, type IRMessageItem, type IRSourceCitation, type IRTokenLogprob } from '../../../ir/types.ts'; import { createIRUnitQueue, createIRUnitRegistry } from '../../../ir/unit-queue.ts'; import { appendContent, createContentState, type ContentState, type ContentStates } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; -import { errorDetailsOf } from '../../../shared/api-error.ts'; import { isCompleteJSONObject, parseJSONObjectAtCompletion } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIResponsesInputAssistantTurn, type OpenAIResponsesItemState, type OpenAIResponsesOutputAssistantState } from '../as-target/assistant-turn.ts'; @@ -177,17 +177,6 @@ const textOrRefusalPart = (state: IR, path: IRPath): Extract { - const details = errorDetailsOf(error, ['message', 'code', 'param']); - return { - type: 'error', - message: error.message, - ...(error.code === undefined ? {} : { code: error.code }), - ...(error.param === undefined ? {} : { param: error.param }), - ...(details === undefined ? {} : { provider_specific_fields: details }), - }; -}; - export const openaiResponsesFromIR = async function* ( records: AsyncIterable, options: OpenAIResponsesSSEOutputOptions, From 2548bf0d149b03e87411ac0943df78ca98287bfe Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:03:34 +0800 Subject: [PATCH 16/66] fix(gateway): retain translation input error details Preserve the param and code supplied by raw translation input failures in Messages and GenerateContent error envelopes. Omit absent details and keep the rejecting candidate performance context intact. Keep protocol error results precise and validate both detail envelopes, the named translation boundary suites, and gateway TypeScript checks. --- .../chat/anthropic-messages/errors_test.ts | 38 +++++++++++----- .../gemini-generate-content/errors_test.ts | 44 +++++++++++++++++++ .../chat/anthropic-messages/errors.ts | 20 ++++----- .../chat/gemini-generate-content/errors.ts | 27 +++++++----- .../src/data-plane/chat/shared/errors.ts | 8 ++++ 5 files changed, 105 insertions(+), 32 deletions(-) create mode 100644 packages/gateway/__tests__/data-plane/chat/gemini-generate-content/errors_test.ts diff --git a/packages/gateway/__tests__/data-plane/chat/anthropic-messages/errors_test.ts b/packages/gateway/__tests__/data-plane/chat/anthropic-messages/errors_test.ts index 67385d36cf..6b7a32e28c 100644 --- a/packages/gateway/__tests__/data-plane/chat/anthropic-messages/errors_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/anthropic-messages/errors_test.ts @@ -1,23 +1,20 @@ import { test } from 'vitest'; import { inputErrorResult } from '../../../../src/data-plane/chat/anthropic-messages/errors.ts'; -import type { ApiErrorResult } from '@floway-dev/provider'; -import { assert, assertEquals } from '@floway-dev/test-utils'; +import { assert, assertEquals, mockPerfTelemetryContext } from '@floway-dev/test-utils'; -const apiErrorOf = (result: ReturnType): ApiErrorResult => result as ApiErrorResult; const bodyOf = (result: ReturnType): Record => - JSON.parse(new TextDecoder().decode(apiErrorOf(result).body)); + JSON.parse(new TextDecoder().decode(result.body)); test('inputErrorResult renders an Anthropic 400 invalid_request_error envelope with top-level request_id', () => { const result = inputErrorResult( { message: "Invalid 'image_url' content part in system or developer message. Only 'text' content parts are supported in system messages on this model." }, ); - const apiError = apiErrorOf(result); - assertEquals(apiError.type, 'api-error'); - assertEquals(apiError.source, 'gateway'); - assertEquals(apiError.status, 400); - assertEquals(apiError.headers.get('content-type'), 'application/json'); + assertEquals(result.type, 'api-error'); + assertEquals(result.source, 'gateway'); + assertEquals(result.status, 400); + assertEquals(result.headers.get('content-type'), 'application/json'); const body = bodyOf(result); assertEquals(body.type, 'error'); @@ -30,7 +27,28 @@ test('inputErrorResult renders an Anthropic 400 invalid_request_error envelope w test('inputErrorResult preserves Anthropic key order: type, error, request_id', () => { const result = inputErrorResult({ message: 'whatever' }); - const raw = new TextDecoder().decode(apiErrorOf(result).body); + const raw = new TextDecoder().decode(result.body); // Key-order is load-bearing for byte-faithfulness with Anthropic-direct. assert(/^\{"type":"error","error":\{"type":"invalid_request_error","message":"whatever"\},"request_id":"req_[A-Za-z0-9]{24}"\}$/.test(raw), `body ${raw} must match Anthropic-direct byte shape`); }); + +test.each([ + { name: 'field and code', fields: { param: 'model', code: 'missing_required_parameter' }, expected: { param: 'model', code: 'missing_required_parameter' } }, + { name: 'field only', fields: { param: 'messages[0].content' }, expected: { param: 'messages[0].content' } }, + { name: 'code only', fields: { code: 'unsupported_content' }, expected: { code: 'unsupported_content' } }, + { name: 'empty values', fields: { param: '', code: '' }, expected: { param: '', code: '' } }, + { name: 'neither field nor code', fields: {}, expected: undefined }, +])('inputErrorResult preserves $name exactly', ({ fields, expected }) => { + const result = inputErrorResult({ message: 'invalid input', ...fields }); + assertEquals(bodyOf(result).error, { + type: 'invalid_request_error', + message: 'invalid input', + ...(expected === undefined ? {} : { provider_specific_fields: expected }), + }); +}); + +test('inputErrorResult retains the rejecting candidate performance context', () => { + const performance = mockPerfTelemetryContext({ upstream: 'rejecting-candidate', model: 'resolved-model' }); + const result = inputErrorResult({ message: 'invalid input', param: 'model', code: 'missing_required_parameter' }, performance); + assert(result.performance === performance); +}); diff --git a/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/errors_test.ts b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/errors_test.ts new file mode 100644 index 0000000000..b999f6fccd --- /dev/null +++ b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/errors_test.ts @@ -0,0 +1,44 @@ +import { test } from 'vitest'; + +import { inputErrorResult } from '../../../../src/data-plane/chat/gemini-generate-content/errors.ts'; +import { assert, assertEquals, mockPerfTelemetryContext } from '@floway-dev/test-utils'; + +const bodyOf = (result: ReturnType): Record => + JSON.parse(new TextDecoder().decode(result.body)); + +test('inputErrorResult renders a GenerateContent 400 INVALID_ARGUMENT envelope', () => { + const result = inputErrorResult({ message: '"inlineData" parts are not supported in user content.' }); + assertEquals(result.type, 'api-error'); + assertEquals(result.source, 'gateway'); + assertEquals(result.status, 400); + assertEquals(result.headers.get('content-type'), 'application/json'); + assertEquals(bodyOf(result).error, { + code: 400, + message: '"inlineData" parts are not supported in user content.', + status: 'INVALID_ARGUMENT', + }); +}); + +test.each([ + { name: 'field and code', fields: { param: 'model', code: 'missing_required_parameter' }, expected: { param: 'model', code: 'missing_required_parameter' } }, + { name: 'field only', fields: { param: 'contents[0].parts' }, expected: { param: 'contents[0].parts' } }, + { name: 'code only', fields: { code: 'unsupported_content' }, expected: { code: 'unsupported_content' } }, + { name: 'empty values', fields: { param: '', code: '' }, expected: { param: '', code: '' } }, + { name: 'neither field nor code', fields: {}, expected: undefined }, +])('inputErrorResult preserves $name exactly', ({ fields, expected }) => { + const result = inputErrorResult({ message: 'invalid input', ...fields }); + assertEquals(bodyOf(result).error, { + code: 400, + message: 'invalid input', + status: 'INVALID_ARGUMENT', + ...(expected === undefined ? {} : { + details: [{ '@type': 'type.googleapis.com/google.protobuf.Struct', value: expected }], + }), + }); +}); + +test('inputErrorResult retains the rejecting candidate performance context', () => { + const performance = mockPerfTelemetryContext({ upstream: 'rejecting-candidate', model: 'resolved-model' }); + const result = inputErrorResult({ message: 'invalid input', param: 'model', code: 'missing_required_parameter' }, performance); + assert(result.performance === performance); +}); diff --git a/packages/gateway/src/data-plane/chat/anthropic-messages/errors.ts b/packages/gateway/src/data-plane/chat/anthropic-messages/errors.ts index 1bc87472f4..b79983c6a3 100644 --- a/packages/gateway/src/data-plane/chat/anthropic-messages/errors.ts +++ b/packages/gateway/src/data-plane/chat/anthropic-messages/errors.ts @@ -1,8 +1,8 @@ import { appendFailedUpstreams } from '../../shared/failed-upstreams.ts'; -import type { ChatServeFailure, InputErrorDetails } from '../shared/errors.ts'; +import { inputErrorFields, type ChatServeFailure, type InputErrorDetails } from '../shared/errors.ts'; import { generateAnthropicId, type AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { ExecuteResult, PerformanceTelemetryContext } from '@floway-dev/provider'; +import type { ApiErrorResult, ExecuteResult, PerformanceTelemetryContext } from '@floway-dev/provider'; // Anthropic Messages error envelope used to render pre-stream // `ChatServeFailure`s. These are gateway-synthesized rather than received @@ -15,30 +15,26 @@ const anthropicErrorResult = ( type: string, message: string, performance?: PerformanceTelemetryContext, -): ExecuteResult> => ({ + extension?: Record, +): ApiErrorResult => ({ type: 'api-error', source: 'gateway', status, headers: new Headers({ 'content-type': 'application/json' }), body: new TextEncoder().encode(JSON.stringify({ type: 'error', - error: { type, message }, + error: { type, message, ...(extension === undefined ? {} : { provider_specific_fields: extension }) }, request_id: generateAnthropicId('req'), })), ...(performance ? { performance } : {}), }); -// Translator surfaced a caller-input violation (unsupported content part, -// disallowed role, missing required field, etc.). Render as a 400 -// invalid_request_error so the caller sees a protocol-shaped failure -// instead of the internal-error 502 envelope. `performance` carries the -// candidate's telemetry attribution when validation failed -// during an attempt (see AttemptState.telemetry). +// Validation failures retain the telemetry of the candidate whose attempt rejected the request. export const inputErrorResult = ( error: InputErrorDetails, performance?: PerformanceTelemetryContext, -): ExecuteResult> => - anthropicErrorResult(400, 'invalid_request_error', error.message, performance); +): ApiErrorResult => + anthropicErrorResult(400, 'invalid_request_error', error.message, performance, inputErrorFields(error)); // `endpoint` selects between `/messages` and `/messages/count_tokens` only in // the `model-unsupported` message string. diff --git a/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts b/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts index edc87687c8..c3b0f50140 100644 --- a/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts +++ b/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts @@ -1,5 +1,5 @@ import { appendFailedUpstreams } from '../../shared/failed-upstreams.ts'; -import type { ChatServeFailure, InputErrorDetails } from '../shared/errors.ts'; +import { inputErrorFields, type ChatServeFailure, type InputErrorDetails } from '../shared/errors.ts'; import type { GeminiGenerateContentErrorResponse } from '@floway-dev/protocols/gemini-generate-content'; import { internalDebugErrorFields, type ApiErrorResult, type PerformanceTelemetryContext, type InternalDebugError } from '@floway-dev/provider'; @@ -45,28 +45,35 @@ export const geminiGenerateContentInternalErrorPayload = (status: number, error: }; }; -const geminiGenerateContentRpcErrorResult = (status: number, message: string, performance?: PerformanceTelemetryContext): ApiErrorResult => ({ +// ProtoJSON Any wraps a Struct's arbitrary JSON in value. +// https://protobuf.dev/programming-guides/json/#any +const geminiGenerateContentRpcErrorResult = ( + status: number, + message: string, + performance?: PerformanceTelemetryContext, + extension?: Record, +): ApiErrorResult => ({ type: 'api-error', source: 'gateway', status, headers: new Headers({ 'content-type': 'application/json' }), body: new TextEncoder().encode(JSON.stringify({ - error: { code: status, message, status: geminiGenerateContentStatusForHttpStatus(status) }, + error: { + code: status, + message, + status: geminiGenerateContentStatusForHttpStatus(status), + ...(extension === undefined ? {} : { details: [{ '@type': 'type.googleapis.com/google.protobuf.Struct', value: extension }] }), + }, })), ...(performance ? { performance } : {}), }); -// Translator surfaced a caller-input violation (unsupported content part, -// disallowed role, missing required field, etc.). Render as a 400 -// INVALID_ARGUMENT envelope so the caller sees a Gemini-generateContent-shaped failure -// instead of the internal-error 500 envelope. `performance` carries the -// candidate's telemetry attribution when validation failed -// during an attempt (see AttemptState.telemetry). +// Validation failures retain the telemetry of the candidate whose attempt rejected the request. export const inputErrorResult = ( error: InputErrorDetails, performance?: PerformanceTelemetryContext, ): ApiErrorResult => - geminiGenerateContentRpcErrorResult(400, error.message, performance); + geminiGenerateContentRpcErrorResult(400, error.message, performance, inputErrorFields(error)); // `endpoint` selects between `:generateContent` and `:countTokens` only in // the `model-unsupported` message string. diff --git a/packages/gateway/src/data-plane/chat/shared/errors.ts b/packages/gateway/src/data-plane/chat/shared/errors.ts index 98812f5f8f..b6923e8997 100644 --- a/packages/gateway/src/data-plane/chat/shared/errors.ts +++ b/packages/gateway/src/data-plane/chat/shared/errors.ts @@ -62,3 +62,11 @@ export const noViableCandidateFailure = ( sawModel ? { kind: 'model-unsupported', model, failedUpstreams } : { kind: 'model-missing', model, failedUpstreams }; + +export const inputErrorFields = (error: InputErrorDetails): Record | undefined => { + const fields = { + ...(error.param === undefined ? {} : { param: error.param }), + ...(error.code === undefined ? {} : { code: error.code }), + }; + return Object.keys(fields).length === 0 ? undefined : fields; +}; From 935b48d32819289b2e65a0b58f931d84e2cd179a Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:12:15 +0800 Subject: [PATCH 17/66] docs(translate): clarify prepared trip integration boundaries Port the reviewed capture, error telemetry, and Responses attempt explanations to their public-boundary owners. Remove redundant dispatcher and assertion narration and update the dashboard capture reference. --- .../src/components/requests/stream-render.ts | 6 +-- .../chat/shared/execute-translation_test.ts | 1 - .../chat/openai-chat-completions/errors.ts | 8 +--- .../chat/openai-responses/attempt.ts | 38 +++---------------- .../data-plane/chat/openai-responses/http.ts | 2 - .../chat/shared/execute-translation.ts | 32 +++------------- 6 files changed, 13 insertions(+), 74 deletions(-) diff --git a/apps/web/src/components/requests/stream-render.ts b/apps/web/src/components/requests/stream-render.ts index baecfb148c..8cc0eee53a 100644 --- a/apps/web/src/components/requests/stream-render.ts +++ b/apps/web/src/components/requests/stream-render.ts @@ -42,11 +42,7 @@ export const detectCollectKind = (path: string): CollectKind | null => { return null; }; -// Mirror of `detectCollectKind` for the target protocol of a translated turn. -// The downstream view derives its kind from `meta.path` (the client path = -// source protocol); the upstream view cannot, because the client path names -// the source, not the target. `meta.targetApi` is stamped at capture time by -// `traverseTranslation` and names the target protocol the inner attempt spoke. +// The upstream view uses meta.targetApi, stamped by captureFromDump, because meta.path identifies the client protocol. export const collectKindFromTargetApi = (api: string | null | undefined): CollectKind | null => { if (api === 'anthropicMessages') return 'anthropic-messages'; if (api === 'openaiResponses') return 'openai-responses'; diff --git a/packages/gateway/__tests__/data-plane/chat/shared/execute-translation_test.ts b/packages/gateway/__tests__/data-plane/chat/shared/execute-translation_test.ts index 0e20777312..48e81adb8a 100644 --- a/packages/gateway/__tests__/data-plane/chat/shared/execute-translation_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/shared/execute-translation_test.ts @@ -56,7 +56,6 @@ describe('executeTranslation', () => { }, innerAttempt); expect(seen).toEqual({ model: 'm', stream: true }); - // The caller's own payload is not mutated. expect(source).toEqual({ model: 'm', stream: false }); }); diff --git a/packages/gateway/src/data-plane/chat/openai-chat-completions/errors.ts b/packages/gateway/src/data-plane/chat/openai-chat-completions/errors.ts index c28736f571..96568f511a 100644 --- a/packages/gateway/src/data-plane/chat/openai-chat-completions/errors.ts +++ b/packages/gateway/src/data-plane/chat/openai-chat-completions/errors.ts @@ -4,13 +4,7 @@ import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; import type { ExecuteResult, PerformanceTelemetryContext } from '@floway-dev/provider'; -// Translator surfaced a caller-input violation. Render as a 400 -// invalid_request_error so the caller sees a protocol-shaped failure -// instead of the internal-error 502 envelope. `param` falls back to -// `messages` (the OpenAI Chat Completions canonical messages field) when the -// translator did not carry a more specific path. `performance` carries the -// candidate's telemetry attribution when validation failed -// during an attempt (see AttemptState.telemetry). +// Validation failures retain the telemetry of the candidate whose attempt rejected the request. export const inputErrorResult = ( error: InputErrorDetails, performance?: PerformanceTelemetryContext, diff --git a/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts b/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts index 4da1ac83f0..1b36bc596c 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts @@ -42,40 +42,14 @@ interface OpenAIResponsesAttemptBaseArgs { export type OpenAIResponsesAttemptInvokeArgs = OpenAIResponsesAttemptBaseArgs; type OpenAIResponsesAttemptGenerateArgs = Omit; -// Single entry point for both `action: 'generate'` and `action: 'compact'`. -// Envelope-drain branches on the caller's intent (`action` passed by value), -// not on `invocation.action`. Interceptors are free to mutate `ctx.action` -// to steer inner dispatch — and, by the project's interceptor convention, -// they do not restore on the way out — so post-chain `invocation.action` -// reflects whatever the last writer left it at. The shape of the result we -// hand back is the caller's contract; keying off the caller's value is the -// only place that contract lives. +// Capture the caller's action before interceptors: invocation action selects upstream execution, +// while caller action selects the returned resource shape. // -// The module-boundary invariant `compact-shaped ⇒ targetApi='openaiResponses'` -// at dispatch time is enforced in two places, each at the layer that owns -// the corresponding piece of state: +// Compact execution requires a native Responses target. On translated targets, the compact +// shim pivots execution to generate and consumes compact input items before source reading. // -// - `invocation.action === 'compact'` is caught inside `dispatchOpenAIResponses`'s -// `case 'messages'` / `case 'openai-chat-completions'` arms — action is a -// OpenAI-Responses-level metadata field that the translators never see. -// - A `compaction_trigger` (or any other compact-shaped) item in input is -// caught by the translator itself — the `openai-responses-via-anthropic-messages` and -// `openai-responses-via-openai-chat-completions` translators reject any input-item -// variant they do not handle, so a compaction_trigger that slipped past -// the shim surfaces as a translator-level error rather than a silent -// drop. -// -// Both safety nets fire pre-upstream-call, live inside the chain (not after -// the interceptor finally blocks), and stay independent of the shim's -// presence. -// -// OpenAI Responses state and client affinity both belong to the native source edge, -// outside this candidate attempt. Native serve passes the already-hydrated -// candidate payload; translated inner OpenAI Responses -// calls pass only their translated payload. Keeping this function free of -// affinity decoding, state hydration, and persistence -// prevents an inner OpenAI Responses target from owning another source protocol's -// client-visible state. +// Hydration, affinity and persistence belong to the native source edge, so translated inner +// calls cannot own another source protocol's client state. export const openaiResponsesAttempt = { invoke: async (args: OpenAIResponsesAttemptInvokeArgs): Promise => { const { action, ctx, candidate, headers: sourceHeaders } = args; diff --git a/packages/gateway/src/data-plane/chat/openai-responses/http.ts b/packages/gateway/src/data-plane/chat/openai-responses/http.ts index d6f198ef0a..3db06a3b7c 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/http.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/http.ts @@ -50,8 +50,6 @@ const respondWithInternalError = async (c: AuthedContext, error: unknown, reques return finalizeGatewayResponse(effectiveCtx, response); }; -// Pre-stream throw dispatcher. The previous-response case renders its protocol envelope; anything else falls -// through to the internal-error 502 path. const respondToThrow = async (c: AuthedContext, error: unknown, requestBody: RequestBody, ctx?: GatewayCtx): Promise => { if (error instanceof PreviousResponseNotFoundError) { const response = previousResponseNotFoundResponse(error.previousResponseId); diff --git a/packages/gateway/src/data-plane/chat/shared/execute-translation.ts b/packages/gateway/src/data-plane/chat/shared/execute-translation.ts index fe8f2b31ed..50ea24587a 100644 --- a/packages/gateway/src/data-plane/chat/shared/execute-translation.ts +++ b/packages/gateway/src/data-plane/chat/shared/execute-translation.ts @@ -3,22 +3,13 @@ import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { ChatTargetApi, ExecuteResult } from '@floway-dev/provider'; import type { TranslateTrip, TranslateInputError } from '@floway-dev/translate'; -// Protocol-agnostic capture hook for the pre-translation (target-protocol) -// upstream view. `executeTranslation` invokes it as it relays the inner -// attempt's frames/bytes through `trip.events`; the call site constructs one -// from its `(ctx.dump, targetApi)` via `captureFromDump`. The target protocol -// is statically known at every call site (the enclosing `if`/`case` branch), -// so the dashboard can render the captured frames with the right per-protocol -// serializer without re-deriving it from the client path. +// Capture target-protocol upstream data before translation so dumps use the target serializer. export interface TranslationUpstreamCapture { upstreamFrame(frame: ProtocolFrame): void; upstreamApiError(error: { status: number; headers: Headers; body: Uint8Array }): void; } -// `dump` is null when the api key opts out of dumps; returns `undefined` so -// `executeTranslation` skips all upstream capture work (no tee, no api-error -// copy) — zero per-request cost on that path. `targetApi` is stamped eagerly so -// `meta.targetApi` is set even when the upstream stream produces zero frames. +// Stamp the target API before reading frames so an empty upstream stream retains its protocol identity. export const captureFromDump = ( dump: DumpAccumulator | null, targetApi: ChatTargetApi, @@ -31,16 +22,8 @@ export const captureFromDump = ( }; }; -// On an upstream api-error, `trip.error` translates the source envelope so -// the pair can rewrite the body into the source protocol's envelope — the -// canonical case is `anthropic-messages-via-*` translating an upstream context-window -// error into the Anthropic `prompt is too long:` shape Claude Code recognizes -// for auto-compaction. Returning `undefined` passes the upstream body through verbatim. -// -// When `capture` is provided, the ORIGINAL target-protocol frames (events -// path) or the verbatim upstream api-error envelope (api-error path) are tee'd -// to the dump BEFORE translation/rewrite, so the dump records what the upstream -// actually sent independent of what Floway translated it into. +// Source error rendering may recognize an upstream failure; undefined preserves it verbatim. +// Capture the target envelope before source rendering. export const executeTranslation = async ( payload: SP, translator: (p: Omit & { readonly stream: true }) => Promise | TranslateInputError>, @@ -61,15 +44,10 @@ export const executeTranslation = async ( : teeUpstreamFrames(inner.events, capture.upstreamFrame); return { ...inner, events: trip.events(events) }; } - // Capture the ORIGINAL upstream api-error envelope before the source - // protocol restates it. Fires for EVERY upstream api-error — even when the - // trip states none — because the verbatim target-protocol body is still the - // pre-translation view operators need to diagnose upstream behavior. if (inner.type === 'api-error' && inner.source === 'upstream') { capture?.upstreamApiError({ status: inner.status, headers: inner.headers, body: inner.body }); const rewritten = trip.error(inner.status, inner.headers, inner.body); - // A restated failure is a JSON envelope, not bytes: the transport encodes - // it here, the only place a response body becomes wire form. + // Error renderers return JSON envelopes; this transport boundary encodes the response bytes. if (rewritten !== undefined) { return { ...inner, From d462a2b7ebbb1a8e0de5bbbdd29fff2f3767e1a3 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:12:16 +0800 Subject: [PATCH 18/66] docs: clarify input translation projection decisions Apply reviewed input-reader, target-writer and request regression comments from the complete feature snapshot. Preserve finalized prerequisite error, replay and output ownership. --- .../__tests__/protocols/lifecycle_test.ts | 4 +- .../as-source/allowed-tools_test.ts | 7 +--- ...to-openai-chat-completions-request_test.ts | 10 +---- .../anthropic-messages/as-source/input-to.ts | 40 +++++-------------- .../as-target/input-from.ts | 20 ++-------- .../as-source/input-to.ts | 33 ++++----------- .../as-source/input-to.ts | 8 +--- .../as-target/input-from.ts | 30 ++++---------- .../openai-responses/as-source/input-to.ts | 18 ++------- .../openai-responses/as-target/input-from.ts | 24 +++-------- 10 files changed, 40 insertions(+), 154 deletions(-) diff --git a/packages/translate/__tests__/protocols/lifecycle_test.ts b/packages/translate/__tests__/protocols/lifecycle_test.ts index f6bc53b20f..74fe742f5d 100644 --- a/packages/translate/__tests__/protocols/lifecycle_test.ts +++ b/packages/translate/__tests__/protocols/lifecycle_test.ts @@ -302,9 +302,7 @@ test('Messages publishes cumulative usage progress before pulling the rest of th }); test('Responses states a grouped call its group on the announcement and the snapshot alike', async () => { - // The request declared this call inside a group, so both the opening event and - // the terminal snapshot name it by the pair; an announcement that dropped the - // group would disagree with the snapshot the client already saw. + const records: IRRecord[] = [ { type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }, { type: 'write', kind: 'assign', path: ['model'], value: 'served' }, 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 6de1a4f6bd..6e38a9718a 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 @@ -111,8 +111,7 @@ for (const target of ['chat', 'messages'] as const) { } test('requireCallableSelector rejects a namespace that is not a string', () => { - // The selector's namespace is carried into the IR as a string, so a member - // stating anything else is malformed rather than something to pass through. + assertEquals(requireCallableSelector({ type: 'function', name: 'read', namespace: 'files' }).namespace, 'files'); assertThrows(() => requireCallableSelector({ type: 'function', name: 'read', namespace: 42 }), TranslateInputException); }); @@ -138,9 +137,7 @@ test('Messages rejects an optional single-tool subset rather than forcing that c }); test('a dotted selector is only read as a pair when that group is declared', async () => { - // A flat declaration may spell its name with a dot; that dot is not a group - // separator, and reading it as one bound the selector to a different callable - // — or refused a legal request. + // A dot in a flat declaration belongs to its name; treating it as a namespace separator selects a different callable. const flat = await buildChat(source({ type: 'function', name: 'a.b' }), createTestSidecarCodec()); assertEquals(flat.target.tool_choice, { type: 'function', function: { name: 'a.b' } }); }); diff --git a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts index 55d0bc95cd..dedbf0f5a4 100644 --- a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts @@ -351,11 +351,7 @@ test('buildTargetPayload drops redacted opaque blocks while keeping readable thi } as OpenAIChatCompletionsAssistantMessageEx); }); -// OpenAI strict-mode JSON Schema validators reject {type: 'object'} without a -// `properties` field. Anthropic accepts that shape, so the input_schema must -// be normalized before forwarding. The reverse direction in -// `translate/src/protocols/openai-chat-completions/as-source/input-to.ts` already -// defaults `parameters` to {type: 'object', properties: {}}. Ref: +// OpenAI strict-mode validators require object schemas to state properties. The target writer supplies missing properties. // https://github.com/caozhiyuan/copilot-api/commit/ad57069826843c5d17d7b0e5ef2f75050128893c test('buildTargetPayload defaults missing input_schema.properties to {} for object tools', async () => { const result = await buildWithTestSidecar({ @@ -651,9 +647,7 @@ test('buildTargetPayload asks upstream for continuous streaming usage stats', as }); test('rejects a metadata value the neutral model cannot state as a string pair', async () => { - // Messages admits any JSON value as a tag while every metadata-carrying - // target states string pairs, so a value with no string spelling fails at the - // reader rather than being dropped or coerced on the way to a target. + // Our input extension accepts arbitrary metadata values; the reader omits nulls and rejects non-string values. await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts index 29b5e95b9c..9aa693fd2f 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts @@ -156,12 +156,7 @@ const clientItems = ( return items; }; -// A server tool runs on Anthropic's own backend and its result is written into -// the assistant message by the API, so the client never answers it. Every target -// we write states one call and its result as a separate call/result pair anyway -// (Chat's `tool` message follows its `tool_calls`), so the pair has to become a -// turn boundary here. -// +// Server tools return results inside assistant content. Lower each call/result round into assistant and client turns to preserve source order. // https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool type ServerToolResultBlock = Extract { return { type: 'function', @@ -335,16 +324,9 @@ const anthropicMessagesToolsToIR = (payload: AnthropicMessagesPayload): Pick { const format = payload.output_config?.format; - // Messages' structured-output schema is already strict by contract, so the - // fact is stated here rather than left for a target to invent. It states no - // name, while the OpenAI wires require one; this is the established spelling. + // Messages schemas are strict. We assign a stable name because the source format has none. // https://platform.claude.com/docs/en/build-with-claude/structured-outputs const outputFormat: IRInputOutputFormat | undefined = format?.type === 'json_schema' ? { type: 'json_schema', name: 'messages_response', schema: klona(format.schema), strict: true } @@ -373,9 +355,7 @@ const anthropicMessagesSettingsToIR = (payload: AnthropicMessagesPayload): IRInp }; }; -// Messages admits any JSON value as a metadata tag while the IR states string -// pairs, so a value with no string spelling is a caller error rather than a -// silent drop. `user_id: null` is Messages' way of stating no value at all. +// Our metadata input extension is normalized to string values; null-valued entries are omitted and non-string values fail here. const metadataToIR = (metadata: AnthropicMessagesMetadataEx | undefined): Record | undefined => { if (metadata === undefined) return undefined; const entries = Object.entries(metadata).flatMap(([key, value]): [string, string][] => { @@ -386,13 +366,11 @@ const metadataToIR = (metadata: AnthropicMessagesMetadataEx | undefined): Record return entries.length === 0 ? undefined : Object.fromEntries(entries); }; -// Messages states the accelerated lane as `speed` and the general lane as -// `service_tier`; every other protocol names both with the one tier string, so -// `speed` wins when a request states both. +// Messages speed takes precedence over service_tier when projecting onto the shared tier field. // https://docs.claude.com/en/build-with-claude/fast-mode const serviceTierToIR = (payload: AnthropicMessagesPayload): string | undefined => { if (payload.speed === 'fast') return FAST_SERVICE_TIER; - // `standard` is the default lane, which a target already assumes. + if (payload.speed === 'standard') return undefined; return payload.service_tier ?? undefined; }; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts index 8788bb0d2e..23a2384c3c 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts @@ -208,15 +208,12 @@ export const anthropicMessagesConversationFromIR = ( const system: AnthropicMessagesTextBlockParam[] = []; let hoisting = leading; const messages: AnthropicMessagesMessage[] = []; - // Messages spells a single text block as a bare string, which is the shape a - // client sees for a plain user turn. + const userMessage = (blocks: AnthropicMessagesUserContentBlock[]): AnthropicMessagesMessage => ({ role: 'user', content: blocks.length === 1 && blocks[0].type === 'text' ? blocks[0].text : blocks, }); - // One IR turn becomes one message: the items inside it belong together, and a - // turn boundary is what keeps two runs apart. The IR states that grouping, so - // the writer never re-derives it from item kinds. + // Turn boundaries control user-message grouping; item kinds do not create new client turns. const append = (blocks: AnthropicMessagesUserContentBlock[]): void => { if (blocks.length > 0) messages.push(userMessage(blocks)); }; @@ -287,12 +284,6 @@ export const anthropicMessagesHeaderFromIR = ( }; }; -/** - * Messages states one ceiling, `max_tokens`, and its structured-output schema - * lives in `output_config.format`. It accepts neither `seed` nor Chat's - * `max_completion_tokens` spelling, so a source that only stated the latter - * still lands here as `max_tokens`. - */ export const anthropicMessagesSettingsFromIR = (settings: IRInputSettings): Omit, 'model'> => { const ceiling = settings.max_output_tokens ?? settings.max_visible_output_tokens; const format = settings.output_format; @@ -300,12 +291,7 @@ export const anthropicMessagesSettingsFromIR = (settings: IRInputSettings): Omit ? { format: { type: 'json_schema' as const, schema: format.schema } } : undefined; - // Anthropic splits the single effort axis across two slots: `thinking` and - // `output_config.effort`. A stated budget is the more specific quantity, so it - // decides the thinking slot — zero being this axis's off switch — while the - // level states the effort. With no budget, an effort of `none` moves to the - // off switch, because this scale has no `none` of its own. Whether the target - // honours the off switch is the target's business. + // Messages separates thinking mode and budget from output_config.effort. Dynamic thinking takes precedence over a numeric budget. // https://platform.claude.com/docs/en/build-with-claude/effort const thinking = settings.reasoning_budget_dynamic === true ? { type: 'adaptive' as const } diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts index bf5fffff51..bab90f0c82 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts @@ -111,9 +111,7 @@ const assistantItems = (content: GeminiGenerateContentContent, contentIndex: num case 'function_call': { const call = part.functionCall!; flush(); - // An absent `functionCall.args` stays absent: `JSON.stringify(undefined)` - // is `undefined`, so the field is omitted exactly as before. An explicit - // `null` keeps `null`, because both OpenAI protocols distinguish the two. + // Omitted args leaves argument text absent; explicit null becomes the JSON text "null". items.push({ type: 'function_call', ...(call.id === undefined ? {} : { call_id: call.id }), name: call.name, arguments: JSON.stringify(call.args) }); return; } @@ -129,8 +127,7 @@ const assistantItems = (content: GeminiGenerateContentContent, contentIndex: num return; } case 'inline_data': - // A model part has no media variant on any target; the current routes - // reject it rather than flattening it. + throw new TranslateInputException('"inline_data" parts are not supported in model content.'); default: throw new TranslateInputException(`"${kind}" parts are not supported in model content.`); @@ -145,10 +142,7 @@ const systemParts = (content: GeminiGenerateContentContent | undefined): IRInput return text === null ? [] : [{ type: 'text', text }]; }; -// One IR turn per source turn. `partitionGeminiGenerateContentTurns` already -// folds consecutive same-role contents into one turn, so handing back one turn -// per content would re-split a grouping this reader had just made — the turn -// boundary the partition draws is the boundary the IR keeps. +// One partitioned source turn becomes one IR turn, including consecutive same-role contents. const turnToIRTurns = ( turn: GeminiGenerateContentTurn, contentIndex: number, @@ -171,19 +165,13 @@ const turnToIRTurns = ( }), }]; -/** A GenerateContent system instruction is the conversation's leading prompt. */ const geminiGenerateContentSystemToIR = (content: GeminiGenerateContentContent | undefined): IRInputTurn | undefined => { const parts = systemParts(content); return parts.length === 0 ? undefined : { kind: 'client', items: [{ type: 'prompt', role: 'system', content: parts }] }; }; -// GenerateContent declares functions in groups and narrows them through -// `toolConfig.functionCallingConfig`. The IR states that narrowing as -// `allowed_tools`, keeping the complete declaration list separate from which -// functions the model may call. -// https://github.com/googleapis/googleapis/blob/master/google/ai/generativelanguage/v1beta/content.proto -// (FunctionCallingConfig: `allowed_function_names` only applies to ANY and -// VALIDATED, and limits which functions the model calls.) +// GenerateContent restricts selectable functions through allowed_tools while retaining the complete declarations. +// https://github.com/googleapis/googleapis/blob/bc7e3baa28fbb223fa93782e130260fab8205bfc/google/ai/generativelanguage/v1beta/content.proto const toolsToIR = (payload: GeminiGenerateContentPayload): Pick => { const declarations = payload.tools?.flatMap(group => group.functionDeclarations ?? []) ?? []; const config = payload.toolConfig?.functionCallingConfig; @@ -249,7 +237,6 @@ export const readGeminiGenerateContentRequest = (payload: GeminiGenerateContentR }; }; -/** GenerateContent keeps every sampling knob inside `generationConfig`. */ const geminiGenerateContentSettingsToIR = (payload: GeminiGenerateContentRequest): IRInputSettings => { const config = payload.generationConfig; const budget = config?.thinkingConfig?.thinkingBudget; @@ -258,18 +245,12 @@ const geminiGenerateContentSettingsToIR = (payload: GeminiGenerateContentRequest // different quantity the caller may state instead, so it travels on its own // field and each target decides what it can do with it. const statedLevel = geminiGenerateContentReasoningEffortToIR(config?.thinkingConfig); - // A stated level beside a stated budget is the one combination GenerateContent - // refuses outright: the two knobs are mutually exclusive, and no model accepts - // both. Neither can be preferred here, because which one a model understands - // depends on its family, and a tie-break would invert a caller who asked for - // one and got the other. Refusing keeps this reader's answer the one Google - // itself gives. An empty level names no tier, so it is not a statement. + // Reject a simultaneous budget and non-empty thinking level. The empty string is exempt from this conflict check. // https://github.com/cline/cline/issues/7957 if (statedLevel !== undefined && statedLevel !== '' && budget !== undefined) { throw new TranslateInputException('Unable to submit request because thinking_budget and thinking_level are not supported together.'); } - // A budget of zero is GenerateContent's off switch, which every target spells - // on the effort axis. + // A zero thinking budget maps to disabled reasoning. const effort = statedLevel ?? (budget === 0 ? 'none' : undefined); const schema = config === undefined ? undefined : geminiResponseSchema(config); const outputFormat: IRInputOutputFormat | undefined = schema !== undefined diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts index 5cba0ab920..c5a157ae8f 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts @@ -210,9 +210,6 @@ const clientItems = (message: Exclude { if (tool.type === 'custom') { const format = tool.custom.format; @@ -310,10 +307,7 @@ const openaiChatCompletionsSettingsToIR = (payload: OpenAIChatCompletionsPayload ...(payload.n == null ? {} : { choice_count: payload.n }), ...(payload.presence_penalty === undefined ? {} : { presence_penalty: payload.presence_penalty }), ...(payload.frequency_penalty === undefined ? {} : { frequency_penalty: payload.frequency_penalty }), - // `include_usage` alone asks for the final report; the vLLM/SGLang extension - // asks for one on every chunk. Both are the client's own rendering choice, - // so the field states what it asked for and a target never receives it. - // https://github.com/vllm-project/vllm/blob/d5f0a6e829faa69d1db289bf62b14dae136c02b2/vllm/entrypoints/openai/serving_engine.py#L112-L121 + }; }; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts index 82afe195e1..091dd477eb 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts @@ -88,9 +88,7 @@ const assistantMessage = (items: readonly IRInputAssistantItemWithCallIds[]): Op for (const part of item.content) if (part.type === 'text') text.push(part.text); else refusals.push(part.refusal); break; case 'reasoning': - // `reasoning_text` is the extension the gateway already emits for - // translated output; it is a target-only spelling here because no reader - // can identify which alias a Chat source meant. + // We write readable reasoning as reasoning_text; input IR does not retain the source alias spelling. if (item.summary !== undefined) reasoning.push(item.summary.join('')); if (item.content !== undefined) reasoning.push(item.content.join('')); break; @@ -98,16 +96,12 @@ const assistantMessage = (items: readonly IRInputAssistantItemWithCallIds[]): Op toolCalls.push({ id: item.call_id, type: 'function', - // Chat requires `arguments`, and the IR states an absent value for a - // call that takes no argument object at all; the empty object is that - // call's argument list written out. + // Chat requires argument text; omitted source arguments use an empty JSON object. function: { name: item.name, arguments: item.arguments ?? '{}' }, }); break; case 'custom_tool_call': - // Chat Completions must express a freeform tool call as a function call - // whose single argument is `input`; the surrounding route restores the - // custom shape on the response path. + // Freeform calls use the same function wrapper as their declarations; the response path restores the custom shape. toolCalls.push({ id: item.call_id, type: 'function', function: { name: item.name, arguments: JSON.stringify({ input: item.input }) } }); break; } @@ -181,13 +175,6 @@ export const openaiChatCompletionsConversationFromIR = ( ? assistantMessage(turn.items) : clientMessages(turn.items, legacyCallNames)); -/** - * Writes the settings onto a Chat Completions payload. `max_tokens` is - * deprecated and incompatible with the o-series models, so the ceiling is - * stated as `max_completion_tokens`. - * - * https://github.com/openai/openai-openapi/blob/master/openapi.yaml (max_tokens) - */ // OpenAI's strict-mode validators reject an object schema without `properties`, // which is a target-side constraint rather than something the IR states. const normalizeParameters = (schema: Readonly>): Record => { @@ -217,8 +204,7 @@ const toolFromIR = (tool: IRInputTool): OpenAIChatCompletionsTool => { name: tool.name, ...(tool.description === undefined ? {} : { description: tool.description }), ...(tool.parameters === undefined ? {} : { parameters: normalizeParameters(tool.parameters) }), - // Chat defaults a function tool to non-strict, so an omitted value states - // that default rather than letting the field go missing. + // Keep omitted strictness absent so the Chat target applies its own default. ...(tool.strict === undefined ? {} : { strict: tool.strict }), }, }; @@ -254,11 +240,7 @@ export const openaiChatCompletionsHeaderFromIR = (request: IRInputRequestHeader) // A trip guarantees a streaming request; a request that says otherwise // cannot be carried by this side at all. stream: requireStreaming(request.settings), - // A streamed request asks the upstream for usage on every chunk, not only - // the last one. `include_usage` is the OpenAI-standard flag; - // `continuous_usage_stats` is the vLLM/SGLang extension that lets a - // translation state input tokens before any output exists. Upstreams that - // do not implement the extension ignore it. + // Request final usage and the vLLM extension for cumulative usage on each chunk. // https://github.com/vllm-project/vllm/blob/d5f0a6e829faa69d1db289bf62b14dae136c02b2/vllm/entrypoints/generate/base/protocol.py#L241-L243 stream_options: { include_usage: true, continuous_usage_stats: true } as OpenAIChatCompletionsStreamOptionsEx, ...(tools === undefined || tools.length === 0 ? {} : { tools: tools.map(toolFromIR) }), @@ -276,6 +258,8 @@ export const openaiChatCompletionsSettingsFromIR = (settings: IRInputSettings): const reasoningEffort = chatReasoningEffort(settings); return { + // Use the replacement cap spelling, which includes reasoning tokens. + // https://github.com/openai/openai-node/blob/61539248cbe04665de68a71e6fd878127ae4db87/src/resources/chat/completions/completions.ts ...(ceiling === undefined ? {} : { max_completion_tokens: ceiling }), ...(settings.temperature === undefined ? {} : { temperature: settings.temperature }), ...(settings.top_p === undefined ? {} : { top_p: settings.top_p }), diff --git a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts index d92118176e..7385611009 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts @@ -165,8 +165,7 @@ const clientItems = (items: readonly CanonicalOpenAIResponsesInputItem[]): IRInp tool_kind: 'custom' as const, ...(item.status === 'incomplete' ? { is_error: true } : {}), }]; - // A deferred inventory states declarations, which the request's tool list - // already carries; it is not something the model said. + // Deferred inventories contribute declarations to the request header; they are not conversational model output. case 'additional_tools': case 'tool_search_output': return []; @@ -175,7 +174,6 @@ const clientItems = (items: readonly CanonicalOpenAIResponsesInputItem[]): IRInp } }); -/** The top-level `instructions` field as its own leading system item. */ const openaiResponsesInstructionsToIR = (instructions: string | null | undefined): IRInputTurn | undefined => instructions ? { kind: 'client', items: [{ type: 'prompt', role: 'system', content: [{ type: 'text', text: instructions }] }] } : undefined; @@ -183,18 +181,12 @@ const openaiResponsesInstructionsToIR = (instructions: string | null | undefined const openaiResponsesTurnToIR = (turn: OpenAIResponsesConversationTurn): IRInputTurn[] => { const items = turn.items; if (turn.role !== 'assistant') return [{ kind: 'client', items: clientItems(items) }]; - // Every assistant item was dropped (an opaque reasoning carrier, say). The - // turn carried a boundary that must survive, so writers still see it as a - // separator but emit no message for it. + // Keep an assistant turn even when all its items are dropped, preserving its boundary between client turns. return [{ kind: 'assistant', items: assistantItems(items) }]; }; -// Responses declares flat function and custom tools beside the hosted kinds. -// A hosted tool runs on the vendor's own infrastructure and has no meaning in -// another protocol, so a request that carries one cannot enter this IR. const toolToIR = (tool: Extract, namespace?: string): IRInputTool => { - // A declaration this protocol groups states its group beside the name inside - // it, and every other protocol reads that pair as the callable's identity. + // Preserve namespace/name identity here; flat-name targets allocate wire names later. const group = namespace ?? ('namespace' in tool && typeof tool.namespace === 'string' ? tool.namespace : undefined); const identity = group === undefined ? {} : { namespace: group }; if (tool.type === 'custom') { @@ -316,10 +308,6 @@ const openaiResponsesToolsToIR = (payload: CanonicalOpenAIResponsesPayload): Pic }; }; -/** - * Responses states the ceiling once, as `max_output_tokens`. Its `text.format` - * carries the structured-output schema flat, unlike Chat's nested spelling. - */ const openaiResponsesSettingsToIR = (payload: CanonicalOpenAIResponsesPayload): IRInputSettings => { const format = payload.text?.format; const outputFormat: IRInputOutputFormat | undefined = format === undefined || format === null ? undefined diff --git a/packages/translate/src/protocols/openai-responses/as-target/input-from.ts b/packages/translate/src/protocols/openai-responses/as-target/input-from.ts index dbf22069a7..cfca9b3dcd 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/input-from.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/input-from.ts @@ -50,9 +50,7 @@ const toolResultOutput = (output: string | readonly IRInputToolResultContentPart return fileContent(part.file, part.filename); }); -// Responses accepts a bare string or a part array. The array is the general -// spelling, so it is used unconditionally: the IR states each part and the -// target keeps them, without a writer having to know how the source phrased it. +// The content-array projection preserves IR part boundaries. const userContent = (item: Extract): OpenAIResponsesInputContent[] => item.content.map(part => { switch (part.type) { case 'text': return { type: 'input_text' as const, text: part.text }; @@ -87,9 +85,7 @@ const assistantItems = (items: readonly IRInputAssistantItemWithCallIds[]): Cano type: 'function_call', call_id: item.call_id, name: item.name, - // Responses requires `arguments`, and the IR states an absent value for a - // call that takes no argument object at all; the empty object is that - // call's argument list written out. + // Responses requires argument text; omitted source arguments use an empty JSON object. arguments: item.arguments ?? '{}', status: 'completed', }; @@ -109,9 +105,7 @@ const clientItems = (items: readonly IRInputClientItemWithCallIds[]): CanonicalO return { type: 'message', role: item.role, content }; } case 'message': { - // One text part is what a plain prompt states, and Responses spells a - // text message as a bare string; several parts keep their array so the - // boundaries between them survive. + // A single text part uses bare-string content; multipart user content retains its part boundaries. if (item.content.length === 1 && item.content[0].type === 'text') { return { type: 'message', role: 'user', content: item.content[0].text }; } @@ -177,11 +171,6 @@ const requireSchema = (format: IRInputOutputFormat): Readonly { const toolChoiceFromIR = (choice: IRInputToolChoice): NonNullable => { if (typeof choice === 'string') return choice; - // Responses states a narrowed declaration set natively, which is how the IR's - // `allowed_tools` is expressed on this wire. + // Responses expresses the permitted callable subset in tool_choice.allowed_tools. if (choice.type === 'allowed_tools') { return { type: 'allowed_tools', @@ -293,9 +281,7 @@ export const openaiResponsesSettingsFromIR = (settings: IRInputSettings): Partia format: format.type === 'json_schema' ? { type: 'json_schema' as const, - // Responses requires both a name and a schema; the IR makes them - // optional because Chat and Messages spell them differently. A - // source without a schema cannot be expressed here at all. + // Responses JSON Schema output requires a schema and a name; an omitted source name uses a stable fallback. name: format.name ?? 'response', schema: requireSchema(format), ...(format.description === undefined ? {} : { description: format.description }), From c0b82c0dbf1a0d7690476580ea360fbfe5159eed Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:16:06 +0800 Subject: [PATCH 19/66] docs(translate): explain consecutive upstream choice indexes Keep the reviewed choice-count rationale beside the owning builder and gap assertion. Additional consecutive choices remain valid and skipped indexes remain malformed. --- packages/translate/__tests__/ir/edge-cases_test.ts | 1 + packages/translate/src/ir/stream.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/packages/translate/__tests__/ir/edge-cases_test.ts b/packages/translate/__tests__/ir/edge-cases_test.ts index 379df16b2a..eb0dbe9dcb 100644 --- a/packages/translate/__tests__/ir/edge-cases_test.ts +++ b/packages/translate/__tests__/ir/edge-cases_test.ts @@ -117,6 +117,7 @@ test('ChatCompletions ignores late identity changes and accepts later model upda }); test('ChatCompletions choice indexes must arrive in sequence', async () => { + // Choice indexes open consecutively from zero; opening choice 1 first leaves a gap. await expect(collectIR(irFromOpenAIChatCompletions(iterate([ eventFrame({ id: 'chat_choices', model: 'served', choices: [{ index: 1, delta: {}, finish_reason: 'stop' }] } as any), ])))).rejects.toThrow('out of sequence'); diff --git a/packages/translate/src/ir/stream.ts b/packages/translate/src/ir/stream.ts index 13fc9677b5..aaf3fb5c75 100644 --- a/packages/translate/src/ir/stream.ts +++ b/packages/translate/src/ir/stream.ts @@ -49,6 +49,10 @@ export interface IRChoiceHandle extends IRWriteHandle { textParts: () => readonly { item: IRItemHandle; part: IRPartHandle; text: string }[]; } +/** + * Open choices in consecutive upstream index order. Skipped indices are malformed; + * additional consecutive choices remain valid. + */ export const createIRBuilder = () => { const collector = createIRCollector(); const { state } = collector; From a399acda02cf358c2fce8bacc46e14b6971cae17 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:16:12 +0800 Subject: [PATCH 20/66] test: align composed routes with finalized helper contracts Use the existing test helper owners and their import order throughout input translation regressions. Match the retained output-adapter startup diagnostic while preserving the missing-model failure assertion. --- .../translate/__tests__/protocols/call-association_test.ts | 2 +- .../translate/__tests__/protocols/callable-names_test.ts | 2 +- .../__tests__/protocols/chat-legacy-round-trip_test.ts | 2 +- .../translate/__tests__/protocols/copy-ownership_test.ts | 2 +- packages/translate/__tests__/protocols/edge-cases_test.ts | 6 +++--- packages/translate/__tests__/protocols/fixtures.ts | 2 +- packages/translate/__tests__/protocols/lifecycle_test.ts | 2 +- .../as-source/continuous-usage_test.ts | 2 +- .../protocols/openai-responses/as-target/snapshots_test.ts | 2 +- packages/translate/__tests__/protocols/ownership_test.ts | 4 ++-- packages/translate/__tests__/protocols/routes_test.ts | 4 ++-- packages/translate/__tests__/protocols/streams_test.ts | 2 +- packages/translate/__tests__/protocols/usage_test.ts | 7 ++++--- .../translate/src/protocols/openai-responses/as-source.ts | 2 +- 14 files changed, 21 insertions(+), 20 deletions(-) diff --git a/packages/translate/__tests__/protocols/call-association_test.ts b/packages/translate/__tests__/protocols/call-association_test.ts index 41d08b22e2..d56668f4dd 100644 --- a/packages/translate/__tests__/protocols/call-association_test.ts +++ b/packages/translate/__tests__/protocols/call-association_test.ts @@ -2,8 +2,8 @@ import { expect, test, vi } from 'vitest'; import { openaiResponsesSourceProtocol } from '../../src/protocols/openai-responses/as-source.ts'; import { toOpenAIResponsesInputAssistantTurn } from '../../src/protocols/openai-responses/as-target/assistant-turn.ts'; -import { iterate } from '../shared/iterables.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { iterate } from '../shared/iterables.ts'; import { expectTrip } from '../trip.ts'; import { createAnthropicMessagesUsage, toAnthropicMessagesUsageDelta, type AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; diff --git a/packages/translate/__tests__/protocols/callable-names_test.ts b/packages/translate/__tests__/protocols/callable-names_test.ts index a843f076c6..51132ebfd7 100644 --- a/packages/translate/__tests__/protocols/callable-names_test.ts +++ b/packages/translate/__tests__/protocols/callable-names_test.ts @@ -6,9 +6,9 @@ import { type IRRecord } from '../../src/ir/output/records.ts'; import { consumeIRRecords } from '../../src/ir/output/stream.ts'; import { anthropicMessagesTargetProtocol } from '../../src/protocols/anthropic-messages/as-target.ts'; import { openaiChatCompletionsTargetProtocol } from '../../src/protocols/openai-chat-completions/as-target.ts'; -import { iterate } from '../shared/iterables.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { createRoundTripReaderCapture } from '../round-trip/test-helpers.ts'; +import { iterate } from '../shared/iterables.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; 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 7fe331ae92..a4dd709709 100644 --- a/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts +++ b/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts @@ -1,8 +1,8 @@ import { expect, test } from 'vitest'; import { toOpenAIResponsesInputAssistantTurn } from '../../src/protocols/openai-responses/as-target/assistant-turn.ts'; -import { iterate } from '../shared/iterables.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { iterate } from '../shared/iterables.ts'; import { expectTrip } from '../trip.ts'; import { collectAnthropicMessagesProtocolEventsToResult, type AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; diff --git a/packages/translate/__tests__/protocols/copy-ownership_test.ts b/packages/translate/__tests__/protocols/copy-ownership_test.ts index 0457329e6e..c9f0ae3b40 100644 --- a/packages/translate/__tests__/protocols/copy-ownership_test.ts +++ b/packages/translate/__tests__/protocols/copy-ownership_test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; -import { collect, iterate } from '../shared/iterables.ts'; import { readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents } from './helpers.ts'; +import { collect, iterate } from '../shared/iterables.ts'; import { eventFrame } from '@floway-dev/protocols/common'; test.each([ diff --git a/packages/translate/__tests__/protocols/edge-cases_test.ts b/packages/translate/__tests__/protocols/edge-cases_test.ts index 47d120f436..2051b1b776 100644 --- a/packages/translate/__tests__/protocols/edge-cases_test.ts +++ b/packages/translate/__tests__/protocols/edge-cases_test.ts @@ -1,14 +1,14 @@ import { expect, test, vi } from 'vitest'; -import { writeAnthropicMessagesEvents, writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; -import { collectIR, completeIR } from '../ir/output/helpers.ts'; -import { collect, iterate } from '../shared/iterables.ts'; import { fixtureFrames, nativeResult } from './fixtures.ts'; +import { writeAnthropicMessagesEvents, writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; import { createIRBuilder } from '../../src/ir/output/builder.ts'; import { readOpenAIChatCompletionsEvents as irFromOpenAIChatCompletionsProduction } from '../../src/protocols/openai-chat-completions/as-target/events.ts'; import { buildSourceEventsFor, buildTargetPayloadFor, emptyPayload, pairs } from '../composition/helpers.ts'; +import { collectIR, completeIR } from '../ir/output/helpers.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { createRoundTripReaderCapture } from '../round-trip/test-helpers.ts'; +import { collect, iterate } from '../shared/iterables.ts'; import { eventFrame } from '@floway-dev/protocols/common'; const events = (pair: readonly [any, any], payload: any, codec: any) => diff --git a/packages/translate/__tests__/protocols/fixtures.ts b/packages/translate/__tests__/protocols/fixtures.ts index 0e8e31bfde..4c451d2c0e 100644 --- a/packages/translate/__tests__/protocols/fixtures.ts +++ b/packages/translate/__tests__/protocols/fixtures.ts @@ -1,8 +1,8 @@ import { expect, test } from 'vitest'; import { decodeRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; -import { collect, iterate } from '../shared/iterables.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { collect, iterate } from '../shared/iterables.ts'; import { collectAnthropicMessagesProtocolEventsToResult } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, doneFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { collectGeminiGenerateContentProtocolEventsToResult } from '@floway-dev/protocols/gemini-generate-content'; diff --git a/packages/translate/__tests__/protocols/lifecycle_test.ts b/packages/translate/__tests__/protocols/lifecycle_test.ts index 74fe742f5d..aeac038d33 100644 --- a/packages/translate/__tests__/protocols/lifecycle_test.ts +++ b/packages/translate/__tests__/protocols/lifecycle_test.ts @@ -1,7 +1,7 @@ import { expect, test, vi } from 'vitest'; -import { writeAnthropicMessagesEvents, writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; import { nativeResult } from './fixtures.ts'; +import { writeAnthropicMessagesEvents, writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; import { IRAbsent } from '../../src/ir/output/absent.ts'; import type { IRRecord } from '../../src/ir/output/records.ts'; import { collectIR } from '../ir/output/helpers.ts'; 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 d6e9e9795e..a54be4f6d4 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,7 +1,7 @@ import { expect, test } from 'vitest'; -import { collect, iterate } from '../../../shared/iterables.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { collect, iterate } from '../../../shared/iterables.ts'; import { expectTrip } from '../../../trip.ts'; import { fixtureFrames } from '../../fixtures.ts'; import type { OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; diff --git a/packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts index 4b3a58b0e8..8a57aa52e4 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts @@ -1,8 +1,8 @@ import { expect, test } from 'vitest'; import { collectIR } from '../../../ir/output/helpers.ts'; -import { collect, iterate } from '../../../shared/iterables.ts'; import { createRoundTripReaderCapture } from '../../../round-trip/test-helpers.ts'; +import { collect, iterate } from '../../../shared/iterables.ts'; import { readOpenAIResponsesEvents } from '../../helpers.ts'; import { eventFrame } from '@floway-dev/protocols/common'; diff --git a/packages/translate/__tests__/protocols/ownership_test.ts b/packages/translate/__tests__/protocols/ownership_test.ts index 7d73630343..705ccd72e1 100644 --- a/packages/translate/__tests__/protocols/ownership_test.ts +++ b/packages/translate/__tests__/protocols/ownership_test.ts @@ -1,9 +1,9 @@ import { expect, test, vi } from 'vitest'; +import { nativeResult } from './fixtures.ts'; +import { writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; import { collectIR } from '../ir/output/helpers.ts'; import { collect, iterate } from '../shared/iterables.ts'; -import { writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; -import { nativeResult } from './fixtures.ts'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; const chunk = (delta: any, extra: any = {}) => eventFrame({ id: 'chat_owner', model: 'served', created: 1, choices: [{ index: 0, delta, finish_reason: null, ...extra }] } as any); diff --git a/packages/translate/__tests__/protocols/routes_test.ts b/packages/translate/__tests__/protocols/routes_test.ts index 56b9177840..d35dd649b2 100644 --- a/packages/translate/__tests__/protocols/routes_test.ts +++ b/packages/translate/__tests__/protocols/routes_test.ts @@ -1,5 +1,6 @@ import { test } from 'vitest'; +import { fixtureFrames, type nativeResult } from './fixtures.ts'; import { partitionAnthropicMessagesTurns } from '../../src/protocols/anthropic-messages/as-source/conversation-turns.ts'; import { findAnthropicMessagesSidecarOccurrences } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; import { findGeminiGenerateContentSidecarOccurrences } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; @@ -12,9 +13,8 @@ import { toOpenAIResponsesInputAssistantTurn } from '../../src/protocols/openai- import { decodeRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; import { requireJSONObject } from '../../src/shared/json/object.ts'; import type { TranslateInputError } from '../../src/types.ts'; -import { collect, iterate } from '../shared/iterables.ts'; -import { fixtureFrames, type nativeResult } from './fixtures.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { collect, iterate } from '../shared/iterables.ts'; import { expectTrip } from '../trip.ts'; import { collectAnthropicMessagesProtocolEventsToResult, type AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; import { collectGeminiGenerateContentProtocolEventsToResult, type GeminiGenerateContentContent, type GeminiGenerateContentRequest } from '@floway-dev/protocols/gemini-generate-content'; diff --git a/packages/translate/__tests__/protocols/streams_test.ts b/packages/translate/__tests__/protocols/streams_test.ts index 3431214725..45421aeb08 100644 --- a/packages/translate/__tests__/protocols/streams_test.ts +++ b/packages/translate/__tests__/protocols/streams_test.ts @@ -482,7 +482,7 @@ describe('output IR streaming', () => { ['Messages', writeAnthropicMessagesEvents], ['GenerateContent', writeGeminiGenerateContentEvents], ] as const)('%s rejects output IR start without a model', async (_name, adapter) => { - await expect(collect(adapter(iterate([{ type: 'start', id: 'source' }] as any), options))).rejects.toThrow('requires an output IR model'); + await expect(collect(adapter(iterate([{ type: 'start', id: 'source' }] as any), options))).rejects.toThrow('requires an IR model'); }); }); diff --git a/packages/translate/__tests__/protocols/usage_test.ts b/packages/translate/__tests__/protocols/usage_test.ts index 9a80422585..8ddcc21aaa 100644 --- a/packages/translate/__tests__/protocols/usage_test.ts +++ b/packages/translate/__tests__/protocols/usage_test.ts @@ -1,15 +1,16 @@ import { expect, test } from 'vitest'; -import { writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; -import { iterate } from '../shared/iterables.ts'; import { fixtureFrames } from './fixtures.ts'; +import { writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; import { usageFromIR as messagesUsageFromIR } from '../../src/protocols/anthropic-messages/as-source/usage.ts'; import { usageToIR as messagesUsageToIR } from '../../src/protocols/anthropic-messages/as-target/usage.ts'; import { usageFromIR } from '../../src/protocols/gemini-generate-content/as-source/usage.ts'; import { usageFromIR as chatUsageFromIR } from '../../src/protocols/openai-chat-completions/as-source/usage.ts'; import { usageToIR as chatUsageToIR } from '../../src/protocols/openai-chat-completions/as-target/usage.ts'; import { usageFromIR as responsesUsageFromIR } from '../../src/protocols/openai-responses/as-source/usage.ts'; -import { usageToIR as responsesUsageToIR } from '../../src/protocols/openai-responses/as-target/usage.ts';import { collectIR } from '../ir/output/helpers.ts'; +import { usageToIR as responsesUsageToIR } from '../../src/protocols/openai-responses/as-target/usage.ts'; +import { collectIR } from '../ir/output/helpers.ts'; +import { iterate } from '../shared/iterables.ts'; import { eventFrame } from '@floway-dev/protocols/common'; const initialUsage = { input_tokens: 17, output_tokens: 2, total_tokens: 19, input_tokens_details: { cached_tokens: 3, cache_write_tokens: 4 } }; diff --git a/packages/translate/src/protocols/openai-responses/as-source.ts b/packages/translate/src/protocols/openai-responses/as-source.ts index 1911f261f2..8168e7fb4e 100644 --- a/packages/translate/src/protocols/openai-responses/as-source.ts +++ b/packages/translate/src/protocols/openai-responses/as-source.ts @@ -1,7 +1,7 @@ import { restateOpenAIResponsesError } from './as-source/error-body.ts'; import { writeOpenAIResponsesEvents } from './as-source/events.ts'; -import { createRandomOpenAIResponsesResponseId } from './as-source/response-id.ts'; import { readOpenAIResponsesRequest } from './as-source/input-to.ts'; +import { createRandomOpenAIResponsesResponseId } from './as-source/response-id.ts'; import { collectOpenAIResponsesReplayContents, findOpenAIResponsesSidecarOccurrences, verifyOpenAIResponsesTurnDigest } from './as-source/round-trip.ts'; import type { SourceProtocol } from '../../composition/index.ts'; import type { OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; From 20a322019afa4977613dc9ef402504ea370a127d Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:16:52 +0800 Subject: [PATCH 21/66] docs(translate): explain native error ownership contracts Move reviewed error-format explanations to dedicated protocol error owners and HTTP wrappers. Clarify diagnostic field projection, unknown-body passthrough, and replacement headers while preserving the existing status policy and envelope metadata behavior. --- .../as-source/error-body_test.ts | 2 ++ .../anthropic-messages/as-source/error-body.ts | 1 + .../anthropic-messages/as-source/error.ts | 2 ++ .../as-source/error-body.ts | 4 ++++ .../gemini-generate-content/as-source/error.ts | 11 +++++++++-- .../as-source/error-body.ts | 1 + .../openai-responses/as-source/error-body.ts | 4 ++++ packages/translate/src/shared/api-error.ts | 14 +++++++++++++- 8 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-source/error-body_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/error-body_test.ts index ddf1fbf724..b6216750a9 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-source/error-body_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-source/error-body_test.ts @@ -31,6 +31,8 @@ test.each([ ['message-only signal', { error: { message: "This model's maximum context length is 4097 tokens. However, your messages resulted in 4498 tokens." } }], ])('restates a context overflow from the %s as the prompt-too-long wording', (_name, body) => { const result = restateAnthropicMessagesError(...upstreamOf(502, body)); + // Use our 400 invalid_request_error mapping and the prompt-too-long compatibility prefix. + // Claude Code recognizes that substring without requiring this complete message or HTTP status. assertEquals(result!.status, 400); assertEquals(result!.headers.get('content-type'), 'application/json'); const parsed = decoded(result); diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/error-body.ts b/packages/translate/src/protocols/anthropic-messages/as-source/error-body.ts index 68a1a70741..6ca5ff6f0f 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/error-body.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/error-body.ts @@ -2,6 +2,7 @@ import { anthropicMessagesErrorEnvelope } from './error.ts'; import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; import type { ApiErrorTranslator } from '../../../types.ts'; +/** Reuse the stream error envelope for HTTP failures; unrecognized bodies remain verbatim upstream responses. */ export const restateAnthropicMessagesError: ApiErrorTranslator = (upstreamStatus, headers, upstreamBody) => { const error = irErrorPayloadFromBody(upstreamBody); if (error === undefined) return undefined; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/error.ts b/packages/translate/src/protocols/anthropic-messages/as-source/error.ts index fc45587217..0b6f00e81c 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/error.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/error.ts @@ -14,6 +14,7 @@ const MESSAGES_ERROR_TYPES = [ 'overloaded_error', ]; +/** HTTP and SSE share the Messages error object. Context overflow uses the shared prompt-too-long compatibility message. */ const messagesError = (error: IRErrorPayload, contextExceeded: boolean): AnthropicMessagesErrorEventEx['error'] => { const type = contextExceeded ? 'invalid_request_error' @@ -26,6 +27,7 @@ const messagesError = (error: IRErrorPayload, contextExceeded: boolean): Anthrop export const anthropicMessagesErrorFromIR = (error: IRErrorPayload): AnthropicMessagesErrorEventEx['error'] => messagesError(error, isContextExceededError(error)); +/** Classify overflow once so its compatibility message and our chosen 400 mapping stay consistent. */ export const anthropicMessagesErrorEnvelope = (error: IRErrorPayload): { readonly body: unknown; readonly status: number | undefined } => { const contextExceeded = isContextExceededError(error); return { diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/error-body.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/error-body.ts index fd77080bea..bb8995b3c9 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/error-body.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/error-body.ts @@ -2,6 +2,10 @@ import { geminiGenerateContentErrorFromIR } from './error.ts'; import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; import type { ApiErrorTranslator } from '../../../types.ts'; +/** + * Restate recognized failures as RPC error envelopes using the native error constructor's status. + * Unrecognized bodies pass through verbatim. + */ export const restateGeminiGenerateContentError: ApiErrorTranslator = (upstreamStatus, headers, upstreamBody) => { const error = irErrorPayloadFromBody(upstreamBody); if (error === undefined) return undefined; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts index 7c2b974075..33bf6e291b 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts @@ -3,7 +3,10 @@ import { errorDetailsOf } from '../../../shared/api-error.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; import type { GeminiGenerateContentErrorResponse } from '@floway-dev/protocols/gemini-generate-content'; -// https://ai.google.dev/api/rest/v1beta/Status +/** + * Use the Google RPC status vocabulary for the numeric error code. + * https://ai.google.dev/api/rest/v1beta/Status + */ const statusNameOf = (status: number): string => { switch (status) { case 400: return 'INVALID_ARGUMENT'; @@ -21,9 +24,13 @@ const statusNameOf = (status: number): string => { } }; +/** + * HTTP failures supply an upstream status; streaming failures derive a code from the error kind. + * Context overflow selects our local 400 code. + */ export const geminiGenerateContentErrorFromIR = (error: IRErrorPayload, httpStatus?: number): GeminiGenerateContentErrorResponse['error'] => { const details = errorDetailsOf(error, ['message']); - // Arbitrary error fields use the source's Struct details container. + // Preserve unmodeled diagnostic fields in a chosen Struct Any inside details. // https://protobuf.dev/reference/protobuf/google.protobuf/#struct const extension = details === undefined ? {} : { details: [{ '@type': 'type.googleapis.com/google.protobuf.Struct', value: details }] }; const kind = error.code ?? error.type; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/error-body.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/error-body.ts index 84db14ec20..4018a309b8 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/error-body.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/error-body.ts @@ -2,6 +2,7 @@ import { openaiChatCompletionsErrorFromIR } from './error.ts'; import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; import type { ApiErrorTranslator } from '../../../types.ts'; +/** Reuse the stream error object, preserve upstream HTTP status, and pass unrecognized bodies through unchanged. */ export const restateOpenAIChatCompletionsError: ApiErrorTranslator = (upstreamStatus, headers, upstreamBody) => { const error = irErrorPayloadFromBody(upstreamBody); if (error === undefined) return undefined; diff --git a/packages/translate/src/protocols/openai-responses/as-source/error-body.ts b/packages/translate/src/protocols/openai-responses/as-source/error-body.ts index 0463d6c678..196dbc7dc5 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/error-body.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/error-body.ts @@ -2,6 +2,10 @@ import { openaiResponsesErrorEventOf } from './error.ts'; import { irErrorPayloadFromBody, restateError } from '../../../shared/api-error.ts'; import type { ApiErrorTranslator } from '../../../types.ts'; +/** + * Before a response resource exists, render recognized failures as Responses error events. + * Preserve upstream status and unrecognized bodies. + */ export const restateOpenAIResponsesError: ApiErrorTranslator = (upstreamStatus, headers, upstreamBody) => { const error = irErrorPayloadFromBody(upstreamBody); if (error === undefined) return undefined; diff --git a/packages/translate/src/shared/api-error.ts b/packages/translate/src/shared/api-error.ts index 2a95afa549..561d55b4e6 100644 --- a/packages/translate/src/shared/api-error.ts +++ b/packages/translate/src/shared/api-error.ts @@ -6,7 +6,10 @@ const recordOf = (value: unknown): Readonly> | undefined const stringOf = (value: unknown): string | undefined => typeof value === 'string' ? value : undefined; -// Unknown bodies keep their original bytes; parser failures other than invalid syntax propagate. +/** + * Recognize structured upstream error envelopes for cross-protocol rendering. Undefined includes + * malformed JSON, invalid UTF-8 and unrecognized bodies, preserving the original response untouched. + */ export const irErrorPayloadFromBody = (body: Uint8Array): IRErrorPayload | undefined => { let text: string; try { @@ -29,6 +32,7 @@ export const irErrorPayloadFromBody = (body: Uint8Array): IRErrorPayload | undef return payload === undefined ? undefined : withEnvelopeDetails(payload, details); }; +/** Restatement requires a message; preserve every unrepresented field in diagnostic details. */ export const errorPayloadOf = (error: Readonly>): IRErrorPayload | undefined => { const message = stringOf(error.message) ?? stringOf(error.msg); if (message === undefined) return undefined; @@ -76,6 +80,7 @@ const errorEventPayloadOf = (event: Readonly>) => { export const errorPayloadFromEvent = (event: Readonly>): IRErrorPayload => errorPayloadFromWire(errorEventPayloadOf(event)); +/** Target adapters own wire shaping; source writers own extension-field projection. */ // Canonical wire payloads retain identity when their envelope adds no metadata. export const errorPayloadFromWire = (wire: unknown): IRErrorPayload => { const { source, details } = errorEnvelopeOf(recordOf(wire) ?? {}); @@ -85,6 +90,7 @@ export const errorPayloadFromWire = (wire: unknown): IRErrorPayload => { const { msg: _msg, ...fields } = source; return withEnvelopeDetails({ ...fields, message: aliasMessage }, details); } + // Failures without message text still need an error frame; use an empty message. const extra = extensionOf(source, NAMED_ERROR_FIELDS); return withEnvelopeDetails({ message: '', ...(extra === undefined ? {} : { provider_specific_fields: extra }) }, details); }; @@ -104,9 +110,14 @@ const extensionOf = (error: Readonly>, representedFields return Object.keys(rest).length === 0 ? own : { ...rest, ...own }; }; +/** Each source writer lists fields represented in its envelope; all remaining values stay in diagnostic extensions. */ export const errorDetailsOf = (error: IRErrorPayload, representedFields: readonly string[]): Readonly> | undefined => extensionOf(error, new Set(representedFields)); +/** + * Return a JSON envelope without transport encoding. Preserve semantic upstream headers while + * replacing the media type and removing length and encoding headers tied to the replaced body. + */ export const restateError = ( status: number, headers: Headers, @@ -120,6 +131,7 @@ export const restateError = ( const restatedHeaders = (headers: Headers): Headers => { const kept = new Headers(headers); kept.set('content-type', 'application/json'); + // A replacement body can change length and encoding, so remove both upstream body headers. kept.delete('content-length'); kept.delete('content-encoding'); return kept; From 4792e62f6e71233e7aa90f76feb109b5b801af01 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:17:00 +0800 Subject: [PATCH 22/66] docs(gateway): explain Gemini input error field carrier Keep the reviewed Struct Any explanation beside the public-boundary field-preservation matrix. --- .../data-plane/chat/gemini-generate-content/errors_test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/errors_test.ts b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/errors_test.ts index b999f6fccd..f05691c0cf 100644 --- a/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/errors_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/errors_test.ts @@ -27,6 +27,7 @@ test.each([ { name: 'neither field nor code', fields: {}, expected: undefined }, ])('inputErrorResult preserves $name exactly', ({ fields, expected }) => { const result = inputErrorResult({ message: 'invalid input', ...fields }); + // We preserve the translator's field and code in a Struct Any inside details. assertEquals(bodyOf(result).error, { code: 400, message: 'invalid input', From eb282f3bfff3324ee82248f07a3dde4a3617f303 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:17:26 +0800 Subject: [PATCH 23/66] test: preserve upstream choice cardinality regressions Retain both prerequisite choice-order and complete consecutive-choice tests while adapting the surrounding fixtures to composed input translation. --- .../__tests__/protocols/edge-cases_test.ts | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/translate/__tests__/protocols/edge-cases_test.ts b/packages/translate/__tests__/protocols/edge-cases_test.ts index 2051b1b776..2293bf443e 100644 --- a/packages/translate/__tests__/protocols/edge-cases_test.ts +++ b/packages/translate/__tests__/protocols/edge-cases_test.ts @@ -132,14 +132,28 @@ test('ChatCompletions ignores late identity changes and accepts later model upda expect((await collectIR(readOpenAIChatCompletionsEvents(iterate(input)))).model).toBe('fallback'); }); -test('ChatCompletions choice indexes must stay in sequence', async () => { - // A stream that opens choice 1 without ever stating choice 0 describes a - // candidate the request never had; the IR cannot represent the gap. +test('ChatCompletions choice indexes must arrive in sequence', async () => { await expect(collectIR(readOpenAIChatCompletionsEvents(iterate([ eventFrame({ id: 'chat_choices', model: 'served', choices: [{ index: 1, delta: {}, finish_reason: 'stop' }] } as any), ])))).rejects.toThrow('out of sequence'); }); +test('ChatCompletions accepts every consecutively reported upstream choice', async () => { + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate([ + eventFrame({ + id: 'chat_choices', model: 'served', choices: [ + { index: 0, delta: { content: 'first' }, finish_reason: 'stop' }, + { index: 1, delta: { content: 'second' }, finish_reason: 'stop' }, + ], + } as any), + ]))); + expect(ir.choices).toHaveLength(2); + expect(ir.choices.map(choice => choice.items)).toEqual([ + [{ type: 'message', content: [{ type: 'text', text: 'first' }] }], + [{ type: 'message', content: [{ type: 'text', text: 'second' }] }], + ]); +}); + test('Responses rejects output events before the initial envelope', async () => { await expect(collectIR(readOpenAIResponsesEvents(iterate([ eventFrame({ type: 'response.output_item.added', output_index: 0, item: { type: 'message', id: 'msg', role: 'assistant', content: [] } } as any), From dd936c1482b6604464b9f6704490e322832f863d Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:19:50 +0800 Subject: [PATCH 24/66] test(translate): group output error ownership checks Move the existing three immutable error identity cases together under their protocol ownership file, retaining every test body and assertion. --- .../__tests__/protocols/copy-ownership_test.ts | 16 ++++++++++++++++ .../__tests__/protocols/ownership_test.ts | 13 +------------ 2 files changed, 17 insertions(+), 12 deletions(-) create mode 100644 packages/translate/__tests__/protocols/copy-ownership_test.ts diff --git a/packages/translate/__tests__/protocols/copy-ownership_test.ts b/packages/translate/__tests__/protocols/copy-ownership_test.ts new file mode 100644 index 0000000000..c9f0ae3b40 --- /dev/null +++ b/packages/translate/__tests__/protocols/copy-ownership_test.ts @@ -0,0 +1,16 @@ +import { expect, test } from 'vitest'; + +import { readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents } from './helpers.ts'; +import { collect, iterate } from '../shared/iterables.ts'; +import { eventFrame } from '@floway-dev/protocols/common'; + +test.each([ + ['ChatCompletions', readOpenAIChatCompletionsEvents, (error: object) => ({ id: 'chat', model: 'm', choices: [], error })], + ['Responses', readOpenAIResponsesEvents, (error: object) => ({ type: 'error', error })], + ['Messages', readAnthropicMessagesEvents, (error: object) => ({ type: 'error', error })], +] as const)('%s reader transfers immutable error objects without copying', async (_name, reader, event) => { + const error = Object.freeze({ type: 'api_error', message: 'original', details: Object.freeze([Object.freeze({ text: 'original' })]) }); + const frames = await collect(reader(iterate([eventFrame(event(error) as any)]))); + const record = frames.find(record => record.type === 'error'); + expect(record?.error).toBe(error); +}); diff --git a/packages/translate/__tests__/protocols/ownership_test.ts b/packages/translate/__tests__/protocols/ownership_test.ts index 05d7a849e8..705ccd72e1 100644 --- a/packages/translate/__tests__/protocols/ownership_test.ts +++ b/packages/translate/__tests__/protocols/ownership_test.ts @@ -1,7 +1,7 @@ import { expect, test, vi } from 'vitest'; import { nativeResult } from './fixtures.ts'; -import { writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; +import { writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; import { collectIR } from '../ir/output/helpers.ts'; import { collect, iterate } from '../shared/iterables.ts'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; @@ -109,14 +109,3 @@ test('range-less Messages citations do not expand to whole-part ChatCompletions const generateContentFrames = await collect(writeGeminiGenerateContentEvents(readAnthropicMessagesEvents(iterate(messagesCitationFrames('source quote'))))); expect(generateContentFrames.some(frame => frame.type === 'event' && 'candidates' in frame.event && frame.event.candidates?.some(candidate => candidate.groundingMetadata !== undefined))).toBe(false); }); - -test.each([ - ['ChatCompletions', readOpenAIChatCompletionsEvents, (error: object) => ({ id: 'chat', model: 'm', choices: [], error })], - ['Responses', readOpenAIResponsesEvents, (error: object) => ({ type: 'error', error })], - ['Messages', readAnthropicMessagesEvents, (error: object) => ({ type: 'error', error })], -] as const)('%s reader transfers immutable error objects without copying', async (_name, reader, event) => { - const error = Object.freeze({ type: 'api_error', message: 'original', details: Object.freeze([Object.freeze({ text: 'original' })]) }); - const frames = await collect(reader(iterate([eventFrame(event(error) as any)]))); - const record = frames.find(record => record.type === 'error'); - expect(record?.error).toBe(error); -}); From 7c183cc5ce0164b1547f28bb47769e986d9ed4eb Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:20:47 +0800 Subject: [PATCH 25/66] fix: reserve modern call IDs during legacy replay pairing Allocate legacy Chat result bookkeeping IDs outside the complete modern call inventory and previously claimed IDs. This prevents a restored legacy call from reclassifying a later modern tool result.\n\nReproduce the collision through the public Gemini route using generated production-signed replay history, covering one and multiple future modern IDs. --- .../round-trip-call-identity_test.ts | 45 +++++++++++++++++++ .../as-target/legacy-calls.ts | 11 ++++- 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 packages/gateway/__tests__/data-plane/chat/gemini-generate-content/round-trip-call-identity_test.ts diff --git a/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/round-trip-call-identity_test.ts b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/round-trip-call-identity_test.ts new file mode 100644 index 0000000000..950009f15a --- /dev/null +++ b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/round-trip-call-identity_test.ts @@ -0,0 +1,45 @@ +import { expect, test } from 'vitest'; + +import { createSidecarCodecFactory, SidecarDomain } from '../../../../src/data-plane/chat/shared/sidecar-codec.ts'; +import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; +import { collectGeminiGenerateContentProtocolEventsToResult } from '@floway-dev/protocols/gemini-generate-content'; +import type { GeminiGenerateContentContent } from '@floway-dev/protocols/gemini-generate-content'; +import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; +import { routeGeminiGenerateContentViaOpenAIChatCompletions } from '@floway-dev/translate'; + +test.each([{ modernIds: ['legacy_call_1'] }, { modernIds: ['legacy_call_1', 'legacy_call_2'] }])('signed legacy Chat replay preserves later modern call IDs: $modernIds', async ({ modernIds }) => { + const codec = () => createSidecarCodecFactory('native-legacy-call-identity').codecFor(SidecarDomain.TranslatedAssistantTurn); + const question: GeminiGenerateContentContent = { role: 'user', parts: [{ text: 'question' }] }; + const first = await routeGeminiGenerateContentViaOpenAIChatCompletions({ model: 'model', contents: [question] }, { sidecarCodec: codec() }); + if (first.type === 'input-error') throw new Error(first.message); + const upstream = async function* (): AsyncGenerator> { + yield eventFrame({ + id: 'legacy', object: 'chat.completion.chunk', created: 1, model: 'model', + choices: [{ index: 0, delta: { role: 'assistant', function_call: { name: 'lookup', arguments: '{}' } }, finish_reason: 'function_call' }], + }); + yield doneFrame(); + }; + const produced = await collectGeminiGenerateContentProtocolEventsToResult(first.events(upstream())); + const native = produced.candidates?.[0]?.content; + if (native === undefined) throw new Error('Expected the generated Gemini assistant turn'); + expect(native.parts?.some(part => typeof part.thoughtSignature === 'string')).toBe(true); + + const replay = await routeGeminiGenerateContentViaOpenAIChatCompletions({ + model: 'model', + contents: [ + question, + native, + { role: 'user', parts: [{ functionResponse: { name: 'lookup', response: 'LEGACY' } }] }, + ...modernIds.flatMap((id, index): GeminiGenerateContentContent[] => [ + { role: 'model', parts: [{ functionCall: { id, name: `modern_${index}`, args: {} } }] }, + { role: 'user', parts: [{ functionResponse: { id, name: `modern_${index}`, response: `MODERN_${index}` } }] }, + ]), + ], + }, { sidecarCodec: codec() }); + if (replay.type === 'input-error') throw new Error(replay.message); + expect(replay.target.messages[1]).toMatchObject({ role: 'assistant', function_call: { name: 'lookup', arguments: '{}' } }); + expect(replay.target.messages[2]).toEqual({ role: 'function', name: 'lookup', content: '"LEGACY"' }); + expect(replay.target.messages.slice(3).filter(message => message.role !== 'assistant')).toEqual(modernIds.map((id, index) => ({ + role: 'tool', tool_call_id: id, content: `"MODERN_${index}"`, + }))); +}); diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts index df47a04549..b0fcd0e245 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts @@ -42,6 +42,7 @@ export const chatLegacyCallsOf = ( turns: readonly (IRInputTurn | RestoredAssistantTurn)[], ): ChatLegacyCalls => { const stated = statedCallIds(turns); + const claimed = new Set(stated); const callIds = new Map(); const names = new Map(); let allocated = 0; @@ -64,8 +65,14 @@ export const chatLegacyCallsOf = ( } continue; } - allocated += 1; - const callId = answer.call_id ?? `legacy_call_${allocated}`; + let callId = answer.call_id; + if (callId === undefined) { + do { + allocated += 1; + callId = `legacy_call_${allocated}`; + } while (claimed.has(callId)); + } + claimed.add(callId); callIds.set(piece.turn, callId); names.set(callId, name); } From dc4be3ae04291b04086541f747391e3d53668087 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:22:38 +0800 Subject: [PATCH 26/66] test: retain signed replay and late usage protocol coverage Use valid Gemini result objects in the production HMAC replay collision regression. Preserve the parser-to-translator-to-serializer-to-parser late usage scenario, including service tier and token totals, through the public Messages-to-Responses route. --- .../round-trip-call-identity_test.ts | 8 +++--- ...essages-to-openai-responses-events_test.ts | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/round-trip-call-identity_test.ts b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/round-trip-call-identity_test.ts index 950009f15a..d78c4d2ddf 100644 --- a/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/round-trip-call-identity_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/round-trip-call-identity_test.ts @@ -29,17 +29,17 @@ test.each([{ modernIds: ['legacy_call_1'] }, { modernIds: ['legacy_call_1', 'leg contents: [ question, native, - { role: 'user', parts: [{ functionResponse: { name: 'lookup', response: 'LEGACY' } }] }, + { role: 'user', parts: [{ functionResponse: { name: 'lookup', response: { label: 'LEGACY' } } }] }, ...modernIds.flatMap((id, index): GeminiGenerateContentContent[] => [ { role: 'model', parts: [{ functionCall: { id, name: `modern_${index}`, args: {} } }] }, - { role: 'user', parts: [{ functionResponse: { id, name: `modern_${index}`, response: `MODERN_${index}` } }] }, + { role: 'user', parts: [{ functionResponse: { id, name: `modern_${index}`, response: { label: `MODERN_${index}` } } }] }, ]), ], }, { sidecarCodec: codec() }); if (replay.type === 'input-error') throw new Error(replay.message); expect(replay.target.messages[1]).toMatchObject({ role: 'assistant', function_call: { name: 'lookup', arguments: '{}' } }); - expect(replay.target.messages[2]).toEqual({ role: 'function', name: 'lookup', content: '"LEGACY"' }); + expect(replay.target.messages[2]).toEqual({ role: 'function', name: 'lookup', content: '{"label":"LEGACY"}' }); expect(replay.target.messages.slice(3).filter(message => message.role !== 'assistant')).toEqual(modernIds.map((id, index) => ({ - role: 'tool', tool_call_id: id, content: `"MODERN_${index}"`, + role: 'tool', tool_call_id: id, content: `{"label":"MODERN_${index}"}`, }))); }); diff --git a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-events_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-events_test.ts index 3456ed4660..61ad07c086 100644 --- a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-events_test.ts +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-events_test.ts @@ -1,7 +1,33 @@ +import { expect, test } from 'vitest'; + import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; import { testOutputTranslation } from '../fixtures.ts'; +import { parseAnthropicMessagesStream, collectAnthropicMessagesProtocolEventsToResult, anthropicMessagesProtocolFrameToSSEFrame } from '@floway-dev/protocols/anthropic-messages'; +import { parseOpenAIResponsesStream } from '@floway-dev/protocols/openai-responses'; +import { routeAnthropicMessagesViaOpenAIResponses } from '@floway-dev/translate'; testOutputTranslation('anthropic-messages', 'openai-responses', (frames, codec) => { const [source, target] = pairs.messagesToResponses; return buildSourceEventsFor(source, target, emptyPayload.messages, { sidecarCodec: codec }, frames); }); + +test('Responses late fixed usage reaches the Messages client through full usage delta', async () => { + const response = { id: 'resp_usage', model: 'gpt-test', object: 'response', output: [], error: null, incomplete_details: null }; + const events = [ + { type: 'response.created', response: { ...response, status: 'in_progress' } }, + { type: 'response.completed', response: { ...response, status: 'completed', service_tier: 'priority', usage: { input_tokens: 3, output_tokens: 4, total_tokens: 7 } } }, + ]; + const sourceSSE = events.map((event, sequence_number) => `event: ${event.type}\ndata: ${JSON.stringify({ ...event, sequence_number })}\n\n`).join(''); + const trip = await routeAnthropicMessagesViaOpenAIResponses({ model: 'gpt-test', max_tokens: 64, stream: true, messages: [] }, { sidecarCodec: createTestSidecarCodec() }); + if (trip.type === 'input-error') throw new Error(trip.message); + let clientSSE = ''; + for await (const frame of trip.events(parseOpenAIResponsesStream(new Response(sourceSSE).body!))) { + const sse = anthropicMessagesProtocolFrameToSSEFrame(frame); + if (sse) clientSSE += `event: ${sse.event}\ndata: ${sse.data}\n\n`; + } + const result = await collectAnthropicMessagesProtocolEventsToResult(parseAnthropicMessagesStream(new Response(clientSSE).body!)); + expect(result.usage.service_tier).toBe('priority'); + expect(result.usage.input_tokens).toBe(3); + expect(result.usage.output_tokens).toBe(4); +}); From 0ced6b89dde6804cd19a6cbe8fc42469ac083bd3 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:25:43 +0800 Subject: [PATCH 27/66] test: retain the existing initialization regression path --- .../as-source/{initialization_test.ts => initialize_test.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/translate/__tests__/protocols/openai-responses/as-source/{initialization_test.ts => initialize_test.ts} (100%) diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/initialization_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts similarity index 100% rename from packages/translate/__tests__/protocols/openai-responses/as-source/initialization_test.ts rename to packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts From 3317e0a829aa9a4c6f9b0114cd1ef63b6ed97f6e Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:29:29 +0800 Subject: [PATCH 28/66] test: retain prerequisite event fixture ownership Keep the finalized output-event regression imports, callback locals and protocol-key fixture mapping at their existing owner versions. Input translation requires no changes to these test contracts. --- .../anthropic-messages/as-source/events_test.ts | 12 ++++++------ packages/translate/__tests__/protocols/fixtures.ts | 8 +++++--- .../gemini-generate-content/as-source/events_test.ts | 10 +++++----- .../openai-chat-completions/as-source/events_test.ts | 2 +- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-source/events_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/events_test.ts index 28d00eccc3..9f4768a45a 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-source/events_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-source/events_test.ts @@ -4,8 +4,8 @@ import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; import { writeAnthropicMessagesEvents } from '../../../../src/protocols/anthropic-messages/as-source/events.ts'; import { hashRoundTripContent } from '../../../../src/round-trip/hash.ts'; import { appendReadField, collectEmittedFieldPlacements, createReferenceBuilder, createReadFieldState, createFieldReferencer } from '../../../../src/round-trip/reference/reference-builder.ts'; -import { type RoundTripWriter } from '../../../../src/round-trip/writer.ts'; -import { type AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; +import type { RoundTripWriter } from '../../../../src/round-trip/writer.ts'; +import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; test.each(['text', 'refusal', 'audio'] as const)('resolves blocked %s against the emitted Messages text field', async kind => { const references = createReferenceBuilder(); @@ -31,8 +31,8 @@ test.each(['text', 'refusal', 'audio'] as const)('resolves blocked %s against th let prepared = false; const roundTrip: RoundTripWriter<'anthropicMessages'> = { builder: references, - encodeSidecar: async (_choice, _check, turn, state, textPlacements) => { - const views = collectEmittedFieldPlacements(turn, state, textPlacements); + encodeSidecar: async (_choice, _check, turn, state, textViews) => { + const views = collectEmittedFieldPlacements(turn, state, textViews); expect([...views.values()]).toContainEqual({ enclosingText: 'Hi', offset: 0 }); const finalizer = createFieldReferencer(references, views); expect(await finalizer.referenceField('Hi', field)).toMatchObject({ parts: [0] }); @@ -105,8 +105,8 @@ test.each(['{"x":1,}', '{"x": 1}'])('tracks the emitted canonical JSON view with let prepared = false; const roundTrip: RoundTripWriter<'anthropicMessages'> = { builder: references, - encodeSidecar: async (_choice, _check, turn, state, textPlacements) => { - const views = collectEmittedFieldPlacements(turn, state, textPlacements); + encodeSidecar: async (_choice, _check, turn, state, textViews) => { + const views = collectEmittedFieldPlacements(turn, state, textViews); expect([...views.values()]).toContainEqual({ enclosingText: '{"x":1}', offset: 0 }); const finalizer = createFieldReferencer(references, views); expect(await finalizer.referenceField(original, field)).toBe(original); diff --git a/packages/translate/__tests__/protocols/fixtures.ts b/packages/translate/__tests__/protocols/fixtures.ts index 4c451d2c0e..ffa020514f 100644 --- a/packages/translate/__tests__/protocols/fixtures.ts +++ b/packages/translate/__tests__/protocols/fixtures.ts @@ -1,5 +1,6 @@ import { expect, test } from 'vitest'; +import type { AssistantTurnProtocol } from '../../src/round-trip/input-assistant-turn.ts'; import { decodeRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { collect, iterate } from '../shared/iterables.ts'; @@ -11,12 +12,12 @@ import { collectOpenAIResponsesProtocolEventsToResult, openaiResponsesResultToEv type Wire = Record; export type IRTestProtocol = 'openai-chat-completions' | 'openai-responses' | 'anthropic-messages' | 'gemini-generate-content'; -const assistantTurnProtocolOf = { +const protocolKeys: Record = { 'openai-chat-completions': 'openaiChatCompletions', 'openai-responses': 'openaiResponses', 'anthropic-messages': 'anthropicMessages', 'gemini-generate-content': 'geminiGenerateContent', -} as const; +}; interface Fixture { text?: string[]; refusal?: string; thinking?: string[]; signature?: string; tools?: { name: string; id: string; args: Wire }[]; stop?: string; usage?: Wire; tier?: string } export const fixtureFrames = (protocol: IRTestProtocol, fixture: Fixture): ProtocolFrame[] => { @@ -115,11 +116,12 @@ export const testOutputTranslation = (target: IRTestProtocol, source: IRTestProt const result = await nativeResult(target, await collect(translate(iterate(fixtureFrames(source, { thinking: ['reason'], signature: 'opaque', text: ['answer'] })), codec))); expect(reasoningText(target, result)).toBe('reason'); expect(bodyText(target, result)).toBe('answer'); + const sourceName = protocolKeys[target]; const carrier = target === 'openai-chat-completions' ? result.choices[0].message.reasoning_details.find((item: Wire) => item.type === 'reasoning.encrypted').data : target === 'openai-responses' ? result.output.at(-1).encrypted_content : target === 'anthropic-messages' ? result.content.at(-1).data : result.candidates[0].content.parts.at(-1).thoughtSignature; - const sidecar = await decodeRoundTripSidecar(codec, assistantTurnProtocolOf[target], carrier); + const sidecar = await decodeRoundTripSidecar(codec, sourceName, carrier); expect(JSON.stringify(sidecar)).toContain('opaque'); if (target === 'anthropic-messages') expect(result.content.find((part: Wire) => part.type === 'thinking').signature).toBe('AA=='); }); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/events_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/events_test.ts index f9998b430d..95ba186551 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/events_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/events_test.ts @@ -3,7 +3,7 @@ import { expect, test } from 'vitest'; import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; import { writeGeminiGenerateContentEvents } from '../../../../src/protocols/gemini-generate-content/as-source/events.ts'; import { appendReadField, collectEmittedFieldPlacements, createReferenceBuilder, createReadFieldState, createFieldReferencer } from '../../../../src/round-trip/reference/reference-builder.ts'; -import { type RoundTripWriter } from '../../../../src/round-trip/writer.ts'; +import type { RoundTripWriter } from '../../../../src/round-trip/writer.ts'; test('resolves delayed Gemini audio atoms within the complete emitted data field', async () => { const references = createReferenceBuilder(); @@ -24,8 +24,8 @@ test('resolves delayed Gemini audio atoms within the complete emitted data field let prepared = false; const roundTrip: RoundTripWriter<'geminiGenerateContent'> = { builder: references, - encodeSidecar: async (_choice, _check, turn, state, textPlacements) => { - const views = collectEmittedFieldPlacements(turn, state, textPlacements); + encodeSidecar: async (_choice, _check, turn, state, textViews) => { + const views = collectEmittedFieldPlacements(turn, state, textViews); expect([...views.values()]).toEqual([{ enclosingText: 'YQ==', offset: 0 }]); const finalizer = createFieldReferencer(references, views); expect(await finalizer.referenceField('Q==', field)).toMatchObject({ parts: [[0, 1, 4]] }); @@ -53,8 +53,8 @@ test.each(['{"x":1,}', '{"x": 1}'])('tracks the emitted canonical JSON view with let prepared = false; const roundTrip: RoundTripWriter<'geminiGenerateContent'> = { builder: references, - encodeSidecar: async (_choice, _check, turn, state, textPlacements) => { - const views = collectEmittedFieldPlacements(turn, state, textPlacements); + encodeSidecar: async (_choice, _check, turn, state, textViews) => { + const views = collectEmittedFieldPlacements(turn, state, textViews); expect([...views.values()]).toContainEqual({ enclosingText: '{"x":1}', offset: 0 }); const finalizer = createFieldReferencer(references, views); expect(await finalizer.referenceField(original, field)).toBe(original); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/events_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/events_test.ts index 0860f8bbfd..a4f6563b6c 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/events_test.ts @@ -3,7 +3,7 @@ import { expect, test } from 'vitest'; import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; import { writeOpenAIChatCompletionsEvents } from '../../../../src/protocols/openai-chat-completions/as-source/events.ts'; import { createReferenceBuilder, collectEmittedFieldPlacements, createReadFieldState, createFieldReferencer, appendReadField } from '../../../../src/round-trip/reference/reference-builder.ts'; -import { type RoundTripWriter } from '../../../../src/round-trip/writer.ts'; +import type { RoundTripWriter } from '../../../../src/round-trip/writer.ts'; test('prepares each Chat choice independently while another choice has partial audio', async () => { const source = createIRBuilder(); From 13305b6c86271152e6b7442627b406974e1e492d Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:34:30 +0800 Subject: [PATCH 29/66] fix(translate): retain the public translator contract Keep the creator function type exported when removing request backend runtime adapters. The input IR leaf preserves the public translation boundary introduced by its prerequisite. --- packages/translate/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/translate/src/index.ts b/packages/translate/src/index.ts index 3b4190b364..52e8f37e7d 100644 --- a/packages/translate/src/index.ts +++ b/packages/translate/src/index.ts @@ -1,5 +1,5 @@ export type { SidecarCodec } from './round-trip/codec.ts'; -export type { TranslateTrip, TranslateInputError, TranslateOptions } from './types.ts'; +export type { TranslateTrip, TranslateInputError, TranslateOptions, Translator } from './types.ts'; export { SidecarTaggedValue } from './round-trip/codec.ts'; export { canonicalizeOpenAIResponsesPayload } from './protocols/openai-responses/canonicalize-payload.ts'; From b940339ffb30a57a92929ba17a50809bbf30ea0c Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:39:23 +0800 Subject: [PATCH 30/66] refactor(gateway): remove the obsolete translation image adapter Request translation no longer consumes the nullable image loader. Keep the existing fetcher and its live image-generation consumer, remove the unused wrapper and type, and name the module and tests for their retained behavior. --- ...test.ts => external-image-fetcher_test.ts} | 20 +++++++++---------- .../server-tools/image-generation.ts | 2 +- ...ge-loader.ts => external-image-fetcher.ts} | 10 ---------- 3 files changed, 10 insertions(+), 22 deletions(-) rename packages/gateway/__tests__/data-plane/chat/shared/{external-image-loader_test.ts => external-image-fetcher_test.ts} (80%) rename packages/gateway/src/data-plane/chat/shared/{external-image-loader.ts => external-image-fetcher.ts} (91%) diff --git a/packages/gateway/__tests__/data-plane/chat/shared/external-image-loader_test.ts b/packages/gateway/__tests__/data-plane/chat/shared/external-image-fetcher_test.ts similarity index 80% rename from packages/gateway/__tests__/data-plane/chat/shared/external-image-loader_test.ts rename to packages/gateway/__tests__/data-plane/chat/shared/external-image-fetcher_test.ts index 5b4686257b..4e396cae7e 100644 --- a/packages/gateway/__tests__/data-plane/chat/shared/external-image-loader_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/shared/external-image-fetcher_test.ts @@ -1,10 +1,10 @@ import { test } from 'vitest'; -import { createExternalImageFetcher, createExternalImageLoader } from '../../../../src/data-plane/chat/shared/external-image-loader.ts'; +import { createExternalImageFetcher } from '../../../../src/data-plane/chat/shared/external-image-fetcher.ts'; import { initExternalResourceFetcher } from '@floway-dev/platform'; import { assert, assertEquals, assertRejects } from '@floway-dev/test-utils'; -test('external image loader follows relative redirects and memoizes one request chain', async () => { +test('external image fetcher follows relative redirects and memoizes one request chain', async () => { const requested: string[] = []; initExternalResourceFetcher((url, signal) => { assertEquals(signal.aborted, false); @@ -30,19 +30,17 @@ test('external image loader follows relative redirects and memoizes one request assertEquals(requested, ['https://example.com/start', 'https://example.com/image.png']); }); -test('external image loader rejects unsafe URL syntax before egress', async () => { +test('external image fetcher rejects unsafe URL syntax before egress', async () => { let requests = 0; initExternalResourceFetcher(() => { requests += 1; return Promise.resolve(new Response()); }); - const load = createExternalImageLoader(); const fetchImage = createExternalImageFetcher(); assertEquals(await fetchImage('file:///etc/passwd'), { type: 'invalid-url' }); - assertEquals(await load('file:///etc/passwd'), null); - assertEquals(await load('https://user:secret@example.com/image.png'), null); - assertEquals(await load('not a URL'), null); + assertEquals(await fetchImage('https://user:secret@example.com/image.png'), { type: 'invalid-url' }); + assertEquals(await fetchImage('not a URL'), { type: 'invalid-url' }); assertEquals(requests, 0); }); @@ -56,7 +54,7 @@ test('external image fetcher reports non-success and oversized responses', async assertEquals(await fetchImage('https://example.com/oversized'), { type: 'too-large', limitBytes: 50 * 1024 * 1024 }); }); -test('external image loader bounds redirect chains', async () => { +test('external image fetcher bounds redirect chains', async () => { let requests = 0; initExternalResourceFetcher(_url => { requests += 1; @@ -74,7 +72,7 @@ test('external image loader bounds redirect chains', async () => { assertEquals(requests, 6); }); -test('external image loader cancels a streamed body beyond the byte limit', async () => { +test('external image fetcher cancels a streamed body beyond the byte limit', async () => { const chunk = new Uint8Array(1024 * 1024); let cancelled = false; initExternalResourceFetcher(() => Promise.resolve(new Response(new ReadableStream({ @@ -104,13 +102,13 @@ test('external image fetcher preserves transport failure detail', async () => { assertEquals(result.error, expected); }); -test('external image loader propagates downstream cancellation', async () => { +test('external image fetcher propagates downstream cancellation', async () => { const controller = new AbortController(); controller.abort(new Error('downstream cancelled')); initExternalResourceFetcher(() => Promise.reject(new Error('should not fetch'))); await assertRejects( - () => createExternalImageLoader(controller.signal)('https://example.com/image.png'), + () => createExternalImageFetcher(controller.signal)('https://example.com/image.png'), Error, 'downstream cancelled', ); diff --git a/packages/gateway/src/data-plane/chat/openai-responses/interceptors/server-tools/image-generation.ts b/packages/gateway/src/data-plane/chat/openai-responses/interceptors/server-tools/image-generation.ts index b0671b87f7..2c9caeff41 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/interceptors/server-tools/image-generation.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/interceptors/server-tools/image-generation.ts @@ -8,7 +8,7 @@ import { appendFailedUpstreams } from '../../../../shared/failed-upstreams.ts'; import type { AttemptState } from '../../../../shared/gateway-ctx.ts'; import { recordPerformance, type PerformanceTelemetryContext } from '../../../../shared/telemetry/performance.ts'; import { recordTokenUsage, tokenUsageFromOpenAIImagesBody } from '../../../../shared/telemetry/usage.ts'; -import { createExternalImageFetcher, type ExternalImageFetchResult } from '../../../shared/external-image-loader.ts'; +import { createExternalImageFetcher, type ExternalImageFetchResult } from '../../../shared/external-image-fetcher.ts'; import type { ServerToolHistoryExpansion, ServerToolLifecycleEvent, ServerToolOutputItem, ServerToolRegistration, ServerToolSlotResult } from '../server-tool-shim.ts'; import { dimensionsFromBytes, getImageProcessor, type BackgroundScheduler } from '@floway-dev/platform'; import { decodeForgivingBase64, encodeHex, isImageMediaType, isJsonObject, mediaTypeEssence, parseSSEStream } from '@floway-dev/protocols/common'; diff --git a/packages/gateway/src/data-plane/chat/shared/external-image-loader.ts b/packages/gateway/src/data-plane/chat/shared/external-image-fetcher.ts similarity index 91% rename from packages/gateway/src/data-plane/chat/shared/external-image-loader.ts rename to packages/gateway/src/data-plane/chat/shared/external-image-fetcher.ts index c6b8cbb58c..89e26711a3 100644 --- a/packages/gateway/src/data-plane/chat/shared/external-image-loader.ts +++ b/packages/gateway/src/data-plane/chat/shared/external-image-fetcher.ts @@ -131,13 +131,3 @@ export const createExternalImageFetcher = (downstreamSignal?: AbortSignal): Exte return request; }; }; - -type ExternalImageLoader = (url: string) => Promise<{ mediaType: string | null; data: Uint8Array } | null>; - -export const createExternalImageLoader = (downstreamSignal?: AbortSignal): ExternalImageLoader => { - const fetchImage = createExternalImageFetcher(downstreamSignal); - return async url => { - const result = await fetchImage(url); - return result.type === 'success' ? { mediaType: result.mediaType, data: result.data } : null; - }; -}; From 13703d23211fa7ee0c20cbda91898c6d93426b90 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 13:40:51 +0800 Subject: [PATCH 31/66] refactor(translate): reuse the protocol JSON object guard Remove the duplicate structural guard and import isJsonObject from protocols/common. Keep tool-argument and IR-specific object validation at the translation boundary. --- .../protocols/anthropic-messages/as-source/events.ts | 5 +++-- .../openai-chat-completions/as-source/events.ts | 10 +++++----- .../src/protocols/openai-responses/as-source/events.ts | 9 +++++---- packages/translate/src/shared/json/object.ts | 4 ---- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/events.ts b/packages/translate/src/protocols/anthropic-messages/as-source/events.ts index ad3a41b51e..26c7630bfd 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/events.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/events.ts @@ -16,7 +16,7 @@ import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan import { isContextExceededError } from '../../../shared/context-window-error.ts'; import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; import { parseJSONObjectUnlessTruncated } from '../../../shared/json/completion.ts'; -import { parseJSONObject, requireJSONObject, isJSONRecord } from '../../../shared/json/object.ts'; +import { parseJSONObject, requireJSONObject } from '../../../shared/json/object.ts'; import type { AnthropicMessagesAssistantState, AnthropicMessagesInputAssistantTurn } from '../as-target/assistant-turn.ts'; import { generateAnthropicId, @@ -29,6 +29,7 @@ import { type AnthropicMessagesStreamEventEx, type AnthropicMessagesUsage, } from '@floway-dev/protocols/anthropic-messages'; +import { isJsonObject } from '@floway-dev/protocols/common'; import { eventFrame, type EventFrame } from '@floway-dev/protocols/common'; const MESSAGES_ERROR_TYPES = [ @@ -55,7 +56,7 @@ interface MessagesUnit { } const unitKind = (value: unknown): MessagesUnit['kind'] | 'custom_tool_call' | undefined => { - if (!isJSONRecord(value)) return undefined; + if (!isJsonObject(value)) return undefined; return value.type === 'text' || value.type === 'refusal' || value.type === 'audio' diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts index a18f4bdd4d..dbebad7b07 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts @@ -10,9 +10,9 @@ import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; import { appendContent, createContentState, type ContentStates, type ContentState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; -import { isJSONRecord } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIChatCompletionsInputAssistantTurn, type OpenAIChatCompletionsOutputAssistantState } from '../as-target/assistant-turn.ts'; +import { isJsonObject } from '@floway-dev/protocols/common'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { type OpenAIChatCompletionsAnnotation, @@ -42,7 +42,7 @@ const tierFields = (tier?: string): { service_tier?: string } => tier === undefi type IRToolCallItem = Extract; const isIRItem = (value: unknown): value is IROutputItem => { - if (!isJSONRecord(value)) return false; + if (!isJsonObject(value)) return false; switch (value.type) { case 'message': return Array.isArray(value.content); case 'reasoning': return true; @@ -53,7 +53,7 @@ const isIRItem = (value: unknown): value is IROutputItem => { }; const partType = (value: unknown): IROutputMessageItem['content'][number]['type'] | undefined => { - if (!isJSONRecord(value)) return undefined; + if (!isJsonObject(value)) return undefined; return value.type === 'text' || value.type === 'refusal' || value.type === 'image' || value.type === 'audio' ? value.type : undefined; }; @@ -64,7 +64,7 @@ type LogprobMetadata = { readonly scope: 'choice' } | { }; const logprobMetadata = (value: unknown): LogprobMetadata | undefined => { - if (!isJSONRecord(value)) return undefined; + if (!isJsonObject(value)) return undefined; if (value.scope === 'choice') return { scope: 'choice' }; if (value.scope === 'text_part' && typeof value.item_index === 'number' && typeof value.content_index === 'number') { return { scope: 'text_part', item_index: value.item_index, content_index: value.content_index }; @@ -73,7 +73,7 @@ const logprobMetadata = (value: unknown): LogprobMetadata | undefined => { }; const isIRTokenLogprob = (value: unknown): value is IROutputTokenLogprob => - isJSONRecord(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; + isJsonObject(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; // Emitted deltas carry the assistant extension fields (reasoning, annotations) beside the standard ones. type ChatChoiceChunk = Omit & { delta: OpenAIChatCompletionsAssistantDeltaEx }; diff --git a/packages/translate/src/protocols/openai-responses/as-source/events.ts b/packages/translate/src/protocols/openai-responses/as-source/events.ts index a9910775f3..85cc651b33 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/events.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/events.ts @@ -15,9 +15,10 @@ import { appendContent, createContentState, type ContentState, type ContentState import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; import { parseJSONObjectUnlessTruncated } from '../../../shared/json/completion.ts'; -import { isCompleteJSONObject, isJSONRecord } from '../../../shared/json/object.ts'; +import { isCompleteJSONObject } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIResponsesInputAssistantTurn, type OpenAIResponsesItemState, type OpenAIResponsesOutputAssistantState } from '../as-target/assistant-turn.ts'; +import { isJsonObject } from '@floway-dev/protocols/common'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { createRandomOpenAIResponsesItemId, @@ -70,7 +71,7 @@ type ResponsesMessageState = OpenAIResponsesItemState, ContentStates>; const initializedType = (value: unknown): IROutputItem['type'] | MessagePart['type'] | undefined => { - if (!isJSONRecord(value)) return undefined; + if (!isJsonObject(value)) return undefined; return value.type === 'message' || value.type === 'reasoning' || value.type === 'function_call' @@ -86,14 +87,14 @@ const initializedType = (value: unknown): IROutputItem['type'] | MessagePart['ty type TextPartLogprobMetadata = { readonly item_index: number; readonly content_index: number }; const textPartLogprobMetadata = (value: unknown): TextPartLogprobMetadata | undefined => { - if (!isJSONRecord(value) || value.scope !== 'text_part') return undefined; + if (!isJsonObject(value) || value.scope !== 'text_part') return undefined; return typeof value.item_index === 'number' && typeof value.content_index === 'number' ? { item_index: value.item_index, content_index: value.content_index } : undefined; }; const isIRTokenLogprob = (value: unknown): value is IROutputTokenLogprob => - isJSONRecord(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; + isJsonObject(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; const reasoningPartAdded = (field: ReasoningField, itemId: string, outputIndex: number, index: number): OpenAIResponsesStreamEventEx => field === 'summary' diff --git a/packages/translate/src/shared/json/object.ts b/packages/translate/src/shared/json/object.ts index 499d7132da..7edcfe3426 100644 --- a/packages/translate/src/shared/json/object.ts +++ b/packages/translate/src/shared/json/object.ts @@ -2,10 +2,6 @@ import { rawJSON } from './raw-json.ts'; import type { IRJSONObject } from '../../ir/common/json.ts'; import { isJsonObject, parseJSONWithRawNumbers } from '@floway-dev/protocols/common'; -/** Structural record check that also permits raw JSON token objects; isPlainJSONObject excludes those tokens. */ -export const isJSONRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - export const isPlainJSONObject = (value: unknown): value is IRJSONObject => isJsonObject(value) && !rawJSON.isRawJSON(value); export const requireJSONObject = (value: unknown): IRJSONObject => { From f462664473e66d0e9b4600410e6eec92b1de85c3 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 13:41:12 +0800 Subject: [PATCH 32/66] refactor(protocols): own native request normalization Move Responses canonicalization and Gemini Part classification into their protocol modules. Protocol errors are ordinary native data, adapted at translation entrypoints. Share HTTP/RPC status facts while preserving gateway status policy. --- .../chat/gemini-generate-content/attempt.ts | 4 +- .../chat/gemini-generate-content/errors.ts | 16 ++-- .../chat/gemini-generate-content/respond.ts | 6 +- .../data-plane/chat/openai-responses/http.ts | 6 +- .../chat/openai-responses/websocket.ts | 5 +- .../gateway/src/data-plane/models/gemini.ts | 4 +- .../error-status_test.ts | 10 +++ .../gemini-generate-content/part_test.ts | 14 ++++ .../canonicalize-payload_test.ts | 18 +++- .../gemini-generate-content/error-status.ts | 18 ++++ .../src/gemini-generate-content/index.ts | 3 + .../src/gemini-generate-content/part.ts | 39 +++++++++ .../openai-responses/canonicalize-payload.ts | 67 +++++++++++++++ .../protocols/src/openai-responses/index.ts | 2 + packages/translate/AGENTS.md | 3 + .../as-source/namespace-tools_test.ts | 12 ++- .../protocols/public-boundary_test.ts | 2 - packages/translate/src/index.ts | 1 - .../as-source/content.ts | 40 +-------- .../via-anthropic-messages/request.ts | 3 +- .../via-openai-chat-completions/request.ts | 3 +- .../via-openai-responses/request.ts | 3 +- .../openai-responses/canonicalize-payload.ts | 84 ------------------- .../via-anthropic-messages/request.ts | 5 +- .../via-openai-chat-completions/request.ts | 5 +- 25 files changed, 209 insertions(+), 164 deletions(-) create mode 100644 packages/protocols/__tests__/gemini-generate-content/error-status_test.ts create mode 100644 packages/protocols/__tests__/gemini-generate-content/part_test.ts rename packages/{translate/__tests__/protocols => protocols/__tests__}/openai-responses/canonicalize-payload_test.ts (84%) create mode 100644 packages/protocols/src/gemini-generate-content/error-status.ts create mode 100644 packages/protocols/src/gemini-generate-content/part.ts create mode 100644 packages/protocols/src/openai-responses/canonicalize-payload.ts delete mode 100644 packages/translate/src/protocols/openai-responses/canonicalize-payload.ts diff --git a/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts b/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts index a38b0366ec..3bf5954679 100644 --- a/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts +++ b/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts @@ -1,4 +1,4 @@ -import { inputErrorResult, geminiGenerateContentInternalErrorPayload, geminiGenerateContentStatusForHttpStatus } from './errors.ts'; +import { inputErrorResult, geminiGenerateContentInternalErrorPayload, gatewayErrorStatus } from './errors.ts'; import { geminiGenerateContentCountTokensInterceptors, geminiGenerateContentInterceptors } from './interceptors/index.ts'; import { stripUnsupportedPartFieldsFromPayload } from './interceptors/strip-unsupported-part-fields.ts'; import { stripUnsupportedToolsFromPayload } from './interceptors/strip-unsupported-tools.ts'; @@ -122,7 +122,7 @@ const reshapeAnthropicMessagesCountAsGeminiGenerateContent = (anthropicMessagesR const geminiGenerateContentErrorPlainResult = (status: number, message: string, upstream?: string): PlainResult => plainResult( status, new Headers({ 'content-type': 'application/json' }), - new TextEncoder().encode(JSON.stringify({ error: { code: status, message, status: geminiGenerateContentStatusForHttpStatus(status) } })), + new TextEncoder().encode(JSON.stringify({ error: { code: status, message, status: gatewayErrorStatus(status) } })), upstream, ); diff --git a/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts b/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts index c3b0f50140..91a26a429d 100644 --- a/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts +++ b/packages/gateway/src/data-plane/chat/gemini-generate-content/errors.ts @@ -1,27 +1,21 @@ import { appendFailedUpstreams } from '../../shared/failed-upstreams.ts'; import { inputErrorFields, type ChatServeFailure, type InputErrorDetails } from '../shared/errors.ts'; -import type { GeminiGenerateContentErrorResponse } from '@floway-dev/protocols/gemini-generate-content'; +import { geminiGenerateContentStatusForHttpStatus, type GeminiGenerateContentErrorResponse } from '@floway-dev/protocols/gemini-generate-content'; import { internalDebugErrorFields, type ApiErrorResult, type PerformanceTelemetryContext, type InternalDebugError } from '@floway-dev/provider'; // Google RPC Status envelope, used by Gemini's `error` channel everywhere // (HTTP body, SSE-tunnelled error event). -export const geminiGenerateContentStatusForHttpStatus = (status: number): string => { +export const gatewayErrorStatus = (status: number): string => { switch (status) { case 400: - return 'INVALID_ARGUMENT'; case 401: - return 'UNAUTHENTICATED'; case 403: - return 'PERMISSION_DENIED'; case 404: - return 'NOT_FOUND'; case 429: - return 'RESOURCE_EXHAUSTED'; case 500: - return 'INTERNAL'; case 502: case 503: - return 'UNAVAILABLE'; + return geminiGenerateContentStatusForHttpStatus(status)!; default: return 'INTERNAL'; } @@ -36,7 +30,7 @@ export const geminiGenerateContentInternalErrorPayload = (status: number, error: error: { code: status, message: error.message, - status: geminiGenerateContentStatusForHttpStatus(status), + status: gatewayErrorStatus(status), details: [ ...(stack === undefined ? [] : [{ '@type': 'type.googleapis.com/google.rpc.DebugInfo', stackEntries: stack.split('\n') }]), { '@type': 'type.googleapis.com/google.protobuf.Struct', value: { type: error.type, ...fields } }, @@ -61,7 +55,7 @@ const geminiGenerateContentRpcErrorResult = ( error: { code: status, message, - status: geminiGenerateContentStatusForHttpStatus(status), + status: gatewayErrorStatus(status), ...(extension === undefined ? {} : { details: [{ '@type': 'type.googleapis.com/google.protobuf.Struct', value: extension }] }), }, })), diff --git a/packages/gateway/src/data-plane/chat/gemini-generate-content/respond.ts b/packages/gateway/src/data-plane/chat/gemini-generate-content/respond.ts index 6b9e606376..d5276d94d4 100644 --- a/packages/gateway/src/data-plane/chat/gemini-generate-content/respond.ts +++ b/packages/gateway/src/data-plane/chat/gemini-generate-content/respond.ts @@ -2,7 +2,7 @@ import type { Context } from 'hono'; import { streamSSE } from 'hono/streaming'; import { wrapGeminiGenerateContentAffinityEgress } from './affinity/egress.ts'; -import { geminiGenerateContentInternalErrorPayload, geminiGenerateContentStatusForHttpStatus } from './errors.ts'; +import { geminiGenerateContentInternalErrorPayload, gatewayErrorStatus } from './errors.ts'; import type { GatewayCtx } from '../../shared/gateway-ctx.ts'; import { type StreamCompletion, writeSSEFrames } from '../../shared/sse.ts'; import { recordFailedRequest } from '../../shared/telemetry/performance.ts'; @@ -92,7 +92,7 @@ const googleRpcHttpStatusCode = (status: number): number => (Number.isInteger(st const geminiGenerateContentRpcErrorPayload = (status: number, message: string): GeminiGenerateContentErrorResponse => { const code = googleRpcHttpStatusCode(status); return { - error: { code, message, status: geminiGenerateContentStatusForHttpStatus(code) }, + error: { code, message, status: gatewayErrorStatus(code) }, }; }; @@ -113,7 +113,7 @@ export const geminiGenerateContentInternalRpcErrorResponse = (status: number, er const geminiGenerateContentErrorResponse = (status: number, message: string, error?: InternalDebugError): Response => { // For gateway-minted errors, a non-500 that maps to INTERNAL is coerced to 500. - const code = geminiGenerateContentStatusForHttpStatus(status) === 'INTERNAL' && status !== 500 ? 500 : status; + const code = gatewayErrorStatus(status) === 'INTERNAL' && status !== 500 ? 500 : status; const payload = error === undefined ? geminiGenerateContentRpcErrorPayload(code, message) : geminiGenerateContentInternalErrorPayload(code, error); return Response.json(payload, { status: code }); }; diff --git a/packages/gateway/src/data-plane/chat/openai-responses/http.ts b/packages/gateway/src/data-plane/chat/openai-responses/http.ts index 3db06a3b7c..67e4ae565a 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/http.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/http.ts @@ -12,8 +12,8 @@ import { settle } from '../../shared/telemetry/settle.ts'; import { createChatGatewayCtxFromHono, type ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import { providerModelsUnavailableResponse } from '../shared/upstream-models-error.ts'; import type { OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; +import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; import { internalErrorResult, toInternalDebugError } from '@floway-dev/provider'; -import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/translate'; // OpenAI's verbatim previous_response_not_found envelope. Codex compares this // body byte-for-byte against upstream — see the cross-references on @@ -70,7 +70,7 @@ export const openaiResponsesHttp = { const payload = parsePayload(requestBody); if (!('input' in payload)) { const errorCtx = createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); - return finalizeGatewayResponse(errorCtx, respondOpenAIResponsesFailure(inputErrorResult(payload), errorCtx)); + return finalizeGatewayResponse(errorCtx, respondOpenAIResponsesFailure(inputErrorResult(payload.error), errorCtx)); } const wantsStream = payload.stream === true; ctx = createChatGatewayCtxFromHono(c, { wantsStream, requestBody: takeRequestBody(requestBody), model: payload.model, backgroundScheduler: backgroundSchedulerFromContext(c) }, (apiKey, requestStartedAt) => createOpenAIResponsesHttpStore(apiKey, requestStartedAt, payload.store ?? undefined)); @@ -89,7 +89,7 @@ export const openaiResponsesHttp = { const payload = parsePayload(requestBody); if (!('input' in payload)) { const errorCtx = createGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), backgroundScheduler: backgroundSchedulerFromContext(c) }); - return finalizeGatewayResponse(errorCtx, respondOpenAIResponsesFailure(inputErrorResult(payload), errorCtx)); + return finalizeGatewayResponse(errorCtx, respondOpenAIResponsesFailure(inputErrorResult(payload.error), errorCtx)); } ctx = createChatGatewayCtxFromHono(c, { wantsStream: false, requestBody: takeRequestBody(requestBody), model: payload.model, backgroundScheduler: backgroundSchedulerFromContext(c) }, (apiKey, requestStartedAt) => createOpenAIResponsesHttpStore(apiKey, requestStartedAt, payload.store ?? undefined)); const result = await openaiResponsesServe.compact({ payload, ctx, headers: inboundHeaders(c) }); diff --git a/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts b/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts index fa073da736..094feb111c 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts @@ -19,10 +19,9 @@ import { createChatGatewayCtxFromHono, type ChatGatewayCtx } from '../shared/gat import { SourceStreamState, eventResultMetadata } from '../shared/respond.ts'; import type { BackgroundScheduler } from '@floway-dev/platform'; import { eventFrame, isJsonMediaType, type ProtocolFrame } from '@floway-dev/protocols/common'; -import { OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE } from '@floway-dev/protocols/openai-responses'; +import { OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE, canonicalizeOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; import { isOpenAIResponsesTerminalEvent, type CanonicalOpenAIResponsesPayload, type ClientOpenAIResponsesStreamEvent, type OpenAIResponsesOutputItemEx, type OpenAIResponsesRequestPayloadEx, type OpenAIResponsesResultEx, type OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; import { type ExecuteResult, internalDebugErrorFields, toInternalDebugError } from '@floway-dev/provider'; -import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/translate'; interface WorkerWebSocket extends WebSocket { accept(): void; @@ -365,7 +364,7 @@ const handleClientMessage = async ( : Object.fromEntries(Object.entries(message).filter(([key]) => key !== 'type' && key !== 'event_id')); const payload = canonicalizeOpenAIResponsesPayload({ ...source, stream: true }); if (!('input' in payload)) { - turnFailure.fail(400, { type: 'invalid_request_error', code: payload.code ?? 'invalid_request_error', message: payload.message, param: payload.param }); + turnFailure.fail(400, { type: 'invalid_request_error', code: payload.error.code ?? 'invalid_request_error', message: payload.error.message, param: payload.error.param }); return; } previousResponseId = payload.previous_response_id ?? undefined; diff --git a/packages/gateway/src/data-plane/models/gemini.ts b/packages/gateway/src/data-plane/models/gemini.ts index c6b1655d70..c0aea8a525 100644 --- a/packages/gateway/src/data-plane/models/gemini.ts +++ b/packages/gateway/src/data-plane/models/gemini.ts @@ -7,7 +7,7 @@ import { getRepo } from '../../repo/index.ts'; import type { ModelAliasesRepo, User } from '../../repo/types.ts'; import { backgroundSchedulerFromContext } from '../../runtime/background.ts'; import { getRuntimeLocation } from '../../runtime/runtime-info.ts'; -import { geminiGenerateContentStatusForHttpStatus } from '../chat/gemini-generate-content/errors.ts'; +import { gatewayErrorStatus } from '../chat/gemini-generate-content/errors.ts'; import { mergeAliasesIntoModels } from '../shared/listing/alias.ts'; import type { ModelPricing } from '@floway-dev/protocols/common'; import type { InternalModel } from '@floway-dev/provider'; @@ -51,7 +51,7 @@ const toGeminiModel = (model: InternalModel): GeminiModel => { const geminiError = (status: number, message: string): Response => Response.json( - { error: { code: status, message, status: geminiGenerateContentStatusForHttpStatus(status) } }, + { error: { code: status, message, status: gatewayErrorStatus(status) } }, { status: status as 400 | 404 | 500 | 502 }, ); diff --git a/packages/protocols/__tests__/gemini-generate-content/error-status_test.ts b/packages/protocols/__tests__/gemini-generate-content/error-status_test.ts new file mode 100644 index 0000000000..0c312550a0 --- /dev/null +++ b/packages/protocols/__tests__/gemini-generate-content/error-status_test.ts @@ -0,0 +1,10 @@ +import { expect, test } from 'vitest'; + +import { geminiGenerateContentStatusForHttpStatus } from '../../src/gemini-generate-content/index.ts'; + +test('known HTTP codes have protocol status names while unmapped codes have no policy default', () => { + expect(geminiGenerateContentStatusForHttpStatus(400)).toBe('INVALID_ARGUMENT'); + expect(geminiGenerateContentStatusForHttpStatus(408)).toBe('DEADLINE_EXCEEDED'); + expect(geminiGenerateContentStatusForHttpStatus(502)).toBe('UNAVAILABLE'); + expect(geminiGenerateContentStatusForHttpStatus(599)).toBeUndefined(); +}); diff --git a/packages/protocols/__tests__/gemini-generate-content/part_test.ts b/packages/protocols/__tests__/gemini-generate-content/part_test.ts new file mode 100644 index 0000000000..60c7ab26c9 --- /dev/null +++ b/packages/protocols/__tests__/gemini-generate-content/part_test.ts @@ -0,0 +1,14 @@ +import { expect, test } from 'vitest'; + +import { geminiGenerateContentPartKind, type GeminiGenerateContentPart } from '../../src/gemini-generate-content/index.ts'; + +test('data classification ignores metadata and preserves a standalone signature', () => { + expect(geminiGenerateContentPartKind({ text: 'thought', thought: true, thoughtSignature: 'signed' })).toBe('text'); + expect(geminiGenerateContentPartKind({ functionCall: { name: 'lookup', args: {} } })).toBe('function_call'); + expect(geminiGenerateContentPartKind({ thoughtSignature: 'signed' })).toBeNull(); +}); + +test('conflicting or missing data fields expose the invalid Part', () => { + expect(() => geminiGenerateContentPartKind({ text: 'text', functionCall: { name: 'lookup' } } as unknown as GeminiGenerateContentPart)).toThrow('conflicting content fields: text, functionCall'); + expect(() => geminiGenerateContentPartKind({})).toThrow('no recognized content'); +}); diff --git a/packages/translate/__tests__/protocols/openai-responses/canonicalize-payload_test.ts b/packages/protocols/__tests__/openai-responses/canonicalize-payload_test.ts similarity index 84% rename from packages/translate/__tests__/protocols/openai-responses/canonicalize-payload_test.ts rename to packages/protocols/__tests__/openai-responses/canonicalize-payload_test.ts index c3e7fa5bfd..09f3af1134 100644 --- a/packages/translate/__tests__/protocols/openai-responses/canonicalize-payload_test.ts +++ b/packages/protocols/__tests__/openai-responses/canonicalize-payload_test.ts @@ -1,7 +1,7 @@ -import { test } from 'vitest'; +import { expect, test } from 'vitest'; +import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; import { assert, assertEquals } from '@floway-dev/test-utils'; -import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/translate'; test('canonicalizes string and implicit-message wire inputs', () => { assertEquals(canonicalizeOpenAIResponsesPayload({ model: 'gpt-test', input: 'hello' }), { @@ -61,7 +61,9 @@ test('rejects a payload without a usable model at the canonical boundary', () => { model: null, input: 'hello' }, ]) { assertEquals(canonicalizeOpenAIResponsesPayload(payload), { - type: 'input-error', message: "Missing required parameter: 'model'.", param: 'model', code: 'missing_required_parameter', + error: { + message: "Missing required parameter: 'model'.", param: 'model', code: 'missing_required_parameter', + }, }); } }); @@ -79,7 +81,9 @@ test('rejects malformed untyped input items at the canonical boundary', () => { { role: 'user', content: 'invalid phase', phase: 42 }, ]) { assertEquals(canonicalizeOpenAIResponsesPayload({ model: 'gpt-test', input: [malformed] }), { - type: 'input-error', message: 'Untyped OpenAI Responses input items require a valid role and content.', param: 'input[0]', + error: { + message: 'Untyped OpenAI Responses input items require a valid role and content.', param: 'input[0]', + }, }); } }); @@ -103,3 +107,9 @@ test('canonicalizeOpenAIResponsesPayload preserves reasoning.context verbatim, i assert('input' in canonicalFuture); assertEquals(canonicalFuture.reasoning, { context: 'future_mode' }); }); + +test('unexpected getters retain their original error and evaluation order', () => { + const failure = new Error('unexpected getter failure', { cause: new Error('cause') }); + expect(() => canonicalizeOpenAIResponsesPayload({ get model() { throw failure; } })).toThrow(failure); + expect(() => canonicalizeOpenAIResponsesPayload({ model: 'm', input: [null], get metadata() { throw failure; } })).toThrow(failure); +}); diff --git a/packages/protocols/src/gemini-generate-content/error-status.ts b/packages/protocols/src/gemini-generate-content/error-status.ts new file mode 100644 index 0000000000..d680a8c222 --- /dev/null +++ b/packages/protocols/src/gemini-generate-content/error-status.ts @@ -0,0 +1,18 @@ +// Google RPC status names for the HTTP error codes used by this protocol. +// https://ai.google.dev/api/rest/v1beta/Status +const HTTP_ERROR_STATUSES = new Map([ + [400, 'INVALID_ARGUMENT'], + [401, 'UNAUTHENTICATED'], + [403, 'PERMISSION_DENIED'], + [404, 'NOT_FOUND'], + [408, 'DEADLINE_EXCEEDED'], + [409, 'ABORTED'], + [429, 'RESOURCE_EXHAUSTED'], + [500, 'INTERNAL'], + [501, 'UNIMPLEMENTED'], + [502, 'UNAVAILABLE'], + [503, 'UNAVAILABLE'], + [504, 'DEADLINE_EXCEEDED'], +]); + +export const geminiGenerateContentStatusForHttpStatus = (status: number): string | undefined => HTTP_ERROR_STATUSES.get(status); diff --git a/packages/protocols/src/gemini-generate-content/index.ts b/packages/protocols/src/gemini-generate-content/index.ts index fa68d890da..5fbc4d7afa 100644 --- a/packages/protocols/src/gemini-generate-content/index.ts +++ b/packages/protocols/src/gemini-generate-content/index.ts @@ -139,3 +139,6 @@ export { GEMINI_GENERATE_CONTENT_CANDIDATE_KEYS, GEMINI_GENERATE_CONTENT_RESULT_ export { GEMINI_GENERATE_CONTENT_MISSING_TERMINAL_MESSAGE, createGeminiGenerateContentCandidateCompletion, isGeminiGenerateContentErrorEvent, isGeminiGenerateContentTerminalEvent, collectGeminiGenerateContentProtocolEventsToResult } from './to-result.ts'; export { createGeminiGenerateContentEventAccumulator, reassembleGeminiGenerateContentEvents, type GeminiGenerateContentEventAccumulator } from './reassemble.ts'; export { geminiGenerateContentProtocolFrameToSSEFrame } from './to-sse.ts'; + +export { geminiGenerateContentPartKind, type GeminiGenerateContentPartKind } from './part.ts'; +export { geminiGenerateContentStatusForHttpStatus } from './error-status.ts'; diff --git a/packages/protocols/src/gemini-generate-content/part.ts b/packages/protocols/src/gemini-generate-content/part.ts new file mode 100644 index 0000000000..ce2c395f8d --- /dev/null +++ b/packages/protocols/src/gemini-generate-content/part.ts @@ -0,0 +1,39 @@ +import type { GeminiGenerateContentPart, GeminiGenerateContentPartData } from './index.ts'; + +export type GeminiGenerateContentPartKind = 'text' | 'inline_data' | 'function_call' | 'function_response' | 'file_data' | 'executable_code' | 'code_execution_result' | 'tool_call' | 'tool_response'; + +type GeminiGenerateContentPartDataField = keyof GeminiGenerateContentPartData; + +// Source of truth for "what counts as a content-bearing field on a part". +// Record forces a matching entry whenever a new field +// is added to GeminiGenerateContentPart, so Part readers cannot silently start +// dropping it. +const GEMINI_GENERATE_CONTENT_PART_FIELD_TO_KIND: Record = { + text: 'text', + inlineData: 'inline_data', + functionCall: 'function_call', + functionResponse: 'function_response', + fileData: 'file_data', + executableCode: 'executable_code', + codeExecutionResult: 'code_execution_result', + toolCall: 'tool_call', + toolResponse: 'tool_response', +}; + +const GEMINI_GENERATE_CONTENT_PART_DATA_FIELDS = Object.keys(GEMINI_GENERATE_CONTENT_PART_FIELD_TO_KIND) as GeminiGenerateContentPartDataField[]; + +// Classify the part by its single content-bearing field. Returns null when +// the part only carries a thoughtSignature (a valid signature carrier with +// no body). Throws when the part sets zero or multiple content fields — +// both are silent-data-loss shapes the Part readers must refuse +// rather than coerce. +export const geminiGenerateContentPartKind = (part: GeminiGenerateContentPart): GeminiGenerateContentPartKind | null => { + const presentFields = GEMINI_GENERATE_CONTENT_PART_DATA_FIELDS.filter(field => part[field] !== undefined); + if (presentFields.length === 1) return GEMINI_GENERATE_CONTENT_PART_FIELD_TO_KIND[presentFields[0]]; + if (presentFields.length > 1) { + throw new Error(`Gemini generateContent part sets conflicting content fields: ${presentFields.join(', ')}.`); + } + if (part.thoughtSignature !== undefined) return null; + const keys = Object.keys(part); + throw new Error(`Gemini generateContent part has no recognized content. Keys present: ${keys.length ? keys.join(', ') : '(none)'}.`); +}; diff --git a/packages/protocols/src/openai-responses/canonicalize-payload.ts b/packages/protocols/src/openai-responses/canonicalize-payload.ts new file mode 100644 index 0000000000..aa2179b91f --- /dev/null +++ b/packages/protocols/src/openai-responses/canonicalize-payload.ts @@ -0,0 +1,67 @@ +import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesEasyInputMessageEx, CanonicalOpenAIResponsesInputItem, OpenAIResponsesRequestPayloadEx } from './index.ts'; + +// Expand shorthand input into explicitly discriminated message items. +export const canonicalizeOpenAIResponsesPayload = (value: unknown): CanonicalOpenAIResponsesPayload | { + readonly error: { readonly message: string; readonly param?: string; readonly code?: string }; +} => { + const hasValidPromptCacheBreakpoint = (content: Record): boolean => { + const breakpoint = content.prompt_cache_breakpoint; + if (breakpoint === undefined || breakpoint === null) return true; + return typeof breakpoint === 'object' + && typeof (breakpoint as Record).mode === 'string'; + }; + + const isImplicitEasyInputMessage = (item: unknown): item is OpenAIResponsesEasyInputMessageEx & { type?: undefined } => { + if (typeof item !== 'object' || item === null) return false; + const message = item as Record; + if (message.type !== undefined) return false; + if (message.role !== 'user' && message.role !== 'assistant' && message.role !== 'system' && message.role !== 'developer') return false; + if (message.phase !== undefined && message.phase !== null && typeof message.phase !== 'string') return false; + return typeof message.content === 'string' + || (Array.isArray(message.content) && message.content.every(part => { + if (typeof part !== 'object' || part === null) return false; + const content = part as Record; + switch (content.type) { + case 'input_text': + case 'output_text': + return typeof content.text === 'string' && hasValidPromptCacheBreakpoint(content); + case 'input_image': + return (typeof content.image_url === 'string' || typeof content.file_id === 'string') + && hasValidPromptCacheBreakpoint(content); + case 'input_file': + return hasValidPromptCacheBreakpoint(content); + default: + return false; + } + })); + }; + + if (typeof value !== 'object' || value === null) { + return { error: { message: 'OpenAI Responses payload must be an object.' } }; + } + const payload = value as OpenAIResponsesRequestPayloadEx; + // https://github.com/mattermost/mattermost-plugin-agents/issues/476 + if (typeof payload.model !== 'string' || payload.model.length === 0) { + return { error: { message: "Missing required parameter: 'model'.", param: 'model', code: 'missing_required_parameter' } }; + } + const input: unknown = payload.input; + if (typeof input !== 'string' && !Array.isArray(input)) { + return { error: { message: 'OpenAI Responses input must be a string or an array.', param: 'input' } }; + } + const canonical: CanonicalOpenAIResponsesPayload = { ...payload, model: payload.model, input: [] }; + const items = canonical.input; + if (typeof input === 'string') { + items.push({ type: 'message', role: 'user', content: input }); + } else { + for (const [index, item] of input.entries()) { + if (isImplicitEasyInputMessage(item)) { + items.push({ ...item, type: 'message' }); + } else if (typeof item !== 'object' || item === null || (item as { type?: unknown }).type === undefined) { + return { error: { message: 'Untyped OpenAI Responses input items require a valid role and content.', param: `input[${index}]` } }; + } else { + items.push(item as CanonicalOpenAIResponsesInputItem); + } + } + } + return canonical; +}; diff --git a/packages/protocols/src/openai-responses/index.ts b/packages/protocols/src/openai-responses/index.ts index 4b750d5384..16923ec637 100644 --- a/packages/protocols/src/openai-responses/index.ts +++ b/packages/protocols/src/openai-responses/index.ts @@ -1443,3 +1443,5 @@ export { OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE, collectOpenAIResponsesProtoc export { createRandomOpenAIResponsesItemId, type GeneratedOpenAIResponsesItemType } from './item-id.ts'; export { reassembleOpenAIResponsesEvents } from './reassemble.ts'; export { openaiResponsesProtocolFrameToSSEFrame } from './to-sse.ts'; + +export { canonicalizeOpenAIResponsesPayload } from './canonicalize-payload.ts'; diff --git a/packages/translate/AGENTS.md b/packages/translate/AGENTS.md index 0f0319c1e0..795b76c9ad 100644 --- a/packages/translate/AGENTS.md +++ b/packages/translate/AGENTS.md @@ -5,6 +5,9 @@ code. `__tests__/protocols/` mirrors production ownership. Package callers import the package root and call `routeVia(input, options)` to create an independent trip. +Protocol-native request normalization and Part data classification belong to +`@floway-dev/protocols`; translation maps their failures at its own boundary. + ## Categories 1. **Single-pair helper** — keep it in diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-tools_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-tools_test.ts index e33c749b2c..fdf426e86b 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-tools_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-tools_test.ts @@ -1,7 +1,6 @@ import { expect, test } from 'vitest'; import { flattenNamespaceTools, restoreNamespaceEvents, restoreNamespaceOutputItem } from '../../../../src/protocols/openai-responses/as-source/namespace-tools.ts'; -import { requireCanonicalOpenAIResponsesPayload } from '../../../../src/protocols/openai-responses/canonicalize-payload.ts'; import { buildTargetRequest as buildMessagesRequest } from '../../../../src/protocols/openai-responses/via-anthropic-messages/request.ts'; import { buildTargetRequest as buildChatRequest } from '../../../../src/protocols/openai-responses/via-openai-chat-completions/request.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; @@ -9,6 +8,7 @@ import { requireTrip } from '../../require-trip.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; +import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesOutputItemEx, OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; import { routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; @@ -49,6 +49,12 @@ test.each(['additional_tools', 'tool_search_output'] as const)('keeps %s as a us ]); }); +const canonicalPayload = () => { + const canonical = canonicalizeOpenAIResponsesPayload(payload()); + if (!('input' in canonical)) throw new Error(canonical.error.message); + return canonical; +}; + test('namespace setup preserves the original input and allocates historical names only when explicitly flattened', () => { const request: CanonicalOpenAIResponsesPayload = { model: 'm', @@ -107,7 +113,7 @@ test('forced namespace choices use the declaration mapping', async () => { }); test('restores function and custom calls across item events and terminal snapshots', async () => { - const prepared = flattenNamespaceTools(requireCanonicalOpenAIResponsesPayload(payload())); + const prepared = flattenNamespaceTools(canonicalPayload()); const output = [ { type: 'function_call' as const, name: 'agents_spawn_2', call_id: 'spawn', arguments: '{"name":"agents_spawn_2"}', status: 'completed' }, { type: 'custom_tool_call' as const, name: 'agents_audit', call_id: 'audit', input: 'agents_audit' }, @@ -129,7 +135,7 @@ test('restores function and custom calls across item events and terminal snapsho }); test('restores all function/custom output item type combinations without retaining the replaced payload field', () => { - const names = flattenNamespaceTools(requireCanonicalOpenAIResponsesPayload(payload())).names; + const names = flattenNamespaceTools(canonicalPayload()).names; const functionName = names.sourceToTarget.get('agents.spawn'); const customName = names.sourceToTarget.get('agents.audit'); if (functionName === undefined || customName === undefined) throw new Error('Expected flattened namespace tool names'); diff --git a/packages/translate/__tests__/protocols/public-boundary_test.ts b/packages/translate/__tests__/protocols/public-boundary_test.ts index 511e9c6bee..7c2cf32399 100644 --- a/packages/translate/__tests__/protocols/public-boundary_test.ts +++ b/packages/translate/__tests__/protocols/public-boundary_test.ts @@ -2,7 +2,6 @@ import { expect, test } from 'vitest'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { - canonicalizeOpenAIResponsesPayload, routeGeminiGenerateContentViaOpenAIChatCompletions, routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses, @@ -32,7 +31,6 @@ test('public operations preserve unexpected errors and their causes', async () = model: 'source-model', messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://example.com/image.png' } }] }], }, { ...options, fallbackMaxOutputTokens: 32, loadRemoteImage: async () => { throw failure; } })).rejects.toBe(failure); - expect(() => canonicalizeOpenAIResponsesPayload({ get model() { throw failure; } })).toThrow(failure); expect(failure.cause).toBe(cause); }); diff --git a/packages/translate/src/index.ts b/packages/translate/src/index.ts index d7fedbcc9e..805c45f2a9 100644 --- a/packages/translate/src/index.ts +++ b/packages/translate/src/index.ts @@ -1,6 +1,5 @@ export type { RemoteImageData, RemoteImageLoader, SidecarCodec, TranslateTrip, TranslateInputError, TranslateOptions, Translator } from './types.ts'; export { SidecarTaggedValue } from './round-trip/sidecar-tagged-value.ts'; -export { canonicalizeOpenAIResponsesPayload } from './protocols/openai-responses/canonicalize-payload.ts'; export { routeAnthropicMessagesViaOpenAIChatCompletions, routeAnthropicMessagesViaOpenAIResponses, diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/content.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/content.ts index 004ab1c235..f855b7b486 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/content.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/content.ts @@ -1,4 +1,4 @@ -import type { GeminiGenerateContentPartData, GeminiGenerateContentContent, GeminiGenerateContentPart } from '@floway-dev/protocols/gemini-generate-content'; +import type { GeminiGenerateContentContent, GeminiGenerateContentPart } from '@floway-dev/protocols/gemini-generate-content'; const GEMINI_GENERATE_CONTENT_SUPPORTED_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] as const; @@ -18,44 +18,6 @@ export type GeminiGenerateContentInlineData = | { kind: 'audio'; mimeType: keyof typeof GEMINI_GENERATE_CONTENT_AUDIO_FORMATS; format: (typeof GEMINI_GENERATE_CONTENT_AUDIO_FORMATS)[keyof typeof GEMINI_GENERATE_CONTENT_AUDIO_FORMATS]; data: string } | { kind: 'unsupported'; mimeType: string; data: string }; -export type GeminiGenerateContentPartKind = 'text' | 'inline_data' | 'function_call' | 'function_response' | 'file_data' | 'executable_code' | 'code_execution_result' | 'tool_call' | 'tool_response'; - -type GeminiGenerateContentPartDataField = keyof GeminiGenerateContentPartData; - -// Source of truth for "what counts as a content-bearing field on a part". -// Record forces a matching entry whenever a new field -// is added to GeminiGenerateContentPart, so request translators cannot silently start -// dropping it. -const GEMINI_GENERATE_CONTENT_PART_FIELD_TO_KIND: Record = { - text: 'text', - inlineData: 'inline_data', - functionCall: 'function_call', - functionResponse: 'function_response', - fileData: 'file_data', - executableCode: 'executable_code', - codeExecutionResult: 'code_execution_result', - toolCall: 'tool_call', - toolResponse: 'tool_response', -}; - -const GEMINI_GENERATE_CONTENT_PART_DATA_FIELDS = Object.keys(GEMINI_GENERATE_CONTENT_PART_FIELD_TO_KIND) as GeminiGenerateContentPartDataField[]; - -// Classify the part by its single content-bearing field. Returns null when -// the part only carries a thoughtSignature (a valid signature carrier with -// no body). Throws when the part sets zero or multiple content fields — -// both are silent-data-loss shapes the request translators must refuse -// rather than coerce. -export const geminiGenerateContentPartKind = (part: GeminiGenerateContentPart): GeminiGenerateContentPartKind | null => { - const presentFields = GEMINI_GENERATE_CONTENT_PART_DATA_FIELDS.filter(field => part[field] !== undefined); - if (presentFields.length === 1) return GEMINI_GENERATE_CONTENT_PART_FIELD_TO_KIND[presentFields[0]]; - if (presentFields.length > 1) { - throw new Error(`Gemini generateContent part sets conflicting content fields: ${presentFields.join(', ')}.`); - } - if (part.thoughtSignature !== undefined) return null; - const keys = Object.keys(part); - throw new Error(`Gemini generateContent part has no recognized content. Keys present: ${keys.length ? keys.join(', ') : '(none)'}.`); -}; - export const geminiGenerateContentPartText = (part: GeminiGenerateContentPart): string | null => (typeof part.text === 'string' ? part.text : null); export const geminiGenerateContentThoughtText = (part: GeminiGenerateContentPart): string | null => (part.thought === true && typeof part.text === 'string' ? part.text : null); diff --git a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/request.ts b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/request.ts index 4a0001f630..492c9b946b 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/request.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/request.ts @@ -5,7 +5,7 @@ import { TranslateInputException } from '../../../shared/input-error.ts'; import type { SidecarCodec } from '../../../types.ts'; import { applyLastMessageCacheBreakpoint, applyLastSystemCacheBreakpoint, applyLastToolCacheBreakpoint } from '../../anthropic-messages/as-target/cache-breakpoints.ts'; import { anthropicMessagesToolInputSchema } from '../../anthropic-messages/as-target/tool-input-schema.ts'; -import { geminiGenerateContentInlineData, geminiGenerateContentPartKind, geminiGenerateContentPartText, geminiGenerateContentText, geminiGenerateContentThoughtText, geminiGenerateContentVisibleText } from '../as-source/content.ts'; +import { geminiGenerateContentInlineData, geminiGenerateContentPartText, geminiGenerateContentText, geminiGenerateContentThoughtText, geminiGenerateContentVisibleText } from '../as-source/content.ts'; import { partitionGeminiGenerateContentTurns } from '../as-source/conversation-turns.ts'; import { geminiGenerateContentThinkingLevelEffort } from '../as-source/reasoning.ts'; import { collectGeminiGenerateContentReplayContents, hasGeminiGenerateContentThoughtSignature, findGeminiGenerateContentSidecarCarriers, verifyGeminiGenerateContentTurnDigest } from '../as-source/round-trip.ts'; @@ -19,6 +19,7 @@ import { type AnthropicMessagesTool, type AnthropicMessagesUserContentBlock, } from '@floway-dev/protocols/anthropic-messages'; +import { geminiGenerateContentPartKind } from '@floway-dev/protocols/gemini-generate-content'; import type { GeminiGenerateContentContent, GeminiGenerateContentPayload, GeminiGenerateContentGenerationConfig, GeminiGenerateContentPart, GeminiGenerateContentThinkingConfig } from '@floway-dev/protocols/gemini-generate-content'; const inlineDataToContentBlock = (part: GeminiGenerateContentPart): AnthropicMessagesUserContentBlock | null => { diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts index 098478c8d0..71c50594b2 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts @@ -3,12 +3,13 @@ import { klona } from 'klona/json'; import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { geminiGenerateContentInlineData, geminiGenerateContentPartKind, geminiGenerateContentPartText, geminiGenerateContentText, geminiGenerateContentThoughtText, geminiGenerateContentVisibleText } from '../as-source/content.ts'; +import { geminiGenerateContentInlineData, geminiGenerateContentPartText, geminiGenerateContentText, geminiGenerateContentThoughtText, geminiGenerateContentVisibleText } from '../as-source/content.ts'; import { partitionGeminiGenerateContentTurns } from '../as-source/conversation-turns.ts'; import { geminiGenerateContentReasoningEffort } from '../as-source/reasoning.ts'; import { collectGeminiGenerateContentReplayContents, hasGeminiGenerateContentThoughtSignature, findGeminiGenerateContentSidecarCarriers, verifyGeminiGenerateContentTurnDigest } from '../as-source/round-trip.ts'; import { geminiFunctionParameters, geminiResponseSchema } from '../as-source/schema.ts'; import { geminiGenerateContentFunctionCallingIntent, geminiGenerateContentFunctionCallPart, geminiGenerateContentFunctionDeclarations, geminiGenerateContentFunctionResponsePart, type GeminiGenerateContentToolCallIds } from '../as-source/tools.ts'; +import { geminiGenerateContentPartKind } from '@floway-dev/protocols/gemini-generate-content'; import type { GeminiGenerateContentContent, GeminiGenerateContentPayload, GeminiGenerateContentGenerationConfig, GeminiGenerateContentPart } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsUserContentPart, OpenAIChatCompletionsPayload, OpenAIChatCompletionsMessage, OpenAIChatCompletionsTool, OpenAIChatCompletionsToolCall } from '@floway-dev/protocols/openai-chat-completions'; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/request.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/request.ts index b5ff204ae1..411f77d985 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/request.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/request.ts @@ -1,12 +1,13 @@ import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { geminiGenerateContentInlineData, geminiGenerateContentPartKind, geminiGenerateContentPartText, geminiGenerateContentText, geminiGenerateContentThoughtText, geminiGenerateContentVisibleText } from '../as-source/content.ts'; +import { geminiGenerateContentInlineData, geminiGenerateContentPartText, geminiGenerateContentText, geminiGenerateContentThoughtText, geminiGenerateContentVisibleText } from '../as-source/content.ts'; import { partitionGeminiGenerateContentTurns } from '../as-source/conversation-turns.ts'; import { geminiGenerateContentReasoningEffort } from '../as-source/reasoning.ts'; import { collectGeminiGenerateContentReplayContents, hasGeminiGenerateContentThoughtSignature, findGeminiGenerateContentSidecarCarriers, verifyGeminiGenerateContentTurnDigest } from '../as-source/round-trip.ts'; import { geminiFunctionParameters, geminiResponseSchema } from '../as-source/schema.ts'; import { geminiGenerateContentFunctionCallingIntent, geminiGenerateContentFunctionCallPart, geminiGenerateContentFunctionDeclarations, geminiGenerateContentFunctionResponsePart, type GeminiGenerateContentToolCallIds } from '../as-source/tools.ts'; +import { geminiGenerateContentPartKind } from '@floway-dev/protocols/gemini-generate-content'; import type { GeminiGenerateContentContent, GeminiGenerateContentPayload, GeminiGenerateContentGenerationConfig, GeminiGenerateContentPart } from '@floway-dev/protocols/gemini-generate-content'; import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesInputContent, CanonicalOpenAIResponsesInputItem, OpenAIResponsesTool } from '@floway-dev/protocols/openai-responses'; diff --git a/packages/translate/src/protocols/openai-responses/canonicalize-payload.ts b/packages/translate/src/protocols/openai-responses/canonicalize-payload.ts deleted file mode 100644 index f7896b7681..0000000000 --- a/packages/translate/src/protocols/openai-responses/canonicalize-payload.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { TranslateInputException } from '../../shared/input-error.ts'; -import { type TranslateInputError } from '../../types.ts'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesEasyInputMessageEx, CanonicalOpenAIResponsesInputItem, OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; - -// Wire `OpenAIResponsesRequestPayload.input` accepts a bare string and EasyInputMessage -// objects whose `type: "message"` discriminator is omitted. The gateway's -// canonical internal shape is an explicitly discriminated item array: every -// consumer past HTTP / WS entry normalization or cross-protocol translation -// sees `type: "message"` on every message. -// Lifts a wire `OpenAIResponsesRequestPayload` to canonical form. Called at every wire -// boundary that produces a payload destined for internal use and by direct -// OpenAI-Responses-source translators; cross-protocol translators already construct -// `CanonicalOpenAIResponsesPayload` with explicit message discriminators. -export const canonicalizeOpenAIResponsesPayload = (value: unknown): CanonicalOpenAIResponsesPayload | TranslateInputError => { - try { - return requireCanonicalOpenAIResponsesPayload(value); - } catch (error) { - if (error instanceof TranslateInputException) return error.failure; - throw error; - } -}; - -export const requireCanonicalOpenAIResponsesPayload = (value: unknown): CanonicalOpenAIResponsesPayload => { - const hasValidPromptCacheBreakpoint = (content: Record): boolean => { - const breakpoint = content.prompt_cache_breakpoint; - if (breakpoint === undefined || breakpoint === null) return true; - return typeof breakpoint === 'object' - && typeof (breakpoint as Record).mode === 'string'; - }; - - const isImplicitEasyInputMessage = (item: unknown): item is OpenAIResponsesEasyInputMessageEx & { type?: undefined } => { - if (typeof item !== 'object' || item === null) return false; - const message = item as Record; - if (message.type !== undefined) return false; - if (message.role !== 'user' && message.role !== 'assistant' && message.role !== 'system' && message.role !== 'developer') return false; - if (message.phase !== undefined && message.phase !== null && typeof message.phase !== 'string') return false; - return typeof message.content === 'string' - || (Array.isArray(message.content) && message.content.every(part => { - if (typeof part !== 'object' || part === null) return false; - const content = part as Record; - switch (content.type) { - case 'input_text': - case 'output_text': - return typeof content.text === 'string' && hasValidPromptCacheBreakpoint(content); - case 'input_image': - return (typeof content.image_url === 'string' || typeof content.file_id === 'string') - && hasValidPromptCacheBreakpoint(content); - case 'input_file': - return hasValidPromptCacheBreakpoint(content); - default: - return false; - } - })); - }; - - if (typeof value !== 'object' || value === null) { - throw new TranslateInputException('OpenAI Responses payload must be an object.'); - } - const payload = value as OpenAIResponsesRequestPayloadEx; - // Resolution binds `model` straight into a SQL lookup, so the wire boundary is - // the only place a missing id can still become a caller-facing 400; message - // and code reproduce OpenAI's own rejection verbatim. - // https://github.com/mattermost/mattermost-plugin-agents/issues/476 - if (typeof payload.model !== 'string' || payload.model.length === 0) { - throw new TranslateInputException("Missing required parameter: 'model'.", { param: 'model', code: 'missing_required_parameter' }); - } - const input: unknown = payload.input; - if (typeof input !== 'string' && !Array.isArray(input)) { - throw new TranslateInputException('OpenAI Responses input must be a string or an array.', { param: 'input' }); - } - return { - ...payload, - model: payload.model, - input: typeof input === 'string' - ? [{ type: 'message', role: 'user', content: input }] - : input.map((item, index) => { - if (isImplicitEasyInputMessage(item)) return { ...item, type: 'message' }; - if (typeof item !== 'object' || item === null || (item as { type?: unknown }).type === undefined) { - throw new TranslateInputException('Untyped OpenAI Responses input items require a valid role and content.', { param: `input[${index}]` }); - } - return item as CanonicalOpenAIResponsesInputItem; - }), - }; -}; diff --git a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts index 51164bfa0f..4151951f91 100644 --- a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts +++ b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts @@ -14,7 +14,6 @@ import { partitionOpenAIResponsesTurns } from '../as-source/conversation-turns.t import { flattenNamespaceTools, type NamespaceToolNames } from '../as-source/namespace-tools.ts'; import { rejectProgramCaller, rejectProgrammaticOpenAIResponsesPayload } from '../as-source/programmatic-tooling.ts'; import { collectOpenAIResponsesReplayContents, findOpenAIResponsesSidecarCarriers, isOpenAIResponsesEncryptedReasoning, verifyOpenAIResponsesTurnDigest } from '../as-source/round-trip.ts'; -import { requireCanonicalOpenAIResponsesPayload } from '../canonicalize-payload.ts'; import { buildCustomToolInputSchema } from '../common/custom-tool-wrap.ts'; import { ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS, @@ -31,6 +30,7 @@ import { type AnthropicMessagesUserMessage, } from '@floway-dev/protocols/anthropic-messages'; import { isFastServiceTier } from '@floway-dev/protocols/common'; +import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; import type { OpenAIResponsesInputContent, OpenAIResponsesInputImage, @@ -288,7 +288,8 @@ export const buildTargetRequest = async ( codec: SidecarCodec, options: BuildTargetRequestOptions, ): Promise => { - const canonical = requireCanonicalOpenAIResponsesPayload(source); + const canonical = canonicalizeOpenAIResponsesPayload(source); + if (!('input' in canonical)) throw new TranslateInputException(canonical.error.message, canonical.error); const { payload, names: namespaceToolNames, flattenInput } = flattenNamespaceTools(canonical); rejectProgrammaticOpenAIResponsesPayload(payload, 'Anthropic Messages'); const customToolNames = new Set(); diff --git a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/request.ts b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/request.ts index 8af6a7491f..1fc0d54da9 100644 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/request.ts +++ b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/request.ts @@ -9,9 +9,9 @@ import { partitionOpenAIResponsesTurns } from '../as-source/conversation-turns.t import { flattenNamespaceTools, type NamespaceToolNames } from '../as-source/namespace-tools.ts'; import { rejectProgramCaller, rejectProgrammaticOpenAIResponsesPayload } from '../as-source/programmatic-tooling.ts'; import { collectOpenAIResponsesReplayContents, findOpenAIResponsesSidecarCarriers, isOpenAIResponsesEncryptedReasoning, verifyOpenAIResponsesTurnDigest } from '../as-source/round-trip.ts'; -import { requireCanonicalOpenAIResponsesPayload } from '../canonicalize-payload.ts'; import { buildCustomToolInputSchema } from '../common/custom-tool-wrap.ts'; import type { OpenAIChatCompletionsAssistantMessage, OpenAIChatCompletionsResponseFormat, OpenAIChatCompletionsUserContentPart, OpenAIChatCompletionsPayload, OpenAIChatCompletionsMessage, OpenAIChatCompletionsTool, OpenAIChatCompletionsToolCall } from '@floway-dev/protocols/openai-chat-completions'; +import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; import type { OpenAIResponsesCustomToolCallOutputItem, OpenAIResponsesFunctionCallOutputItem, OpenAIResponsesInputImage, CanonicalOpenAIResponsesText, CanonicalOpenAIResponsesInputItem, OpenAIResponsesPayloadEx, OpenAIResponsesRequestPayloadEx, OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; interface AssistantAccumulator { @@ -179,7 +179,8 @@ export interface TargetRequestResult { } export const buildTargetRequest = async (source: OpenAIResponsesRequestPayloadEx, codec: SidecarCodec): Promise => { - const canonical = requireCanonicalOpenAIResponsesPayload(source); + const canonical = canonicalizeOpenAIResponsesPayload(source); + if (!('input' in canonical)) throw new TranslateInputException(canonical.error.message, canonical.error); const { payload, names: namespaceToolNames, flattenInput } = flattenNamespaceTools(canonical); rejectProgrammaticOpenAIResponsesPayload(payload, 'OpenAI Chat Completions'); const customToolNames = new Set(); From 80e8a5c807ff9395ecfe9f908772b9fd54e51933 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 13:42:37 +0800 Subject: [PATCH 33/66] refactor(protocols): own native assistant history normalization Move Chat audio replay and Responses output-to-input normalization into protocol APIs. Keep only reference-state alignment in translate, and move native payload tests to their protocol owners. --- .../openai-chat-completions/history_test.ts | 18 ++++++ .../openai-responses/history_test.ts | 36 +++++++++++ .../src/openai-chat-completions/history.ts | 16 +++++ .../src/openai-chat-completions/index.ts | 2 + .../protocols/src/openai-responses/history.ts | 52 +++++++++++++++ .../protocols/src/openai-responses/index.ts | 2 + packages/translate/AGENTS.md | 3 +- .../protocols/chat-legacy-round-trip_test.ts | 6 +- .../chat-reasoning-round-trip_test.ts | 5 +- .../as-target/assistant-turn_test.ts | 33 ---------- .../__tests__/protocols/routes_test.ts | 8 +-- .../as-target/assistant-turn.ts | 34 ++-------- .../as-target/assistant-turn.ts | 64 ++----------------- 13 files changed, 148 insertions(+), 131 deletions(-) create mode 100644 packages/protocols/__tests__/openai-chat-completions/history_test.ts create mode 100644 packages/protocols/__tests__/openai-responses/history_test.ts create mode 100644 packages/protocols/src/openai-chat-completions/history.ts create mode 100644 packages/protocols/src/openai-responses/history.ts diff --git a/packages/protocols/__tests__/openai-chat-completions/history_test.ts b/packages/protocols/__tests__/openai-chat-completions/history_test.ts new file mode 100644 index 0000000000..73fd08058c --- /dev/null +++ b/packages/protocols/__tests__/openai-chat-completions/history_test.ts @@ -0,0 +1,18 @@ +import { expect, test } from 'vitest'; + +import { toOpenAIChatCompletionsInputAssistantMessage } from '../../src/openai-chat-completions/index.ts'; + +test('native Chat history retains residual fields and only replays audio identity', () => { + const details = [{ type: 'reasoning.summary' as const, summary: 'native' }]; + const output = { role: 'assistant' as const, content: 'answer', refusal: null, audio: { id: 'a', data: 'bytes', expires_at: 4, transcript: 'text' }, reasoning_details: details }; + const input = toOpenAIChatCompletionsInputAssistantMessage(output); + expect(input).toEqual({ role: 'assistant', content: 'answer', refusal: null, audio: { id: 'a' }, reasoning_details: details }); + expect(input.reasoning_details).toBe(details); + expect(output.audio.data).toBe('bytes'); +}); + +test('native Chat history distinguishes absent, null and explicitly undefined audio', () => { + expect(toOpenAIChatCompletionsInputAssistantMessage({ role: 'assistant', content: null, refusal: null })).not.toHaveProperty('audio'); + expect(toOpenAIChatCompletionsInputAssistantMessage({ role: 'assistant', content: null, refusal: null, audio: null })).toHaveProperty('audio', null); + expect(toOpenAIChatCompletionsInputAssistantMessage({ role: 'assistant', content: null, refusal: null, audio: undefined })).toHaveProperty('audio', undefined); +}); diff --git a/packages/protocols/__tests__/openai-responses/history_test.ts b/packages/protocols/__tests__/openai-responses/history_test.ts new file mode 100644 index 0000000000..e2eaad76eb --- /dev/null +++ b/packages/protocols/__tests__/openai-responses/history_test.ts @@ -0,0 +1,36 @@ +import { expect, test } from 'vitest'; + +import { toOpenAIResponsesInputItems, type OpenAIResponsesOutputItemEx } from '../../src/openai-responses/index.ts'; + +test('normalizes Responses output into native history while preserving replayable fields', () => { + const imageCall: Extract = { + type: 'image_generation_call', id: 'ig-data', result: 'image-base64', status: 'completed', action: 'generate', output_format: 'png', + }; + const messageWithSDKFields = { + type: 'message', id: 'message-a', role: 'assistant', status: 'completed', phase: 'final_answer', created_by: 'sdk', + content: [{ type: 'output_text', text: 'answer', annotations: [{ type: 'url_citation', url: 'https://example.test', title: 'source', start_index: 0, end_index: 6 }], logprobs: [], parsed: { answer: true } }], + } satisfies Extract & { created_by: string; content: (Extract['content'][number], { type: 'output_text' }> & { parsed: { answer: boolean } })[] }; + const functionCallWithSDKFields = { + type: 'function_call', id: 'function-a', call_id: 'call-a', name: 'lookup', arguments: '{"q":"a"}', parsed_arguments: { q: 'a' }, status: 'completed', created_by: 'sdk', + } satisfies Extract & { parsed_arguments: { q: string }; created_by: string }; + const outputHistory: OpenAIResponsesOutputItemEx[] = [ + messageWithSDKFields, + functionCallWithSDKFields, + { type: 'shell_call_output', id: 'shell-output', call_id: 'shell-call', output: [{ stdout: 'ok', stderr: '', outcome: { type: 'exit', exit_code: 0 }, created_by: 'sdk' }], status: 'completed', created_by: 'sdk' }, + { type: 'computer_call_output', call_id: 'computer-call', output: { type: 'computer_screenshot', file_id: 'screenshot' }, status: 'failed', created_by: 'sdk' }, + { type: 'custom_tool_call_output', call_id: 'custom-incomplete', output: 'incomplete', status: 'incomplete' }, + { type: 'custom_tool_call_output', id: 'custom-completed', call_id: 'custom-completed-call', output: 'complete', status: 'completed' }, + { type: 'additional_tools', id: 'additional-tools-developer', role: 'developer', tools: [] }, + { type: 'image_generation_call', id: 'ig-null', result: null, status: 'completed' }, + imageCall, + ]; + expect(toOpenAIResponsesInputItems(outputHistory)).toEqual([ + { type: 'message', id: 'message-a', role: 'assistant', status: 'completed', phase: 'final_answer', content: [{ type: 'output_text', text: 'answer', annotations: [{ type: 'url_citation', url: 'https://example.test', title: 'source', start_index: 0, end_index: 6 }], logprobs: [] }] }, + { type: 'function_call', id: 'function-a', call_id: 'call-a', name: 'lookup', arguments: '{"q":"a"}', status: 'completed' }, + { type: 'shell_call_output', id: 'shell-output', call_id: 'shell-call', output: [{ stdout: 'ok', stderr: '', outcome: { type: 'exit', exit_code: 0 } }], status: 'completed' }, + { type: 'custom_tool_call_output', id: 'custom-completed', call_id: 'custom-completed-call', output: 'complete' }, + { type: 'additional_tools', id: 'additional-tools-developer', role: 'developer', tools: [] }, + { type: 'image_generation_call', id: 'ig-null', result: null, status: 'completed' }, + imageCall, + ]); +}); diff --git a/packages/protocols/src/openai-chat-completions/history.ts b/packages/protocols/src/openai-chat-completions/history.ts new file mode 100644 index 0000000000..4dafbdbd29 --- /dev/null +++ b/packages/protocols/src/openai-chat-completions/history.ts @@ -0,0 +1,16 @@ +import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsAssistantOutputMessageEx } from './index.ts'; + +export const toOpenAIChatCompletionsInputAssistantMessage = (outputTurn: OpenAIChatCompletionsAssistantOutputMessageEx): OpenAIChatCompletionsAssistantMessageEx => { + // The SDK serializes supplied message fields directly; prior audio input uses an ID reference. + // https://github.com/openai/openai-node/blob/f57c9651c8d22ae82cacca27317cfa7126491011/src/resources/chat/completions/completions.ts#L195-L210 + // https://github.com/openai/openai-node/blob/f57c9651c8d22ae82cacca27317cfa7126491011/src/client.ts#L637-L637 + // https://github.com/openai/openai-node/blob/f57c9651c8d22ae82cacca27317cfa7126491011/src/internal/request-options.ts#L115-L144 + // The response object also carries audio bytes, expiry, and transcript. + // https://github.com/openai/openai-node/blob/f57c9651c8d22ae82cacca27317cfa7126491011/src/resources/chat/completions/completions.ts#L884-L935 + // https://github.com/openai/openai-node/blob/f57c9651c8d22ae82cacca27317cfa7126491011/src/resources/chat/completions/completions.ts#L962-L984 + const { audio, ...message } = outputTurn; + return { + ...message, + ...(Object.hasOwn(outputTurn, 'audio') ? { audio: audio === null || audio === undefined ? audio : { id: audio.id } } : {}), + }; +}; diff --git a/packages/protocols/src/openai-chat-completions/index.ts b/packages/protocols/src/openai-chat-completions/index.ts index 17bfc4fc53..3df39c747f 100644 --- a/packages/protocols/src/openai-chat-completions/index.ts +++ b/packages/protocols/src/openai-chat-completions/index.ts @@ -181,3 +181,5 @@ export { parseOpenAIChatCompletionsStream, type ParseOpenAIChatCompletionsStream export { collectOpenAIChatCompletionsProtocolEventsToResult } from './to-result.ts'; export { createOpenAIChatCompletionsEventAccumulator, reassembleOpenAIChatCompletionsEvents, type OpenAIChatCompletionsEventAccumulator } from './reassemble.ts'; export { openaiChatCompletionsProtocolFrameToSSEFrame } from './to-sse.ts'; + +export { toOpenAIChatCompletionsInputAssistantMessage } from './history.ts'; diff --git a/packages/protocols/src/openai-responses/history.ts b/packages/protocols/src/openai-responses/history.ts new file mode 100644 index 0000000000..9caf97e5db --- /dev/null +++ b/packages/protocols/src/openai-responses/history.ts @@ -0,0 +1,52 @@ +import type { CanonicalOpenAIResponsesInputItem, OpenAIResponsesOutputItemEx } from './index.ts'; + +// Match the official SDK's history normalization for SDK-owned fields and replay-only item transforms. +// https://github.com/openai/openai-node/blob/f57c9651c8d22ae82cacca27317cfa7126491011/src/lib/responses/ResponseInputItems.ts#L41-L67 +// https://github.com/openai/openai-node/blob/f57c9651c8d22ae82cacca27317cfa7126491011/src/lib/responses/ResponseInputItems.ts#L115-L160 +type OpenAIResponsesOutputMessage = Extract; +type OpenAIResponsesOutputContent = OpenAIResponsesOutputMessage['content'][number]; + +const stripCreatedBy = (item: OpenAIResponsesOutputItemEx): OpenAIResponsesOutputItemEx => { + if (!('created_by' in item)) return item; + const { created_by: _createdBy, ...historyItem } = item; + return historyItem; +}; + +const outputContentToHistoryContent = (part: OpenAIResponsesOutputContent): OpenAIResponsesOutputContent => { + if ((part.type !== 'output_text' && part.type !== 'refusal') || !('parsed' in part)) return part; + const { parsed: _parsed, ...historyContent } = part; + return historyContent; +}; + +// OpenAI defines output and replayable input items as separate unions. After the +// SDK's documented output-only fields and variants are removed below, the wire +// object is an input item even though TypeScript cannot encode that union relation. +const asInputItem = (item: OpenAIResponsesOutputItemEx): CanonicalOpenAIResponsesInputItem => item as CanonicalOpenAIResponsesInputItem; + +export const toOpenAIResponsesInputItem = (item: OpenAIResponsesOutputItemEx): CanonicalOpenAIResponsesInputItem | null => { + const historyItem = stripCreatedBy(item); + if (historyItem.type === 'message') { + const content = historyItem.content.map(part => outputContentToHistoryContent(part)); + return asInputItem(content.every((part, index) => part === historyItem.content[index]) ? historyItem : { ...historyItem, content }); + } + if (historyItem.type === 'function_call') { + if (!('parsed_arguments' in historyItem)) return asInputItem(historyItem); + const { parsed_arguments: _parsedArguments, ...withoutParsedArguments } = historyItem; + return asInputItem(withoutParsedArguments); + } + if (historyItem.type === 'shell_call_output') { + return asInputItem({ ...historyItem, output: historyItem.output.map(({ created_by: _createdBy, ...output }) => output) }); + } + if (historyItem.type === 'computer_call_output') return historyItem.status === 'failed' ? null : asInputItem(historyItem); + if (historyItem.type === 'custom_tool_call_output') { + if ('status' in historyItem && historyItem.status !== 'completed') return null; + const { status: _status, ...inputItem } = historyItem; + return asInputItem(inputItem); + } + return asInputItem(historyItem); +}; + +export const toOpenAIResponsesInputItems = (output: readonly OpenAIResponsesOutputItemEx[]): CanonicalOpenAIResponsesInputItem[] => output.flatMap(item => { + const input = toOpenAIResponsesInputItem(item); + return input === null ? [] : [input]; +}); diff --git a/packages/protocols/src/openai-responses/index.ts b/packages/protocols/src/openai-responses/index.ts index 4b750d5384..97fd1b3d8e 100644 --- a/packages/protocols/src/openai-responses/index.ts +++ b/packages/protocols/src/openai-responses/index.ts @@ -1443,3 +1443,5 @@ export { OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE, collectOpenAIResponsesProtoc export { createRandomOpenAIResponsesItemId, type GeneratedOpenAIResponsesItemType } from './item-id.ts'; export { reassembleOpenAIResponsesEvents } from './reassemble.ts'; export { openaiResponsesProtocolFrameToSSEFrame } from './to-sse.ts'; + +export { toOpenAIResponsesInputItem, toOpenAIResponsesInputItems } from './history.ts'; diff --git a/packages/translate/AGENTS.md b/packages/translate/AGENTS.md index 8a7031c696..2a1b4ad5ff 100644 --- a/packages/translate/AGENTS.md +++ b/packages/translate/AGENTS.md @@ -26,7 +26,8 @@ import the source protocol's subpath and call 6. **Assistant-turn round trips** — keep owner-specific sidecar validation, replay 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`. + reference shapes and state alignment belong in `as-target/assistant-turn.ts`. + Native output-to-input payload normalization belongs in `@floway-dev/protocols`. Transport encoding is injected through `TranslationContext.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/chat-legacy-round-trip_test.ts b/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts index 0e54d49260..54aea8e2a6 100644 --- a/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts +++ b/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts @@ -1,12 +1,12 @@ import { expect, test } from 'vitest'; -import { toOpenAIResponsesInputAssistantTurn } from '../../src/protocols/openai-responses/as-target/assistant-turn.ts'; import { iterate } from '../ir/helpers.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { collectAnthropicMessagesProtocolEventsToResult, type AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { collectGeminiGenerateContentProtocolEventsToResult, type GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIChatCompletionsAssistantDeltaEx, OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; +import { toOpenAIResponsesInputItems } from '@floway-dev/protocols/openai-responses'; 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'; @@ -81,7 +81,7 @@ test('Responses binds public namespaced call IDs to the native legacy function n expect(call.name).toBe('lookup'); expect(call.namespace).toBe('public'); 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}` }); + payload.input.push(...toOpenAIResponsesInputItems(result.output), { type: 'function_call_output', call_id: call.call_id, output: `result-${index}` }); } const replay = await routeOpenAIResponses(payload).viaOpenAIChatCompletions(context); assertNativeHistory(replay.target, nativeName, ['result-0', 'result-1', 'result-2']); @@ -142,7 +142,7 @@ test('restored legacy results retain the ordinary Responses input_file rejection 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); + const input = toOpenAIResponsesInputItems(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.'); } 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..584c4b8ff4 100644 --- a/packages/translate/__tests__/protocols/chat-reasoning-round-trip_test.ts +++ b/packages/translate/__tests__/protocols/chat-reasoning-round-trip_test.ts @@ -1,10 +1,9 @@ import { expect, test } from 'vitest'; -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 { toOpenAIResponsesInputItems, reassembleOpenAIResponsesEvents } from '@floway-dev/protocols/openai-responses'; import { routeOpenAIResponses } from '@floway-dev/translate/openai-responses'; const chunk = (delta: OpenAIChatCompletionsAssistantDeltaEx, finish_reason: 'stop' | null = null) => eventFrame({ @@ -25,7 +24,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 routeOpenAIResponses({ model: 'source', input: toOpenAIResponsesInputItems(result.output) }).viaOpenAIChatCompletions(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/openai-responses/as-target/assistant-turn_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-target/assistant-turn_test.ts index 370387ba19..3bae3671e5 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-target/assistant-turn_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-target/assistant-turn_test.ts @@ -3,39 +3,6 @@ import { expect, test } from 'vitest'; import { toOpenAIResponsesInputAssistantTurn } from '../../../../src/protocols/openai-responses/as-target/assistant-turn.ts'; import type { OpenAIResponsesOutputItemEx } from '@floway-dev/protocols/openai-responses'; -test('normalizes Responses output into native history while preserving replayable fields', () => { - const imageCall: Extract = { - type: 'image_generation_call', id: 'ig-data', result: 'image-base64', status: 'completed', action: 'generate', output_format: 'png', - }; - const messageWithSDKFields = { - type: 'message', id: 'message-a', role: 'assistant', status: 'completed', phase: 'final_answer', created_by: 'sdk', - content: [{ type: 'output_text', text: 'answer', annotations: [{ type: 'url_citation', url: 'https://example.test', title: 'source', start_index: 0, end_index: 6 }], logprobs: [], parsed: { answer: true } }], - } satisfies Extract & { created_by: string; content: (Extract['content'][number], { type: 'output_text' }> & { parsed: { answer: boolean } })[] }; - const functionCallWithSDKFields = { - type: 'function_call', id: 'function-a', call_id: 'call-a', name: 'lookup', arguments: '{"q":"a"}', parsed_arguments: { q: 'a' }, status: 'completed', created_by: 'sdk', - } satisfies Extract & { parsed_arguments: { q: string }; created_by: string }; - const outputHistory: OpenAIResponsesOutputItemEx[] = [ - messageWithSDKFields, - functionCallWithSDKFields, - { type: 'shell_call_output', id: 'shell-output', call_id: 'shell-call', output: [{ stdout: 'ok', stderr: '', outcome: { type: 'exit', exit_code: 0 }, created_by: 'sdk' }], status: 'completed', created_by: 'sdk' }, - { type: 'computer_call_output', call_id: 'computer-call', output: { type: 'computer_screenshot', file_id: 'screenshot' }, status: 'failed', created_by: 'sdk' }, - { type: 'custom_tool_call_output', call_id: 'custom-incomplete', output: 'incomplete', status: 'incomplete' }, - { type: 'custom_tool_call_output', id: 'custom-completed', call_id: 'custom-completed-call', output: 'complete', status: 'completed' }, - { type: 'additional_tools', id: 'additional-tools-developer', role: 'developer', tools: [] }, - { type: 'image_generation_call', id: 'ig-null', result: null, status: 'completed' }, - imageCall, - ]; - expect(toOpenAIResponsesInputAssistantTurn(outputHistory)).toEqual([ - { type: 'message', id: 'message-a', role: 'assistant', status: 'completed', phase: 'final_answer', content: [{ type: 'output_text', text: 'answer', annotations: [{ type: 'url_citation', url: 'https://example.test', title: 'source', start_index: 0, end_index: 6 }], logprobs: [] }] }, - { type: 'function_call', id: 'function-a', call_id: 'call-a', name: 'lookup', arguments: '{"q":"a"}', status: 'completed' }, - { type: 'shell_call_output', id: 'shell-output', call_id: 'shell-call', output: [{ stdout: 'ok', stderr: '', outcome: { type: 'exit', exit_code: 0 } }], status: 'completed' }, - { type: 'custom_tool_call_output', id: 'custom-completed', call_id: 'custom-completed-call', output: 'complete' }, - { type: 'additional_tools', id: 'additional-tools-developer', role: 'developer', tools: [] }, - { type: 'image_generation_call', id: 'ig-null', result: null, status: 'completed' }, - imageCall, - ]); -}); - test('keeps Responses state aligned when output-only items are removed', () => { const message: Extract = { type: 'message', id: 'msg', role: 'assistant', status: 'completed', diff --git a/packages/translate/__tests__/protocols/routes_test.ts b/packages/translate/__tests__/protocols/routes_test.ts index 4b8ceefead..ed652e4391 100644 --- a/packages/translate/__tests__/protocols/routes_test.ts +++ b/packages/translate/__tests__/protocols/routes_test.ts @@ -5,10 +5,8 @@ import { findAnthropicMessagesSidecarOccurrences } from '../../src/protocols/ant import { findGeminiGenerateContentSidecarOccurrences } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; import { partitionOpenAIChatCompletionsTurns } from '../../src/protocols/openai-chat-completions/as-source/conversation-turns.ts'; import { findOpenAIChatCompletionsSidecarOccurrences } from '../../src/protocols/openai-chat-completions/as-source/round-trip.ts'; -import { toOpenAIChatCompletionsInputAssistantTurn } from '../../src/protocols/openai-chat-completions/as-target/assistant-turn.ts'; import { partitionOpenAIResponsesTurns } from '../../src/protocols/openai-responses/as-source/conversation-turns.ts'; import { findOpenAIResponsesSidecarOccurrences } from '../../src/protocols/openai-responses/as-source/round-trip.ts'; -import { toOpenAIResponsesInputAssistantTurn } from '../../src/protocols/openai-responses/as-target/assistant-turn.ts'; import { decodeRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; import { requireJSONObject } from '../../src/shared/json/object.ts'; import { collect, iterate } from '../ir/helpers.ts'; @@ -16,7 +14,9 @@ import { fixtureFrames, type nativeResult } from '../ir/translation-cases.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { collectAnthropicMessagesProtocolEventsToResult, type AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; import { collectGeminiGenerateContentProtocolEventsToResult, type GeminiGenerateContentContent, type GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; +import { toOpenAIChatCompletionsInputAssistantMessage } from '@floway-dev/protocols/openai-chat-completions'; import { collectOpenAIChatCompletionsProtocolEventsToResult, type OpenAIChatCompletionsAssistantMessageEx, type OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; +import { toOpenAIResponsesInputItems } from '@floway-dev/protocols/openai-responses'; 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'; @@ -142,12 +142,12 @@ const collectAssistantTurn = async (protocol: Protocol, frames: Parameters = ReferenceState; export type OpenAIChatCompletionsOutputAssistantTurnState = ReferenceState; -export function toOpenAIChatCompletionsInputAssistantTurn( - outputTurn: OpenAIChatCompletionsOutputAssistantTurn, -): OpenAIChatCompletionsInputAssistantTurn; -export function toOpenAIChatCompletionsInputAssistantTurn( +export const toOpenAIChatCompletionsInputAssistantTurn = ( outputTurn: OpenAIChatCompletionsOutputAssistantTurn, outputState: OpenAIChatCompletionsOutputAssistantTurnState, -): { turn: OpenAIChatCompletionsInputAssistantTurn; state: OpenAIChatCompletionsInputAssistantTurnState }; -export function toOpenAIChatCompletionsInputAssistantTurn( - outputTurn: OpenAIChatCompletionsOutputAssistantTurn, - outputState?: OpenAIChatCompletionsOutputAssistantTurnState, -): OpenAIChatCompletionsInputAssistantTurn | { - turn: OpenAIChatCompletionsInputAssistantTurn; - state: OpenAIChatCompletionsInputAssistantTurnState; -} { - // The SDK serializes supplied message fields directly; prior audio input uses an ID reference. - // https://github.com/openai/openai-node/blob/f57c9651c8d22ae82cacca27317cfa7126491011/src/resources/chat/completions/completions.ts#L195-L210 - // https://github.com/openai/openai-node/blob/f57c9651c8d22ae82cacca27317cfa7126491011/src/client.ts#L637-L637 - // https://github.com/openai/openai-node/blob/f57c9651c8d22ae82cacca27317cfa7126491011/src/internal/request-options.ts#L115-L144 - // The response object also carries audio bytes, expiry, and transcript. - // https://github.com/openai/openai-node/blob/f57c9651c8d22ae82cacca27317cfa7126491011/src/resources/chat/completions/completions.ts#L884-L935 - // https://github.com/openai/openai-node/blob/f57c9651c8d22ae82cacca27317cfa7126491011/src/resources/chat/completions/completions.ts#L962-L984 - const { audio, ...message } = outputTurn; - const turn = { - ...message, - ...(Object.hasOwn(outputTurn, 'audio') ? { audio: audio === null || audio === undefined ? audio : { id: audio.id } } : {}), - }; - if (outputState === undefined) return turn; - return { turn, state: Object.assign({}, outputState) }; -} +): { turn: OpenAIChatCompletionsInputAssistantTurn; state: OpenAIChatCompletionsInputAssistantTurnState } => ({ + turn: toOpenAIChatCompletionsInputAssistantMessage(outputTurn), + state: Object.assign({}, outputState), +}); diff --git a/packages/translate/src/protocols/openai-responses/as-target/assistant-turn.ts b/packages/translate/src/protocols/openai-responses/as-target/assistant-turn.ts index 1455e2b918..caa8618125 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/assistant-turn.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/assistant-turn.ts @@ -1,5 +1,5 @@ import type { ReferenceState, ReferenceStateLeaves, ReferenceableValue, TextRule } from '../../../round-trip/reference/types.ts'; -import type { CanonicalOpenAIResponsesInputItem, OpenAIResponsesOutputItemEx } from '@floway-dev/protocols/openai-responses'; +import { toOpenAIResponsesInputItem, type OpenAIResponsesOutputItemEx, type CanonicalOpenAIResponsesInputItem } from '@floway-dev/protocols/openai-responses'; type OpenAIResponsesItemRule = T extends { type: 'message' } ? { content: TextRule | [{ text: TextRule; refusal: TextRule }] } @@ -24,69 +24,15 @@ export type OpenAIResponsesOutputAssistantTurn = readonly OpenAIResponsesOutputI export type OpenAIResponsesOutputAssistantTurnState = Array | undefined>; export type OpenAIResponsesInputAssistantTurnState = Array | undefined>; -type OpenAIResponsesOutputMessage = Extract; -type OpenAIResponsesOutputContent = OpenAIResponsesOutputMessage['content'][number]; - -const stripCreatedBy = (item: OpenAIResponsesOutputItemEx): OpenAIResponsesOutputItemEx => { - if (!('created_by' in item)) return item; - const { created_by: _createdBy, ...historyItem } = item; - return historyItem; -}; - -const outputContentToHistoryContent = (part: OpenAIResponsesOutputContent): OpenAIResponsesOutputContent => { - if ((part.type !== 'output_text' && part.type !== 'refusal') || !('parsed' in part)) return part; - const { parsed: _parsed, ...historyContent } = part; - return historyContent; -}; - -// OpenAI defines output and replayable input items as separate unions. After the -// SDK's documented output-only fields and variants are removed below, the wire -// object is an input item even though TypeScript cannot encode that union relation. -const asInputItem = (item: OpenAIResponsesOutputItemEx): CanonicalOpenAIResponsesInputItem => item as CanonicalOpenAIResponsesInputItem; - -const outputItemToHistoryItem = (item: OpenAIResponsesOutputItemEx): CanonicalOpenAIResponsesInputItem | null => { - const historyItem = stripCreatedBy(item); - if (historyItem.type === 'message') { - const content = historyItem.content.map(part => outputContentToHistoryContent(part)); - return asInputItem(content.every((part, index) => part === historyItem.content[index]) ? historyItem : { ...historyItem, content }); - } - if (historyItem.type === 'function_call') { - if (!('parsed_arguments' in historyItem)) return asInputItem(historyItem); - const { parsed_arguments: _parsedArguments, ...withoutParsedArguments } = historyItem; - return asInputItem(withoutParsedArguments); - } - if (historyItem.type === 'shell_call_output') { - return asInputItem({ ...historyItem, output: historyItem.output.map(({ created_by: _createdBy, ...output }) => output) }); - } - if (historyItem.type === 'computer_call_output') return historyItem.status === 'failed' ? null : asInputItem(historyItem); - if (historyItem.type === 'custom_tool_call_output') { - if ('status' in historyItem && historyItem.status !== 'completed') return null; - const { status: _status, ...inputItem } = historyItem; - return asInputItem(inputItem); - } - return asInputItem(historyItem); -}; - -export function toOpenAIResponsesInputAssistantTurn( - turn: OpenAIResponsesOutputAssistantTurn, -): OpenAIResponsesInputAssistantTurn; -export function toOpenAIResponsesInputAssistantTurn( +export const toOpenAIResponsesInputAssistantTurn = ( turn: OpenAIResponsesOutputAssistantTurn, state: OpenAIResponsesOutputAssistantTurnState, -): { turn: OpenAIResponsesInputAssistantTurn; state: OpenAIResponsesInputAssistantTurnState }; -export function toOpenAIResponsesInputAssistantTurn( - turn: OpenAIResponsesOutputAssistantTurn, - state?: OpenAIResponsesOutputAssistantTurnState, -) { - // Match the official SDK's history normalization for SDK-owned fields and replay-only item transforms. - // https://github.com/openai/openai-node/blob/f57c9651c8d22ae82cacca27317cfa7126491011/src/lib/responses/ResponseInputItems.ts#L41-L67 - // https://github.com/openai/openai-node/blob/f57c9651c8d22ae82cacca27317cfa7126491011/src/lib/responses/ResponseInputItems.ts#L115-L160 +): { turn: OpenAIResponsesInputAssistantTurn; state: OpenAIResponsesInputAssistantTurnState } => { const items = turn.flatMap((item, index) => { - const historyItem = outputItemToHistoryItem(item); + const historyItem = toOpenAIResponsesInputItem(item); return historyItem === null ? [] : [{ turn: historyItem, sourceIndex: index }]; }); const inputTurn = items.map(item => item.turn); - if (state === undefined) return inputTurn; const inputState: OpenAIResponsesInputAssistantTurnState = []; inputState.length = items.length; items.forEach(({ turn: inputItem, sourceIndex }, index) => { @@ -95,4 +41,4 @@ export function toOpenAIResponsesInputAssistantTurn Date: Thu, 12 Nov 2026 13:43:41 +0800 Subject: [PATCH 34/66] refactor(translate): use protocol status names in error projection Share the native HTTP/RPC mapping while retaining this boundary's UNKNOWN classification for 502 and unrecognized codes. --- .../as-source/error.ts | 24 +++---------------- 1 file changed, 3 insertions(+), 21 deletions(-) diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts index 33bf6e291b..cf8ac50146 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts @@ -1,28 +1,10 @@ import type { IRErrorPayload } from '../../../ir/types.ts'; import { errorDetailsOf } from '../../../shared/api-error.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; -import type { GeminiGenerateContentErrorResponse } from '@floway-dev/protocols/gemini-generate-content'; +import { geminiGenerateContentStatusForHttpStatus, type GeminiGenerateContentErrorResponse } from '@floway-dev/protocols/gemini-generate-content'; -/** - * Use the Google RPC status vocabulary for the numeric error code. - * https://ai.google.dev/api/rest/v1beta/Status - */ -const statusNameOf = (status: number): string => { - switch (status) { - case 400: return 'INVALID_ARGUMENT'; - case 401: return 'UNAUTHENTICATED'; - case 403: return 'PERMISSION_DENIED'; - case 404: return 'NOT_FOUND'; - case 408: return 'DEADLINE_EXCEEDED'; - case 409: return 'ABORTED'; - case 429: return 'RESOURCE_EXHAUSTED'; - case 500: return 'INTERNAL'; - case 501: return 'UNIMPLEMENTED'; - case 503: return 'UNAVAILABLE'; - case 504: return 'DEADLINE_EXCEEDED'; - default: return 'UNKNOWN'; - } -}; +// This translated-error boundary retains UNKNOWN for an upstream 502. +const statusNameOf = (status: number): string => status === 502 ? 'UNKNOWN' : geminiGenerateContentStatusForHttpStatus(status) ?? 'UNKNOWN'; /** * HTTP failures supply an upstream status; streaming failures derive a code from the error kind. From 109c374996573d22f480deeb5ec342a0f16d9cf8 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 15:08:18 +0800 Subject: [PATCH 35/66] style: align merged protocol boundary imports --- .../__tests__/protocols/chat-reasoning-round-trip_test.ts | 3 +-- .../src/protocols/openai-chat-completions/as-source/events.ts | 2 +- .../src/protocols/openai-responses/as-source/events.ts | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) 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 799ab06dc3..aa6194444b 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,10 @@ -import { toOpenAIResponsesInputItems } from '@floway-dev/protocols/openai-responses'; import { expect, test } from 'vitest'; import { requireTrip } from './require-trip.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsAssistantDeltaEx, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; -import { reassembleOpenAIResponsesEvents } from '@floway-dev/protocols/openai-responses'; +import { toOpenAIResponsesInputItems, reassembleOpenAIResponsesEvents } from '@floway-dev/protocols/openai-responses'; import { routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; const chunk = (delta: OpenAIChatCompletionsAssistantDeltaEx, finish_reason: 'stop' | null = null) => eventFrame({ diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts index 9a68e6acce..99ef0092f8 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts @@ -1,4 +1,3 @@ -import { isJsonObject } from '@floway-dev/protocols/common'; import { openaiChatCompletionsErrorFromIR } from './error.ts'; import { collectOpenAIChatCompletionsTextPlacements, computeOpenAIChatCompletionsTurnDigest, createOpenAIChatCompletionsSidecarDetails } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; @@ -13,6 +12,7 @@ import { appendEmittedField, createEmittedFieldState, type EmittedFieldStates, t import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIChatCompletionsInputAssistantTurn, type OpenAIChatCompletionsOutputAssistantTurnState } from '../as-target/assistant-turn.ts'; +import { isJsonObject } from '@floway-dev/protocols/common'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { type OpenAIChatCompletionsAnnotation, diff --git a/packages/translate/src/protocols/openai-responses/as-source/events.ts b/packages/translate/src/protocols/openai-responses/as-source/events.ts index 314a559d83..840105fdf8 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/events.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/events.ts @@ -1,4 +1,3 @@ -import { isJsonObject } from '@floway-dev/protocols/common'; import { openaiResponsesErrorEventOf } from './error.ts'; import { restoreNamespaceOutputItem, type NamespaceToolNames } from './namespace-tools.ts'; import { createRandomOpenAIResponsesResponseId } from './response-id.ts'; @@ -20,6 +19,7 @@ import { parseJSONObjectUnlessTruncated } from '../../../shared/json/completion. import { isCompleteJSONObject } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIResponsesInputAssistantTurn, type OpenAIResponsesItemState, type OpenAIResponsesOutputAssistantTurnState } from '../as-target/assistant-turn.ts'; +import { isJsonObject } from '@floway-dev/protocols/common'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { createRandomOpenAIResponsesItemId, From a6f49571566902f14ce10f3e81aa336ace4c697a Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 15:40:52 +0800 Subject: [PATCH 36/66] fix(copilot): preserve refusals in retained compaction history Keep assistant refusal content and its existing retention-budget treatment while applying role-specific content contracts. Replace the unsupported text-normalization expectation with a mixed-content regression that also checks retained history. --- .../__tests__/compaction_test.ts | 12 ++++++---- packages/provider-copilot/src/compaction.ts | 23 +++++++------------ 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/packages/provider-copilot/__tests__/compaction_test.ts b/packages/provider-copilot/__tests__/compaction_test.ts index 851af1227c..d984be3740 100644 --- a/packages/provider-copilot/__tests__/compaction_test.ts +++ b/packages/provider-copilot/__tests__/compaction_test.ts @@ -55,13 +55,17 @@ test('normalizes every retained text part to input_text — assistant output_tex expect(userPart.type).toBe('input_text'); }); -test('retains assistant refusal text in the compacted input spelling', () => { +test('preserves retained assistant refusals without charging them as text', () => { + const refusal = { type: 'refusal' as const, refusal: 'x'.repeat(64_001 * 4) }; const result = compactionResponse([ - { type: 'message', role: 'assistant', content: [{ type: 'refusal', refusal: 'Cannot provide that.' }] }, + { type: 'message', role: 'user', content: 'older' }, + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'reply' }, refusal] }, + { type: 'message', role: 'user', content: 'recent' }, ], generatedResult([compaction])); - expect(result.output[0]).toMatchObject({ - type: 'message', role: 'assistant', content: [{ type: 'input_text', text: 'Cannot provide that.' }], + expect(shape(result)).toEqual(['message:user', 'message:assistant', 'message:user', 'compaction']); + expect(result.output[1]).toMatchObject({ + type: 'message', role: 'assistant', content: [{ type: 'input_text', text: 'reply' }, refusal], }); }); diff --git a/packages/provider-copilot/src/compaction.ts b/packages/provider-copilot/src/compaction.ts index f9598745e5..dc87ff8733 100644 --- a/packages/provider-copilot/src/compaction.ts +++ b/packages/provider-copilot/src/compaction.ts @@ -8,7 +8,7 @@ // https://github.com/openai/codex/blob/3d805abdf09093bfa806f359a5adc6514766c420/codex-rs/core/src/compact_remote_v2.rs#L439-L501 // https://github.com/openai/codex/blob/3d805abdf09093bfa806f359a5adc6514766c420/codex-rs/utils/string/src/truncate.rs#L71-L74 -import { isOpenAIResponsesCompactionItem, createRandomOpenAIResponsesItemId, type OpenAIResponsesCompactionResultEx, type OpenAIResponsesCompactionTriggerItem, type OpenAIResponsesInputContent, type OpenAIResponsesAssistantInputContent, type CanonicalOpenAIResponsesInputItem, type OpenAIResponsesInputMessageEx, type OpenAIResponsesOutputItemEx, type OpenAIResponsesResultEx } from '@floway-dev/protocols/openai-responses'; +import { isOpenAIResponsesCompactionItem, createRandomOpenAIResponsesItemId, type OpenAIResponsesCompactionResultEx, type OpenAIResponsesCompactionTriggerItem, type OpenAIResponsesAssistantInputContent, type CanonicalOpenAIResponsesInputItem, type CanonicalOpenAIResponsesText, type OpenAIResponsesInputMessageEx, type OpenAIResponsesOutputItemEx, type OpenAIResponsesResultEx } from '@floway-dev/protocols/openai-responses'; export const COMPACTION_TRIGGER: OpenAIResponsesCompactionTriggerItem = { type: 'compaction_trigger' }; @@ -26,17 +26,9 @@ const RETAINED_BUDGET_TOKENS = 64_000; const APPROX_BYTES_PER_TOKEN = 4; const encoder = new TextEncoder(); -// Native compact echoes every text part — including assistant `output_text` — -// as `input_text` so the client can resend `output` verbatim as next-turn -// `input`. A replayed refusal has no input spelling, so its text is retained as -// ordinary input text; dropping it would shorten the history the client resends. -const normalizeContent = (content: string | OpenAIResponsesAssistantInputContent[]): OpenAIResponsesInputContent[] => { +const normalizeContent = (content: string | Content[]): (Content | CanonicalOpenAIResponsesText)[] => { if (typeof content === 'string') return [{ type: 'input_text', text: content }]; - return content.map(part => { - if (part.type === 'output_text') return { ...part, type: 'input_text' as const }; - if (part.type === 'refusal') return { type: 'input_text' as const, text: part.refusal }; - return part; - }); + return content.map(part => (part.type === 'output_text' ? { ...part, type: 'input_text' } : part)); }; const isRetainedMessage = (item: CanonicalOpenAIResponsesInputItem): item is OpenAIResponsesInputMessageEx => @@ -58,8 +50,10 @@ export const compactionResponse = (input: CanonicalOpenAIResponsesInputItem[], g const item = input[i]; if (!isRetainedMessage(item)) continue; - const content = normalizeContent(item.content); - const tokens = content.reduce((sum, part) => + const message = item.role === 'assistant' + ? { role: item.role, content: normalizeContent(item.content) } + : { role: item.role, content: normalizeContent(item.content) }; + const tokens = message.content.reduce((sum, part) => part.type === 'input_text' ? sum + Math.ceil(encoder.encode(part.text).length / APPROX_BYTES_PER_TOKEN) : sum, 0); @@ -70,8 +64,7 @@ export const compactionResponse = (input: CanonicalOpenAIResponsesInputItem[], g type: 'message', id: createRandomOpenAIResponsesItemId('message'), status: item.status ?? 'completed', - role: item.role, - content, + ...message, ...(item.phase !== undefined ? { phase: item.phase } : {}), }); } From 1a9783987fe66a8749aaa96e7ea225219d92171c Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 15:55:09 +0800 Subject: [PATCH 37/66] fix(translate): accept case-insensitive HTTP image schemes Compare HTTP and HTTPS URI schemes without case sensitivity while forwarding the original image URL. Cover lower, upper and mixed-case spellings through the Messages request translation boundary. Reference: https://www.rfc-editor.org/rfc/rfc3986#section-3.1 --- ...-chat-completions-to-anthropic-messages-request_test.ts | 7 ++++--- .../protocols/anthropic-messages/as-target/input-from.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-request_test.ts index fa2eb87cff..945bd05f84 100644 --- a/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-request_test.ts @@ -610,13 +610,14 @@ test('image_url with data URL → base64 image block', async () => { assertEquals(img.source.data, 'iVBORw0KGgo='); }); -test('a remote image URL is stated as the URL Messages accepts', async () => { +test.each(['http', 'https', 'HTTP', 'HTTPS', 'hTtP', 'hTtPs'])('preserves a remote image URL with %s scheme', async scheme => { + const url = `${scheme}://example.com/image.png`; const result = await buildWithTestSidecar(mkPayload({ messages: [{ role: 'user', content: [ { type: 'text', text: 'What is this?' }, - { type: 'image_url', image_url: { url: 'https://example.com/image.png' } }, + { type: 'image_url', image_url: { url } }, ], }], })); @@ -624,7 +625,7 @@ test('a remote image URL is stated as the URL Messages accepts', async () => { // Preserve remote image URLs; provider-specific fetching belongs at the gateway boundary. const blocks = userBlocks(result, 0); assertEquals(blocks.length, 2); - assertEquals(blocks[1], { type: 'image', source: { type: 'url', url: 'https://example.com/image.png' }, cache_control: { type: 'ephemeral' } }); + assertEquals(blocks[1], { type: 'image', source: { type: 'url', url }, cache_control: { type: 'ephemeral' } }); }); test('image with jpeg media type', async () => { diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts index 23a2384c3c..d7c03df9a3 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts @@ -22,7 +22,7 @@ const imageSource = (media: IRInputMedia): AnthropicMessagesImageSource => { switch (media.source_kind) { case 'url': { // Messages remote images require HTTP(S) URLs; invalid schemes are translation input errors. - const scheme = media.url.slice(0, media.url.indexOf(':')); + const scheme = media.url.slice(0, media.url.indexOf(':')).toLowerCase(); if (scheme !== 'http' && scheme !== 'https') { throw new TranslateInputException(`Messages image input requires an http or https URL, got '${media.url}'.`); } From 1a8c049fb02d476aa808fce2d7f8fa1f539e0ed7 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 17:20:41 +0800 Subject: [PATCH 38/66] fix(gateway): retain token-counting input failure telemetry Keep expected token-counting input failures as gateway API errors so final response rendering records the selected candidate's zero-output error. Preserve fallback across candidates and verify final failure attribution and successful fallback through real HTTP requests and performance records. --- .../chat/gemini-generate-content/http_test.ts | 69 +++++++++++++++++++ .../chat/gemini-generate-content/attempt.ts | 9 +-- .../interceptors/types.ts | 11 ++- 3 files changed, 77 insertions(+), 12 deletions(-) diff --git a/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/http_test.ts b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/http_test.ts index dd177a5582..91d95146d9 100644 --- a/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/http_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/http_test.ts @@ -225,6 +225,75 @@ test('POST /v1beta/models/:model:countTokens accepts the generateContentRequest assertEquals(await response.json(), { totalTokens: 7 }); }); +test.each([ + { upstreams: ['up_count_only'] }, + { upstreams: ['up_count_first', 'up_count_last'] }, +])('countTokens records the final translation input failure once for $upstreams', async ({ upstreams }) => { + const repo = installRepo(); + const recordFailure = vi.spyOn(repo.performance, 'recordZeroOutputError'); + const callAnthropicMessagesCountTokens = vi.fn(async (): Promise => { + throw new Error('Unsupported input must fail before the upstream call'); + }); + const candidates = upstreams.map(upstream => makeCandidate({ upstream, targetApi: 'anthropicMessages', callAnthropicMessagesCountTokens })); + queueCandidates(candidates); + + const response = await makeApp().request('/v1beta/models/test-model:countTokens', { + method: 'POST', + headers: new Headers({ 'content-type': 'application/json' }), + body: JSON.stringify({ contents: [{ role: 'user', parts: [{ inlineData: { mimeType: 'audio/wav', data: 'AQID' } }] }] }), + }); + + assertEquals(response.status, 400); + const body = await response.json() as { error: { status: string; message: string } }; + assertEquals(body.error.status, 'INVALID_ARGUMENT'); + assert(body.error.message.includes('audio/wav')); + assertEquals(callAnthropicMessagesCountTokens.mock.calls.length, 0); + assertEquals(recordFailure.mock.calls.length, 1); + assertEquals(recordFailure.mock.calls[0][0].keyId, API_KEY_ID); + assertEquals(recordFailure.mock.calls[0][0].upstream, upstreams.at(-1)); + assertEquals(recordFailure.mock.calls[0][0].model, candidates.at(-1)!.model.id); + assertEquals(recordFailure.mock.calls[0][0].operation, 'chat'); + const rows = await repo.performance.listAll(); + assertEquals(rows.map(row => ({ requests: row.requests, errors: row.errorsNoOutput, neutral: row.neutral })), [ + { requests: 1, errors: 1, neutral: 0 }, + ]); +}); + +test('generateContent retries a translation input failure and records the successful candidate', async () => { + const repo = installRepo(); + const recordFailure = vi.spyOn(repo.performance, 'recordZeroOutputError'); + const recordSuccess = vi.spyOn(repo.performance, 'recordNeutral'); + const callAnthropicMessages = vi.fn(async (): Promise> => { + throw new Error('Unsupported input must fail before the upstream call'); + }); + const callOpenAIChatCompletions = vi.fn(async (): Promise> => ({ + ok: true, events: makeProtocolFrames(makeOpenAIChatCompletionsEvents()), modelKey: 'k', headers: new Headers(), + })); + queueCandidates([ + makeCandidate({ upstream: 'up_messages', targetApi: 'anthropicMessages', callAnthropicMessages }), + makeCandidate({ upstream: 'up_chat', targetApi: 'openaiChatCompletions', callOpenAIChatCompletions }), + ]); + + const response = await makeApp().request('/v1beta/models/test-model:generateContent', { + method: 'POST', + headers: new Headers({ 'content-type': 'application/json' }), + body: JSON.stringify({ contents: [{ role: 'user', parts: [{ inlineData: { mimeType: 'audio/wav', data: 'AQID' } }] }] }), + }); + + assertEquals(response.status, 200); + const body = await response.json() as { candidates: unknown[] }; + assertEquals(body.candidates.length, 1); + assertEquals(callAnthropicMessages.mock.calls.length, 0); + assertEquals(callOpenAIChatCompletions.mock.calls.length, 1); + assertEquals(recordFailure.mock.calls.length, 0); + assertEquals(recordSuccess.mock.calls.length, 1); + assertEquals(recordSuccess.mock.calls[0][0].upstream, 'up_chat'); + const rows = await repo.performance.listAll(); + assertEquals(rows.map(row => ({ requests: row.requests, errors: row.errorsNoOutput, neutral: row.neutral })), [ + { requests: 1, errors: 0, neutral: 1 }, + ]); +}); + test('POST /v1beta/models/:model:generateContent translates through Anthropic Messages target end to end', async () => { installRepo(); const callAnthropicMessages = vi.fn(async (): Promise> => ({ diff --git a/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts b/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts index 3bf5954679..409e4b4d48 100644 --- a/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts +++ b/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts @@ -12,7 +12,7 @@ import { chatTargetPicker } from '../shared/target-picker.ts'; import { runInterceptors } from '@floway-dev/interceptor'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; -import { type ModelCandidate, plainResult, toInternalDebugError, type ExecuteResult, type GeminiGenerateContentInvocation, type PlainResult } from '@floway-dev/provider'; +import { type ModelCandidate, plainResult, toInternalDebugError, type ApiErrorResult, type ExecuteResult, type GeminiGenerateContentInvocation, type PlainResult } from '@floway-dev/provider'; import { routeGeminiGenerateContentViaAnthropicMessages, routeGeminiGenerateContentViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIResponses, type TranslateInputError } from '@floway-dev/translate'; // Gemini generateContent has no native upstream target in the provider API; prefer OpenAI Chat Completions @@ -48,7 +48,7 @@ export const geminiGenerateContentAttempt = { }); }, - countTokens: async (args: GeminiGenerateContentAttemptCountTokensArgs): Promise => { + countTokens: async (args: GeminiGenerateContentAttemptCountTokensArgs): Promise => { const { payload: sourcePayload, ctx, candidate, headers: sourceHeaders } = args; const payload = structuredClone(sourcePayload); const headers = new Headers(sourceHeaders); @@ -73,10 +73,7 @@ export const geminiGenerateContentAttempt = { stripUnsupportedToolsFromPayload(cleaned); delete cleaned.safetySettings; const trip = await routeGeminiGenerateContentViaAnthropicMessages(cleaned, transCtx); - if (trip.type === 'input-error') { - const failure = inputErrorResult(trip, ctx.attempt.telemetry); - return plainResult(failure.status, failure.headers, failure.body); - } + if (trip.type === 'input-error') return inputErrorResult(trip, ctx.attempt.telemetry); const { stream: _stream, ...target } = trip.target; const anthropicMessagesResult = await anthropicMessagesAttempt.countTokens({ payload: target, ctx, candidate, headers: invocation.headers, anthropicBeta: [], diff --git a/packages/gateway/src/data-plane/chat/gemini-generate-content/interceptors/types.ts b/packages/gateway/src/data-plane/chat/gemini-generate-content/interceptors/types.ts index e81d2e52f0..b6e0220eed 100644 --- a/packages/gateway/src/data-plane/chat/gemini-generate-content/interceptors/types.ts +++ b/packages/gateway/src/data-plane/chat/gemini-generate-content/interceptors/types.ts @@ -2,7 +2,7 @@ import type { GatewayCtx } from '../../../shared/gateway-ctx.ts'; import type { Interceptor } from '@floway-dev/interceptor'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; -import type { ExecuteResult, GeminiGenerateContentInvocation, PlainResult } from '@floway-dev/provider'; +import type { ApiErrorResult, ExecuteResult, GeminiGenerateContentInvocation, PlainResult } from '@floway-dev/provider'; export type GeminiGenerateContentInterceptor = Interceptor< GeminiGenerateContentInvocation, @@ -10,14 +10,13 @@ export type GeminiGenerateContentInterceptor = Interceptor< ExecuteResult> >; -// countTokens is a one-shot, non-streaming HTTP exchange — the terminal -// returns a `PlainResult` carrying the reshaped Gemini generateContent envelope, not an event -// stream. The interceptor chain still runs against a `GeminiGenerateContentInvocation` so -// payload-shaped reads stay symmetric with the generate path. Interceptors +// countTokens returns a reshaped HTTP body or an API error. The interceptor +// chain still uses GeminiGenerateContentInvocation so payload-shaped reads +// stay symmetric with the generate path. Interceptors // registered here MUST be pure header/payload mutators; post-`run()` result // inspection is not portable to this result type. export type GeminiGenerateContentCountTokensInterceptor = Interceptor< GeminiGenerateContentInvocation, GatewayCtx, - PlainResult + ApiErrorResult | PlainResult >; From 508c2e1e379f9a9d55099b5338cf3b20f2b73e36 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 17:21:59 +0800 Subject: [PATCH 39/66] fix(translate): preserve colliding error metadata Preserve every distinct same-named upstream error value beneath the first available numeric suffix while reserving original keys. Keep container and inner-error precedence, and coalesce equal JSON structures without rewriting raw number tokens. Merge HTTP and SSE namespaces before projection, keeping loose fields and canonical values from being duplicated during source rendering. Verified 91 scoped tests, the translate TypeScript project, and ESLint for all changed files. --- .../as-source/error-body_test.ts | 4 +- .../protocols/cross-protocol-errors_test.ts | 52 ++++++++++-- .../via-openai-responses/events_test.ts | 8 +- .../__tests__/shared/api-error_test.ts | 43 ++++++++++ packages/translate/src/shared/api-error.ts | 83 ++++++++++++++----- 5 files changed, 158 insertions(+), 32 deletions(-) diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-source/error-body_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/error-body_test.ts index b6216750a9..47379896a5 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-source/error-body_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-source/error-body_test.ts @@ -80,13 +80,13 @@ test('carries the fields the target protocol states that the IR does not name', }); }); -test('merges loose error fields with the upstream container and keeps its collision priority', () => { +test('preserves colliding loose error fields beneath the upstream container priority', () => { const result = restateAnthropicMessagesError(...upstreamOf(400, { error: { message: 'bad request', provider_specific_fields: { stack: 'theirs' }, stack: 'legacy', retry_after: 10, code: 'invalid_value', param: 'temperature' }, })); assertEquals((decoded(result).error as Record).provider_specific_fields, { - stack: 'theirs', retry_after: 10, code: 'invalid_value', param: 'temperature', + stack: 'theirs', stack_1: 'legacy', retry_after: 10, code: 'invalid_value', param: 'temperature', }); }); diff --git a/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts b/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts index 0a2cea6c25..bcb8138826 100644 --- a/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts +++ b/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts @@ -43,17 +43,17 @@ const nestedEvent = (error: typeof failure) => ({ type: 'error' as const, error const expectedChat = { error: { message: failure.message, type: failure.type, code: failure.code, param: failure.param, - provider_specific_fields: { status: failure.status, msg: failure.msg, request_id: 'req_1', retry_after: 9 }, + provider_specific_fields: { status: failure.status, msg: failure.msg, request_id: 'req_1', retry_after: 9, retry_after_1: 7 }, }, }; const expectedResponses = { type: 'error', message: failure.message, code: failure.code, param: failure.param, - provider_specific_fields: { type: failure.type, status: failure.status, msg: failure.msg, request_id: 'req_1', retry_after: 9 }, + provider_specific_fields: { type: failure.type, status: failure.status, msg: failure.msg, request_id: 'req_1', retry_after: 9, retry_after_1: 7 }, }; const expectedMessages = { type: 'error', error: { type: failure.type, message: failure.message, - provider_specific_fields: { code: failure.code, param: failure.param, status: failure.status, msg: failure.msg, request_id: 'req_1', retry_after: 9 }, + provider_specific_fields: { code: failure.code, param: failure.param, status: failure.status, msg: failure.msg, request_id: 'req_1', retry_after: 9, retry_after_1: 7 }, }, }; const expectedGemini = { @@ -62,7 +62,7 @@ const expectedGemini = { details: [{ '@type': 'type.googleapis.com/google.protobuf.Struct', value: { type: failure.type, code: failure.code, param: failure.param, status: failure.status, - msg: failure.msg, request_id: 'req_1', retry_after: 9, + msg: failure.msg, request_id: 'req_1', retry_after: 9, retry_after_1: 7, }, }], }, @@ -152,7 +152,7 @@ test('Responses nested error envelopes preserve request_id in both phases', asyn expect(await collect(trip.events(iterate([eventFrame(envelope)])))).toEqual([eventFrame(expected)]); }); -test('Responses nested error envelopes preserve outer metadata in both phases', async () => { +test('Responses nested error envelopes preserve every collision and explicit suffix in both phases', async () => { const trip = await requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses(chat, options())); const error = { message: 'failed', type: 'upstream_type', code: null, param: null, @@ -160,12 +160,16 @@ test('Responses nested error envelopes preserve outer metadata in both phases', }; const envelope = { type: 'error' as const, sequence_number: 17, error, - request_id: 'outer', trace: { region: 'us' }, provider_specific_fields: { outer_detail: true }, + request_id: 'outer_loose', request_id_1: 'original suffix', trace: { region: 'us' }, + provider_specific_fields: { request_id: 'outer_bag', outer_detail: true }, }; const expected = { error: { message: 'failed', type: 'upstream_type', code: null, param: null, - provider_specific_fields: { request_id: 'inner_bag', retry_after: 7, trace: { region: 'us' }, outer_detail: true }, + provider_specific_fields: { + request_id: 'inner_bag', retry_after: 7, trace: { region: 'us' }, outer_detail: true, + request_id_1: 'original suffix', request_id_2: 'inner_loose', request_id_3: 'outer_bag', request_id_4: 'outer_loose', + }, }, }; const http = trip.error(502, new Headers(), new TextEncoder().encode(JSON.stringify(envelope))); @@ -173,6 +177,40 @@ test('Responses nested error envelopes preserve outer metadata in both phases', expect(await collect(trip.events(iterate([eventFrame(envelope)])))).toEqual([eventFrame(expected)]); }); +test('Responses nested error envelopes coalesce equal JSON metadata in both phases', async () => { + const trip = await requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses(chat, options())); + const envelope = { + type: 'error' as const, + error: { + message: 'failed', trace: { region: 'us', attempts: [1, false, null] }, + provider_specific_fields: { trace: { attempts: [1, false, null], region: 'us' } }, + }, + trace: { attempts: [1, false, null], region: 'us' }, + provider_specific_fields: { trace: { region: 'us', attempts: [1, false, null] } }, + }; + const expected = { + error: { message: 'failed', provider_specific_fields: { trace: { region: 'us', attempts: [1, false, null] } } }, + }; + expect(trip.error(502, new Headers(), new TextEncoder().encode(JSON.stringify(envelope)))?.body).toEqual(expected); + expect(await collect(trip.events(iterate([eventFrame(envelope)])))).toEqual([eventFrame(expected)]); +}); + +test('Messages projects a canonical code once when outer metadata states the same value', async () => { + const trip = await requireTrip(routeAnthropicMessagesViaOpenAIResponses(messages, options())); + const envelope = { + type: 'error' as const, code: 'source-code', + error: { message: 'failed', code: 'source-code', provider_specific_fields: { code: 'container-code' } }, + }; + const expected = { + type: 'error', error: { + type: 'api_error', message: 'failed', + provider_specific_fields: { code: 'container-code', code_1: 'source-code' }, + }, + }; + expect(trip.error(502, new Headers(), new TextEncoder().encode(JSON.stringify(envelope)))?.body).toEqual(expected); + expect(await collect(trip.events(iterate([eventFrame(envelope)])))).toEqual([eventFrame(expected)]); +}); + test('native target errors retain canonical object identity', async () => { const chatRecords = await collect(irFromOpenAIChatCompletions(iterate([eventFrame(chatEvent(failure))]), { roundTrip: createRoundTripReaderCapture<'openaiChatCompletions'>().roundTrip, diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts index 6f2dfbac5c..dc53f30682 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts @@ -408,7 +408,7 @@ test('translateToSourceEvents translates OpenAI Responses failed terminal events }); }); -test.each(['flat error', 'nested error', 'failed response'])('translateToSourceEvents preserves the error provider namespace without copying (%s)', async path => { +test.each(['flat error', 'nested error', 'failed response'])('translateToSourceEvents preserves the error provider namespace and colliding loose fields (%s)', async path => { const provider_specific_fields = { name: 'TranslateInputException', stack: 'translator stack', @@ -433,9 +433,11 @@ test.each(['flat error', 'nested error', 'failed response'])('translateToSourceE message: error.message, ...(path === 'flat error' ? {} : { type: error.type }), code: error.code, - provider_specific_fields, + provider_specific_fields: { + ...provider_specific_fields, + name_1: error.name, stack_1: error.stack, cause_1: error.cause, target_api_1: error.target_api, + }, }); - expect(translated.provider_specific_fields).toBe(provider_specific_fields); const sse = writeChatFrame(frames[0], includeUsageChunk); if (sse === null) throw new Error('expected error SSE frame'); expect(JSON.parse(sse.data)).toEqual({ error: translated }); diff --git a/packages/translate/__tests__/shared/api-error_test.ts b/packages/translate/__tests__/shared/api-error_test.ts index e3e90446b5..53ad7b7095 100644 --- a/packages/translate/__tests__/shared/api-error_test.ts +++ b/packages/translate/__tests__/shared/api-error_test.ts @@ -87,3 +87,46 @@ test('envelope transport fields stay outside the error metadata', () => { message: 'failed', code: null, provider_specific_fields: { request_id: 'req_1' }, }); }); + +test('error metadata collisions reserve original suffix keys across every namespace', () => { + const body = new TextEncoder().encode(JSON.stringify({ + error: { + message: 'failed', trace: 'inner loose', + provider_specific_fields: { trace: 'inner container' }, + }, + trace: 'outer loose', trace_1: 'original suffix', + provider_specific_fields: { trace: 'outer container' }, + })); + expect(irErrorPayloadFromBody(body)).toEqual({ + message: 'failed', + provider_specific_fields: { + trace: 'inner container', trace_1: 'original suffix', + trace_2: 'inner loose', trace_3: 'outer container', trace_4: 'outer loose', + }, + }); +}); + +test('equal values at one metadata key coalesce across namespaces without consuming explicit suffix keys', () => { + const body = new TextEncoder().encode(JSON.stringify({ + error: { + message: 'failed', trace: { region: 'us', attempts: [1, false, null] }, + provider_specific_fields: { trace: 'preferred' }, + }, + trace: { attempts: [1, false, null], region: 'us' }, + trace_1: { region: 'us', attempts: [1, false, null] }, + provider_specific_fields: { trace: { region: 'us', attempts: [1, false, null] } }, + })); + expect(irErrorPayloadFromBody(body)).toEqual({ + message: 'failed', + provider_specific_fields: { + trace: 'preferred', trace_1: { region: 'us', attempts: [1, false, null] }, + trace_2: { region: 'us', attempts: [1, false, null] }, + }, + }); +}); + +test('metadata equality preserves distinct raw number tokens and coalesces identical tokens', () => { + const body = new TextEncoder().encode('{"error":{"message":"failed","amount":9007199254740993,"same":{"amount":1.00},"provider_specific_fields":{"amount":9007199254740992,"same":{"amount":1.00}}}}'); + const payload = irErrorPayloadFromBody(body); + expect(JSON.stringify(payload)).toBe('{"message":"failed","provider_specific_fields":{"amount":9007199254740992,"same":{"amount":1.00},"amount_1":9007199254740993}}'); +}); diff --git a/packages/translate/src/shared/api-error.ts b/packages/translate/src/shared/api-error.ts index 561d55b4e6..1f7308a028 100644 --- a/packages/translate/src/shared/api-error.ts +++ b/packages/translate/src/shared/api-error.ts @@ -1,4 +1,5 @@ import type { IRErrorPayload } from '../ir/types.ts'; +import { rawJSON } from './json/raw-json.ts'; import { parseJSONWithRawNumbers } from '@floway-dev/protocols/common'; const recordOf = (value: unknown): Readonly> | undefined => @@ -28,12 +29,14 @@ export const irErrorPayloadFromBody = (body: Uint8Array): IRErrorPayload | undef const envelopeRecord = recordOf(parsed); if (envelopeRecord === undefined) return undefined; const { source, details } = errorEnvelopeOf(envelopeRecord.type === 'error' ? errorEventPayloadOf(envelopeRecord) : envelopeRecord); - const payload = errorPayloadOf(source); - return payload === undefined ? undefined : withEnvelopeDetails(payload, details); + return errorPayloadOf(source, details); }; /** Restatement requires a message; preserve every unrepresented field in diagnostic details. */ -export const errorPayloadOf = (error: Readonly>): IRErrorPayload | undefined => { +export const errorPayloadOf = ( + error: Readonly>, + envelopeDetails: readonly Readonly>[] = [], +): IRErrorPayload | undefined => { const message = stringOf(error.message) ?? stringOf(error.msg); if (message === undefined) return undefined; const type = stringOf(error.type); @@ -45,7 +48,7 @@ export const errorPayloadOf = (error: Readonly>): IRErro ...(code === undefined ? [] : [error.code === null || typeof error.code === 'string' ? 'code' : 'status']), ...(param === undefined ? [] : ['param']), ]); - const extra = extensionOf(error, represented); + const extra = extensionOf(error, represented, envelopeDetails); return { message, ...(type === undefined ? {} : { type }), @@ -60,16 +63,19 @@ const errorEnvelopeOf = (envelope: Readonly>) => { const transportFields = envelope.type === 'error' ? ['type', 'sequence_number'] : []; if (nested !== undefined) return { source: nested, - details: extensionOf(envelope, new Set(['error', ...transportFields])), + details: extensionNamespacesOf(envelope, new Set(['error', ...transportFields])), }; - return { source: envelope, details: undefined }; + return { source: envelope, details: [] }; }; -// Error-level extension fields take precedence over envelope metadata in their shared container. -const withEnvelopeDetails = (payload: IRErrorPayload, details: Readonly> | undefined): IRErrorPayload => - details === undefined ? payload : { - ...payload, - provider_specific_fields: { ...details, ...errorDetailsOf(payload, ['message', 'type', 'code', 'param']) }, +// After merging, loose fields live only in the container so source projection cannot add them again. +const withEnvelopeDetails = (payload: IRErrorPayload, details: readonly Readonly>[]): IRErrorPayload => + details.length === 0 ? payload : { + message: payload.message, + ...(payload.type === undefined ? {} : { type: payload.type }), + ...(payload.code === undefined ? {} : { code: payload.code }), + ...(payload.param === undefined ? {} : { param: payload.param }), + provider_specific_fields: extensionOf(payload, new Set(['message', 'type', 'code', 'param']), details), }; const errorEventPayloadOf = (event: Readonly>) => { @@ -91,23 +97,60 @@ export const errorPayloadFromWire = (wire: unknown): IRErrorPayload => { return withEnvelopeDetails({ ...fields, message: aliasMessage }, details); } // Failures without message text still need an error frame; use an empty message. - const extra = extensionOf(source, NAMED_ERROR_FIELDS); - return withEnvelopeDetails({ message: '', ...(extra === undefined ? {} : { provider_specific_fields: extra }) }, details); + const extra = extensionOf(source, NAMED_ERROR_FIELDS, details); + return { message: '', ...(extra === undefined ? {} : { provider_specific_fields: extra }) }; }; const hasErrorMessage = (value: Readonly>): value is Readonly> & { readonly message: string } => stringOf(value.message) !== undefined; const NAMED_ERROR_FIELDS: ReadonlySet = new Set(['message', 'msg', 'type', 'code', 'status', 'param']); -// The upstream container wins collisions; loose fields share that same container. -const extensionOf = (error: Readonly>, representedFields: ReadonlySet): Readonly> | undefined => { +const extensionNamespacesOf = (error: Readonly>, representedFields: ReadonlySet) => { const own = recordOf(error.provider_specific_fields); - const rest: Record = {}; - for (const [key, value] of Object.entries(error)) { - if (representedFields.has(key) || key === 'provider_specific_fields' || own !== undefined && Object.hasOwn(own, key)) continue; - rest[key] = value; + const rest = Object.fromEntries(Object.entries(error).filter(([key]) => !representedFields.has(key) && key !== 'provider_specific_fields')); + return [...(own === undefined ? [] : [own]), ...(Object.keys(rest).length === 0 ? [] : [rest])]; +}; + +const sameJSONValue = (left: unknown, right: unknown): boolean => { + if (left === right) return true; + if (rawJSON.isRawJSON(left) || rawJSON.isRawJSON(right)) return JSON.stringify(left) === JSON.stringify(right); + if (Array.isArray(left)) return Array.isArray(right) && left.length === right.length && left.every((value, index) => sameJSONValue(value, right[index])); + const leftObject = recordOf(left); + const rightObject = recordOf(right); + if (leftObject === undefined || rightObject === undefined) return false; + const keys = Object.keys(leftObject); + return keys.length === Object.keys(rightObject).length && keys.every(key => Object.hasOwn(rightObject, key) && sameJSONValue(leftObject[key], rightObject[key])); +}; + +// Inner fields precede outer fields, and each upstream container precedes its loose fields. +// Reserve every original key before suffix allocation so generated names cannot take explicit names. +const extensionOf = ( + error: Readonly>, + representedFields: ReadonlySet, + envelopeDetails: readonly Readonly>[] = [], +): Readonly> | undefined => { + const namespaces = [...extensionNamespacesOf(error, representedFields), ...envelopeDetails]; + if (namespaces.length === 0) return undefined; + const values = new Map(); + for (const namespace of namespaces) { + for (const [key, value] of Object.entries(namespace)) { + // A named field already carries this value; another source writer can project it once. + if (representedFields.has(key) && sameJSONValue(error[key], value)) continue; + const previous = values.get(key); + if (previous === undefined) values.set(key, [value]); + else if (!previous.some(existing => sameJSONValue(existing, value))) previous.push(value); + } + } + if (namespaces.length === 1 && values.size === Object.keys(namespaces[0]).length) return namespaces[0]; + const merged: Record = Object.fromEntries([...values].map(([key, entries]) => [key, entries[0]])); + for (const [key, entries] of values) { + for (const value of entries.slice(1)) { + let suffix = 1; + while (Object.hasOwn(merged, `${key}_${suffix}`)) suffix++; + merged[`${key}_${suffix}`] = value; + } } - return Object.keys(rest).length === 0 ? own : { ...rest, ...own }; + return merged; }; /** Each source writer lists fields represented in its envelope; all remaining values stay in diagnostic extensions. */ From 7879f76dec11b850b7a03ec0f220a18facfb996a Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 17:34:17 +0800 Subject: [PATCH 40/66] style(translate): order integrated error helper imports --- packages/translate/src/shared/api-error.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/translate/src/shared/api-error.ts b/packages/translate/src/shared/api-error.ts index 71fa7704ae..e39942a1f1 100644 --- a/packages/translate/src/shared/api-error.ts +++ b/packages/translate/src/shared/api-error.ts @@ -1,5 +1,5 @@ -import type { IRErrorPayload } from '../ir/output/error.ts'; import { rawJSON } from './json/raw-json.ts'; +import type { IRErrorPayload } from '../ir/output/error.ts'; import { parseJSONWithRawNumbers } from '@floway-dev/protocols/common'; const recordOf = (value: unknown): Readonly> | undefined => From 8b4714aa60b2f6635f34f6fdfc137516105d517c Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 18:30:27 +0800 Subject: [PATCH 41/66] fix(translate): use the protocol status for upstream 502 errors Remove the translated-error exception that turned HTTP 502 into UNKNOWN. Reuse the protocol mapping to UNAVAILABLE while preserving HTTP 502, the unknown-status fallback and context-overflow classification. --- .../as-source/error-body_test.ts | 10 +++++----- .../gemini-generate-content/as-source/error.ts | 5 +---- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/error-body_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/error-body_test.ts index 35e416fad2..8b2ecb5dc8 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/error-body_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/error-body_test.ts @@ -15,12 +15,12 @@ const upstreamOf = (status: number, body: unknown, headers: Record { - const result = restateGeminiGenerateContentError(...upstreamOf(503, { error: { message: 'upstream overloaded' } })); - assertEquals(result!.status, 503); - assertEquals(decoded(result).error.code, 503); +test.each([502, 503])('keeps upstream HTTP %i with its native RPC status', status => { + const result = restateGeminiGenerateContentError(...upstreamOf(status, { error: { message: 'upstream unavailable' } })); + assertEquals(result!.status, status); + assertEquals(decoded(result).error.code, status); assertEquals(decoded(result).error.status, 'UNAVAILABLE'); - assertEquals(decoded(result).error.message, 'upstream overloaded'); + assertEquals(decoded(result).error.message, 'upstream unavailable'); }); test('keeps a status the inferred table would have flattened', () => { diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts index cf8ac50146..03ab047555 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/error.ts @@ -3,9 +3,6 @@ import { errorDetailsOf } from '../../../shared/api-error.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; import { geminiGenerateContentStatusForHttpStatus, type GeminiGenerateContentErrorResponse } from '@floway-dev/protocols/gemini-generate-content'; -// This translated-error boundary retains UNKNOWN for an upstream 502. -const statusNameOf = (status: number): string => status === 502 ? 'UNKNOWN' : geminiGenerateContentStatusForHttpStatus(status) ?? 'UNKNOWN'; - /** * HTTP failures supply an upstream status; streaming failures derive a code from the error kind. * Context overflow selects our local 400 code. @@ -20,5 +17,5 @@ export const geminiGenerateContentErrorFromIR = (error: IRErrorPayload, httpStat : kind === 'rate_limit_exceeded' || kind === 'rate_limit_error' ? 429 : kind === 'invalid_prompt' || kind === 'invalid_request_error' || isContextExceededError(error) ? 400 : kind === 'overloaded_error' ? 503 : 500; - return { code, status: statusNameOf(code), message: error.message, ...extension }; + return { code, status: geminiGenerateContentStatusForHttpStatus(code) ?? 'UNKNOWN', message: error.message, ...extension }; }; From 368c4170845f934240c808171a70b675f39ccfda Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 19:15:26 +0800 Subject: [PATCH 42/66] fix(gateway): preserve Responses continuation errors Carry standard Responses error bodies through the hosted-tool event lifecycle, drain the following response.failed resource, and reconstruct non-streaming errors without retaining an HTTP-result side channel. Preserve accumulated usage telemetry while allowing stateful, affinity, HTTP, and WebSocket consumers to observe the complete failure lifecycle. --- .../openai-responses/affinity/egress_test.ts | 25 ++ .../interceptors/server-tool-shim_test.ts | 371 +++++------------- .../openai-responses/items/output_test.ts | 31 ++ .../chat/openai-responses/respond_test.ts | 125 +++++- .../chat/openai-responses/websocket_test.ts | 66 +++- .../chat/openai-responses/affinity/egress.ts | 2 - .../interceptors/server-tool-shim.ts | 108 +++-- .../chat/openai-responses/items/output.ts | 7 +- .../chat/openai-responses/respond.ts | 23 +- .../chat/openai-responses/websocket.ts | 7 +- .../openai-responses/reassemble_test.ts | 44 ++- .../__tests__/openai-responses/stream_test.ts | 22 +- .../openai-responses/to-result_test.ts | 39 +- .../protocols/src/openai-responses/index.ts | 2 +- .../src/openai-responses/reassemble.ts | 29 +- .../protocols/src/openai-responses/stream.ts | 14 +- .../src/openai-responses/terminal-event.ts | 10 +- .../src/openai-responses/to-result.ts | 6 +- 18 files changed, 537 insertions(+), 394 deletions(-) diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/affinity/egress_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/affinity/egress_test.ts index eea0a81206..98e7306e14 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/affinity/egress_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/affinity/egress_test.ts @@ -208,6 +208,31 @@ describe('OpenAI Responses affinity egress', () => { expect(output).toEqual([eventFrame({ type: 'response.failed', response: response([], 'failed') })]); }); + test.each([ + { item: { type: 'reasoning' as const, id: 'rs_complete', summary: [], encrypted_content: 'opaque' }, shift: 0 }, + { item: { type: 'message' as const, id: 'msg_complete', role: 'assistant' as const, status: 'completed', content: [{ type: 'output_text' as const, text: 'answer', annotations: [] }] }, shift: 2 }, + ])('preserves the error and following failed response after a $item.type item', async ({ item, shift }) => { + const error = { type: 'error' as const, sequence_number: 3, code: null, param: 'tools', message: 'native failure', vendor: { retryable: false } }; + const failed = { ...response([item], 'failed'), error: { code: 'vendor_failure', message: error.message }, usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 } }; + const output: OpenAIResponsesStreamEventEx[] = []; + for await (const frame of wrapOpenAIResponsesAffinityEgress(frames([ + eventFrame({ type: 'response.created', sequence_number: 0, response: response([], 'in_progress') }), + eventFrame({ type: 'response.output_item.added', sequence_number: 1, output_index: 0, item }), + eventFrame({ type: 'response.output_item.done', sequence_number: 2, output_index: 0, item }), + eventFrame(error), + eventFrame({ type: 'response.failed', sequence_number: 4, response: failed }), + ]), { codec: immediateCodec, affinity })) { + if (frame.type === 'event') output.push(frame.event); + } + + expect(output.map(event => event.sequence_number)).toEqual(Array.from({ length: 5 + shift }, (_, index) => index)); + expect(output.find(event => event.type === 'error')).toEqual({ ...error, sequence_number: 3 + shift }); + const terminal = output.at(-1); + if (terminal?.type !== 'response.failed') throw new Error('expected response.failed'); + const finalizedItems = output.flatMap(event => event.type === 'response.output_item.done' ? [event.item] : []); + expect(terminal.response).toEqual({ ...failed, output: finalizedItems }); + }); + test('wraps compaction_summary as a natural carrier without inserting a prefix', async () => { const item = { type: 'compaction_summary', id: 'cmp_upstream', encrypted_content: 'opaque' } as unknown as OpenAIResponsesOutputItemEx; const output: ProtocolFrame[] = []; diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/server-tool-shim_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/server-tool-shim_test.ts index fab9d21a0c..8f29a5827c 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/server-tool-shim_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/server-tool-shim_test.ts @@ -2773,42 +2773,31 @@ test('next-turn echo retains original arguments while execution repairs a traili assertEquals((backend.calls[0].request as WebSearchProviderRequest).query, 'q'); }); -test('upstream sends bare `error` frame BEFORE any response.created: shim throws from the events iterator (no synthetic response.failed with empty identity)', async () => { - // A bare `{type:'error'}` arriving before any `response.created` - // produces no captured identity (id / model). Synthesizing a - // `response.failed` envelope would require those fields. The - // synthesizer throws instead of inventing values; the throw - // escapes the events generator and is surfaced upward by the - // source responder. The unreachable-in-practice path stays - // honest about the upstream protocol violation rather than - // silently lying about the served identity. +test('upstream sends an error event before response.created: shim forwards it without inventing a response', async () => { makeStubDeps(); const shim = withOpenAIResponsesWebSearchShim; const inv = makeInvocation(); + const error = { + type: 'error' as const, + message: 'upstream dropped before response shell', + code: null, + param: null, + }; const errorOnlyTurn: ScriptedTurn = [ - eventFrame({ - type: 'error', - message: 'upstream dropped before response shell', - }), + eventFrame(error), ]; const script = scriptedRun([errorOnlyTurn]); const result = await shim(inv, makeGatewayCtx(), script.run); assert(result.type === 'events'); - let thrown: unknown = undefined; - try { - for await (const _ of result.events) { /* drain */ } - } catch (e) { - thrown = e; - } - assert(thrown instanceof Error); + const events = eventPayloads(await collectFrames(result.events)); + assertEquals(events.length, 1); + const { sequence_number: sequenceNumber, ...forwarded } = events[0] as typeof error & { sequence_number: number }; + assertEquals(forwarded, error); + assertEquals(sequenceNumber, 0); }); -test('upstream sends bare `error` frame AFTER response.created: shim emits response.failed with synthesized id + last-seen model', async () => { - // When model is captured before the bare error arrives, the shim - // can synthesize a wire-valid `response.failed` envelope. The - // synthesized id is the shim's own (`resp_shim_`), and the - // model is the last-seen upstream model. +test('upstream sends an error event after response.created: shim forwards both events without synthesizing a terminal resource', async () => { makeStubDeps(); const shim = withOpenAIResponsesWebSearchShim; const inv = makeInvocation(); @@ -2838,16 +2827,12 @@ test('upstream sends bare `error` frame AFTER response.created: shim emits respo const result = await shim(inv, makeGatewayCtx(), script.run); assert(result.type === 'events'); const events = eventPayloads(await collectFrames(result.events)); - const failed = events[events.length - 1] as Extract; - assertEquals(failed.type, 'response.failed'); - assert(failed.response.id.startsWith('resp_shim_')); - assertEquals(failed.response.model, 'gpt-5.4-2025-01-20'); - assertEquals(failed.response.status, 'failed'); - assertEquals(failed.response.error?.message, 'mid-stream upstream blew up'); - // Upstream-supplied code carries through verbatim. - assertEquals(failed.response.error?.code, 'server_error'); - assert(failed.response.error?.provider_specific_fields === diagnostics); - assertFalse('type' in (failed.response.error as object)); + assertEquals(events.map(event => event.type), ['response.created', 'error']); + const error = events[1] as Extract; + assert('message' in error); + assertEquals(error.message, 'mid-stream upstream blew up'); + assertEquals(error.code, 'server_error'); + assert(error.provider_specific_fields === diagnostics); }); test('upstream iterator rejects before yielding any frame: shim surfaces the throw through the events iterator', async () => { @@ -4351,139 +4336,42 @@ test('turn 1 pure-text response streams BEFORE upstream terminal (no TTFT regres while (!(await iter.next()).done) { /* drain */ } }); -test('mid-stream upstream error yields response.failed and closes the SSE stream', async () => { - // After turn 1 emits a web_search call, turn 2's run() returns an - // upstream-error envelope. The shim must yield a single terminal - // response.failed frame and end the iterator — switching the outer - // envelope to upstream-error is impossible: the outer envelope shape - // is locked once the first frame is yielded. - makeStubDeps(); - const shim = withOpenAIResponsesWebSearchShim; - const inv = makeInvocation(); - - let runCalls = 0; - const run = async (): Promise>> => { - runCalls += 1; - if (runCalls === 1) { - const frames: AsyncIterable> = (async function* () { - for (const f of searchCallTurn(0, 'call_1', 'q1')) yield f; - })(); - return { - type: 'events', - events: frames, - modelIdentity: testTelemetryModelIdentity, - }; - } - return { - type: 'api-error', - source: 'upstream', - status: 503, - headers: new Headers({ 'content-type': 'application/json' }), - body: new TextEncoder().encode('{"error":"upstream temporarily unavailable"}'), - }; - }; - - const result = await shim(inv, makeGatewayCtx(), run); - assert(result.type === 'events'); - const events = eventPayloads(await collectFrames(result.events)); - - const terminal = events[events.length - 1]; - assertEquals(terminal.type, 'response.failed'); - const failedEv = terminal as Extract; - assertEquals(failedEv.response.status, 'failed'); - assert(failedEv.response.error !== undefined); - // Pass-through code: the shim quotes upstream's HTTP status - // (`upstream_`) when the body has no OpenAI-shaped error - // envelope. No normalization to a spec enum value. - assertEquals(failedEv.response.error?.code, 'upstream_503'); - assert(failedEv.response.error?.message.includes('503')); - assertFalse(events.some(e => e.type === 'response.completed')); - assertEquals(runCalls, 2); -}); - -test('mid-stream 429 pass-through: code reflects upstream HTTP status (no spec-enum normalization)', async () => { - // The shim no longer normalizes upstream error codes to the - // OpenAPI `OpenAIResponsesErrorCode` enum. A 429 with no OpenAI-shaped - // body falls back to `upstream_429` — the HTTP status is the most - // honest signal, and downstream clients pattern-matching on - // upstream's actual code see it directly. - makeStubDeps(); - const shim = withOpenAIResponsesWebSearchShim; - const inv = makeInvocation(); - - let runCalls = 0; - const run = async (): Promise>> => { - runCalls += 1; - if (runCalls === 1) { - const frames: AsyncIterable> = (async function* () { - for (const f of searchCallTurn(0, 'call_1', 'q1')) yield f; - })(); - return { type: 'events', events: frames, modelIdentity: testTelemetryModelIdentity }; - } - return { - type: 'api-error', - source: 'upstream', - status: 429, - headers: new Headers({ 'content-type': 'text/plain' }), - body: new TextEncoder().encode('rate limited'), - }; - }; - - const result = await shim(inv, makeGatewayCtx(), run); - assert(result.type === 'events'); - const events = eventPayloads(await collectFrames(result.events)); - const failed = events[events.length - 1] as Extract; - assertEquals(failed.response.error?.code, 'upstream_429'); -}); - -test('mid-stream 400 with non-OpenAI body falls back to upstream_400 code (no spec-enum normalization)', async () => { - // Without an OpenAI-shaped `error.code` in the body, the shim - // synthesizes `upstream_` rather than mapping to a spec - // enum value. The body excerpt and status live in `error.message` - // so clients still see the upstream detail. - makeStubDeps(); - const shim = withOpenAIResponsesWebSearchShim; - const inv = makeInvocation(); - - let runCalls = 0; - const run = async (): Promise>> => { - runCalls += 1; - if (runCalls === 1) { - const frames: AsyncIterable> = (async function* () { - for (const f of searchCallTurn(0, 'call_1', 'q1')) yield f; - })(); - return { type: 'events', events: frames, modelIdentity: testTelemetryModelIdentity }; - } - return { - type: 'api-error', - source: 'upstream', - status: 400, - headers: new Headers({ 'content-type': 'text/plain' }), - body: new TextEncoder().encode('bad request'), - }; - }; - - const result = await shim(inv, makeGatewayCtx(), run); - assert(result.type === 'events'); - const events = eventPayloads(await collectFrames(result.events)); - const failed = events[events.length - 1] as Extract; - assertEquals(failed.response.error?.code, 'upstream_400'); - // The HTTP status is preserved in the diagnostic message so clients - // pattern-matching for 4xx see it. - assert(failed.response.error?.message.includes('400')); - assert(failed.response.error?.message.includes('bad request')); - assertFalse('type' in (failed.response.error as object)); -}); - -test('mid-stream upstream error with OpenAI-shaped JSON body forwards code/type/message verbatim', async () => { - // When upstream is OpenAI-compatible and gives us a standard error - // envelope, pass it through unchanged so SDKs see the same vocabulary - // they'd see hitting upstream directly. +test.each([ + { + body: { + type: 'error', + message: 'You exceeded your current quota.', + code: null, + param: 'tools', + provider_specific_fields: { request_id: 'req_flat', retryable: true }, + }, + responseError: null, + }, + { + body: { + type: 'error', + error: { + message: 'You exceeded your current quota.', + type: 'insufficient_quota', + code: 'insufficient_quota', + param: null, + provider_specific_fields: { request_id: 'req_nested', retryable: true }, + }, + trace_id: 'trace_nested', + }, + responseError: { + message: 'You exceeded your current quota.', + type: 'insufficient_quota', + code: 'insufficient_quota', + param: null, + provider_specific_fields: { request_id: 'req_nested', retryable: true }, + }, + }, +] as const)('mid-stream HTTP error body becomes a lossless error event followed by response.failed: $body', async ({ body, responseError }) => { makeStubDeps(); const shim = withOpenAIResponsesWebSearchShim; const inv = makeInvocation(); - const diagnostics = { name: 'RateLimitError', cause: { limit: 'images' }, target_api: 'openaiResponses' }; let runCalls = 0; const run = async (): Promise>> => { runCalls += 1; @@ -4498,26 +4386,26 @@ test('mid-stream upstream error with OpenAI-shaped JSON body forwards code/type/ source: 'upstream', status: 429, headers: new Headers({ 'content-type': 'application/json' }), - body: new TextEncoder().encode(JSON.stringify({ - error: { - message: 'You exceeded your current quota.', - type: 'insufficient_quota', - code: 'insufficient_quota', - param: null, - provider_specific_fields: diagnostics, - }, - })), + body: new TextEncoder().encode(JSON.stringify(body)), }; }; const result = await shim(inv, makeGatewayCtx(), run); assert(result.type === 'events'); const events = eventPayloads(await collectFrames(result.events)); + const errorIndex = events.findIndex(event => event.type === 'error'); + assert(errorIndex >= 0); + const error = events[errorIndex] as Extract; + const { sequence_number: sequenceNumber, ...unstamped } = error; + assertEquals(unstamped, body); + assertEquals(typeof sequenceNumber, 'number'); + const failed = events[events.length - 1] as Extract; - assertEquals(failed.response.error?.code, 'insufficient_quota'); - assertEquals(failed.response.error?.type, 'insufficient_quota'); - assertEquals(failed.response.error?.message, 'You exceeded your current quota.'); - assertEquals(failed.response.error?.provider_specific_fields, diagnostics); + assertEquals(failed.type, 'response.failed'); + assertEquals(failed.response.status, 'failed'); + assertEquals(failed.response.error, responseError); + assertFalse(events.some(event => event.type === 'response.completed')); + assertEquals(runCalls, 2); }); test('upstream response.incomplete forwards as response.incomplete with the same incomplete_details.reason', async () => { @@ -6478,112 +6366,65 @@ test('consumeTurn surfaces upstream response.incomplete as terminalStatus.incomp assertEquals(result.summary.terminalStatus.kind, 'incomplete'); }); -test('consumeTurn surfaces bare `error` event as terminalStatus.failed with a synthesized envelope', async () => { - const state = createMergeState(); - const result = await consumeTurn( - framesOf( - mkResponseCreated(), - eventFrame({ - type: 'error', - message: 'upstream blew up', - code: 'server_error', - }), - ), - state, - true, - ); - assertEquals(result.summary.terminalStatus.kind, 'failed'); - const ts = result.summary.terminalStatus as Extract; - assertEquals(ts.response.status, 'failed'); - assertEquals(ts.response.error?.message, 'upstream blew up'); - assertEquals(ts.response.error?.code, 'server_error'); - // Synthesized envelope's id is the shim-synthesized response id - // — upstream's id is not exposed downstream. - assertEquals(ts.response.id, state.synthesizedResponseId); -}); - -test('consumeTurn defaults missing `error.code` to spec-defined `server_error` (no synthetic `unknown_upstream_error` literal)', async () => { - // A bare upstream `{type: 'error'}` frame without a `code` field - // falls back to the OpenAPI `OpenAIResponsesErrorCode` enum value - // `'server_error'`. The previous `'unknown_upstream_error'` - // synthetic literal is not in the enum and typed SDKs (openai-python - // `Literal[...]` with strict Pydantic validation, openai-node - // literal union, openai-go `OpenAIResponsesErrorCode string` named type) - // reject unknown values at parse time. Reference: - // https://github.com/openai/openai-openapi/blob/master/openapi.yaml +test.each([ + { type: 'error' as const, message: 'upstream blew up', code: null, param: 'tools', vendor: { retryable: false } }, + { type: 'error' as const, message: 'upstream stated an empty code', code: '', param: null }, +])('consumeTurn forwards a standalone error event without rewriting it: $message', async error => { const state = createMergeState(); const result = await consumeTurn( framesOf( mkResponseCreated(), - eventFrame({ - type: 'error', - message: 'upstream blew up without a code', - }), + eventFrame(error), ), state, true, ); - const ts = result.summary.terminalStatus as Extract; - assertEquals(ts.response.error?.code, 'server_error'); - // No synthetic `type` — the spec's OpenAIResponsesError schema defines - // only `{code, message}` and the upstream `error` frame doesn't - // carry a `type` field on the wire either. - assertFalse('type' in (ts.response.error as object)); -}); - -test('consumeTurn treats empty-string `error.code` as missing (same fallback as undefined)', async () => { - // `??` only handles null/undefined; an explicit `code: ''` would - // survive and reach the wire as the synthesized response.failed's - // `error.code`, where typed SDKs reject the empty string the same - // way they reject any non-enum value. Mirror `parseUpstreamErrorBody`'s - // `length > 0` guard for shape parity between the bare-error path - // and the upstream-error HTTP body path. + assertEquals(result.summary.terminalStatus, { kind: 'error', event: error }); + const forwarded = eventPayloads(result.downstreamFrames); + assertEquals(forwarded.map(event => event.type), ['response.created', 'error']); + const { sequence_number: _sequenceNumber, ...forwardedError } = forwarded[1] as typeof error & { sequence_number: number }; + assertEquals(forwardedError, error); +}); + +test('consumeTurn keeps an error notification and the following native failed resource', async () => { const state = createMergeState(); + const error = { type: 'error' as const, message: 'upstream blew up', code: null, param: 'tools' }; + const failed: OpenAIResponsesResultEx = { + id: 'upstream_failed', object: 'response', model: 'test-model', output: [], status: 'failed', + error: { code: 'rate_limit_exceeded', message: error.message }, incomplete_details: null, + usage: { input_tokens: 4, output_tokens: 1, total_tokens: 5 }, + }; const result = await consumeTurn( framesOf( mkResponseCreated(), - eventFrame({ - type: 'error', - message: 'upstream blew up with empty code', - code: '', - }), + eventFrame(error), + eventFrame({ type: 'response.failed', response: failed }), ), state, true, ); const ts = result.summary.terminalStatus as Extract; - assertEquals(ts.response.error?.code, 'server_error'); -}); - -test('consumeTurn surfaces bare `error` event arriving BEFORE response.created as terminalStatus `bare-error-pre-shell` (no synthesized response)', async () => { - // The success-path "no fallback" contract refuses to synthesize a - // wire-valid response envelope that lies about the served identity. - // The bare-error path's job is the opposite: surface upstream's - // failure verbatim. But when the failure happens before any - // identity is captured (truncated TLS, transport drop, intermediate - // proxy injection), we cannot synthesize a wire-valid - // `OpenAIResponsesResult` (id and model are required, not nullable). Use - // a distinct terminal status `bare-error-pre-shell` so the shim's - // outer loop can short-circuit to a non-events `upstream-error` - // result instead of fabricating empty-string identity fields. + assertEquals(ts.response, failed); + assertEquals(result.summary.turnUsage, { input_tokens: 4, output_tokens: 1, total_tokens: 5 }); + const forwarded = eventPayloads(result.downstreamFrames); + assertEquals(forwarded.map(event => event.type), ['response.created', 'error']); + const { sequence_number: _sequenceNumber, ...forwardedError } = forwarded.at(-1) as typeof error & { sequence_number: number }; + assertEquals(forwardedError, error); +}); + +test('consumeTurn returns a pre-shell error event without inventing a response resource', async () => { const state = createMergeState(); + const error = { type: 'error' as const, message: 'upstream dropped before response shell', code: null, param: null }; const result = await consumeTurn( - framesOf( - eventFrame({ - type: 'error', - message: 'upstream dropped before response shell', - }), - ), + framesOf(eventFrame(error)), state, true, ); - assertEquals(result.summary.terminalStatus.kind, 'bare-error-pre-shell'); - const ts = result.summary.terminalStatus as Extract; - assertEquals(ts.error.message, 'upstream dropped before response shell'); - // Spec-defined fallback (no `code` on upstream's error frame). See - // the matching test above for the in-shell path; both default to - // `server_error` from the `OpenAIResponsesErrorCode` enum. - assertEquals(ts.error.code, 'server_error'); + assertEquals(result.summary.terminalStatus, { kind: 'error', event: error }); + const forwarded = eventPayloads(result.downstreamFrames); + assertEquals(forwarded.length, 1); + const { sequence_number: _sequenceNumber, ...forwardedError } = forwarded[0] as typeof error & { sequence_number: number }; + assertEquals(forwardedError, error); }); test('consumeTurnStreaming yields forwarded frames before upstream completes', async () => { @@ -7567,8 +7408,8 @@ test('server-tool loop preserves internal failure diagnostics on the failed reso test('consumeTurn retains namespaced diagnostics before a response shell is announced', async () => { const diagnostics = { name: 'UpstreamError', stack: 'UpstreamError: no response shell', cause: { connection: 'closed' } }; - const result = await consumeTurn(framesOf(eventFrame({ type: 'error', error: { message: 'No response shell', code: 'connection_error', provider_specific_fields: diagnostics } })), createMergeState(), true); - assert(result.summary.terminalStatus.kind === 'bare-error-pre-shell'); - assertEquals(result.summary.terminalStatus.error.code, 'connection_error'); - assert(result.summary.terminalStatus.error.provider_specific_fields === diagnostics); + const error = { type: 'error' as const, error: { message: 'No response shell', code: 'connection_error', provider_specific_fields: diagnostics } }; + const result = await consumeTurn(framesOf(eventFrame(error)), createMergeState(), true); + assert(result.summary.terminalStatus.kind === 'error'); + assertEquals(result.summary.terminalStatus.event, error); }); diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/items/output_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/items/output_test.ts index e34ba1d4d5..7f83abd62d 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/items/output_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/items/output_test.ts @@ -278,6 +278,37 @@ test('client output persists a completed item before forwarding an error event', expect(await repo.openaiResponsesSnapshots.lookup('key-a', 'resp_public', 0)).toBeNull(); }); +test('client output preserves an error notification and the following failed response without committing a snapshot', async () => { + const { repo, store } = memoryOutputHarness(); + const error = { type: 'error' as const, sequence_number: 3, code: null, message: 'upstream failed', param: 'tools', vendor: { retryable: false } }; + const response = { + ...responseFor([completedReasoningItem]), + status: 'failed' as const, + error: { code: 'vendor_failure', message: error.message }, + usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 }, + }; + const input = async function* (): AsyncIterable> { + yield eventFrame({ type: 'response.created', sequence_number: 0, response: { ...response, status: 'in_progress', output: [], error: null } }); + yield eventFrame({ type: 'response.output_item.added', sequence_number: 1, output_index: 0, item: completedReasoningItem }); + yield eventFrame({ type: 'response.output_item.done', sequence_number: 2, output_index: 0, item: completedReasoningItem }); + yield eventFrame(error); + yield eventFrame({ type: 'response.failed', sequence_number: 4, response }); + }; + const output: ProtocolFrame[] = []; + for await (const frame of wrapOpenAIResponsesClientOutput(input(), { store, responseId: 'resp_public' })) { + output.push(frame); + if (frame.type === 'event' && frame.event.type === 'error') { + expect(await repo.openaiResponsesItems.lookupMany('key-a', ['rs_upstream'], 0)).toHaveLength(1); + } + } + + expect(output.slice(-2)).toEqual([ + eventFrame(error), + eventFrame({ type: 'response.failed', sequence_number: 4, response: { ...response, id: 'resp_public' } }), + ]); + expect(await repo.openaiResponsesSnapshots.lookup('key-a', 'resp_public', 0)).toBeNull(); +}); + test('client output does not persist a partial item without output_item.done', async () => { const { repo, store } = memoryOutputHarness(); const item = completedReasoningItem; diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/respond_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/respond_test.ts index eafa41d188..091535cdc8 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/respond_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/respond_test.ts @@ -4,12 +4,13 @@ import { beforeEach, expect, test } from 'vitest'; import { respondOpenAIResponses, respondOpenAIResponsesFailure } from '../../../../src/data-plane/chat/openai-responses/respond.ts'; import { OPENAI_RESPONSES_REASONING_INCLUDED_HEADER } from '../../../../src/data-plane/chat/openai-responses/response-headers.ts'; import { initRepo } from '../../../../src/repo/index.ts'; +import { tokenCountsFromUsage } from '../../../../src/repo/usage-metrics.ts'; import { InMemoryRepo } from '../../../repo/memory.ts'; import { mockChatGatewayCtx } from '../../../test-utils/gateway-ctx.ts'; -import { eventFrame } from '@floway-dev/protocols/common'; +import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; import { collectOpenAIResponsesProtocolEventsToResult, parseOpenAIResponsesStream, type OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; -import { eventResult, internalErrorResult, toInternalDebugError } from '@floway-dev/provider'; -import { testTelemetryModelIdentity } from '@floway-dev/test-utils'; +import { eventResult, internalErrorResult, toInternalDebugError, type EventResultMetadata } from '@floway-dev/provider'; +import { mockPerfTelemetryContext, testTelemetryModelIdentity } from '@floway-dev/test-utils'; beforeEach(() => { initRepo(new InMemoryRepo()); }); @@ -80,7 +81,123 @@ test('Responses SSE standalone error and failed response carry identical complet expect(failed.response.error).toEqual((error as { error: unknown }).error); const terminalOnly = await collectOpenAIResponsesProtocolEventsToResult((async function* () { yield eventFrame(failed); })()); expect(terminalOnly.error).toEqual(failed.response.error); - await expect(collectOpenAIResponsesProtocolEventsToResult(parseOpenAIResponsesStream(new Response(wire).body!))).rejects.toThrow('Upstream SSE error'); + await expect(collectOpenAIResponsesProtocolEventsToResult(parseOpenAIResponsesStream(new Response(wire).body!))).rejects.toMatchObject({ message: 'broken', cause: error }); +}); + +test.each([false, true])('Responses HTTP consumes an upstream error followed by its failed resource when stream=%s', async wantsStream => { + const item = { type: 'reasoning' as const, id: 'rs_complete', summary: [] }; + const error = { type: 'error' as const, sequence_number: 3, code: null, message: 'upstream failed', param: null, vendor: { detail: 'native' } }; + const failed = { + id: 'resp_upstream', object: 'response' as const, model: 'm', status: 'failed' as const, + output: [item], error: { code: 'vendor_failure', message: error.message }, incomplete_details: null, + usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 }, + }; + const frames = async function* () { + yield eventFrame({ type: 'response.created', sequence_number: 0, response: { ...failed, status: 'in_progress', output: [], error: null } }); + yield eventFrame({ type: 'response.output_item.added', sequence_number: 1, output_index: 0, item }); + yield eventFrame({ type: 'response.output_item.done', sequence_number: 2, output_index: 0, item }); + yield eventFrame(error); + yield eventFrame({ type: 'response.failed', sequence_number: 4, response: failed }); + }; + const app = new Hono().get('/', c => respondOpenAIResponses(c, eventResult(frames(), testTelemetryModelIdentity), wantsStream, mockChatGatewayCtx(), { model: 'm', input: [] })); + const response = await app.request('/'); + expect(response.status).toBe(wantsStream ? 200 : 502); + if (!wantsStream) { + const { sequence_number: _sequenceNumber, ...body } = error; + expect(await response.json()).toEqual(body); + return; + } + const events = []; + for await (const frame of parseOpenAIResponsesStream(response.body!)) if (frame.type === 'event') events.push(frame.event); + expect(events.map(event => event.type)).toEqual(['response.created', 'response.output_item.added', 'response.output_item.done', 'error', 'response.failed']); + expect(events[3]).toEqual(error); + expect(events.map(event => event.sequence_number)).toEqual([0, 1, 2, 3, 4]); + const terminal = events.at(-1); + if (terminal?.type !== 'response.failed') throw new Error('Missing failed response'); + const created = events[0]; + if (created.type !== 'response.created') throw new Error('Missing created response'); + expect(terminal.response.id).toBe(created.response.id); + expect(terminal.response).toMatchObject({ + status: 'failed', output: [item], error: failed.error, + usage: { ...failed.usage, input_tokens_details: { cached_tokens: 0 }, output_tokens_details: { reasoning_tokens: 0 } }, + }); + expect(terminal.response.id).not.toBe(failed.id); +}); + +test.each([ + { type: 'error' as const, sequence_number: 0, code: null, message: 'native failure', param: null, vendor: { detail: 'flat' } }, + { type: 'error' as const, sequence_number: 0, error: { code: 'vendor_failure', message: 'native failure', param: 'tools', vendor: { detail: 'nested' } }, trace: 'native-trace' }, +])('Responses HTTP preserves a standalone upstream error event %j', async error => { + for (const wantsStream of [false, true]) { + const frames = async function* () { yield eventFrame(error); yield doneFrame(); }; + const app = new Hono().get('/', c => respondOpenAIResponses(c, eventResult(frames(), testTelemetryModelIdentity), wantsStream, mockChatGatewayCtx(), { model: 'm', input: [] })); + const response = await app.request('/'); + expect(response.status).toBe(wantsStream ? 200 : 502); + if (wantsStream) { + const wire = await response.text(); + expect(wire.split('data: [DONE]').length - 1).toBe(1); + const events = []; + for await (const frame of parseOpenAIResponsesStream(new Response(wire).body!)) if (frame.type === 'event') events.push(frame.event); + expect(events).toEqual([error]); + } else { + const { sequence_number: _sequenceNumber, ...body } = error; + expect(await response.json()).toEqual(body); + } + } +}); + +test.each([ + { type: 'error' as const, sequence_number: 3, code: null, param: 'tools', message: 'rate limited', vendor: { retryable: true } }, + { type: 'error' as const, sequence_number: 3, error: { code: null, param: null, message: 'rate limited', vendor: { retryable: true } }, trace: 'native-trace' }, +])('Responses non-streaming failure drains the failed resource, preserves error fields, and records usage once: %j', async error => { + const repo = new InMemoryRepo(); + initRepo(repo); + const background: Promise[] = []; + const modelIdentity = { ...testTelemetryModelIdentity, upstream: 'continuation-upstream' }; + const telemetry = mockPerfTelemetryContext({ keyId: 'key_test', upstream: modelIdentity.upstream, runtimeLocation: 'TEST' }); + let resolveMetadata!: (metadata: EventResultMetadata) => void; + const finalMetadata = new Promise(resolve => { resolveMetadata = resolve; }); + let readFailedResource = false; + const item = { type: 'reasoning' as const, id: 'rs_complete', summary: [] }; + const failed = { + id: 'resp_aggregate', object: 'response' as const, model: 'm', status: 'failed' as const, + output: [item], error: null, incomplete_details: null, + usage: { input_tokens: 15, output_tokens: 2, total_tokens: 17 }, + }; + const frames = async function* () { + try { + yield eventFrame({ type: 'response.created', sequence_number: 0, response: { ...failed, status: 'in_progress', output: [] } }); + yield eventFrame({ type: 'response.output_item.added', sequence_number: 1, output_index: 0, item }); + yield eventFrame({ type: 'response.output_item.done', sequence_number: 2, output_index: 0, item }); + yield eventFrame(error); + readFailedResource = true; + yield eventFrame({ type: 'response.failed', sequence_number: 4, response: failed }); + } finally { + resolveMetadata({ modelIdentity, performance: telemetry, billableUsage: { input: 10, cacheRead: 3, cacheWrite: 2, cacheWrite1h: 0, output: 2 } }); + } + }; + const ctx = mockChatGatewayCtx({ + backgroundScheduler: promise => { background.push(promise); }, + attempt: { timing: { upstreamCallStartedAt: 0, firstOutputTokenAt: 1 }, telemetry }, + }); + const result = eventResult(frames(), testTelemetryModelIdentity, { + performance: telemetry, + finalMetadata, + }); + const app = new Hono().get('/', c => respondOpenAIResponses(c, result, false, ctx, { model: 'm', input: [] })); + const response = await app.request('/'); + expect(response.status).toBe(502); + expect(readFailedResource).toBe(true); + const { sequence_number: _sequenceNumber, ...body } = error; + expect(await response.json()).toEqual(body); + await Promise.all(background); + const usage = await repo.usage.listAll(); + expect(usage).toHaveLength(1); + expect(usage[0]).toMatchObject({ requests: 1, upstream: modelIdentity.upstream }); + expect(tokenCountsFromUsage(usage[0])).toEqual({ input: 10, input_cache_read: 3, input_cache_write: 2, output: 2 }); + const performance = await repo.performance.listAll(); + expect(performance).toHaveLength(1); + expect(performance[0]).toMatchObject({ requests: 1, errorsWithOutput: 1, errorsNoOutput: 0, ttftSamplesOk: 0, tpotSamples: 1 }); }); test('Responses collect failures preserve nested causes in the HTTP error namespace', async () => { diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/websocket_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/websocket_test.ts index e73158b5fd..eef9808a16 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/websocket_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/websocket_test.ts @@ -1279,6 +1279,7 @@ test('OpenAI Responses WebSocket evicts a failed continuation target so the next test('OpenAI Responses WebSocket evicts a continuation that failed through a streamed terminal event', async () => { const { apiKey } = await setupAppTest(); let responseCalls = 0; + const upstreamError = { type: 'error', sequence_number: 1, code: null, param: 'tools', message: 'the upstream gave up mid-turn', vendor: { retryable: false } }; await withMockedFetch( async request => { @@ -1304,7 +1305,8 @@ test('OpenAI Responses WebSocket evicts a continuation that failed through a str }; return sseResponse([ { event: 'response.created', data: { type: 'response.created', response: { ...failing, status: 'in_progress', error: null }, sequence_number: 0 } }, - { event: 'response.failed', data: { type: 'response.failed', response: failing, sequence_number: 1 } }, + { event: 'error', data: upstreamError }, + { event: 'response.failed', data: { type: 'response.failed', response: failing, sequence_number: 2 } }, { data: '[DONE]' }, ]); } @@ -1345,6 +1347,10 @@ test('OpenAI Responses WebSocket evicts a continuation that failed through a str }, })); const failedMessages = await failedTurn; + assertEquals(failedMessages.map(message => message.type), ['response.created', 'error', 'response.in_progress', 'response.failed']); + assertEquals(failedMessages[1], { ...upstreamError, event_id: 'evt_streamed_failure' }); + assertEquals(failedMessages.map(message => message.sequence_number), [0, 1, 2, 3]); + assert(failedMessages.every(message => message.event_id === 'evt_streamed_failure')); assertEquals(failedMessages.at(-1)?.type, 'response.failed'); const evicted = waitForMessages(client, messages => messages.some(message => message.type === 'error')); @@ -1375,6 +1381,64 @@ test('OpenAI Responses WebSocket evicts a continuation that failed through a str ); }); +test.each([ + { type: 'error', code: null, param: null, message: 'native failure', vendor: { detail: 'flat' } }, + { type: 'error', error: { code: 'vendor_failure', param: 'tools', message: 'native failure', vendor: { detail: 'nested' } }, trace: 'native-trace' }, +].flatMap(error => [false, true].map(announced => ({ error, announced }))))('OpenAI Responses WebSocket preserves a standalone upstream error and accepts the next turn %j', async ({ error, announced }) => { + const { apiKey, repo } = await setupAppTest(); + await repo.apiKeys.save({ ...apiKey, dumpRetentionSeconds: 3600 }); + const dumps = installDumpStubs(initDumpStore, initDumpBroker); + let responseCalls = 0; + const nativeError = { ...error, sequence_number: announced ? 1 : 0 }; + await withMockedFetch( + async request => { + const url = new URL(request.url); + if (url.hostname === 'update.code.visualstudio.com') return jsonResponse(['1.110.1']); + if (url.pathname === '/copilot_internal/v2/token') { + return jsonResponse({ token: 'copilot-access-token', expires_at: 4102444800, refresh_in: 3600, endpoints: { api: 'https://api.individual.githubcopilot.com' } }); + } + if (url.pathname === '/models') { + return jsonResponse(copilotModels([{ id: 'gpt-direct-responses', supported_endpoints: ['/responses'] }])); + } + if (url.pathname === '/responses') { + responseCalls += 1; + if (responseCalls === 1) return sseResponse([ + ...(announced ? [{ event: 'response.created', data: { type: 'response.created', sequence_number: 0, response: { id: 'resp_error', object: 'response', model: 'gpt-direct-responses', status: 'in_progress', output: [], error: null, incomplete_details: null } } }] : []), + { event: 'error', data: nativeError }, + { data: '[DONE]' }, + ]); + return sseOpenAIResponsesResponse({ id: 'resp_after_error', object: 'response', model: 'gpt-direct-responses', status: 'completed', output: [] }); + } + throw new Error(`Unhandled fetch ${request.url}`); + }, + async () => await withWorkerWebSocketRuntime(async () => { + const client = await connectOpenAIResponsesWebSocket(apiKey.key); + const raw = recordRawMessages(client); + try { + const received = waitForMessages(client, messages => messages.some(message => message.type === 'error')); + client.send(JSON.stringify({ type: 'response.create', event_id: 'evt_standalone', response: { model: 'gpt-direct-responses', input: 'hello' } })); + const messages = await received; + assertEquals(messages.map(message => message.type), announced ? ['response.created', 'error'] : ['error']); + assertEquals(messages.at(-1), { ...nativeError, event_id: 'evt_standalone' }); + await vi.waitFor(() => assertEquals(dumps.stored.length, 1)); + assertEquals(raw.messages.map(message => JSON.parse(message)), messages); + assertEquals(dumps.stored[0]?.record.meta.responseBytes, raw.messages.reduce((total, message) => total + new TextEncoder().encode(message).byteLength, 0)); + const nextTurn = waitForMessages(client, messages => messages.some(isTerminalResponseEvent)); + if (announced) { + const created = messages[0] as { response: { id: string } }; + client.send(JSON.stringify({ type: 'response.interrupt', event_id: 'evt_late_interrupt', response_id: created.response.id, mode: 'discard_partial_items' })); + } + client.send(JSON.stringify({ type: 'response.create', event_id: 'evt_after_error', response: { model: 'gpt-direct-responses', input: 'next turn' } })); + assert((await nextTurn).every(message => message.event_id === 'evt_after_error')); + assertEquals(responseCalls, 2); + } finally { + raw.stop(); + client.close(); + } + }), + ); +}); + test('OpenAI Responses WebSocket store:true durable snapshots can chain through local session cache', async () => { const { apiKey, repo } = await setupAppTest(); let turn = 0; diff --git a/packages/gateway/src/data-plane/chat/openai-responses/affinity/egress.ts b/packages/gateway/src/data-plane/chat/openai-responses/affinity/egress.ts index 2fb1ef007e..65d19159fa 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/affinity/egress.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/affinity/egress.ts @@ -94,7 +94,6 @@ const wrapNaturalOpenAIResponsesAffinity = async function* ( continue; } yield frame; - if (event.type === 'error') return; } }; @@ -291,7 +290,6 @@ const wrapOpenAIResponsesFirstCarrier = async function* ( } yield eventFrame(shifted(event)); - if (event.type === 'error') return; } }; diff --git a/packages/gateway/src/data-plane/chat/openai-responses/interceptors/server-tool-shim.ts b/packages/gateway/src/data-plane/chat/openai-responses/interceptors/server-tool-shim.ts index 38c3ac1871..a27dac0c99 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/interceptors/server-tool-shim.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/interceptors/server-tool-shim.ts @@ -1,11 +1,10 @@ import { jsonrepair } from 'jsonrepair'; import type { OpenAIResponsesInterceptor, OpenAIResponsesInvocation } from './types.ts'; -import { truncatePreservingCodePoints } from '../../../shared/text.ts'; import type { ChatGatewayCtx } from '../../shared/gateway-ctx.ts'; import { SidecarDomain } from '../../shared/sidecar-codec.ts'; import type { InterceptorRun } from '@floway-dev/interceptor'; -import { eventFrame, isJsonObject, sumBillableUsage, type ProtocolFrame } from '@floway-dev/protocols/common'; +import { eventFrame, isJsonObject, parseJSONWithRawNumbers, sumBillableUsage, type ProtocolFrame } from '@floway-dev/protocols/common'; import { createRandomOpenAIResponsesItemId, isOpenAIResponsesAssistantInputItem, @@ -19,6 +18,7 @@ import { type OpenAIResponsesOutputItemEx, type OpenAIResponsesResultEx, type OpenAIResponsesErrorEx, + type OpenAIResponsesErrorPayload, type OpenAIResponsesStreamEventEx, type OpenAIResponsesTool, type OpenAIResponsesToolChoice, @@ -152,14 +152,14 @@ type ActiveServerTool = Extract & { // How a single upstream turn ended, as observed while consuming its // stream. Carries the raw upstream `response` for failed/incomplete so -// the loop can lift the upstream `error` / `incomplete_details`, plus a -// `bare-error-pre-shell` variant for an `error` event that arrived -// before any `response.created` (no model known yet). Distinct from +// the loop can lift its error and usage. Error notifications remain separate +// from a response resource, including when no resource follows. Distinct from // `SynthesizedTerminal`, which is the shim's own outgoing terminal. export type UpstreamTerminal = | { kind: 'completed' } | { kind: 'failed'; response: OpenAIResponsesResultEx } | { kind: 'incomplete'; response: OpenAIResponsesResultEx } + | { kind: 'error'; event: Extract } | { kind: 'bare-error-pre-shell'; error: OpenAIResponsesErrorEx }; export interface TurnSummary { @@ -693,6 +693,7 @@ export const consumeTurnStreaming = async function* ( let sawClientToolCall = false; let turnUsage: MergeUsage = {}; let terminalStatus: UpstreamTerminal | undefined = undefined; + let errorEvent: Extract | undefined; const openItems = new Map(); const openItemIds = new Map(); @@ -748,26 +749,8 @@ export const consumeTurnStreaming = async function* ( } if (event.type === 'error') { - const e = 'error' in event ? event.error : event; - const code = typeof e.code === 'string' && e.code.length > 0 ? e.code : 'server_error'; - const failure: OpenAIResponsesErrorEx = { message: e.message, code, ...(e.provider_specific_fields === undefined ? {} : { provider_specific_fields: e.provider_specific_fields }) }; - if (merge.lastSeenModel === null) { - terminalStatus = { kind: 'bare-error-pre-shell', error: failure }; - } else { - terminalStatus = { - kind: 'failed', - response: { - id: merge.synthesizedResponseId, - object: 'response', - model: ensureModel(), - output: [], - status: 'failed', - error: failure, - incomplete_details: null, - }, - }; - } - turnUsage = {}; + errorEvent = event; + yield stamp(event); continue; } @@ -924,7 +907,9 @@ export const consumeTurnStreaming = async function* ( } if (terminalStatus === undefined) { - if (merge.lastSeenModel === null) { + if (errorEvent !== undefined) { + terminalStatus = { kind: 'error', event: errorEvent }; + } else if (merge.lastSeenModel === null) { terminalStatus = { kind: 'bare-error-pre-shell', error: { message: 'Upstream stream ended without a terminal event (no response.created observed)', code: 'server_error' }, @@ -948,7 +933,7 @@ export const consumeTurnStreaming = async function* ( if (interceptedByUpstreamIndex.size > 0) { const unmatched = [...interceptedByUpstreamIndex.keys()]; const priorKind = terminalStatus.kind; - const priorLabel = priorKind === 'bare-error-pre-shell' ? 'a pre-shell bare error' : `response.${priorKind}`; + const priorLabel = priorKind === 'error' ? 'an error event' : priorKind === 'bare-error-pre-shell' ? 'a pre-shell bare error' : `response.${priorKind}`; terminalStatus = { kind: 'failed', response: { @@ -969,36 +954,23 @@ export const consumeTurnStreaming = async function* ( return { dispatchedSlots, sawServerToolCall, sawClientToolCall, turnUsage, terminalStatus }; }; -const MAX_BODY_EXCERPT_CHARS = 512; - -const buildErrorFromResult = ( +const errorEventFromResult = ( result: Exclude, { type: 'events' }>, -): NonNullable => { - if (result.type === 'internal-error') return { message: result.error.message, code: 'server_error', provider_specific_fields: internalDebugErrorFields(result.error) }; - const decoded = new TextDecoder('utf-8', { fatal: false }).decode(result.body); - let parsed: unknown = undefined; - try { - parsed = JSON.parse(decoded); - } catch { - parsed = undefined; - } - const err = typeof parsed === 'object' && parsed !== null ? (parsed as { error?: unknown }).error : undefined; - if (typeof err === 'object' && err !== null) { - const e = err as Record; - const out: NonNullable = { - message: typeof e.message === 'string' ? e.message : `Upstream returned HTTP ${result.status}`, - code: typeof e.code === 'string' ? e.code : `upstream_${result.status}`, - }; - if (typeof e.type === 'string') out.type = e.type; - if (e.provider_specific_fields !== undefined) out.provider_specific_fields = e.provider_specific_fields as Record; - return out; - } - const truncated = truncatePreservingCodePoints(decoded, MAX_BODY_EXCERPT_CHARS); - const excerpt = truncated.length === decoded.length ? decoded : `${truncated}...`; - return { - message: excerpt.length > 0 ? `Upstream returned HTTP ${result.status}: ${excerpt}` : `Upstream returned HTTP ${result.status}`, - code: `upstream_${result.status}`, +): Extract => { + if (result.type === 'internal-error') return { + type: 'error', code: 'server_error', message: result.error.message, param: null, + provider_specific_fields: internalDebugErrorFields(result.error), }; + const body = parseJSONWithRawNumbers(new TextDecoder().decode(result.body)) as Extract; + return { ...body, type: 'error' }; +}; + +const responseErrorFromEvent = ( + event: Extract, +): OpenAIResponsesErrorEx | null => { + const { type: _type, sequence_number: _sequenceNumber, ...fields } = event; + const payload = ('error' in fields && isJsonObject(fields.error) ? fields.error : fields) as OpenAIResponsesErrorPayload; + return typeof payload.code === 'string' ? { ...payload, code: payload.code, message: payload.message } : null; }; const invalidRequestEnvelope = ( @@ -1159,6 +1131,10 @@ async function* runMultiTurnLoop(args: { yield synthesizeTerminalEnvelope(merge, { kind: 'incomplete', incompleteDetails: turn.terminalStatus.response.incomplete_details }, active); return; } + if (turn.terminalStatus.kind === 'error') { + if (turn.sawServerToolCall) yield* materializeServerToolItems(turn.dispatchedSlots, merge, replayCodec); + return; + } if (turn.terminalStatus.kind === 'bare-error-pre-shell') { yield synthesizeTerminalEnvelope(merge, { kind: 'failed', @@ -1211,7 +1187,12 @@ async function* runMultiTurnLoop(args: { const nextResult = await run(); if (nextResult.type !== 'events') { - yield synthesizeTerminalEnvelope(merge, { kind: 'failed', error: buildErrorFromResult(nextResult) }, active); + const event = { ...errorEventFromResult(nextResult), sequence_number: merge.sequenceNumber++ }; + yield eventFrame(event); + yield synthesizeTerminalEnvelope(merge, { + kind: 'failed', + error: responseErrorFromEvent(event), + }, active); return; } metadata.modelIdentity = nextResult.modelIdentity; @@ -1228,13 +1209,18 @@ async function* runMultiTurnLoop(args: { midStreamError = error; throw error; } + const failure = { + type: 'error' as const, + code: 'server_error', + message: `Upstream stream failed mid-response: ${error instanceof Error ? error.message : String(error)}`, + param: null, + provider_specific_fields: internalDebugErrorFields(toInternalDebugError(error)), + sequence_number: merge.sequenceNumber++, + }; + yield eventFrame(failure); yield synthesizeTerminalEnvelope(merge, { kind: 'failed', - error: { - code: 'server_error', - message: `Upstream stream failed mid-response: ${error instanceof Error ? error.message : String(error)}`, - provider_specific_fields: internalDebugErrorFields(toInternalDebugError(error)), - }, + error: responseErrorFromEvent(failure), }, active); } finally { if (midStreamError === undefined) resolveFinalMetadata(metadata); diff --git a/packages/gateway/src/data-plane/chat/openai-responses/items/output.ts b/packages/gateway/src/data-plane/chat/openai-responses/items/output.ts index 7c1d3e374c..88dd463c59 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/items/output.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/items/output.ts @@ -7,7 +7,7 @@ import { isOpenAIResponsesCompactionItem, openaiResponsesResultToEvents, type Op // Complete output items become reusable at their first done frame, so each row // commits before that frame is yielded. Later done frames remain // visible but cannot replace the durable item. The response snapshot commits -// separately before a successful terminal frame. Failed/error terminals keep +// separately before a successful terminal frame. Failed responses keep // completed item rows but never a snapshot. // // One client response can span several upstream calls behind the server-tool @@ -95,11 +95,6 @@ export const wrapOpenAIResponsesClientOutput = async function* ( yield eventFrame({ ...event, response: clientEnvelope(event.response) }); return; } - if (event.type === 'error') { - yield frame; - return; - } - yield frame; } }; diff --git a/packages/gateway/src/data-plane/chat/openai-responses/respond.ts b/packages/gateway/src/data-plane/chat/openai-responses/respond.ts index 64e1f5e9bb..d09d4a133d 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/respond.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/respond.ts @@ -12,7 +12,7 @@ import { forwardUpstreamHeaders, mergeForwardedUpstreamHeaders } from '../../sha import { SourceStreamState, eventResultMetadata, plainResultToResponse } from '../shared/respond.ts'; import { doneFrame, eventFrame, type ProtocolFrame, sseCommentFrame, sseFrame } from '@floway-dev/protocols/common'; import { openaiResponsesProtocolFrameToSSEFrame, OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE, collectOpenAIResponsesProtocolEventsToResult } from '@floway-dev/protocols/openai-responses'; -import { isOpenAIResponsesTerminalEvent, type CanonicalOpenAIResponsesPayload, type ClientResponseResource, type ClientOpenAIResponsesStreamEvent, type OpenAIResponsesStreamEventEx, type OpenAIResponsesErrorEx } from '@floway-dev/protocols/openai-responses'; +import { isOpenAIResponsesErrorEvent, isOpenAIResponsesTerminalEvent, type CanonicalOpenAIResponsesPayload, type ClientResponseResource, type ClientOpenAIResponsesStreamEvent, type OpenAIResponsesStreamEventEx, type OpenAIResponsesErrorEx } from '@floway-dev/protocols/openai-responses'; import { type ExecuteResult, type PlainResult, type InternalDebugError, internalDebugErrorFields, toInternalDebugError } from '@floway-dev/provider'; import { apiErrorToResponse } from '@floway-dev/provider'; @@ -68,8 +68,16 @@ export const respondOpenAIResponses = async ( headers.set(OPENAI_RESPONSES_REASONING_INCLUDED_HEADER, OPENAI_RESPONSES_REASONING_INCLUDED_VALUE); return Response.json(response, { headers }); } catch (error) { - recordFailedRequest(ctx, result.performance); ctx.dump?.failed(error); + if (error instanceof Error && isOpenAIResponsesErrorEvent(error.cause)) { + const metadata = await eventResultMetadata(result); + const usage = tokenUsageFromBillableUsage(metadata.billableUsage); + ctx.dump?.success(metadata.modelIdentity, usage); + settle(ctx, metadata.performance, metadata.modelIdentity, usage, true); + const { sequence_number: _sequenceNumber, ...body } = error.cause; + return Response.json(body, { status: 502 }); + } + recordFailedRequest(ctx, result.performance); return internalOpenAIResponsesErrorResponse(502, toInternalDebugError(error)); } } @@ -111,17 +119,18 @@ const internalOpenAIResponsesErrorResponse = (status: number, error: InternalDeb // --- frame observation --- -const isOpenAIResponsesTerminalFrame = (frame: ProtocolFrame) => frame.type === 'event' && isOpenAIResponsesTerminalEvent(frame.event); - const observeOpenAIResponsesFrames = async function* (frames: AsyncIterable>, state: SourceStreamState, ctx: GatewayCtx) { for await (const frame of frames) { ctx.dump?.frame(frame); - const failed = frame.type === 'event' && (frame.event.type === 'error' || frame.event.type === 'response.failed'); + if (frame.type === 'done') continue; + const failed = frame.event.type === 'error' || frame.event.type === 'response.failed'; + const terminal = isOpenAIResponsesTerminalEvent(frame.event); if (failed) state.failed = true; - if (isOpenAIResponsesTerminalFrame(frame) && !failed) state.completed = true; + if (terminal && !failed) state.completed = true; yield frame; - if (isOpenAIResponsesTerminalFrame(frame)) return; + if (terminal) return; } + if (state.failed) return; throw new Error(OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE); }; diff --git a/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts b/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts index 094feb111c..7776ead410 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/websocket.ts @@ -638,16 +638,17 @@ const respondOpenAIResponsesWebSocket = async (input: { } } - if (terminalEvent === undefined) { + if (terminalEvent === undefined && !state.failed) { throw new Error(OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE); } // Renumbered here rather than where it was buffered: keep-alives can still // fire while the generator drains behind the terminal event, and each of // those takes a slot that has to land before the terminal event's own. - if (!sendOpenAIResponsesEvent(socket, sequence.renumber(terminalEvent), eventId, ctx.dump)) { + if (terminalEvent !== undefined && !sendOpenAIResponsesEvent(socket, sequence.renumber(terminalEvent), eventId, ctx.dump)) { completion = 'cancel'; return; } + turn.terminal = true; completion = 'eof'; } catch (error) { if ((signal.aborted && !turn.interrupted) || isClosed()) { @@ -662,7 +663,7 @@ const respondOpenAIResponsesWebSocket = async (input: { const failed = state.failedAfter(completion); if (failed) { // `fail` cannot carry the eviction for every failed turn: one that - // streamed an `error` or `response.failed` terminal answered the client + // streamed an `error` notification or `response.failed` answered the client // with an event rather than an error envelope, and one the client // abandoned before the terminal event answered with nothing at all. // Both settle here. For the abandoned turn the eviction is inert — the diff --git a/packages/protocols/__tests__/openai-responses/reassemble_test.ts b/packages/protocols/__tests__/openai-responses/reassemble_test.ts index 77a933f88d..4e8d652198 100644 --- a/packages/protocols/__tests__/openai-responses/reassemble_test.ts +++ b/packages/protocols/__tests__/openai-responses/reassemble_test.ts @@ -4,14 +4,7 @@ import type { OpenAIResponsesOutputItemEx, OpenAIResponsesResultEx, OpenAIRespon import { reassembleOpenAIResponsesEvents } from '../../src/openai-responses/reassemble.ts'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; -type OpenAIResponsesReassembleEvent = - | OpenAIResponsesStreamEventEx - | { - type: 'error'; - message?: string; - }; - -function makeEvents(chunks: Array<{ event?: string; data: unknown }>): AsyncIterable { +function makeEvents(chunks: Array<{ event?: string; data: unknown }>): AsyncIterable { return (async function* () { for (const chunk of chunks) { if (typeof chunk.data === 'string') continue; @@ -95,10 +88,39 @@ test('reassembleOpenAIResponsesEvents handles incomplete event', async () => { assertEquals(result.status, 'incomplete'); }); -test('reassembleOpenAIResponsesEvents throws on error event', async () => { - const body = makeEvents([{ event: 'error', data: { type: 'error', message: 'bad request' } }]); +test.each([ + { type: 'error', code: null, param: 'model', message: 'bad request', sequence_number: 1, request_id: 'req_flat' }, + { type: 'error', code: null, param: null, message: 'bad request', error: null, sequence_number: 1, request_id: 'req_extension' }, + { type: 'error', error: { code: 'rate_limit_exceeded', param: null, message: 'bad request', request_id: 'req_nested' }, sequence_number: 1 }, +] as const)('reassembleOpenAIResponsesEvents preserves standalone errors as the original cause', async error => { + const body = makeEvents([{ data: error }]); + const caught: unknown = await reassembleOpenAIResponsesEvents(body).catch((caught: unknown) => caught); + + expect(caught).toBeInstanceOf(Error); + expect(caught).toMatchObject({ message: 'bad request' }); + expect((caught as Error).cause).toBe(error); +}); + +test('reassembleOpenAIResponsesEvents drains through response.failed before returning the original error', async () => { + const failed: OpenAIResponsesResultEx = { + id: 'resp_failed', object: 'response', model: 'gpt-test', status: 'failed', + output: [{ type: 'reasoning', id: 'rs_complete', summary: [{ type: 'summary_text', text: 'Work before failure' }] }], + error: { code: 'rate_limit_exceeded', message: 'Limit reached' }, + incomplete_details: null, + usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 }, + }; + const error = { type: 'error', code: null, param: 'model', message: 'Limit reached', sequence_number: 1 } satisfies OpenAIResponsesStreamEventEx; + let emittedFailed = false; + const events = async function* (): AsyncIterable { + yield { type: 'response.created', sequence_number: 0, response: { ...failed, status: 'in_progress', output: [], error: null, usage: null } }; + yield error; + emittedFailed = true; + yield { type: 'response.failed', sequence_number: 2, response: failed }; + }; + const caught: unknown = await reassembleOpenAIResponsesEvents(events()).catch((caught: unknown) => caught); - await assertRejects(() => reassembleOpenAIResponsesEvents(body), Error, 'bad request'); + expect(emittedFailed).toBe(true); + expect(caught).toMatchObject({ message: error.message, cause: error }); }); test('reassembleOpenAIResponsesEvents throws when stream ends without terminal event', async () => { diff --git a/packages/protocols/__tests__/openai-responses/stream_test.ts b/packages/protocols/__tests__/openai-responses/stream_test.ts index ce9454e391..8a578e59f8 100644 --- a/packages/protocols/__tests__/openai-responses/stream_test.ts +++ b/packages/protocols/__tests__/openai-responses/stream_test.ts @@ -344,7 +344,7 @@ test('parseOpenAIResponsesStream fast-paths response.incomplete terminal', async assertEquals(eventTypes.includes('response.output_item.added'), true); }); -test('parseOpenAIResponsesStream passes raw error terminal through (no .response to expand)', async () => { +test('parseOpenAIResponsesStream passes raw error notifications through', async () => { const frames = await collect(parse( sseFrame(JSON.stringify({ message: 'connection reset', code: 'ECONNRESET' }), 'error'), sseFrame('[DONE]'), @@ -356,6 +356,26 @@ test('parseOpenAIResponsesStream passes raw error terminal through (no .response assertEquals(errorEvent.type === 'event' && (errorEvent.event as { message?: string }).message, 'connection reset'); }); +test('parseOpenAIResponsesStream preserves error notifications and expands the following failed resource', async () => { + const error = { type: 'error', code: null, param: 'model', message: 'Limit reached', sequence_number: 1, request_id: 'req_failed' }; + const failed = makeResponse('failed', { error: { code: 'rate_limit_exceeded', message: 'Limit reached' } }); + const frames = await collect(parse( + sseFrame(JSON.stringify({ type: 'response.created', response: { ...failed, status: 'in_progress', output: [], error: null }, sequence_number: 0 })), + sseFrame(JSON.stringify(error), 'error'), + sseFrame(JSON.stringify({ type: 'response.failed', response: failed, sequence_number: 2 })), + sseFrame('[DONE]'), + )); + + assertEquals(frames[1], { type: 'event', event: error }); + assertEquals(frames.map(frame => frame.type === 'event' ? frame.event.type : frame.type), [ + 'response.created', 'error', 'response.in_progress', 'response.output_item.added', + 'response.content_part.added', 'response.output_text.delta', 'response.output_text.done', + 'response.content_part.done', 'response.output_item.done', 'response.failed', 'done', + ]); + const terminal = frames.at(-2); + assertEquals(terminal?.type === 'event' && 'response' in terminal.event && terminal.event.response, failed); +}); + test('parseOpenAIResponsesStream fast-paths when ping interleaves the wrappers', async () => { const completed = makeResponse('completed'); const frames = await collect(parse( diff --git a/packages/protocols/__tests__/openai-responses/to-result_test.ts b/packages/protocols/__tests__/openai-responses/to-result_test.ts index d58507c4df..f38d112484 100644 --- a/packages/protocols/__tests__/openai-responses/to-result_test.ts +++ b/packages/protocols/__tests__/openai-responses/to-result_test.ts @@ -1,6 +1,6 @@ -import { test } from 'vitest'; +import { expect, test } from 'vitest'; -import { eventFrame } from '../../src/common/index.ts'; +import { doneFrame, eventFrame } from '../../src/common/index.ts'; import { openaiResponsesResultToEvents, type OpenAIResponsesResultEx, type OpenAIResponsesStreamEventEx } from '../../src/openai-responses/index.ts'; import { collectOpenAIResponsesProtocolEventsToResult } from '../../src/openai-responses/to-result.ts'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; @@ -51,3 +51,38 @@ test('collectOpenAIResponsesProtocolEventsToResult rejects streams without termi await assertRejects(async () => await collectOpenAIResponsesProtocolEventsToResult(events()), Error, 'OpenAI Responses stream ended without a terminal event.'); }); + +test('collectOpenAIResponsesProtocolEventsToResult drains response.failed and returns the original error', async () => { + const failed: OpenAIResponsesResultEx = { + id: 'resp_failed', object: 'response', model: 'gpt-test', status: 'failed', + output: [{ type: 'reasoning', id: 'rs_complete', summary: [{ type: 'summary_text', text: 'Work before failure' }] }], + error: { code: 'rate_limit_exceeded', message: 'Limit reached' }, + incomplete_details: null, + usage: { input_tokens: 10, output_tokens: 2, total_tokens: 12 }, + }; + let emittedFailed = false; + async function* events() { + yield eventFrame({ type: 'response.created', sequence_number: 0, response: { ...failed, status: 'in_progress', output: [], error: null, usage: null } } satisfies OpenAIResponsesStreamEventEx); + yield eventFrame({ type: 'error', code: null, param: 'model', message: 'Limit reached', sequence_number: 1 } satisfies OpenAIResponsesStreamEventEx); + emittedFailed = true; + yield eventFrame({ type: 'response.failed', sequence_number: 2, response: failed } satisfies OpenAIResponsesStreamEventEx); + throw new Error('Read beyond the failed response'); + } + + const caught: unknown = await collectOpenAIResponsesProtocolEventsToResult(events()).catch((caught: unknown) => caught); + expect(emittedFailed).toBe(true); + expect(caught).toMatchObject({ + message: 'Limit reached', + cause: { type: 'error', code: null, param: 'model', message: 'Limit reached', sequence_number: 1 }, + }); +}); + +test('collectOpenAIResponsesProtocolEventsToResult preserves an error notification when its stream ends', async () => { + const error = { type: 'error', code: null, param: 'model', message: 'Limit reached', sequence_number: 1, request_id: 'req_only' } as const; + async function* events() { + yield eventFrame(error); + yield doneFrame(); + } + + await expect(collectOpenAIResponsesProtocolEventsToResult(events())).rejects.toMatchObject({ message: error.message, cause: error }); +}); diff --git a/packages/protocols/src/openai-responses/index.ts b/packages/protocols/src/openai-responses/index.ts index 16923ec637..07209022eb 100644 --- a/packages/protocols/src/openai-responses/index.ts +++ b/packages/protocols/src/openai-responses/index.ts @@ -1420,7 +1420,7 @@ type OpenAIResponsesStreamEventVariant = // is identical aside from the type tag's role. export type OpenAIResponsesReasoningItem = OpenAIResponsesInputReasoning | OpenAIResponsesOutputReasoning; -export { isOpenAIResponsesTerminalEvent, openaiResponsesResultFromStreamEvent } from './terminal-event.ts'; +export { isOpenAIResponsesTerminalEvent, isOpenAIResponsesErrorEvent, openaiResponsesResultFromStreamEvent } from './terminal-event.ts'; export { isOpenAIResponsesAssistantInputItem } from './assistant-items.ts'; export { WEB_SEARCH_HOSTED_TYPE_NAMES, collectOpenAIResponsesToolEntries, collectOpenAIResponsesTools, mapOpenAIResponsesTools } from './tools.ts'; diff --git a/packages/protocols/src/openai-responses/reassemble.ts b/packages/protocols/src/openai-responses/reassemble.ts index 419b463ddb..e18a117c8b 100644 --- a/packages/protocols/src/openai-responses/reassemble.ts +++ b/packages/protocols/src/openai-responses/reassemble.ts @@ -1,26 +1,23 @@ import type { OpenAIResponsesResultEx, OpenAIResponsesStreamEventEx } from './index.ts'; -type OpenAIResponsesReassembleEvent = - | OpenAIResponsesStreamEventEx - | { - type: 'error'; - message?: string; - }; +export const OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE = 'OpenAI Responses stream ended without a terminal event.'; -export async function reassembleOpenAIResponsesEvents(events: AsyncIterable): Promise { +export async function reassembleOpenAIResponsesEvents(events: AsyncIterable): Promise { + let error: Extract | undefined; for await (const event of events) { - const rawEvent = event as unknown as Record; - const type = rawEvent.type as string; - - if (type === 'error') { - const message = (rawEvent.message as string | undefined) ?? JSON.stringify(event); - throw new Error(`Upstream SSE error: ${message}`); + if (event.type === 'error') { + error = event; } - if (type === 'response.completed' || type === 'response.incomplete' || type === 'response.failed') { - return rawEvent.response as OpenAIResponsesResultEx; + if (event.type === 'response.completed' || event.type === 'response.incomplete' || event.type === 'response.failed') { + if (event.type === 'response.failed' && error !== undefined) throw new Error(errorMessage(error), { cause: error }); + return event.response; } } - throw new Error('SSE stream ended without a terminal response event'); + if (error !== undefined) throw new Error(errorMessage(error), { cause: error }); + throw new Error(OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE); } + +const errorMessage = (error: Extract): string => + 'message' in error ? error.message : error.error.message; diff --git a/packages/protocols/src/openai-responses/stream.ts b/packages/protocols/src/openai-responses/stream.ts index c9db7e95d4..9ba5938314 100644 --- a/packages/protocols/src/openai-responses/stream.ts +++ b/packages/protocols/src/openai-responses/stream.ts @@ -7,15 +7,14 @@ export interface ParseOpenAIResponsesStreamOptions { signal?: AbortSignal; } -// Deny-list: anything that is not a wrapper (`response.queued` / -// `response.created` / `response.in_progress`) and not terminal is treated as -// content-bearing. Future OpenAI Responses event types fall through as structured by -// default, which is safer than missing an allow-list entry and incorrectly -// triggering the fast-path expansion below. +// Lifecycle wrappers, resource terminals, and error notifications carry no +// content deltas. Unknown event types count as structured content so a new +// content event cannot accidentally trigger duplicate fast-path expansion. const isStructuredOpenAIResponsesEvent = (event: { type: string }): boolean => event.type !== 'response.queued' && event.type !== 'response.created' && event.type !== 'response.in_progress' + && event.type !== 'error' && !isOpenAIResponsesTerminalEvent(event as OpenAIResponsesStreamEventEx); // Some OpenAI Responses upstreams emit the event type only via the SSE `event:` @@ -49,9 +48,8 @@ const sequencer = () => { // a terminal `response.completed` / `response.incomplete` / `response.failed`, // skipping every content-bearing structured event. This parser expands the // terminal in place via `openaiResponsesResultToEvents` so downstream consumers -// always observe one canonical full event sequence. `error` terminals carry -// no `response` payload, so we cannot expand them; they continue to surface -// as their original frame for downstream handlers. +// always observe one canonical full event sequence. Error notifications pass +// through before the terminal response and do not suppress its expansion. export const parseOpenAIResponsesStream = ( body: ReadableStream, options: ParseOpenAIResponsesStreamOptions = {}, diff --git a/packages/protocols/src/openai-responses/terminal-event.ts b/packages/protocols/src/openai-responses/terminal-event.ts index ef06d6dda6..460ca5e405 100644 --- a/packages/protocols/src/openai-responses/terminal-event.ts +++ b/packages/protocols/src/openai-responses/terminal-event.ts @@ -1,7 +1,15 @@ import type { OpenAIResponsesResultEx, OpenAIResponsesStreamEventEx } from './index.ts'; +// Streaming errors are followed by the failed response resource. +// https://github.com/openresponses/openresponses/blob/92c12d96d7b61d6d15e2214daa5e9c6000ab6e1c/src/specifications/2026-04-24.mdx#L419-L444 export const isOpenAIResponsesTerminalEvent = (event: Pick): boolean => - event.type === 'response.completed' || event.type === 'response.incomplete' || event.type === 'response.failed' || event.type === 'error'; + event.type === 'response.completed' || event.type === 'response.incomplete' || event.type === 'response.failed'; + +export const isOpenAIResponsesErrorEvent = (value: unknown): value is Extract => { + if (value === null || typeof value !== 'object' || !('type' in value) || value.type !== 'error') return false; + return ('message' in value && typeof value.message === 'string') + || ('error' in value && value.error !== null && typeof value.error === 'object' && 'message' in value.error && typeof value.error.message === 'string'); +}; // Typed accessor for the `response` payload carried on lifecycle envelopes // (`response.queued`, `response.created`, `response.in_progress`, `response.completed`, diff --git a/packages/protocols/src/openai-responses/to-result.ts b/packages/protocols/src/openai-responses/to-result.ts index 1d173f7a0c..857e4505a6 100644 --- a/packages/protocols/src/openai-responses/to-result.ts +++ b/packages/protocols/src/openai-responses/to-result.ts @@ -1,10 +1,9 @@ import type { ClientResponseResource, ClientOpenAIResponsesStreamEvent } from './client-resource.ts'; import type { OpenAIResponsesResultEx, OpenAIResponsesStreamEventEx } from './index.ts'; import { reassembleOpenAIResponsesEvents } from './reassemble.ts'; -import { isOpenAIResponsesTerminalEvent } from './terminal-event.ts'; import { type ProtocolFrame } from '../common/index.ts'; -export const OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE = 'OpenAI Responses stream ended without a terminal event.'; +export { OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE } from './reassemble.ts'; // Reassembly copies each response object through, so a stream whose frames // already carry the completed client resource collects into a completed @@ -20,10 +19,7 @@ export async function collectOpenAIResponsesProtocolEventsToResult(frames: Async if (frame.type === 'done') continue; yield frame.event; - if (isOpenAIResponsesTerminalEvent(frame.event)) return; } - - throw new Error(OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE); }; return await reassembleOpenAIResponsesEvents(events()); From cd914637ad68f540122e3762638177f255fc3e5b Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 19:32:21 +0800 Subject: [PATCH 43/66] fix(gateway): classify collected Responses errors Choose the non-streaming HTTP status from the standard Responses error code or type after the internal error-to-failed lifecycle completes. Preserve 502 only for unknown classifications and keep the reconstructed JSON body unchanged. --- .../chat/openai-responses/respond_test.ts | 20 +++++++++++ .../chat/openai-responses/respond.ts | 34 +++++++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/respond_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/respond_test.ts index 091535cdc8..fa3c127827 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/respond_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/respond_test.ts @@ -146,6 +146,26 @@ test.each([ } }); +test.each([ + { error: { type: 'error' as const, sequence_number: 0, error: { type: 'invalid_request_error', code: null, message: 'invalid tools', param: 'tools' } }, status: 400 }, + { error: { type: 'error' as const, sequence_number: 0, code: 'rate_limit_exceeded', message: 'slow down', param: null }, status: 429 }, + { error: { type: 'error' as const, sequence_number: 0, code: null, message: 'bad key', param: null, provider_specific_fields: { type: 'authentication_error' } }, status: 401 }, + { error: { type: 'error' as const, sequence_number: 0, code: 'server_error', message: 'failed', param: null }, status: 500 }, + { error: { type: 'error' as const, sequence_number: 0, code: 'vendor_failure', message: 'failed', param: null }, status: 502 }, +] as const)('Responses non-streaming error classification selects HTTP $status', async ({ error, status }) => { + const failed = { id: 'resp_failed', object: 'response', model: 'm', status: 'failed' as const, output: [], error: null, incomplete_details: null }; + const frames = async function* () { + yield eventFrame(error); + yield eventFrame({ type: 'response.failed', sequence_number: 1, response: failed }); + }; + const app = new Hono().get('/', c => respondOpenAIResponses(c, eventResult(frames(), testTelemetryModelIdentity), false, mockChatGatewayCtx(), { model: 'm', input: [] })); + const response = await app.request('/'); + + expect(response.status).toBe(status); + const { sequence_number: _sequenceNumber, ...body } = error; + expect(await response.json()).toEqual(body); +}); + test.each([ { type: 'error' as const, sequence_number: 3, code: null, param: 'tools', message: 'rate limited', vendor: { retryable: true } }, { type: 'error' as const, sequence_number: 3, error: { code: null, param: null, message: 'rate limited', vendor: { retryable: true } }, trace: 'native-trace' }, diff --git a/packages/gateway/src/data-plane/chat/openai-responses/respond.ts b/packages/gateway/src/data-plane/chat/openai-responses/respond.ts index d09d4a133d..fc8250be84 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/respond.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/respond.ts @@ -10,7 +10,7 @@ import { settle } from '../../shared/telemetry/settle.ts'; import { tokenUsageFromBillableUsage } from '../../shared/telemetry/usage.ts'; import { forwardUpstreamHeaders, mergeForwardedUpstreamHeaders } from '../../shared/upstream-response.ts'; import { SourceStreamState, eventResultMetadata, plainResultToResponse } from '../shared/respond.ts'; -import { doneFrame, eventFrame, type ProtocolFrame, sseCommentFrame, sseFrame } from '@floway-dev/protocols/common'; +import { doneFrame, eventFrame, isJsonObject, type ProtocolFrame, sseCommentFrame, sseFrame } from '@floway-dev/protocols/common'; import { openaiResponsesProtocolFrameToSSEFrame, OPENAI_RESPONSES_MISSING_TERMINAL_MESSAGE, collectOpenAIResponsesProtocolEventsToResult } from '@floway-dev/protocols/openai-responses'; import { isOpenAIResponsesErrorEvent, isOpenAIResponsesTerminalEvent, type CanonicalOpenAIResponsesPayload, type ClientResponseResource, type ClientOpenAIResponsesStreamEvent, type OpenAIResponsesStreamEventEx, type OpenAIResponsesErrorEx } from '@floway-dev/protocols/openai-responses'; import { type ExecuteResult, type PlainResult, type InternalDebugError, internalDebugErrorFields, toInternalDebugError } from '@floway-dev/provider'; @@ -75,7 +75,7 @@ export const respondOpenAIResponses = async ( ctx.dump?.success(metadata.modelIdentity, usage); settle(ctx, metadata.performance, metadata.modelIdentity, usage, true); const { sequence_number: _sequenceNumber, ...body } = error.cause; - return Response.json(body, { status: 502 }); + return Response.json(body, { status: openAIResponsesErrorHttpStatus(error.cause) }); } recordFailedRequest(ctx, result.performance); return internalOpenAIResponsesErrorResponse(502, toInternalDebugError(error)); @@ -108,6 +108,36 @@ export const respondOpenAIResponses = async ( // --- error rendering --- +const ERROR_HTTP_STATUS = new Map([ + ['invalid_request_error', 400], + ['invalid_prompt', 400], + ['authentication_error', 401], + ['invalid_api_key', 401], + ['permission_error', 403], + ['not_found_error', 404], + ['conflict_error', 409], + ['unprocessable_entity_error', 422], + ['rate_limit_error', 429], + ['rate_limit_exceeded', 429], + ['insufficient_quota', 429], + ['server_error', 500], + ['internal_error', 500], +]); + +// OpenAI SDKs classify API failures by these HTTP categories. +// https://github.com/openai/openai-node/blob/b579bcb3e586d22269ffe6fe504653a0e05acbe8/src/core/error.ts#L60-L94 +const openAIResponsesErrorHttpStatus = (event: Extract): number => { + const { type: _type, sequence_number: _sequenceNumber, ...fields } = event; + const payload = ('error' in fields && isJsonObject(fields.error) ? fields.error : fields) as Readonly>; + const details = isJsonObject(payload.provider_specific_fields) ? payload.provider_specific_fields : undefined; + for (const classification of [payload.code, payload.type, details?.code, details?.type]) { + if (typeof classification !== 'string') continue; + const status = ERROR_HTTP_STATUS.get(classification); + if (status !== undefined) return status; + } + return 502; +}; + const internalOpenAIResponsesErrorResponse = (status: number, error: InternalDebugError): Response => Response.json({ error: { From 781820cf42c2fc0311a2718aa77a5b7bd2344523 Mon Sep 17 00:00:00 2001 From: Menci Date: Fri, 13 Nov 2026 00:28:17 +0800 Subject: [PATCH 44/66] fix(translate): state both Chat token limit spellings Emit max_tokens beside max_completion_tokens for every translated Chat request so OpenAI's replacement spelling and compatible upstreams that still read the legacy spelling receive the same ceiling. Preserve null and zero explicitly and cover all Chat-target source routes. --- ...rate-content-to-openai-chat-completions-request_test.ts | 1 + .../protocols/pairs/input-controls-request_test.ts | 6 ++++-- ...ai-responses-to-openai-chat-completions-request_test.ts | 1 + .../openai-chat-completions/as-target/input-from.ts | 7 +++++-- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts index 23e3065036..e928ff187c 100644 --- a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts @@ -286,6 +286,7 @@ test('buildTargetPayload maps generation config and reasoning effort', async () stream: true, stream_options: { include_usage: true, continuous_usage_stats: true }, messages: [], + max_tokens: 512, max_completion_tokens: 512, temperature: 0.25, top_p: 0.8, diff --git a/packages/translate/__tests__/protocols/pairs/input-controls-request_test.ts b/packages/translate/__tests__/protocols/pairs/input-controls-request_test.ts index cae3182595..eb8b0599c1 100644 --- a/packages/translate/__tests__/protocols/pairs/input-controls-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/input-controls-request_test.ts @@ -51,6 +51,7 @@ test('nullable token limits and cache controls remain explicit in source IR', as expect(responses.target.max_output_tokens).toBeNull(); const chat = await routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'm', stream: true, input: 'hi', max_output_tokens: null }, options); if (chat.type !== 'trip') throw new Error(chat.message); + expect(chat.target.max_tokens).toBeNull(); expect(chat.target.max_completion_tokens).toBeNull(); }); @@ -62,8 +63,9 @@ test.each([ if (first.type !== 'trip') throw new Error(first.message); const second = await routeOpenAIResponsesViaOpenAIChatCompletions(first.target, options); if (second.type !== 'trip') throw new Error(second.message); - expect(second.target.max_completion_tokens).toBe(limits.max_completion_tokens ?? limits.max_tokens); - expect(second.target).not.toHaveProperty('max_tokens'); + const expected = limits.max_completion_tokens ?? limits.max_tokens; + expect(second.target.max_tokens).toBe(expected); + expect(second.target.max_completion_tokens).toBe(expected); }); const commonControls = { diff --git a/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts index e6e73b61ea..b4c493f5bf 100644 --- a/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts @@ -107,6 +107,7 @@ test('buildTargetPayload merges adjacent assistant reasoning text and tool calls }); assertEquals(result.target.model, 'gpt-test'); + assertEquals(result.target.max_tokens, 256); assertEquals(result.target.max_completion_tokens, 256); assertEquals(result.target.metadata, { trace_id: 'trace_123' }); assertEquals(result.target.store, false); diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts index 1b28599f21..a88f60870c 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts @@ -258,9 +258,12 @@ export const openaiChatCompletionsSettingsFromIR = (settings: IRInputSettings): const reasoningEffort = chatReasoningEffort(settings); return { - // Use the replacement cap spelling, which includes reasoning tokens. + // State both cap spellings. OpenAI uses the replacement + // `max_completion_tokens`, while compatible targets such as Ollama still + // read only `max_tokens`. // https://github.com/openai/openai-node/blob/61539248cbe04665de68a71e6fd878127ae4db87/src/resources/chat/completions/completions.ts - ...(ceiling === undefined ? {} : { max_completion_tokens: ceiling }), + // https://github.com/ollama/ollama/blob/c1db5f9/openai/openai.go + ...(ceiling === undefined ? {} : { max_tokens: ceiling, max_completion_tokens: ceiling }), ...(settings.temperature === undefined ? {} : { temperature: settings.temperature }), ...(settings.top_p === undefined ? {} : { top_p: settings.top_p }), ...(settings.stop === undefined ? {} : { stop: [...settings.stop] }), From eb622b90c70f3e6550c45e778a9ceebb4ab84c61 Mon Sep 17 00:00:00 2001 From: Menci Date: Fri, 13 Nov 2026 01:08:16 +0800 Subject: [PATCH 45/66] fix(translate): preserve input media source values Keep URLs and file_data strings exact in the input IR, including data URL parameters and non-base64 forms. Parse data URLs only at a target that needs inline bytes, preserve stated media types, and invent a generic MIME type only where the target requires one. --- .../__tests__/ir/input/media_test.ts | 43 +++++------- .../pairs/media-values-request_test.ts | 50 ++++++++++++++ packages/translate/src/ir/input/media.ts | 65 ++++++++++++------- packages/translate/src/ir/input/types.ts | 1 + .../anthropic-messages/as-source/input-to.ts | 4 +- .../as-target/input-from.ts | 46 +++++++------ .../as-source/input-to.ts | 7 +- .../as-target/input-from.ts | 12 +++- .../openai-responses/as-source/input-to.ts | 11 +--- .../openai-responses/as-target/input-from.ts | 4 ++ 10 files changed, 157 insertions(+), 86 deletions(-) create mode 100644 packages/translate/__tests__/protocols/pairs/media-values-request_test.ts diff --git a/packages/translate/__tests__/ir/input/media_test.ts b/packages/translate/__tests__/ir/input/media_test.ts index 6c7be03935..b096ae2d11 100644 --- a/packages/translate/__tests__/ir/input/media_test.ts +++ b/packages/translate/__tests__/ir/input/media_test.ts @@ -1,35 +1,28 @@ import { test } from 'vitest'; -import { mediaFromURL } from '../../../src/ir/input/media.ts'; +import { mediaFromURL, parseDataURL } from '../../../src/ir/input/media.ts'; import { assertEquals } from '@floway-dev/test-utils'; -test('a base64 data URL states its media type and body apart', () => { - assertEquals(mediaFromURL('data:application/pdf;base64,JVBERi0='), { - source_kind: 'base64', media_type: 'application/pdf', data: 'JVBERi0=', - }); +test.each([ + 'https://example.com/a.png', + 'data:image/png;charset=utf-8;base64,AAAA', + 'data:image/svg+xml,%3Csvg%2F%3E', +])('a URL remains exactly the source value %s', url => { + assertEquals(mediaFromURL(url), { source_kind: 'url', url }); }); -test('a media type carrying a parameter keeps only its essence', () => { - // RFC 2397 lets the type carry parameters; the payload is what follows the - // `;base64` marker, not the whole URL text. - assertEquals(mediaFromURL('data:text/csv;charset=utf-8;base64,YQ=='), { - source_kind: 'base64', media_type: 'text/csv', data: 'YQ==', - }); - assertEquals(mediaFromURL('data:image/png;charset=utf-8;base64,AAAA'), { - source_kind: 'base64', media_type: 'image/png', data: 'AAAA', +test('target-boundary data URL parsing retains media-type parameters', () => { + assertEquals(parseDataURL('data:text/csv;charset=utf-8;base64,YQ=='), { + media_type: 'text/csv;charset=utf-8', data: 'YQ==', }); + assertEquals(parseDataURL('data:;base64,AAAA'), { data: 'AAAA' }); }); -test('a base64 data URL that states no media type still decodes', () => { - assertEquals(mediaFromURL('data:;base64,AAAA'), { source_kind: 'base64', data: 'AAAA' }); -}); - -test('a data URL this cannot decode is neither a URL nor raw base64', () => { - // Reporting it as a URL would let a caller carry the URL text as the payload. - assertEquals(mediaFromURL('data:text/plain,hi'), undefined); -}); - -test('a value that is not a data URL stays a URL', () => { - assertEquals(mediaFromURL('https://example.com/a.png'), { source_kind: 'url', url: 'https://example.com/a.png' }); - assertEquals(mediaFromURL('AAAA'), { source_kind: 'url', url: 'AAAA' }); +test('target-boundary data URL parsing encodes percent-escaped bytes', () => { + assertEquals(parseDataURL('data:image/svg+xml,%3Csvg%2F%3E'), { + media_type: 'image/svg+xml', data: 'PHN2Zy8+', + }); + assertEquals(parseDataURL('data:text/plain;charset=iso-8859-1,%E9'), { + media_type: 'text/plain;charset=iso-8859-1', data: '6Q==', + }); }); diff --git a/packages/translate/__tests__/protocols/pairs/media-values-request_test.ts b/packages/translate/__tests__/protocols/pairs/media-values-request_test.ts new file mode 100644 index 0000000000..c94e3c6498 --- /dev/null +++ b/packages/translate/__tests__/protocols/pairs/media-values-request_test.ts @@ -0,0 +1,50 @@ +import { expect, test } from 'vitest'; + +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; +import { + routeOpenAIChatCompletionsViaAnthropicMessages, + routeOpenAIChatCompletionsViaOpenAIResponses, + routeOpenAIResponsesViaOpenAIChatCompletions, +} from '../../../src/index.ts'; + +const options = { sidecarCodec: createTestSidecarCodec(), fallbackMaxOutputTokens: 16 }; + +test.each([ + 'data:image/png;charset=utf-8;base64,AAAA', + 'data:image/svg+xml,%3Csvg%2F%3E', +])('image URL %s remains exact between OpenAI request protocols', async url => { + const responses = await routeOpenAIChatCompletionsViaOpenAIResponses({ + model: 'm', stream: true, messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url } }] }], + }, options); + if (responses.type !== 'trip') throw new Error(responses.message); + expect(responses.target.input).toEqual([{ type: 'message', role: 'user', content: [{ type: 'input_image', image_url: url }] }]); + + const chat = await routeOpenAIResponsesViaOpenAIChatCompletions({ + model: 'm', stream: true, input: [{ type: 'message', role: 'user', content: [{ type: 'input_image', image_url: url }] }], + }, options); + if (chat.type !== 'trip') throw new Error(chat.message); + expect(chat.target.messages).toEqual([{ role: 'user', content: [{ type: 'image_url', image_url: { url } }] }]); +}); + +test('raw file_data remains exact between OpenAI request protocols', async () => { + const data = 'JVBERi0xLjQ='; + const responses = await routeOpenAIChatCompletionsViaOpenAIResponses({ + model: 'm', stream: true, messages: [{ role: 'user', content: [{ type: 'file', file: { file_data: data } }] }], + }, options); + if (responses.type !== 'trip') throw new Error(responses.message); + expect(responses.target.input).toEqual([{ type: 'message', role: 'user', content: [{ type: 'input_file', file_data: data }] }]); + + const chat = await routeOpenAIResponsesViaOpenAIChatCompletions({ + model: 'm', stream: true, input: [{ type: 'message', role: 'user', content: [{ type: 'input_file', file_data: data }] }], + }, options); + if (chat.type !== 'trip') throw new Error(chat.message); + expect(chat.target.messages).toEqual([{ role: 'user', content: [{ type: 'file', file: { file_data: data } }] }]); +}); + +test('Messages decodes a data URL only at its target boundary', async () => { + const result = await routeOpenAIChatCompletionsViaAnthropicMessages({ + model: 'm', stream: true, messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'data:image/png;charset=utf-8;base64,AAAA' } }] }], + }, options); + if (result.type !== 'trip') throw new Error(result.message); + expect(result.target.messages).toEqual([{ role: 'user', content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png;charset=utf-8', data: 'AAAA' }, cache_control: { type: 'ephemeral' } }] }]); +}); diff --git a/packages/translate/src/ir/input/media.ts b/packages/translate/src/ir/input/media.ts index c5f9cfa49b..0bba7b9f45 100644 --- a/packages/translate/src/ir/input/media.ts +++ b/packages/translate/src/ir/input/media.ts @@ -1,34 +1,49 @@ import type { IRInputMedia } from './types.ts'; import { TranslateInputException } from '../../shared/input-error.ts'; -// RFC 2397 data URLs may include media-type parameters before ;base64. -// https://www.rfc-editor.org/rfc/rfc2397#section-2 const DATA_URL = /^data:([^,]*),([\s\S]*)$/; const BASE64_MARKER = ';base64'; -/** - * Supported base64 data URLs become inline media; ordinary URLs remain references. - * Undefined identifies a data URL with an unsupported encoding. - */ -export const mediaFromURL = (url: string): IRInputMedia | undefined => { - const dataUrl = DATA_URL.exec(url); - if (dataUrl === null) return { source_kind: 'url', url }; - const header = dataUrl[1]!; - const body = dataUrl[2]!; - if (!header.endsWith(BASE64_MARKER)) return undefined; - const mediaType = header.slice(0, -BASE64_MARKER.length).split(';')[0]!; - const essence = mediaType.trim(); - return { source_kind: 'base64', ...(essence === '' ? {} : { media_type: essence }), data: body }; +/** URLs remain source values. A target decides whether it accepts the scheme or needs inline bytes. */ +export const mediaFromURL = (url: string): IRInputMedia => ({ source_kind: 'url', url }); + +export interface ParsedDataURL { + readonly media_type?: string; + readonly data: string; +} + +const base64OfBytes = (bytes: readonly number[]): string => { + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +}; + +const bytesOfPercentEncodedData = (value: string): number[] => { + const bytes: number[] = []; + for (let index = 0; index < value.length;) { + const encoded = value[index] === '%' ? value.slice(index + 1, index + 3) : ''; + if (/^[0-9a-f]{2}$/i.test(encoded)) { + bytes.push(Number.parseInt(encoded, 16)); + index += 3; + continue; + } + const codePoint = value.codePointAt(index)!; + bytes.push(...new TextEncoder().encode(String.fromCodePoint(codePoint))); + index += codePoint > 0xffff ? 2 : 1; + } + return bytes; }; -/** - * The media a URL states, refusing one whose `data:` form this cannot decode. - * - * A caller that only knows the value is a URL has nothing to fall back on: the - * text of a data URL is not the payload it describes, so carrying it as one - * would send something the upstream cannot read. - */ -export const mediaOrThrow = (media: IRInputMedia | undefined, url: string): IRInputMedia => { - if (media === undefined) throw new TranslateInputException(`Cannot decode a data URL: ${url.slice(0, 32)}`); - return media; +/** Parse a data URL at a target boundary that requires inline bytes. */ +export const parseDataURL = (url: string): ParsedDataURL => { + const match = DATA_URL.exec(url); + if (match === null) throw new TranslateInputException(`Expected a data URL, got '${url.slice(0, 32)}'.`); + const header = match[1]!; + const body = match[2]!; + const encodedAsBase64 = header.toLowerCase().endsWith(BASE64_MARKER); + const mediaType = encodedAsBase64 ? header.slice(0, -BASE64_MARKER.length) : header; + return { + ...(mediaType === '' ? {} : { media_type: mediaType }), + data: encodedAsBase64 ? body : base64OfBytes(bytesOfPercentEncodedData(body)), + }; }; diff --git a/packages/translate/src/ir/input/types.ts b/packages/translate/src/ir/input/types.ts index d94b343592..3bcd88a82a 100644 --- a/packages/translate/src/ir/input/types.ts +++ b/packages/translate/src/ir/input/types.ts @@ -224,6 +224,7 @@ export interface IRInputAudio { // bare base64 remains untyped. Target writers choose the wire representation. export type IRInputMedia = | { readonly source_kind: 'url'; readonly url: string } + | { readonly source_kind: 'data'; readonly data: string } | { readonly source_kind: 'base64'; readonly media_type?: string; readonly data: string } | { readonly source_kind: 'file'; readonly file_id: string }; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts index 9aa693fd2f..e8eb8b820f 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts @@ -3,7 +3,7 @@ import { klona } from 'klona/json'; import { partitionAnthropicMessagesTurns, type AnthropicMessagesConversationTurn } from './conversation-turns.ts'; import type { AnthropicMessagesSourceAssistantTurn } from './round-trip.ts'; import type { SourceReading, SourceTurnSlot } from '../../../composition/reading.ts'; -import { mediaFromURL, mediaOrThrow } from '../../../ir/input/media.ts'; +import { mediaFromURL } from '../../../ir/input/media.ts'; import type { IRInputAssistantContentPart, IRInputRequestHeader, @@ -37,7 +37,7 @@ import { FAST_SERVICE_TIER } from '@floway-dev/protocols/common'; const imageMedia = (source: AnthropicMessagesImageBlock['source']): IRInputMedia => { switch (source.type) { case 'base64': return { source_kind: 'base64', media_type: source.media_type, data: source.data }; - case 'url': return mediaOrThrow(mediaFromURL(source.url), source.url); + case 'url': return mediaFromURL(source.url); case 'file': return { source_kind: 'file', file_id: source.file_id }; } }; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts index 9ae3f6f5f8..a0fffb0274 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts @@ -1,4 +1,5 @@ import type { IRInputAssistantItemWithCallIds, IRInputTurnWithCallIds } from '../../../composition/request/call-ids.ts'; +import { parseDataURL, type ParsedDataURL } from '../../../ir/input/media.ts'; import { requireStreaming } from '../../../ir/input/streaming.ts'; import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputMedia, IRInputTextPart, IRInputToolResultContentPart, IRInputClientItem } from '../../../ir/input/types.ts'; import { buildCustomToolInputSchema } from '../../../shared/custom-tool.ts'; @@ -18,9 +19,20 @@ import type { import { ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS } from '@floway-dev/protocols/anthropic-messages'; import { isFastServiceTier, parseJSONWithRawNumbers } from '@floway-dev/protocols/common'; +const requiredMediaType = (media: ParsedDataURL): string => media.media_type ?? 'application/octet-stream'; + +const inlineMedia = (media: Extract): ParsedDataURL => { + if (media.source_kind === 'base64') return media; + return media.data.startsWith('data:') ? parseDataURL(media.data) : { data: media.data }; +}; + const imageSource = (media: IRInputMedia): AnthropicMessagesImageSource => { switch (media.source_kind) { case 'url': { + if (media.url.startsWith('data:')) { + const inline = parseDataURL(media.url); + return { type: 'base64', media_type: requiredMediaType(inline) as Extract['media_type'], data: inline.data }; + } // Messages remote images require HTTP(S) URLs; invalid schemes are translation input errors. const scheme = media.url.slice(0, media.url.indexOf(':')).toLowerCase(); if (scheme !== 'http' && scheme !== 'https') { @@ -29,36 +41,32 @@ const imageSource = (media: IRInputMedia): AnthropicMessagesImageSource => { return { type: 'url', url: media.url }; } case 'file': return { type: 'file', file_id: media.file_id }; - case 'base64': - if (media.media_type === undefined) throw new TranslateInputException('Anthropic Messages image input requires a media type.'); - return { type: 'base64', media_type: media.media_type as Extract['media_type'], data: media.data }; + case 'data': + case 'base64': { + const inline = inlineMedia(media); + return { type: 'base64', media_type: requiredMediaType(inline) as Extract['media_type'], data: inline.data }; + } } }; const imageBlock = (media: IRInputMedia): AnthropicMessagesImageBlock => ({ type: 'image', source: imageSource(media) }); -/** Messages text documents carry decoded characters; inline IR media carries base64 bytes. */ -const textOfBase64 = (data: string): string => { - const binary = atob(data); - const bytes = Uint8Array.from(binary, character => character.charCodeAt(0)); - return new TextDecoder().decode(bytes); -}; - /** - * The wire distinguishes a plain-text document from a binary one and carries the - * binary MIME type as a string, so the caller's media type travels verbatim and - * the selected upstream decides whether it accepts it. Rewriting an unknown type - * to PDF would describe the bytes wrongly. + * Inline documents remain bytes. The target requires a media type, so an + * untyped raw base64 value receives the generic binary type at this boundary. */ const documentBlock = (media: IRInputMedia): AnthropicMessagesUserContentBlock => { switch (media.source_kind) { - case 'url': return { type: 'document', source: { type: 'url', url: media.url } }; + case 'url': { + if (!media.url.startsWith('data:')) return { type: 'document', source: { type: 'url', url: media.url } }; + const inline = parseDataURL(media.url); + return { type: 'document', source: { type: 'base64', media_type: requiredMediaType(inline), data: inline.data } }; + } case 'file': return { type: 'document', source: { type: 'file', file_id: media.file_id } }; + case 'data': case 'base64': { - if (media.media_type === undefined) throw new TranslateInputException('Anthropic Messages document input requires a media type.'); - return media.media_type === 'text/plain' - ? { type: 'document', source: { type: 'text', media_type: 'text/plain', data: textOfBase64(media.data) } } - : { type: 'document', source: { type: 'base64', media_type: media.media_type, data: media.data } }; + const inline = inlineMedia(media); + return { type: 'document', source: { type: 'base64', media_type: requiredMediaType(inline), data: inline.data } }; } } }; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts index c5a157ae8f..9b4d88e4a9 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts @@ -3,7 +3,7 @@ import { klona } from 'klona/json'; import { partitionOpenAIChatCompletionsTurns, type OpenAIChatCompletionsConversationMessage, type OpenAIChatCompletionsConversationTurn } from './conversation-turns.ts'; import type { OpenAIChatCompletionsSourceAssistantTurn } from './round-trip.ts'; import type { SourceReading } from '../../../composition/reading.ts'; -import { mediaFromURL, mediaOrThrow } from '../../../ir/input/media.ts'; +import { mediaFromURL } from '../../../ir/input/media.ts'; import type { IRInputAssistantContentPart, IRInputAssistantItem, @@ -78,7 +78,7 @@ const userContentPart = (part: OpenAIChatCompletionsUserContentPart): IRInputUse case 'image_url': return { type: 'image', - image: mediaOrThrow(mediaFromURL(part.image_url.url), part.image_url.url), + image: mediaFromURL(part.image_url.url), ...(part.image_url.detail === undefined ? {} : { detail: part.image_url.detail }), }; case 'input_audio': @@ -87,10 +87,9 @@ const userContentPart = (part: OpenAIChatCompletionsUserContentPart): IRInputUse const filename = part.file.filename === undefined ? {} : { filename: part.file.filename }; if (part.file.file_id !== undefined) return { type: 'file', file: { source_kind: 'file', file_id: part.file.file_id }, ...filename }; if (part.file.file_data !== undefined) { - const media = mediaOrThrow(mediaFromURL(part.file.file_data), part.file.file_data); return { type: 'file', - file: media.source_kind === 'base64' ? media : { source_kind: 'base64', data: part.file.file_data }, + file: { source_kind: 'data', data: part.file.file_data }, ...filename, }; } diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts index a88f60870c..16015e1d6f 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts @@ -20,12 +20,17 @@ const audioFormat = (mediaType: string): string => { throw new TranslateInputException(`Cannot translate audio MIME type '${mediaType}' to OpenAI Chat Completions.`); }; -const dataUrl = (media: Extract): string => `data:${media.media_type ?? 'application/octet-stream'};base64,${media.data}`; +const dataUrl = (media: Extract, requireMediaType: boolean): string => media.media_type === undefined && !requireMediaType + ? media.data + : `data:${media.media_type ?? 'application/octet-stream'};base64,${media.data}`; const imageUrlPart = (media: IRInputMedia, detail?: string | null): OpenAIChatCompletionsUserContentPart => ({ type: 'image_url', image_url: { - url: media.source_kind === 'url' ? media.url : media.source_kind === 'base64' ? dataUrl(media) : (() => { throw new TranslateInputException('Cannot translate file_id-only image content to OpenAI Chat Completions.'); })(), + url: media.source_kind === 'url' ? media.url + : media.source_kind === 'data' ? media.data.startsWith('data:') ? media.data : `data:application/octet-stream;base64,${media.data}` + : media.source_kind === 'base64' ? dataUrl(media, true) + : (() => { throw new TranslateInputException('Cannot translate file_id-only image content to OpenAI Chat Completions.'); })(), ...(detail == null ? {} : { detail }), }, }); @@ -34,7 +39,8 @@ const filePart = (media: IRInputMedia, filename?: string | null): OpenAIChatComp const name = filename == null ? {} : { filename }; switch (media.source_kind) { case 'file': return { type: 'file', file: { file_id: media.file_id, ...name } }; - case 'base64': return { type: 'file', file: { file_data: dataUrl(media), ...name } }; + case 'data': return { type: 'file', file: { file_data: media.data, ...name } }; + case 'base64': return { type: 'file', file: { file_data: dataUrl(media, false), ...name } }; case 'url': throw new TranslateInputException('Cannot translate a file URL to OpenAI Chat Completions.'); } }; diff --git a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts index 5efdc2c63a..e2e16ba3d7 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts @@ -5,7 +5,7 @@ import { partitionOpenAIResponsesTurns, type OpenAIResponsesConversationTurn } f import { rejectProgramCaller, rejectProgrammaticOpenAIResponsesPayload } from './programmatic-tooling.ts'; import type { OpenAIResponsesSourceAssistantTurn } from './round-trip.ts'; import type { SourceReading } from '../../../composition/reading.ts'; -import { mediaFromURL, mediaOrThrow } from '../../../ir/input/media.ts'; +import { mediaFromURL } from '../../../ir/input/media.ts'; import type { IRInputAssistantContentPart, IRInputAssistantItem, @@ -35,7 +35,7 @@ import type { } from '@floway-dev/protocols/openai-responses'; const imageMedia = (part: Extract): IRInputMedia => { - if (typeof part.image_url === 'string') return mediaOrThrow(mediaFromURL(part.image_url), part.image_url); + if (typeof part.image_url === 'string') return mediaFromURL(part.image_url); if (typeof part.file_id === 'string') return { source_kind: 'file', file_id: part.file_id }; throw new TranslateInputException('An OpenAI Responses input_image requires image_url or file_id.'); }; @@ -44,12 +44,7 @@ const fileMedia = (part: Extract Date: Fri, 13 Nov 2026 01:14:45 +0800 Subject: [PATCH 46/66] fix(translate): distinguish instructions from prompt history Represent top-level source instructions separately from inline system and developer messages so target hoisting cannot merge their roles or positions. Preserve explicitly empty Chat assistant arrays and Gemini thought turns instead of deleting their conversation boundaries. --- .../__tests__/ir/input/types_test.ts | 3 ++- ...ssages-to-openai-responses-request_test.ts | 14 +++----------- ...tent-to-anthropic-messages-request_test.ts | 10 ++++++++-- .../pairs/input-controls-request_test.ts | 11 +++++++++++ ...nses-to-anthropic-messages-request_test.ts | 12 ++++++------ packages/translate/src/composition/reading.ts | 6 +++--- .../src/composition/request/call-ids.ts | 4 ++-- packages/translate/src/ir/input/types.ts | 15 ++++++++++----- .../anthropic-messages/as-source/input-to.ts | 2 +- .../as-target/input-from.ts | 13 +++++++++++-- .../as-source/input-to.ts | 2 +- .../as-source/input-to.ts | 3 ++- .../as-target/input-from.ts | 2 ++ .../openai-responses/as-source/input-to.ts | 2 +- .../openai-responses/as-target/input-from.ts | 19 +++++++++++-------- 15 files changed, 74 insertions(+), 44 deletions(-) diff --git a/packages/translate/__tests__/ir/input/types_test.ts b/packages/translate/__tests__/ir/input/types_test.ts index 880f29b3c6..01a32bfe8a 100644 --- a/packages/translate/__tests__/ir/input/types_test.ts +++ b/packages/translate/__tests__/ir/input/types_test.ts @@ -14,6 +14,7 @@ describe('input IR shape', () => { test('a conversation carries user and assistant turns in order', () => { const clientItems: IRInputClientItem[] = [ + { type: 'instruction', content: [{ type: 'text', text: 'top-level instruction' }] }, { type: 'prompt', role: 'system', content: [{ type: 'text', text: 'be brief' }] }, { type: 'prompt', role: 'developer', content: [{ type: 'text', text: 'tools are strict' }] }, { type: 'message', content: userParts }, @@ -31,7 +32,7 @@ describe('input IR shape', () => { const turns = [user, assistant]; expect(turns.map(turn => turn.kind)).toEqual(['client', 'assistant']); - expect(user.items.map(item => item.type)).toEqual(['prompt', 'prompt', 'message', 'tool_result', 'tool_result']); + expect(user.items.map(item => item.type)).toEqual(['instruction', 'prompt', 'prompt', 'message', 'tool_result', 'tool_result']); expect(assistant.items.map(item => item.type)).toEqual(['reasoning', 'message', 'function_call', 'custom_tool_call']); }); diff --git a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts index 6d1e3b24ea..40fa097342 100644 --- a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts @@ -341,7 +341,7 @@ test('buildTargetPayload omits temperature when the source omitted it', async () assertFalse('temperature' in result); }); -test('buildTargetPayload prepends multi-block top-level system as a leading input system message preserving block boundaries', async () => { +test('buildTargetPayload writes multi-block top-level system through Responses instructions', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -352,16 +352,8 @@ test('buildTargetPayload prepends multi-block top-level system as a leading inpu messages: [{ role: 'user', content: 'hi' }], }); - assertFalse('instructions' in result); - const input = result.input as Array<{ type: string; role?: string; content?: unknown }>; - assertEquals(input[0], { - type: 'message', - role: 'system', - content: [ - { type: 'input_text', text: 'Alpha' }, - { type: 'input_text', text: 'Beta' }, - ], - }); + assertEquals(result.instructions, 'AlphaBeta'); + assertEquals(result.input, [{ type: 'message', role: 'user', content: 'hi' }]); }); test('buildTargetPayload keeps a single-block top-level system in canonical `instructions` slot', async () => { diff --git a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-request_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-request_test.ts index 5d2305371c..dfcebaacbf 100644 --- a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-request_test.ts @@ -17,6 +17,11 @@ const buildWithTestSidecar = ( options: { fallbackMaxOutputTokens?: number }, ) => buildTargetPayloadFor(pairs.geminiToMessages[0], pairs.geminiToMessages[1], { ...payload, model }, { sidecarCodec: createTestSidecarCodec(), ...options }); +test('buildTargetPayload preserves an empty thought as a thinking turn', async () => { + const result = await buildWithTestSidecar({ contents: [{ role: 'model', parts: [{ text: '', thought: true }] }] }, 'claude-test', noOptions); + assertEquals(result.messages, [{ role: 'assistant', content: [{ type: 'thinking', thinking: '', signature: '' }] }]); +}); + test('buildTargetPayload translates a named thinking level to the effort name', async () => { const request = await buildWithTestSidecar({ contents: [], @@ -103,9 +108,10 @@ test.each(['audio/wav', 'audio/mp3', 'audio/mpeg'])('buildTargetPayload rejects test('buildTargetPayload forwards a non-audio inline MIME type from Gemini as a Messages document', async () => { // GenerateContent states any MIME type inline; the document's media type // travels verbatim so the selected upstream decides whether it accepts it. - // A `text` source states characters, not the base64 the IR carries. + // The target accepts inline bytes, so text data remains base64 instead of + // being decoded under an assumed charset. const request = await buildWithTestSidecar({ contents: [{ role: 'user', parts: [{ inlineData: { mimeType: 'text/plain', data: 'ZGF0YQ==' } }] }] }, 'claude-test', noOptions); - assertEquals(request.messages, [{ role: 'user', content: [{ type: 'document', source: { type: 'text', media_type: 'text/plain', data: 'data' } }] }]); + assertEquals(request.messages, [{ role: 'user', content: [{ type: 'document', source: { type: 'base64', media_type: 'text/plain', data: 'ZGF0YQ==' } }] }]); }); test('buildTargetPayload prefers limits.max_output_tokens over the gateway default when payload omits maxOutputTokens', async () => { diff --git a/packages/translate/__tests__/protocols/pairs/input-controls-request_test.ts b/packages/translate/__tests__/protocols/pairs/input-controls-request_test.ts index eb8b0599c1..e2d299cf0d 100644 --- a/packages/translate/__tests__/protocols/pairs/input-controls-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/input-controls-request_test.ts @@ -17,6 +17,17 @@ import type { OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-res const options = { sidecarCodec: createTestSidecarCodec() }; +test('an explicitly empty Chat assistant content array remains a turn on both targets', async () => { + const source = { model: 'm', stream: true, messages: [{ role: 'assistant' as const, content: [] }] }; + const responses = await routeOpenAIChatCompletionsViaOpenAIResponses(source, options); + if (responses.type !== 'trip') throw new Error(responses.message); + expect(responses.target.input).toEqual([{ type: 'message', role: 'assistant', content: '' }]); + + const messages = await routeOpenAIChatCompletionsViaAnthropicMessages(source, { ...options, fallbackMaxOutputTokens: 16 }); + if (messages.type !== 'trip') throw new Error(messages.message); + expect(messages.target.messages).toEqual([{ role: 'assistant', content: [{ type: 'text', text: '', cache_control: { type: 'ephemeral' } }] }]); +}); + test.each([ { combined: 9, responsesCeiling: 9, messagesCeiling: 9 }, { combined: 0, responsesCeiling: 0, messagesCeiling: 0 }, diff --git a/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts index 8635985fdc..abd23fdf01 100644 --- a/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts @@ -1131,13 +1131,13 @@ test('buildTargetPayload preserves payload.instructions and leading system as se ); assertEquals(result.target.system, [ - { type: 'text', text: 'canonical top-level instructions' }, - { type: 'text', text: 'leading note', cache_control: { type: 'ephemeral' } }, + { type: 'text', text: 'canonical top-level instructions', cache_control: { type: 'ephemeral' } }, ]); - assertEquals(result.target.messages.length, 3); - assertEquals(result.target.messages[0].role, 'user'); - assertEquals(result.target.messages[1], { role: 'system', content: [{ type: 'text', text: 'mid-array note' }] }); - assertEquals(result.target.messages[2].role, 'user'); + assertEquals(result.target.messages.length, 4); + assertEquals(result.target.messages[0], { role: 'system', content: [{ type: 'text', text: 'leading note' }] }); + assertEquals(result.target.messages[1].role, 'user'); + assertEquals(result.target.messages[2], { role: 'system', content: [{ type: 'text', text: 'mid-array note' }] }); + assertEquals(result.target.messages[3].role, 'user'); }); test('buildTargetPayload throws when a system input message contains an image part', async () => { diff --git a/packages/translate/src/composition/reading.ts b/packages/translate/src/composition/reading.ts index ca2cc34c05..af06706432 100644 --- a/packages/translate/src/composition/reading.ts +++ b/packages/translate/src/composition/reading.ts @@ -40,12 +40,12 @@ export const resolveReading = async ( const append = (input: readonly IRInputTurn[]): void => { for (const turn of input) { const previous = turns.at(-1); - // Prompts retain their own boundary for target-side hoisting. Other + // Instructions and prompts retain their own source boundaries. Other // adjacent client pieces belong to the same turn, including server // results followed by the client's next message. if (previous?.kind === 'client' && turn.kind === 'client' - && !previous.items.some(item => item.type === 'prompt') - && !turn.items.some(item => item.type === 'prompt')) { + && !previous.items.some(item => item.type === 'instruction' || item.type === 'prompt') + && !turn.items.some(item => item.type === 'instruction' || item.type === 'prompt')) { turns[turns.length - 1] = { kind: 'client', items: [...previous.items, ...turn.items] }; } else { turns.push(turn); diff --git a/packages/translate/src/composition/request/call-ids.ts b/packages/translate/src/composition/request/call-ids.ts index a314ef3457..77c67a13c3 100644 --- a/packages/translate/src/composition/request/call-ids.ts +++ b/packages/translate/src/composition/request/call-ids.ts @@ -1,4 +1,4 @@ -import type { IRInputAssistantItem, IRInputCustomToolCallItem, IRInputFunctionCallItem, IRInputPromptItem, IRInputToolResultItem, IRInputUserMessageItem, IRInputTurn } from '../../ir/input/types.ts'; +import type { IRInputAssistantItem, IRInputCustomToolCallItem, IRInputFunctionCallItem, IRInputInstructionItem, IRInputPromptItem, IRInputToolResultItem, IRInputUserMessageItem, IRInputTurn } from '../../ir/input/types.ts'; import { TranslateInputException } from '../../shared/input-error.ts'; import type { RestoredAssistantTurn } from '../reading.ts'; @@ -12,7 +12,7 @@ export interface IRInputAssistantTurnWithCallIds { readonly kind: 'assistant'; readonly items: readonly IRInputAssistantItemWithCallIds[]; } -export type IRInputClientItemWithCallIds = IRInputPromptItem | IRInputUserMessageItem | IRInputToolResultItemWithCallId; +export type IRInputClientItemWithCallIds = IRInputInstructionItem | IRInputPromptItem | IRInputUserMessageItem | IRInputToolResultItemWithCallId; export type IRInputToolResultItemWithCallId = IRInputToolResultItem & { readonly call_id: string }; export type IRInputAssistantItemWithCallIds = | Exclude diff --git a/packages/translate/src/ir/input/types.ts b/packages/translate/src/ir/input/types.ts index 3bcd88a82a..66fb617fbe 100644 --- a/packages/translate/src/ir/input/types.ts +++ b/packages/translate/src/ir/input/types.ts @@ -122,8 +122,8 @@ export interface IRInputToolSelector { } // A turn groups one conversation branch. Assistant messages keep their own -// boundaries; adjacent client messages join except at prompts, whose boundaries -// let targets place them inline or hoist them. Items stay ordered while each +// boundaries; top-level instructions and inline prompts retain distinct client +// boundaries. Items stay ordered while each // target chooses its native message grouping. export type IRInputTurn = IRInputClientTurn | IRInputAssistantTurn; @@ -146,7 +146,7 @@ export interface IRInputAssistantTurn { // message's own parts together while tool results and tool calls remain // separate items lets every writer reproduce its own grouping without this IR // prejudging one protocol's shape. -export type IRInputClientItem = IRInputPromptItem | IRInputUserMessageItem | IRInputToolResultItem; +export type IRInputClientItem = IRInputInstructionItem | IRInputPromptItem | IRInputUserMessageItem | IRInputToolResultItem; export type IRInputAssistantItem = | IRInputAssistantMessageItem @@ -154,8 +154,13 @@ export type IRInputAssistantItem = | IRInputFunctionCallItem | IRInputCustomToolCallItem; -// Prompt position represents both top-level instructions and inline system/developer messages. -// Each target chooses which leading prompts it can hoist. +/** A source protocol's top-level instruction field, distinct from conversation history. */ +export interface IRInputInstructionItem { + readonly type: 'instruction'; + readonly content: readonly IRInputTextPart[]; +} + +/** An inline system or developer message at its conversation position. */ export interface IRInputPromptItem { readonly type: 'prompt'; readonly role: 'system' | 'developer'; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts index e8eb8b820f..2281c6cae1 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts @@ -250,7 +250,7 @@ const assistantSegments = (message: Extract { if (system === undefined) return undefined; const content = promptPartsToIR(system); - return content.length === 0 ? undefined : { kind: 'client', items: [{ type: 'prompt', role: 'system', content }] }; + return content.length === 0 ? undefined : { kind: 'client', items: [{ type: 'instruction', content }] }; }; const anthropicMessagesTurnToIR = ( diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts index a0fffb0274..733a8a61a7 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts @@ -118,8 +118,7 @@ const assistantBlocks = (items: readonly IRInputAssistantItemWithCallIds[]): Ant : { type: 'text', text: part.refusal }); case 'reasoning': { // Project readable reasoning summaries into Messages thinking blocks; opaque signed replay state uses sidecars. - const thinking = (item.summary ?? []).join(''); - return thinking === '' ? [] : [{ type: 'thinking', thinking, signature: '' }]; + return item.summary === undefined ? [] : [{ type: 'thinking', thinking: item.summary.join(''), signature: '' }]; } case 'function_call': return [{ @@ -240,6 +239,16 @@ export const anthropicMessagesConversationFromIR = ( } const turnBlocks: AnthropicMessagesUserContentBlock[] = []; for (const item of turn.items) { + if (item.type === 'instruction') { + if (hoisting) { + system.push(...systemContent(item.content)); + hoisting = false; + continue; + } + const blocks = systemContent(item.content); + messages.push({ role: 'system', content: blocks.length === 0 ? '' : blocks }); + continue; + } if (item.type === 'prompt') { if (hoisting) { system.push(...systemContent(item.content)); diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts index e249f98578..680d7fdaee 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts @@ -168,7 +168,7 @@ const turnToIRTurns = ( const geminiGenerateContentSystemToIR = (content: GeminiGenerateContentContent | undefined): IRInputTurn | undefined => { const parts = systemParts(content); - return parts.length === 0 ? undefined : { kind: 'client', items: [{ type: 'prompt', role: 'system', content: parts }] }; + return parts.length === 0 ? undefined : { kind: 'client', items: [{ type: 'instruction', content: parts }] }; }; // GenerateContent restricts selectable functions through allowed_tools while retaining the complete declarations. diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts index 9b4d88e4a9..9ce7cbe95a 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts @@ -122,7 +122,8 @@ const assistantItems = (turn: Extract 0 ? [{ type: 'message', content }] : []; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts index 16015e1d6f..e04af9a3bf 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts @@ -153,6 +153,8 @@ const openaiChatCompletionsClientItem = ( switch (item.type) { case 'message': return [{ role: 'user', content: userContent(item) }]; + case 'instruction': + return [{ role: 'system', content: item.content.length === 1 ? item.content[0].text : item.content.length === 0 ? '' : item.content.map(part => ({ type: 'text' as const, text: part.text })) }]; case 'prompt': // A single text part collapses to the string spelling Chat accepts for // text-only system/developer messages; several parts keep their array so diff --git a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts index e2e16ba3d7..1d60674f3f 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts @@ -170,7 +170,7 @@ const clientItems = (items: readonly CanonicalOpenAIResponsesInputItem[]): IRInp }); const openaiResponsesInstructionsToIR = (instructions: string | null | undefined): IRInputTurn | undefined => instructions - ? { kind: 'client', items: [{ type: 'prompt', role: 'system', content: [{ type: 'text', text: instructions }] }] } + ? { kind: 'client', items: [{ type: 'instruction', content: [{ type: 'text', text: instructions }] }] } : undefined; const openaiResponsesTurnToIR = (turn: OpenAIResponsesConversationTurn): IRInputTurn[] => { diff --git a/packages/translate/src/protocols/openai-responses/as-target/input-from.ts b/packages/translate/src/protocols/openai-responses/as-target/input-from.ts index e15b0c8bfa..b802f09087 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/input-from.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/input-from.ts @@ -1,6 +1,6 @@ import type { IRInputAssistantItemWithCallIds, IRInputTurnWithCallIds, IRInputClientItemWithCallIds } from '../../../composition/request/call-ids.ts'; import { requireStreaming } from '../../../ir/input/streaming.ts'; -import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputOutputFormat, IRInputMedia, IRInputToolResultContentPart, IRInputClientItem, IRInputPromptItem } from '../../../ir/input/types.ts'; +import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputOutputFormat, IRInputMedia, IRInputToolResultContentPart, IRInputClientItem, IRInputInstructionItem, IRInputPromptItem } from '../../../ir/input/types.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import { reasoningEffortForBudget } from '../../../shared/reasoning-budget.ts'; import type { @@ -100,6 +100,8 @@ const assistantItems = (items: readonly IRInputAssistantItemWithCallIds[]): Cano const clientItems = (items: readonly IRInputClientItemWithCallIds[]): CanonicalOpenAIResponsesInputItem[] => items.map(item => { switch (item.type) { + case 'instruction': + return { type: 'message', role: 'system', content: item.content.length === 1 ? item.content[0].text : item.content.map(part => ({ type: 'input_text' as const, text: part.text })) }; case 'prompt': { // A prompt boundary states its text plainly: one part is that string, and // several keep their array so the boundaries between them survive. @@ -127,8 +129,8 @@ const clientItems = (items: readonly IRInputClientItemWithCallIds[]): CanonicalO // A leading system or developer run states the request's prompt, which // Responses keeps in its own top-level slot. Every later system message stays // inline as an ordinary input item. -const isHoistablePrompt = (entry: IRInputClientItem): entry is IRInputPromptItem => - entry.type === 'prompt'; +const isInstruction = (entry: IRInputClientItem): entry is IRInputInstructionItem => entry.type === 'instruction'; +const isPrompt = (entry: IRInputClientItem): entry is IRInputPromptItem => entry.type === 'prompt'; export const openaiResponsesConversationFromIR = ( turns: readonly IRInputTurnWithCallIds[], @@ -146,11 +148,12 @@ export const openaiResponsesConversationFromIR = ( return assistantItems(item.items); } return item.items.flatMap((entry): CanonicalOpenAIResponsesInputItem[] => { - if (hoisting && isHoistablePrompt(entry)) { - // Responses states the slot as one string. A single text part is one - // string; several parts cannot be merged without losing the boundaries - // the source drew, so the run stays inline with each part its own - // `input_text` block. + if (hoisting && isInstruction(entry)) { + instructions.push(entry.content.map(part => part.text).join('')); + hoisting = false; + return []; + } + if (hoisting && isPrompt(entry)) { const parts = entry.content.filter(part => part.text !== ''); if (parts.length === 1) { instructions.push(parts[0].text); From b550ccd57db8d93d9d88e3533c6cc41f4fafef52 Mon Sep 17 00:00:00 2001 From: Menci Date: Fri, 13 Nov 2026 01:23:00 +0800 Subject: [PATCH 47/66] fix(translate): preserve client message and null semantics Represent user, system and developer history as messages with explicit roles while keeping top-level instructions distinct. Carry nullable OpenAI controls through the IR, normalize ProtoJSON nulls as unset, preserve separate refusal values, and avoid empty namespace-description prefixes. --- .../__tests__/composition/reading_test.ts | 6 ++-- .../__tests__/ir/input/types_test.ts | 8 +++--- .../as-target/legacy-calls_test.ts | 4 +-- ...ontent-to-openai-responses-request_test.ts | 21 ++++++++++++++ .../pairs/input-controls-request_test.ts | 28 +++++++++++++++++++ ...nses-to-anthropic-messages-request_test.ts | 9 ++++++ packages/translate/src/composition/reading.ts | 6 ++-- .../src/composition/request/call-ids.ts | 4 +-- packages/translate/src/ir/input/types.ts | 28 +++++++------------ .../anthropic-messages/as-source/input-to.ts | 6 ++-- .../as-target/input-from.ts | 14 +++++----- .../as-source/input-to.ts | 20 +++++++------ .../as-source/input-to.ts | 15 +++++----- .../as-target/input-from.ts | 14 ++++------ .../openai-responses/as-source/input-to.ts | 19 +++++++------ .../openai-responses/as-target/input-from.ts | 19 ++++++------- 16 files changed, 135 insertions(+), 86 deletions(-) diff --git a/packages/translate/__tests__/composition/reading_test.ts b/packages/translate/__tests__/composition/reading_test.ts index 86173ffe52..c951940d25 100644 --- a/packages/translate/__tests__/composition/reading_test.ts +++ b/packages/translate/__tests__/composition/reading_test.ts @@ -19,7 +19,7 @@ test('joins parallel server results and the next user message into one client tu ], }); expect(await resolveReading(reading, async () => undefined)).toEqual([ - { kind: 'client', items: [{ type: 'message', content: [{ type: 'text', text: 'Q' }] }] }, + { kind: 'client', items: [{ type: 'message', role: 'user', content: [{ type: 'text', text: 'Q' }] }] }, { kind: 'assistant', items: [ { type: 'function_call', call_id: 's1', name: 'web_search', arguments: '{"query":"one"}' }, @@ -30,13 +30,13 @@ test('joins parallel server results and the next user message into one client tu kind: 'client', items: [ { type: 'tool_result', call_id: 's1', output: '[]' }, { type: 'tool_result', call_id: 's2', output: '[]' }, - { type: 'message', content: [{ type: 'text', text: 'Next' }] }, + { type: 'message', role: 'user', content: [{ type: 'text', text: 'Next' }] }, ], }, ]); }); -test('keeps prompt and restored assistant boundaries while joining client pieces', async () => { +test('keeps instruction and restored assistant boundaries while joining client pieces', async () => { const reading = readAnthropicMessagesRequest({ model: 'model', max_tokens: 32, system: 'Rules', messages: [ { role: 'user', content: 'Before' }, diff --git a/packages/translate/__tests__/ir/input/types_test.ts b/packages/translate/__tests__/ir/input/types_test.ts index 01a32bfe8a..4dad8c090c 100644 --- a/packages/translate/__tests__/ir/input/types_test.ts +++ b/packages/translate/__tests__/ir/input/types_test.ts @@ -15,9 +15,9 @@ describe('input IR shape', () => { test('a conversation carries user and assistant turns in order', () => { const clientItems: IRInputClientItem[] = [ { type: 'instruction', content: [{ type: 'text', text: 'top-level instruction' }] }, - { type: 'prompt', role: 'system', content: [{ type: 'text', text: 'be brief' }] }, - { type: 'prompt', role: 'developer', content: [{ type: 'text', text: 'tools are strict' }] }, - { type: 'message', content: userParts }, + { type: 'message', role: 'system', content: [{ type: 'text', text: 'be brief' }] }, + { type: 'message', role: 'developer', content: [{ type: 'text', text: 'tools are strict' }] }, + { type: 'message', role: 'user', content: userParts }, { type: 'tool_result', call_id: 'call_1', output: 'ok' }, { type: 'tool_result', call_id: 'call_2', output: [{ type: 'text', text: 'see' }, { type: 'image', image: { source_kind: 'url', url: 'https://example.com/b.png' } }], is_error: true }, ]; @@ -32,7 +32,7 @@ describe('input IR shape', () => { const turns = [user, assistant]; expect(turns.map(turn => turn.kind)).toEqual(['client', 'assistant']); - expect(user.items.map(item => item.type)).toEqual(['instruction', 'prompt', 'prompt', 'message', 'tool_result', 'tool_result']); + expect(user.items.map(item => item.type)).toEqual(['instruction', 'message', 'message', 'message', 'tool_result', 'tool_result']); expect(assistant.items.map(item => item.type)).toEqual(['reasoning', 'message', 'function_call', 'custom_tool_call']); }); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts index ba49505df4..45feae7884 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts @@ -10,7 +10,7 @@ import { assertEquals, assertThrows } from '@floway-dev/test-utils'; type LegacyTurn = InputAssistantTurnFor<'openaiChatCompletions'>; const legacy = (name: string): RestoredAssistantTurn => ({ kind: 'restored', callIdentities: [], turn: { role: 'assistant', function_call: { name, arguments: '{}' } } }); const modern = (callId: string): RestoredAssistantTurn => ({ kind: 'restored', callIdentities: [], turn: { role: 'assistant', tool_calls: [{ id: callId, type: 'function', function: { name: 'lookup', arguments: '{}' } }] } }); -const message = (text: string): IRInputTurn => ({ kind: 'client', items: [{ type: 'message', content: [{ type: 'text', text }] }] }); +const message = (text: string): IRInputTurn => ({ kind: 'client', items: [{ type: 'message', role: 'user', content: [{ type: 'text', text }] }] }); const result = (callId: string): IRInputTurn => ({ kind: 'client', items: [{ type: 'tool_result', call_id: callId, output: '' }] }); test('an answer naming no stated call is the one the legacy call gets', () => { @@ -36,7 +36,7 @@ test('a legacy call the next turn does not answer is refused', () => { }); test('a legacy call answered in the turn after it, after other client content, is accepted', () => { - const turn: IRInputTurn = { kind: 'client', items: [{ type: 'message', content: [{ type: 'text', text: 'chatter' }] }, { type: 'tool_result', call_id: 'client-invented', output: '' }] }; + const turn: IRInputTurn = { kind: 'client', items: [{ type: 'message', role: 'user', content: [{ type: 'text', text: 'chatter' }] }, { type: 'tool_result', call_id: 'client-invented', output: '' }] }; assertEquals([...chatLegacyCallsOf([legacy('lookup'), turn]).names.entries()], [['client-invented', 'lookup']]); }); diff --git a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts index c3465a1b59..ad6be7921b 100644 --- a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts @@ -10,6 +10,27 @@ const buildWithTestSidecar = ( model: string, ) => buildTargetPayloadFor(pairs.geminiToResponses[0], pairs.geminiToResponses[1], { ...payload, model }, { sidecarCodec: createTestSidecarCodec() }); +test('ProtoJSON null configuration members are treated as unset', async () => { + const absentConfig = await buildWithTestSidecar({ contents: [], generationConfig: null as never }, 'gpt-test'); + assertEquals(absentConfig.reasoning, undefined); + + const nullAllowedNames = await buildWithTestSidecar({ + contents: [], tools: [{ functionDeclarations: [{ name: 'f' }] }], + toolConfig: { functionCallingConfig: { mode: 'ANY', allowedFunctionNames: null as never } }, + }, 'gpt-test'); + assertEquals(nullAllowedNames.tool_choice, 'required'); + + const nullBudget = await buildWithTestSidecar({ + contents: [], generationConfig: { thinkingConfig: { thinkingLevel: 'LOW', thinkingBudget: null as never } }, + }, 'gpt-test'); + assertEquals(nullBudget.reasoning, { effort: 'low' }); + + const nullLevel = await buildWithTestSidecar({ + contents: [], generationConfig: { thinkingConfig: { thinkingLevel: null as never, thinkingBudget: 1024 } }, + }, 'gpt-test'); + assertEquals(nullLevel.reasoning, { effort: 'low' }); +}); + test('buildTargetPayload translates LOW to reasoning.effort', async () => { const request = await buildWithTestSidecar({ contents: [], diff --git a/packages/translate/__tests__/protocols/pairs/input-controls-request_test.ts b/packages/translate/__tests__/protocols/pairs/input-controls-request_test.ts index e2d299cf0d..8fd93f0230 100644 --- a/packages/translate/__tests__/protocols/pairs/input-controls-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/input-controls-request_test.ts @@ -66,6 +66,34 @@ test('nullable token limits and cache controls remain explicit in source IR', as expect(chat.target.max_completion_tokens).toBeNull(); }); +test('nullable Chat sampling, storage, parallelism and schema strictness remain explicit', async () => { + const source = { + model: 'm', stream: true, messages: [], temperature: null, top_p: null, store: null, parallel_tool_calls: null as never, + response_format: { type: 'json_schema' as const, json_schema: { name: 'result', schema: {}, strict: null } }, + }; + expect(readOpenAIChatCompletionsRequest(source).request.settings).toMatchObject({ + temperature: null, top_p: null, store: null, parallel_tool_calls: null, + output_format: { type: 'json_schema', name: 'result', schema: {}, strict: null }, + }); + const responses = await routeOpenAIChatCompletionsViaOpenAIResponses(source, options); + if (responses.type !== 'trip') throw new Error(responses.message); + expect(responses.target).toMatchObject({ + temperature: null, top_p: null, store: null, parallel_tool_calls: null, + text: { format: { type: 'json_schema', name: 'result', schema: {}, strict: null } }, + }); +}); + +test('distinct content and scalar assistant refusals both remain in history', async () => { + const result = await routeOpenAIChatCompletionsViaOpenAIResponses({ + model: 'm', stream: true, messages: [{ role: 'assistant', content: [{ type: 'refusal', refusal: 'first' }], refusal: 'second' }], + }, options); + if (result.type !== 'trip') throw new Error(result.message); + expect(result.target.input).toEqual([{ + type: 'message', role: 'assistant', + content: [{ type: 'refusal', refusal: 'first' }, { type: 'refusal', refusal: 'second' }], + }]); +}); + test.each([ { max_tokens: 5 }, { max_tokens: 5, max_completion_tokens: 0 }, diff --git a/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts index abd23fdf01..fd6adf6a7e 100644 --- a/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts @@ -107,6 +107,15 @@ test('buildTargetPayload rejects an agent_message that no interceptor lowered', ); }); +test('an empty namespace description does not prefix a child description', async () => { + const result = await buildWithTestSidecar({ + ...minimalPayload, + tools: [{ type: 'namespace', name: 'group', description: '', tools: [{ type: 'function', name: 'f', description: 'meaning', parameters: { type: 'object' } }] }], + }); + const tool = result.target.tools?.[0]; + assertEquals(tool !== undefined && 'description' in tool ? tool.description : undefined, 'meaning'); +}); + test.each([ { name: 'program', input: [{ type: 'program', id: 'prog_1', call_id: 'call_prog_1', code: 'return 1', fingerprint: 'opaque' }] }, { name: 'program_output', input: [{ type: 'program_output', id: 'prog_out_1', call_id: 'call_prog_1', result: '1', status: 'completed' }] }, diff --git a/packages/translate/src/composition/reading.ts b/packages/translate/src/composition/reading.ts index af06706432..1117f7388b 100644 --- a/packages/translate/src/composition/reading.ts +++ b/packages/translate/src/composition/reading.ts @@ -40,12 +40,12 @@ export const resolveReading = async ( const append = (input: readonly IRInputTurn[]): void => { for (const turn of input) { const previous = turns.at(-1); - // Instructions and prompts retain their own source boundaries. Other + // Instructions and system/developer messages retain their source boundaries. Other // adjacent client pieces belong to the same turn, including server // results followed by the client's next message. if (previous?.kind === 'client' && turn.kind === 'client' - && !previous.items.some(item => item.type === 'instruction' || item.type === 'prompt') - && !turn.items.some(item => item.type === 'instruction' || item.type === 'prompt')) { + && !previous.items.some(item => item.type === 'instruction' || item.type === 'message' && item.role !== 'user') + && !turn.items.some(item => item.type === 'instruction' || item.type === 'message' && item.role !== 'user')) { turns[turns.length - 1] = { kind: 'client', items: [...previous.items, ...turn.items] }; } else { turns.push(turn); diff --git a/packages/translate/src/composition/request/call-ids.ts b/packages/translate/src/composition/request/call-ids.ts index 77c67a13c3..c412f4662e 100644 --- a/packages/translate/src/composition/request/call-ids.ts +++ b/packages/translate/src/composition/request/call-ids.ts @@ -1,4 +1,4 @@ -import type { IRInputAssistantItem, IRInputCustomToolCallItem, IRInputFunctionCallItem, IRInputInstructionItem, IRInputPromptItem, IRInputToolResultItem, IRInputUserMessageItem, IRInputTurn } from '../../ir/input/types.ts'; +import type { IRInputAssistantItem, IRInputClientMessageItem, IRInputCustomToolCallItem, IRInputFunctionCallItem, IRInputInstructionItem, IRInputToolResultItem, IRInputTurn } from '../../ir/input/types.ts'; import { TranslateInputException } from '../../shared/input-error.ts'; import type { RestoredAssistantTurn } from '../reading.ts'; @@ -12,7 +12,7 @@ export interface IRInputAssistantTurnWithCallIds { readonly kind: 'assistant'; readonly items: readonly IRInputAssistantItemWithCallIds[]; } -export type IRInputClientItemWithCallIds = IRInputInstructionItem | IRInputPromptItem | IRInputUserMessageItem | IRInputToolResultItemWithCallId; +export type IRInputClientItemWithCallIds = IRInputInstructionItem | IRInputClientMessageItem | IRInputToolResultItemWithCallId; export type IRInputToolResultItemWithCallId = IRInputToolResultItem & { readonly call_id: string }; export type IRInputAssistantItemWithCallIds = | Exclude diff --git a/packages/translate/src/ir/input/types.ts b/packages/translate/src/ir/input/types.ts index 66fb617fbe..e3bb87b000 100644 --- a/packages/translate/src/ir/input/types.ts +++ b/packages/translate/src/ir/input/types.ts @@ -7,8 +7,8 @@ export interface IRInputSettings { // The return path is a stream, so the request path asks the upstream for one // and a target writer rejects an IR that does not say `true`. readonly stream: boolean; - readonly temperature?: number; - readonly top_p?: number; + readonly temperature?: number | null; + readonly top_p?: number | null; readonly top_k?: number; readonly stop?: readonly string[]; readonly seed?: number; @@ -18,10 +18,10 @@ export interface IRInputSettings { readonly output_format?: IRInputOutputFormat; // Readers normalize metadata to string pairs; target writers do not coerce it again. readonly metadata?: Readonly>; - readonly store?: boolean; + readonly store?: boolean | null; // Preserve service-tier strings; each target owns vocabulary mapping. readonly service_tier?: string; - readonly parallel_tool_calls?: boolean; + readonly parallel_tool_calls?: boolean | null; // Keep effort as an open string, independently of numeric budget. Protocol readers and writers own their thinking-mode mapping. readonly reasoning_effort?: string; // Budget and effort remain independent; a source may cap reasoning tokens without naming an effort level. @@ -58,7 +58,7 @@ export interface IRInputOutputFormat { readonly name?: string; readonly description?: string; readonly schema?: Readonly>; - readonly strict?: boolean; + readonly strict?: boolean | null; } // One request's own fields, from the source's wire to the target's. The @@ -146,7 +146,7 @@ export interface IRInputAssistantTurn { // message's own parts together while tool results and tool calls remain // separate items lets every writer reproduce its own grouping without this IR // prejudging one protocol's shape. -export type IRInputClientItem = IRInputInstructionItem | IRInputPromptItem | IRInputUserMessageItem | IRInputToolResultItem; +export type IRInputClientItem = IRInputInstructionItem | IRInputClientMessageItem | IRInputToolResultItem; export type IRInputAssistantItem = | IRInputAssistantMessageItem @@ -160,18 +160,10 @@ export interface IRInputInstructionItem { readonly content: readonly IRInputTextPart[]; } -/** An inline system or developer message at its conversation position. */ -export interface IRInputPromptItem { - readonly type: 'prompt'; - readonly role: 'system' | 'developer'; - // Prompt parts are text-only; source readers reject unsupported prompt media. - readonly content: readonly IRInputTextPart[]; -} - -export interface IRInputUserMessageItem { - readonly type: 'message'; - readonly content: readonly IRInputUserContentPart[]; -} +/** A client-side conversation message at its source position. */ +export type IRInputClientMessageItem = + | { readonly type: 'message'; readonly role: 'user'; readonly content: readonly IRInputUserContentPart[] } + | { readonly type: 'message'; readonly role: 'system' | 'developer'; readonly content: readonly IRInputTextPart[] }; // Assistant IR projects text and refusals. Source readers reject assistant media that this model cannot represent. export interface IRInputAssistantMessageItem { diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts index 2281c6cae1..ea153fdc91 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts @@ -122,17 +122,17 @@ const clientItems = ( if (typeof message.content !== 'string' && message.content.some(block => block.type !== 'text')) { throw new TranslateInputException('Only text system content is supported.'); } - return [{ type: 'prompt', role: 'system', content: promptPartsToIR(message.content as string | readonly AnthropicMessagesTextBlockParam[]) }]; + return [{ type: 'message', role: 'system', content: promptPartsToIR(message.content as string | readonly AnthropicMessagesTextBlockParam[]) }]; } if (typeof message.content === 'string') { - return [{ type: 'message', content: message.content === '' ? [] : [{ type: 'text', text: message.content }] }]; + return [{ type: 'message', role: 'user', content: message.content === '' ? [] : [{ type: 'text', text: message.content }] }]; } const items: IRInputClientItem[] = []; const pending: IRInputUserContentPart[] = []; const flush = (): void => { if (pending.length === 0) return; - items.push({ type: 'message', content: [...pending] }); + items.push({ type: 'message', role: 'user', content: [...pending] }); pending.length = 0; }; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts index 733a8a61a7..c0f1d06924 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts @@ -74,7 +74,7 @@ const documentBlock = (media: IRInputMedia): AnthropicMessagesUserContentBlock = const systemContent = (content: readonly IRInputTextPart[]): AnthropicMessagesTextBlockParam[] => content.map(part => ({ type: 'text', text: part.text })); const userContent = ( - item: Extract, + item: Extract, ): AnthropicMessagesUserContentBlock[] => { const blocks: AnthropicMessagesUserContentBlock[] = []; for (const part of item.content) { @@ -249,13 +249,13 @@ export const anthropicMessagesConversationFromIR = ( messages.push({ role: 'system', content: blocks.length === 0 ? '' : blocks }); continue; } - if (item.type === 'prompt') { + if (item.type === 'message' && item.role !== 'user') { if (hoisting) { system.push(...systemContent(item.content)); continue; } // Messages states a system message's content as a non-empty block list - // or a bare string; an empty prompt run is the empty string. + // or a bare string; an empty message is the empty string. const blocks = systemContent(item.content); messages.push({ role: 'system', content: blocks.length === 0 ? '' : blocks }); continue; @@ -263,7 +263,7 @@ export const anthropicMessagesConversationFromIR = ( hoisting = false; turnBlocks.push(...(item.type === 'tool_result' ? [{ type: 'tool_result' as const, tool_use_id: item.call_id, content: toolResultContent(item.output), ...(item.is_error === true ? { is_error: true } : {}) }] - : userContent(item))); + : userContent(item as Extract))); } append(turnBlocks); } @@ -284,7 +284,7 @@ export const anthropicMessagesHeaderFromIR = ( const tools = request.tools; const choice = toolChoiceForTools(request.tool_choice, tools); const selected = choice === undefined ? undefined : toolChoiceFromIR(choice, tools ?? []); - const parallel = request.settings.parallel_tool_calls; + const parallel = request.settings.parallel_tool_calls ?? undefined; const toolChoice = parallel === undefined || selected?.type === 'none' ? selected : { ...(selected ?? { type: 'auto' as const }), disable_parallel_tool_use: !parallel }; @@ -326,8 +326,8 @@ export const anthropicMessagesSettingsFromIR = (settings: IRInputSettings): Omit return { ...(ceiling == null ? {} : { max_tokens: ceiling }), - ...(settings.temperature === undefined ? {} : { temperature: settings.temperature }), - ...(settings.top_p === undefined ? {} : { top_p: settings.top_p }), + ...(settings.temperature == null ? {} : { temperature: settings.temperature }), + ...(settings.top_p == null ? {} : { top_p: settings.top_p }), ...(settings.top_k === undefined ? {} : { top_k: settings.top_k }), ...(settings.stop === undefined ? {} : { stop_sequences: [...settings.stop] }), ...(thinking === undefined ? {} : { thinking }), diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts index 680d7fdaee..b03e8ae252 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts @@ -25,13 +25,14 @@ import type { GeminiGenerateContentThinkingConfig, GeminiGenerateContentContent, // sentinel leaves effort unset. Preserve all other open-string values verbatim. // Reference: https://ai.google.dev/api/generate-content#ThinkingLevel const geminiGenerateContentReasoningEffortToIR = (thinkingConfig?: GeminiGenerateContentThinkingConfig): string | undefined => { - switch (thinkingConfig?.thinkingLevel) { + const level = thinkingConfig?.thinkingLevel ?? undefined; + switch (level) { case 'LOW': return 'low'; case 'MEDIUM': return 'medium'; case 'HIGH': return 'high'; case 'MINIMAL': return 'minimal'; case 'THINKING_LEVEL_UNSPECIFIED': return undefined; - default: return thinkingConfig?.thinkingLevel; + default: return level; } }; @@ -54,7 +55,7 @@ const userParts = (content: GeminiGenerateContentContent): IRInputClientItem[] = const pending: IRInputUserContentPart[] = []; const flush = (): void => { if (pending.length === 0) return; - items.push({ type: 'message', content: pending.splice(0) }); + items.push({ type: 'message', role: 'user', content: pending.splice(0) }); }; (content.parts ?? []).forEach(part => { @@ -161,7 +162,7 @@ const turnToIRTurns = ( throw new TranslateInputException(`"${(content as { role: string }).role}" is not a supported content role.`); } return (content.parts?.length ?? 0) === 0 - ? [{ type: 'message' as const, content: [] }] + ? [{ type: 'message' as const, role: 'user' as const, content: [] }] : userParts(content); }), }]; @@ -176,7 +177,7 @@ const geminiGenerateContentSystemToIR = (content: GeminiGenerateContentContent | const toolsToIR = (payload: GeminiGenerateContentPayload): Pick => { const declarations = payload.tools?.flatMap(group => group.functionDeclarations ?? []) ?? []; const config = payload.toolConfig?.functionCallingConfig; - const allowedNames = config?.allowedFunctionNames; + const allowedNames = config?.allowedFunctionNames ?? undefined; const restrictable = config?.mode === 'ANY' || config?.mode === 'VALIDATED'; const permitted = restrictable && allowedNames !== undefined && allowedNames.length > 0 ? new Set(allowedNames) : undefined; @@ -239,13 +240,14 @@ export const readGeminiGenerateContentRequest = (payload: GeminiGenerateContentR }; const geminiGenerateContentSettingsToIR = (payload: GeminiGenerateContentRequest): IRInputSettings => { - const config = payload.generationConfig; - const budget = config?.thinkingConfig?.thinkingBudget; + const config = payload.generationConfig ?? undefined; + const thinkingConfig = config?.thinkingConfig ?? undefined; + const budget = thinkingConfig?.thinkingBudget ?? undefined; // A level is what the caller stated, including an empty one: it is an open // string, and a target that accepts it forwards it verbatim. A budget is a // different quantity the caller may state instead, so it travels on its own // field and each target decides what it can do with it. - const statedLevel = geminiGenerateContentReasoningEffortToIR(config?.thinkingConfig); + const statedLevel = geminiGenerateContentReasoningEffortToIR(thinkingConfig); // Reject a simultaneous budget and non-empty thinking level. The empty string is exempt from this conflict check. // https://github.com/cline/cline/issues/7957 if (statedLevel !== undefined && statedLevel !== '' && budget !== undefined) { @@ -278,7 +280,7 @@ const geminiGenerateContentSettingsToIR = (payload: GeminiGenerateContentRequest // GenerateContent states readable reasoning as a boolean; the neutral // spelling names how much of it the caller wants. A request that asked for // no reasoning at all has nothing to summarize. - ...(effort !== 'none' && config?.thinkingConfig?.includeThoughts === true ? { reasoning_summary: 'detailed' } : {}), + ...(effort !== 'none' && thinkingConfig?.includeThoughts === true ? { reasoning_summary: 'detailed' } : {}), }; }; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts index 9ce7cbe95a..b731087350 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts @@ -115,7 +115,7 @@ const assistantItems = (turn: Extract part.type === 'text' ? { type: 'text' as const, text: part.text } : { type: 'refusal' as const, refusal: part.refusal }); - if (message.refusal !== undefined && message.refusal !== null && !content.some(part => part.type === 'refusal')) { + if (message.refusal !== undefined && message.refusal !== null) { content.push({ type: 'refusal', refusal: message.refusal }); } @@ -176,7 +176,7 @@ const clientItems = (message: Exclude): string | OpenAIChatCompletionsUserContentPart[] => { +const userContent = (item: Extract): string | OpenAIChatCompletionsUserContentPart[] => { // Adjacent text parts join with no separator: the parts are one run of text, // and a separator would invent characters the caller never wrote. if (item.content.every(part => part.type === 'text')) return item.content.map(part => part.text).join(''); @@ -152,15 +152,11 @@ const openaiChatCompletionsClientItem = ( ): OpenAIChatCompletionsMessage[] => { switch (item.type) { case 'message': - return [{ role: 'user', content: userContent(item) }]; + return item.role === 'user' + ? [{ role: 'user', content: userContent(item) }] + : [{ role: item.role, content: item.content.length === 1 ? item.content[0].text : item.content.length === 0 ? '' : item.content.map(part => ({ type: 'text' as const, text: part.text })) }]; case 'instruction': return [{ role: 'system', content: item.content.length === 1 ? item.content[0].text : item.content.length === 0 ? '' : item.content.map(part => ({ type: 'text' as const, text: part.text })) }]; - case 'prompt': - // A single text part collapses to the string spelling Chat accepts for - // text-only system/developer messages; several parts keep their array so - // block boundaries survive. An empty list is not a legal body on this wire, - // so it becomes the empty string. - return [{ role: item.role, content: item.content.length === 1 ? item.content[0].text : item.content.length === 0 ? '' : item.content.map(part => ({ type: 'text' as const, text: part.text })) }]; case 'tool_result': { const projected = openaiChatCompletionsToolResultContent(item.call_id, item.output); lift(projected.lifted); @@ -279,7 +275,7 @@ export const openaiChatCompletionsSettingsFromIR = (settings: IRInputSettings): ...(settings.metadata === undefined ? {} : { metadata: { ...settings.metadata } }), ...(settings.store === undefined ? {} : { store: settings.store }), ...(settings.service_tier === undefined ? {} : { service_tier: settings.service_tier }), - ...(settings.parallel_tool_calls === undefined ? {} : { parallel_tool_calls: settings.parallel_tool_calls }), + ...(settings.parallel_tool_calls == null ? {} : { parallel_tool_calls: settings.parallel_tool_calls }), ...(reasoningEffort === undefined ? {} : { reasoning_effort: reasoningEffort }), ...(settings.prompt_cache_key === undefined ? {} : { prompt_cache_key: settings.prompt_cache_key }), ...(settings.prompt_cache_retention === undefined ? {} : { prompt_cache_retention: settings.prompt_cache_retention }), diff --git a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts index 1d60674f3f..37f065b775 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts @@ -137,9 +137,9 @@ const clientItems = (items: readonly CanonicalOpenAIResponsesInputItem[]): IRInp switch (item.type) { case 'message': switch (item.role) { - case 'user': return [{ type: 'message' as const, content: userContent(item.content) }]; + case 'user': return [{ type: 'message' as const, role: 'user' as const, content: userContent(item.content) }]; case 'system': - case 'developer': return [{ type: 'prompt' as const, role: item.role, content: promptPartsToIR(item) }]; + case 'developer': return [{ type: 'message' as const, role: item.role, content: promptPartsToIR(item) }]; default: throw new TranslateInputException(`Invalid role '${item.role}' in input message.`); } case 'function_call_output': { @@ -274,11 +274,12 @@ const toolGroupToIR = (tool: OpenAIResponsesTool): IRInputTool[] => { } // A group's own description qualifies each child's, the way one merged // declaration states it. - const childDescription = child.description ?? undefined; + const parentDescription = tool.description === '' ? undefined : tool.description; + const childDescription = child.description === '' ? undefined : child.description; return [toolToIR( - tool.description === undefined + parentDescription === undefined ? child - : { ...child, description: childDescription === undefined ? tool.description : `${tool.description}\n\n${childDescription}` }, + : { ...child, description: childDescription === undefined ? parentDescription : `${parentDescription}\n\n${childDescription}` }, tool.name, )]; }); @@ -326,16 +327,16 @@ const openaiResponsesSettingsToIR = (payload: CanonicalOpenAIResponsesPayload): return { model: payload.model, stream: payload.stream === true, - ...(payload.temperature == null ? {} : { temperature: payload.temperature }), - ...(payload.top_p == null ? {} : { top_p: payload.top_p }), + ...(payload.temperature === undefined ? {} : { temperature: payload.temperature }), + ...(payload.top_p === undefined ? {} : { top_p: payload.top_p }), ...(payload.presence_penalty === undefined ? {} : { presence_penalty: payload.presence_penalty }), ...(payload.frequency_penalty === undefined ? {} : { frequency_penalty: payload.frequency_penalty }), ...(payload.max_output_tokens === undefined ? {} : { max_output_tokens: payload.max_output_tokens }), ...(outputFormat === undefined ? {} : { output_format: outputFormat }), ...(payload.metadata == null ? {} : { metadata: payload.metadata }), - ...(payload.store == null ? {} : { store: payload.store }), + ...(payload.store === undefined ? {} : { store: payload.store }), ...(payload.service_tier == null ? {} : { service_tier: payload.service_tier }), - ...(payload.parallel_tool_calls == null ? {} : { parallel_tool_calls: payload.parallel_tool_calls }), + ...(payload.parallel_tool_calls === undefined ? {} : { parallel_tool_calls: payload.parallel_tool_calls }), ...(payload.reasoning?.effort == null ? {} : { reasoning_effort: payload.reasoning.effort }), ...(payload.reasoning?.summary == null ? {} : { reasoning_summary: payload.reasoning.summary }), ...(payload.prompt_cache_key === undefined ? {} : { prompt_cache_key: payload.prompt_cache_key }), diff --git a/packages/translate/src/protocols/openai-responses/as-target/input-from.ts b/packages/translate/src/protocols/openai-responses/as-target/input-from.ts index b802f09087..96c9f5ccc4 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/input-from.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/input-from.ts @@ -1,6 +1,6 @@ import type { IRInputAssistantItemWithCallIds, IRInputTurnWithCallIds, IRInputClientItemWithCallIds } from '../../../composition/request/call-ids.ts'; import { requireStreaming } from '../../../ir/input/streaming.ts'; -import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputOutputFormat, IRInputMedia, IRInputToolResultContentPart, IRInputClientItem, IRInputInstructionItem, IRInputPromptItem } from '../../../ir/input/types.ts'; +import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputOutputFormat, IRInputMedia, IRInputToolResultContentPart, IRInputClientItem, IRInputInstructionItem, IRInputClientMessageItem } from '../../../ir/input/types.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import { reasoningEffortForBudget } from '../../../shared/reasoning-budget.ts'; import type { @@ -102,15 +102,13 @@ const clientItems = (items: readonly IRInputClientItemWithCallIds[]): CanonicalO switch (item.type) { case 'instruction': return { type: 'message', role: 'system', content: item.content.length === 1 ? item.content[0].text : item.content.map(part => ({ type: 'input_text' as const, text: part.text })) }; - case 'prompt': { - // A prompt boundary states its text plainly: one part is that string, and - // several keep their array so the boundaries between them survive. - const content = item.content.length === 1 - ? item.content[0].text - : item.content.map(part => ({ type: 'input_text' as const, text: part.text })); - return { type: 'message', role: item.role, content }; - } case 'message': { + if (item.role !== 'user') { + const content = item.content.length === 1 + ? item.content[0].text + : item.content.map(part => ({ type: 'input_text' as const, text: part.text })); + return { type: 'message', role: item.role, content }; + } // A single text part uses bare-string content; multipart user content retains its part boundaries. if (item.content.length === 1 && item.content[0].type === 'text') { return { type: 'message', role: 'user', content: item.content[0].text }; @@ -130,7 +128,8 @@ const clientItems = (items: readonly IRInputClientItemWithCallIds[]): CanonicalO // Responses keeps in its own top-level slot. Every later system message stays // inline as an ordinary input item. const isInstruction = (entry: IRInputClientItem): entry is IRInputInstructionItem => entry.type === 'instruction'; -const isPrompt = (entry: IRInputClientItem): entry is IRInputPromptItem => entry.type === 'prompt'; +const isPrompt = (entry: IRInputClientItem): entry is Extract => + entry.type === 'message' && entry.role !== 'user'; export const openaiResponsesConversationFromIR = ( turns: readonly IRInputTurnWithCallIds[], From 196bd4bac681e15a7f38e98fe5d8a442205ac0ec Mon Sep 17 00:00:00 2001 From: Menci Date: Fri, 13 Nov 2026 01:26:30 +0800 Subject: [PATCH 48/66] fix(translate): retain source tool restriction behavior Reject deferred and programmatic tools from every Responses inventory, refuse namespace selector extensions, and preserve each source protocol's established orphan-choice behavior. Avoid synthesizing a Messages parallel-tool choice when no declarations exist. --- .../openai-responses/as-source/allowed-tools_test.ts | 10 ++++++++++ .../as-source/programmatic-tooling_test.ts | 10 ++++++++++ .../protocols/pairs/input-controls-request_test.ts | 9 +++++++++ ...esponses-to-openai-chat-completions-request_test.ts | 4 ++-- .../protocols/anthropic-messages/as-source/input-to.ts | 2 +- .../anthropic-messages/as-target/input-from.ts | 2 +- .../gemini-generate-content/as-source/input-to.ts | 2 +- .../openai-chat-completions/as-source/input-to.ts | 2 +- .../openai-chat-completions/as-target/input-from.ts | 2 +- .../protocols/openai-responses/as-source/input-to.ts | 3 +++ .../openai-responses/as-source/programmatic-tooling.ts | 8 ++++++-- .../protocols/openai-responses/as-target/input-from.ts | 2 +- 12 files changed, 46 insertions(+), 10 deletions(-) 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 6e38a9718a..390c27f49d 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 @@ -116,6 +116,16 @@ test('requireCallableSelector rejects a namespace that is not a string', () => { assertThrows(() => requireCallableSelector({ type: 'function', name: 'read', namespace: 42 }), TranslateInputException); }); +test.each(['chat', 'messages'] as const)('rejects namespace selector extensions for %s', async target => { + const payload = { + model: 'm', stream: true, input: 'hi', + tools: [{ type: 'namespace' as const, name: 'files', description: 'files', tools: [{ type: 'function' as const, name: 'read' }] }], + tool_choice: { type: 'allowed_tools' as const, mode: 'required' as const, tools: [{ type: 'namespace', name: 'files', extension: true }] }, + }; + const build = target === 'chat' ? buildChat : buildMessages; + await assertRejects(() => build(payload as never, createTestSidecarCodec()), TranslateInputException, 'namespace selector extensions'); +}); + test.each(['auto', 'required'] as const)('Messages expresses a complete %s callable set without changing its declarations', async mode => { const payload = source({ type: 'allowed_tools', mode, tools: [ diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/programmatic-tooling_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/programmatic-tooling_test.ts index 13dbf85152..d901653565 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/programmatic-tooling_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/programmatic-tooling_test.ts @@ -56,6 +56,16 @@ const payloadCases: Array<{ }, message: 'Programmatic', }, + { + name: 'deferred inventory tool', + payload: { input: [{ type: 'additional_tools', role: 'developer', tools: [{ type: 'function', name: 'lookup', parameters: {}, defer_loading: true }] }] }, + message: 'Deferred', + }, + { + name: 'programmatic inventory caller', + payload: { input: [{ type: 'tool_search_output', call_id: 'search', execution: 'client', tools: [{ type: 'function', name: 'lookup', parameters: {}, allowed_callers: ['programmatic'] }] }] }, + message: 'Programmatic', + }, ]; test.each(payloadCases)('rejectProgrammaticOpenAIResponsesPayload rejects $name', ({ payload, message }) => { diff --git a/packages/translate/__tests__/protocols/pairs/input-controls-request_test.ts b/packages/translate/__tests__/protocols/pairs/input-controls-request_test.ts index 8fd93f0230..70421c8b51 100644 --- a/packages/translate/__tests__/protocols/pairs/input-controls-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/input-controls-request_test.ts @@ -5,6 +5,7 @@ import { routeAnthropicMessagesViaOpenAIResponses, routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses, + routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions, } from '../../../src/index.ts'; import { readAnthropicMessagesRequest } from '../../../src/protocols/anthropic-messages/as-source/input-to.ts'; @@ -198,6 +199,14 @@ test.each(['auto', 'none'] as const)('orphan Messages %s stays in source IR and } }); +test('parallel tool control without declarations does not invent a Messages tool choice', async () => { + const result = await routeOpenAIResponsesViaAnthropicMessages({ + model: 'm', stream: true, input: 'hi', tools: [], parallel_tool_calls: false, + }, { ...options, fallbackMaxOutputTokens: 16 }); + if (result.type !== 'trip') throw new Error(result.message); + expect(result.target).not.toHaveProperty('tool_choice'); +}); + test('tool selection never changes the complete allocated declaration inventory', async () => { const tools = [ { type: 'namespace' as const, name: 'x', description: 'group', tools: [{ type: 'function' as const, name: 'f' }, { type: 'custom' as const, name: 'f' }] }, diff --git a/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts index b4c493f5bf..89b98a9a5d 100644 --- a/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts @@ -326,7 +326,7 @@ test.each(['auto', 'none'] as const)('buildTargetPayload omits orphan string too assertEquals(result.target.tool_choice, undefined); }); -test('buildTargetPayload omits a required tool choice that has no declaration to demand', async () => { +test('buildTargetPayload retains a required tool choice without declarations', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: 'Hi' }], @@ -343,7 +343,7 @@ test('buildTargetPayload omits a required tool choice that has no declaration to text: null, }); - assertEquals(result.target.tool_choice, undefined); + assertEquals(result.target.tool_choice, 'required'); }); test('buildTargetPayload wraps custom tools as single-string function tools and records their names', async () => { diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts index ea153fdc91..b9c0dfd4bb 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts @@ -320,7 +320,7 @@ const anthropicMessagesToolsToIR = (payload: AnthropicMessagesPayload): Pick => { const tools = request.tools; - const choice = tools === undefined || tools.length === 0 ? undefined : request.tool_choice; + const choice = tools === undefined || tools.length === 0 ? request.tool_choice === 'required' ? 'required' : undefined : request.tool_choice; return { ...openaiChatCompletionsSettingsFromIR(request.settings), model: request.settings.model, diff --git a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts index 37f065b775..b7efd88779 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts @@ -224,6 +224,9 @@ const selectionToIR = (selection: IRInputToolSelector, declarations: readonly IR // A selector may name a whole group, which selects each callable it holds. const selectorOf = (payload: CanonicalOpenAIResponsesPayload, selector: Record, declarations: readonly IRInputTool[]): IRInputToolSelector[] => { if (typeof selector !== 'object' || selector?.type !== 'namespace') return [selectionToIR(requireCallableSelector(selector), declarations)]; + if (typeof selector.name !== 'string' || Object.keys(selector).some(key => key !== 'type' && key !== 'name')) { + throw new TranslateInputException('Cannot translate namespace selector extensions.'); + } const groups = [ ...(payload.tools ?? []), ...payload.input.flatMap(item => item.type === 'additional_tools' || item.type === 'tool_search_output' ? item.tools : []), 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 cb90b05e74..94ce4e05ab 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 @@ -3,10 +3,14 @@ import type { CanonicalOpenAIResponsesInputItem, OpenAIResponsesPayloadEx } from export const rejectProgrammaticOpenAIResponsesPayload = (payload: OpenAIResponsesPayloadEx): void => { const toolChoice = payload.tool_choice; - if (payload.tools?.some(hasProgrammaticCaller) === true || (toolChoice !== null && typeof toolChoice === 'object' && toolChoice.type === 'programmatic_tool_calling')) { + const tools = [ + ...(payload.tools ?? []), + ...(Array.isArray(payload.input) ? payload.input.flatMap(item => item.type === 'additional_tools' || item.type === 'tool_search_output' ? item.tools : []) : []), + ]; + if (tools.some(hasProgrammaticCaller) || (toolChoice !== null && typeof toolChoice === 'object' && toolChoice.type === 'programmatic_tool_calling')) { throw new TranslateInputException('Programmatic OpenAI Responses tooling cannot be translated.'); } - if (payload.tools?.some(hasDeferredTool) === true) { + if (tools.some(hasDeferredTool)) { throw new TranslateInputException('Deferred OpenAI Responses tooling cannot be translated.'); } }; diff --git a/packages/translate/src/protocols/openai-responses/as-target/input-from.ts b/packages/translate/src/protocols/openai-responses/as-target/input-from.ts index 96c9f5ccc4..1673e1696c 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/input-from.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/input-from.ts @@ -236,7 +236,7 @@ export const openaiResponsesHeaderFromIR = (request: IRInputRequestHeader): Omit // This wire states a permitted subset natively, so the declarations stay // whole: narrowing them as well would also narrow what the model may see. const tools = request.tools; - const choice = tools === undefined || tools.length === 0 ? undefined : request.tool_choice; + const choice = tools === undefined || tools.length === 0 ? request.tool_choice === 'required' ? 'required' : undefined : request.tool_choice; return { ...openaiResponsesSettingsFromIR(settings), model: settings.model, From 03056baccf9bfeed24280b469783b1c99b6ecb61 Mon Sep 17 00:00:00 2001 From: Menci Date: Fri, 13 Nov 2026 01:29:21 +0800 Subject: [PATCH 49/66] fix(translate): preserve structured tool-result history Map Messages document results into formal IR files, retain unmodeled structured blocks as opaque JSON, preserve JSON null function arguments, and carry server-tool errors through the shared incomplete/is_error result state. --- ...to-openai-chat-completions-request_test.ts | 11 +++++++++ ...ssages-to-openai-responses-request_test.ts | 24 +++++++++++++++++++ ...tent-to-anthropic-messages-request_test.ts | 8 +++++++ .../anthropic-messages/as-source/input-to.ts | 8 +++++++ .../as-target/input-from.ts | 6 ++--- 5 files changed, 53 insertions(+), 4 deletions(-) diff --git a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts index dedbf0f5a4..fad54ceac4 100644 --- a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts @@ -10,6 +10,17 @@ import { assertEquals, assertFalse, assertRejects } from '@floway-dev/test-utils const buildWithTestSidecar = (payload: AnthropicMessagesPayload) => buildTargetPayloadFor(pairs.messagesToChat[0], pairs.messagesToChat[1], { ...payload, stream: true }, { sidecarCodec: createTestSidecarCodec() }); +test('unmodeled structured tool-result blocks retain main opaque JSON behavior', async () => { + const content = [{ type: 'browser_state', tabs: [] }]; + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [ + { role: 'assistant', content: [{ type: 'tool_use', id: 'c', name: 'browse', input: {} }] }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'c', content: content as never }] }, + ], + }); + assertEquals(result.messages.at(-1), { role: 'tool', tool_call_id: 'c', content: JSON.stringify(content) }); +}); + test('buildTargetPayload maps thinking.disabled to reasoning_effort none', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', diff --git a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts index 40fa097342..79e336b9a0 100644 --- a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts @@ -9,6 +9,30 @@ import { assertEquals, assertFalse, assertRejects } from '@floway-dev/test-utils const buildWithTestSidecar = (payload: AnthropicMessagesPayload) => buildTargetPayloadFor(pairs.messagesToResponses[0], pairs.messagesToResponses[1], { ...payload, stream: true }, { sidecarCodec: createTestSidecarCodec() }); +test('server tool errors become incomplete Responses results', async () => { + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [{ + role: 'assistant', content: [ + { type: 'server_tool_use', id: 's', name: 'web_search', input: {} }, + { type: 'web_search_tool_result', tool_use_id: 's', content: { type: 'web_search_tool_result_error', error_code: 'too_many_requests' } }, + ], + }], + }); + const output = result.input.find(item => item.type === 'function_call_output'); + assertEquals(output?.type === 'function_call_output' ? output.status : undefined, 'incomplete'); +}); + +test('document tool results enter the IR as files', async () => { + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [ + { role: 'assistant', content: [{ type: 'tool_use', id: 'c', name: 'read', input: {} }] }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'c', content: [{ type: 'document', source: { type: 'text', media_type: 'text/plain', data: 'hello' } }] as never }] }, + ], + }); + const output = result.input.find(item => item.type === 'function_call_output'); + assertEquals(output?.type === 'function_call_output' ? output.output : undefined, [{ type: 'input_file', file_data: 'data:text/plain;base64,aGVsbG8=' }]); +}); + test('keeps search result runs between the text and images around them', async () => { const first = { type: 'search_result' as const, source: 'https://example.com/1', title: 'First', content: [{ type: 'text' as const, text: 'one' }] }; const second = { ...first, source: 'https://example.com/2', title: 'Second' }; diff --git a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-request_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-request_test.ts index dfcebaacbf..e2cd8270e1 100644 --- a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-request_test.ts @@ -17,6 +17,14 @@ const buildWithTestSidecar = ( options: { fallbackMaxOutputTokens?: number }, ) => buildTargetPayloadFor(pairs.geminiToMessages[0], pairs.geminiToMessages[1], { ...payload, model }, { sidecarCodec: createTestSidecarCodec(), ...options }); +test('a ProtoJSON null function-call args value reaches Messages unchanged', async () => { + const result = await buildWithTestSidecar({ + contents: [{ role: 'model', parts: [{ functionCall: { name: 'f', args: null as never } }] }], + }, 'claude-test', noOptions); + const message = result.messages[0]; + assertEquals(message.role === 'assistant' && Array.isArray(message.content) && message.content[0]?.type === 'tool_use' ? message.content[0].input : undefined, null); +}); + test('buildTargetPayload preserves an empty thought as a thinking turn', async () => { const result = await buildWithTestSidecar({ contents: [{ role: 'model', parts: [{ text: '', thought: true }] }] }, 'claude-test', noOptions); assertEquals(result.messages, [{ role: 'assistant', content: [{ type: 'thinking', thinking: '', signature: '' }] }]); diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts index b9c0dfd4bb..5441d2999b 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts @@ -91,6 +91,9 @@ const userContentPart = ( const toolResultPart = (block: AnthropicMessagesToolResultBlock['content']): IRInputToolResultContentPart[] => { if (block === undefined) return []; if (typeof block === 'string') return [{ type: 'text', text: block }]; + if (block.some(part => part.type !== 'text' && part.type !== 'image' && part.type !== 'document' && part.type !== 'search_result')) { + return [{ type: 'text', text: JSON.stringify(block) }]; + } const parts: IRInputToolResultContentPart[] = []; const searchResults: AnthropicMessagesSearchResultBlock[] = []; // Only consecutive search results share an array, so text and images retain @@ -108,6 +111,7 @@ const toolResultPart = (block: AnthropicMessagesToolResultBlock['content']): IRI flushSearchResults(); if (part.type === 'text') parts.push({ type: 'text', text: part.text }); else if (part.type === 'image') parts.push({ type: 'image', image: imageMedia(part.source) }); + else if (part.type === 'document') parts.push({ type: 'file', file: documentMedia(part) }); else throw new TranslateInputException(`'${(part as { type: string }).type}' tool result content blocks are not supported.`); } flushSearchResults(); @@ -215,6 +219,10 @@ const assistantSegments = (message: Extract => { +const parseArguments = (argumentsJson: string): unknown => { // An out-of-range JSON number such as `1e400` parses to `Infinity` and would // serialize back as `null`. Parsing with raw-number preservation keeps the // caller's exact token. try { const parsed = parseJSONWithRawNumbers(argumentsJson); - return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) - ? parsed as Record - : { raw_arguments: argumentsJson }; + return parsed; } catch { return { raw_arguments: argumentsJson }; } From 2af06eb6ea81e8f6ceacede901fd50a7bb513f54 Mon Sep 17 00:00:00 2001 From: Menci Date: Fri, 13 Nov 2026 01:36:12 +0800 Subject: [PATCH 50/66] fix(translate): keep restored turns target-native Remove source-side call identities from restored turns and reserve their native wire names without rewriting them. Allocate call/result IDs before wire names, and pair legacy Chat calls only with the unique id-less result stating the same function name before modern matching. --- .../composition/request/call-ids_test.ts | 2 +- .../composition/request/tool-names_test.ts | 2 +- .../protocols/call-association_test.ts | 29 +++++---- .../as-target/legacy-calls_test.ts | 50 +++++++++++---- .../round-trip-request_test.ts | 6 +- packages/translate/src/composition/reading.ts | 12 +--- .../src/composition/request/tool-names.ts | 62 +++++++------------ .../anthropic-messages/as-source/input-to.ts | 3 - .../protocols/anthropic-messages/as-target.ts | 14 ++--- .../as-source/input-to.ts | 8 +-- .../as-source/input-to.ts | 9 --- .../openai-chat-completions/as-target.ts | 25 +++----- .../as-target/legacy-calls.ts | 39 ++++++------ .../openai-responses/as-source/input-to.ts | 5 -- 14 files changed, 113 insertions(+), 153 deletions(-) diff --git a/packages/translate/__tests__/composition/request/call-ids_test.ts b/packages/translate/__tests__/composition/request/call-ids_test.ts index d174723903..cb693fd783 100644 --- a/packages/translate/__tests__/composition/request/call-ids_test.ts +++ b/packages/translate/__tests__/composition/request/call-ids_test.ts @@ -14,7 +14,7 @@ const result = (name?: string, callId?: string): IRInputTurn => ({ kind: 'client', items: [{ type: 'tool_result', ...(name === undefined ? {} : { name }), ...(callId === undefined ? {} : { call_id: callId }), output: '' }], }); -const restored = (calls: readonly PendingCall[]): RestoredAssistantTurn => ({ kind: 'restored', callIdentities: [], turn: calls }); +const restored = (calls: readonly PendingCall[]): RestoredAssistantTurn => ({ kind: 'restored', turn: calls }); const restoredCalls = (turn: readonly PendingCall[]): readonly PendingCall[] => turn; const lastResult = (turns: ReturnType) => { diff --git a/packages/translate/__tests__/composition/request/tool-names_test.ts b/packages/translate/__tests__/composition/request/tool-names_test.ts index 73dc199c99..03ba056c99 100644 --- a/packages/translate/__tests__/composition/request/tool-names_test.ts +++ b/packages/translate/__tests__/composition/request/tool-names_test.ts @@ -5,7 +5,7 @@ import type { IRInputRequestHeader, IRInputTool, IRInputTurn } from '../../../sr import { TranslateInputException } from '../../../src/shared/input-error.ts'; import { assertThrows, assertEquals } from '@floway-dev/test-utils'; -const native: NativeCallNames = { ids: turn => turn, rename: turn => turn }; +const native: NativeCallNames = { names: turn => turn }; const request = (tools: readonly IRInputTool[]): IRInputRequestHeader => ({ settings: { model: 'm', stream: true }, tools }); diff --git a/packages/translate/__tests__/protocols/call-association_test.ts b/packages/translate/__tests__/protocols/call-association_test.ts index 3cc4db6237..3469d9ecdd 100644 --- a/packages/translate/__tests__/protocols/call-association_test.ts +++ b/packages/translate/__tests__/protocols/call-association_test.ts @@ -86,19 +86,19 @@ test.each([['chat', 'function'], ['chat', 'custom'], ['messages', 'function'], [ try { if (target === 'chat') { const trip = expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions(request, options)); - expect(trip.target.tools?.map(tool => tool.type === 'function' ? tool.function.name : tool.custom.name)).toEqual(['web_run_2', 'web_run']); + expect(trip.target.tools?.map(tool => tool.type === 'function' ? tool.function.name : tool.custom.name)).toEqual(['web_run_2', 'web_run_3']); expect(trip.target.tool_choice).toEqual({ type: 'allowed_tools', allowed_tools: { mode: 'required', tools: [{ type: 'function', function: { name: 'web_run_2' } }] } }); const calls = trip.target.messages.flatMap(message => message.role === 'assistant' ? message.tool_calls ?? [] : []); - expect(calls.map(item => item.type === 'function' ? item.function.name : item.custom.name)).toEqual(['web_run_2', 'web_run_2']); + expect(calls.map(item => item.type === 'function' ? item.function.name : item.custom.name)).toEqual(['web_run', 'web_run_2']); expect(trip.target.messages[0]).toMatchObject({ reasoning_content: 'native reasoning' }); const returned = await collectOpenAIResponsesProtocolEventsToResult(trip.events(iterate(chatFrames('web_run_2', args)))); expect(returned.output.find(item => item.type === 'function_call' || item.type === 'custom_tool_call')).toMatchObject({ type: kind === 'function' ? 'function_call' : 'custom_tool_call', name: 'run', namespace: 'web' }); } else { const trip = expectTrip(await routeOpenAIResponsesViaAnthropicMessages(request, options)); - expect(trip.target.tools?.map(tool => 'name' in tool ? tool.name : undefined)).toEqual(['web_run_2', 'web_run']); + expect(trip.target.tools?.map(tool => 'name' in tool ? tool.name : undefined)).toEqual(['web_run_2', 'web_run_3']); expect(trip.target.tool_choice).toEqual({ type: 'tool', name: 'web_run_2' }); const calls = trip.target.messages.flatMap(message => message.role === 'assistant' && Array.isArray(message.content) ? message.content.flatMap(block => block.type === 'tool_use' ? [block] : []) : []); - expect(calls.map(item => item.name)).toEqual(['web_run_2', 'web_run_2']); + expect(calls.map(item => item.name)).toEqual(['web_run', 'web_run_2']); expect(trip.target.messages[0].content).toContainEqual({ type: 'thinking', thinking: 'native reasoning', signature: 'native signature' }); expect(trip.target.messages[0].content).toContainEqual({ type: 'redacted_thinking', data: 'native encrypted blob' }); const returned = await collectOpenAIResponsesProtocolEventsToResult(trip.events(iterate(messagesFrames('web_run_2', args)))); @@ -111,19 +111,18 @@ test.each([['chat', 'function'], ['chat', 'custom'], ['messages', 'function'], [ } }); -test('native legacy Chat aliases are rebound without adding a wire call ID', async () => { +test('a native legacy Chat call consumes the unique id-less Gemini result of the same name', async () => { const options = { sidecarCodec: createTestSidecarCodec() }; - const first = expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'm', stream: true, input: 'start', tools: [group('function')] }, options)); - const result = await collectOpenAIResponsesProtocolEventsToResult(first.events(iterate(chatFrames('web_run', '{}', true)))); - const call = result.output.find(item => item.type === 'function_call'); - if (call === undefined) throw new Error('Expected the public legacy call.'); - const replay = expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions({ - model: 'm', stream: true, tools: [group('function'), { type: 'function', name: 'web_run', parameters: { type: 'object', properties: {} } }], - input: [...toOpenAIResponsesInputItems(result.output), { type: 'function_call_output', call_id: call.call_id, output: 'ok' }], + const first = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions({ model: 'm', contents: [{ role: 'user', parts: [{ text: 'start' }] }] }, options)); + const result = await collectGeminiGenerateContentProtocolEventsToResult(first.events(iterate(chatFrames('legacy', '{}', true)))); + const native = result.candidates?.[0]?.content; + if (native === undefined) throw new Error('Expected the public legacy call.'); + const replay = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions({ + model: 'm', contents: [native, { role: 'user', parts: [{ functionResponse: { name: 'legacy', response: { ok: true } } }] }], }, options)); - expect(replay.target.messages[0]).toMatchObject({ role: 'assistant', function_call: { name: 'web_run_2', arguments: '{}' }, reasoning_content: 'native reasoning' }); + expect(replay.target.messages[0]).toMatchObject({ role: 'assistant', function_call: { name: 'legacy', arguments: '{}' }, reasoning_content: 'native reasoning' }); expect(replay.target.messages[0]).not.toHaveProperty('tool_calls'); - expect(replay.target.messages[1]).toEqual({ role: 'function', name: 'web_run_2', content: 'ok' }); + expect(replay.target.messages[1]).toEqual({ role: 'function', name: 'legacy', content: '{"ok":true}' }); }); test.each(['chat', 'messages', 'responses'] as const)('Gemini id-less results answer the complete native and ordinary %s history in FIFO order', async target => { @@ -215,7 +214,7 @@ test('Chat source custom identity renames native Messages calls independently fr messages: [result.choices[0].message, { role: 'tool', tool_call_id: 'native', content: 'ok' }, { role: 'assistant', content: null, tool_calls: [{ type: 'custom', id: 'later', custom: { name: 'edit', input: 'later' } }] }], }, options)); const calls = replay.target.messages.flatMap(message => message.role === 'assistant' && Array.isArray(message.content) ? message.content.flatMap(block => block.type === 'tool_use' ? [block] : []) : []); - expect(calls.map(call => call.name)).toEqual(['edit_2', 'edit_2']); + expect(calls.map(call => call.name)).toEqual(['edit', 'edit_3']); expect(calls.map(call => call.input)).toEqual([{ input: 'raw' }, { input: 'later' }]); expect(replay.target.messages[0].content).toContainEqual({ type: 'thinking', thinking: 'native reasoning', signature: 'native signature' }); }); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts index 45feae7884..40f05f46f4 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts @@ -1,6 +1,7 @@ import { test } from 'vitest'; import type { RestoredAssistantTurn } from '../../../../src/composition/reading.ts'; +import { assignCallIds } from '../../../../src/composition/request/call-ids.ts'; import type { IRInputTurn } from '../../../../src/ir/input/types.ts'; import { chatLegacyCallsOf } from '../../../../src/protocols/openai-chat-completions/as-target/legacy-calls.ts'; import type { InputAssistantTurnFor } from '../../../../src/round-trip/input-assistant-turn.ts'; @@ -8,15 +9,23 @@ import { TranslateInputException } from '../../../../src/shared/input-error.ts'; import { assertEquals, assertThrows } from '@floway-dev/test-utils'; type LegacyTurn = InputAssistantTurnFor<'openaiChatCompletions'>; -const legacy = (name: string): RestoredAssistantTurn => ({ kind: 'restored', callIdentities: [], turn: { role: 'assistant', function_call: { name, arguments: '{}' } } }); -const modern = (callId: string): RestoredAssistantTurn => ({ kind: 'restored', callIdentities: [], turn: { role: 'assistant', tool_calls: [{ id: callId, type: 'function', function: { name: 'lookup', arguments: '{}' } }] } }); +const legacy = (name: string): RestoredAssistantTurn => ({ kind: 'restored', turn: { role: 'assistant', function_call: { name, arguments: '{}' } } }); +const modern = (callId: string): RestoredAssistantTurn => ({ kind: 'restored', turn: { role: 'assistant', tool_calls: [{ id: callId, type: 'function', function: { name: 'lookup', arguments: '{}' } }] } }); +const mixed = (): RestoredAssistantTurn => ({ + kind: 'restored', + turn: { + role: 'assistant', + function_call: { name: 'legacy', arguments: '{}' }, + tool_calls: [{ id: 'modern-id', type: 'function', function: { name: 'modern', arguments: '{}' } }], + }, +}); const message = (text: string): IRInputTurn => ({ kind: 'client', items: [{ type: 'message', role: 'user', content: [{ type: 'text', text }] }] }); const result = (callId: string): IRInputTurn => ({ kind: 'client', items: [{ type: 'tool_result', call_id: callId, output: '' }] }); +const namedResult = (name: string): IRInputTurn => ({ kind: 'client', items: [{ type: 'tool_result', name, output: '' }] }); -test('an answer naming no stated call is the one the legacy call gets', () => { - // The client invented this id because the wire stated none for the call. - const { callIds, names } = chatLegacyCallsOf([legacy('lookup'), result('client-invented')]); - assertEquals([...names.entries()], [['client-invented', 'lookup']]); +test('the unique id-less result naming the legacy function answers it', () => { + const { callIds, names } = chatLegacyCallsOf([legacy('lookup'), namedResult('lookup')]); + assertEquals([...names.values()], ['lookup']); assertEquals(callIds.size, 1); }); @@ -24,9 +33,8 @@ test('an answer naming a call the conversation states is left alone', () => { assertEquals(chatLegacyCallsOf([modern('native-1'), result('native-1')]).names.size, 0); }); -test('two answers naming no stated call is refused as ambiguous', () => { - // Neither can be told apart, so nothing says which answers the legacy call. - const twoAnswers: IRInputTurn = { kind: 'client', items: [{ type: 'tool_result', call_id: 'a', output: '' }, { type: 'tool_result', call_id: 'b', output: '' }] }; +test('two id-less answers naming the legacy function are refused as ambiguous', () => { + const twoAnswers: IRInputTurn = { kind: 'client', items: [{ type: 'tool_result', name: 'lookup', output: '' }, { type: 'tool_result', name: 'lookup', output: '' }] }; assertThrows(() => chatLegacyCallsOf([legacy('lookup'), twoAnswers]), TranslateInputException); }); @@ -35,12 +43,30 @@ test('a legacy call the next turn does not answer is refused', () => { assertThrows(() => chatLegacyCallsOf([legacy('lookup'), message('later')]), TranslateInputException); }); -test('a legacy call answered in the turn after it, after other client content, is accepted', () => { - const turn: IRInputTurn = { kind: 'client', items: [{ type: 'message', role: 'user', content: [{ type: 'text', text: 'chatter' }] }, { type: 'tool_result', call_id: 'client-invented', output: '' }] }; - assertEquals([...chatLegacyCallsOf([legacy('lookup'), turn]).names.entries()], [['client-invented', 'lookup']]); +test('a later uniquely named id-less result answers the legacy call', () => { + const turns = [legacy('lookup'), message('chatter'), namedResult('lookup')]; + assertEquals([...chatLegacyCallsOf(turns).names.values()], ['lookup']); }); test('a legacy call still awaiting its answer at the end is accepted', () => { // The conversation has not moved past the call, so nothing is missing yet. assertEquals(chatLegacyCallsOf([legacy('lookup')]).names.size, 0); }); + +test('legacy matching consumes its uniquely named id-less result before modern matching', () => { + const native = mixed(); + const results: IRInputTurn = { + kind: 'client', + items: [ + { type: 'tool_result', name: 'modern', output: 'MODERN' }, + { type: 'tool_result', name: 'legacy', output: 'LEGACY' }, + ], + }; + const legacy = chatLegacyCallsOf([native, results]); + const assigned = assignCallIds([native, results], turn => [ + { callId: legacy.callIds.get(turn)!, name: 'legacy' }, + { callId: 'modern-id', name: 'modern' }, + ]); + const client = assigned[1]; + assertEquals(client.kind === 'client' ? client.items.map(item => item.type === 'tool_result' ? item.call_id : undefined) : [], ['modern-id', legacy.callIds.get(native.turn)]); +}); diff --git a/packages/translate/__tests__/protocols/openai-responses/round-trip-request_test.ts b/packages/translate/__tests__/protocols/openai-responses/round-trip-request_test.ts index d117a76130..db4e68723f 100644 --- a/packages/translate/__tests__/protocols/openai-responses/round-trip-request_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/round-trip-request_test.ts @@ -52,7 +52,7 @@ it('keeps restored Chat history native while translating bare namespace calls', expect(result.target.messages).toEqual([ { role: 'system', content: 'instructions' }, - { ...targetTurn, tool_calls: targetTurn.tool_calls.map(call => ({ ...call, function: { ...call.function, name: 'web_run_2' } })) }, + targetTurn, { role: 'tool', tool_call_id: 'native', content: 'native result' }, { role: 'assistant', content: null, tool_calls: [{ type: 'function', id: 'bare', function: { name: 'web_run_2', arguments: '{}' } }] }, ]); @@ -76,8 +76,8 @@ it('keeps restored Messages history native while hoisting system and translating expect(result.target.system).toEqual([{ type: 'text', text: 'instructions', cache_control: { type: 'ephemeral' } }]); expect(result.target.messages).toEqual([ - { ...targetTurn, content: targetTurn.content.map(block => ({ ...block, name: 'web_run_2' })) }, - { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'native', content: 'native result', is_error: undefined }] }, + targetTurn, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'native', content: 'native result' }] }, { role: 'assistant', content: [{ type: 'tool_use', id: 'bare', name: 'web_run_2', input: {}, cache_control: { type: 'ephemeral' } }] }, ]); }); diff --git a/packages/translate/src/composition/reading.ts b/packages/translate/src/composition/reading.ts index 1117f7388b..81623a41d5 100644 --- a/packages/translate/src/composition/reading.ts +++ b/packages/translate/src/composition/reading.ts @@ -1,22 +1,16 @@ -import type { IRInputRequestHeader, IRInputToolSelector, IRInputTurn } from '../ir/input/types.ts'; - -export interface SourceCallIdentity extends IRInputToolSelector { - readonly call_id?: string; -} +import type { IRInputRequestHeader, IRInputTurn } from '../ir/input/types.ts'; export interface RestoredAssistantTurn { readonly kind: 'restored'; readonly turn: Turn; - readonly callIdentities: readonly SourceCallIdentity[]; } -/** Native turns authenticate sidecars; naming facts are read separately while restored turns bypass lazy IR conversion. */ +/** Native turns authenticate sidecars and bypass lazy IR conversion. */ export type SourceTurnSlot = | { readonly kind: 'client'; readonly toIRInputTurns: () => IRInputTurn[] } | { readonly kind: 'assistant'; readonly assistantTurn: AssistantTurn; - readonly readCallIdentities: () => readonly SourceCallIdentity[]; readonly toIRInputTurns: () => IRInputTurn[]; }; @@ -62,7 +56,7 @@ export const resolveReading = async ( append(turn.toIRInputTurns()); continue; } - turns.push({ kind: 'restored', turn: result, callIdentities: turn.readCallIdentities() }); + turns.push({ kind: 'restored', turn: result }); } return turns; }; diff --git a/packages/translate/src/composition/request/tool-names.ts b/packages/translate/src/composition/request/tool-names.ts index 1bc41fd6c5..c277c2f178 100644 --- a/packages/translate/src/composition/request/tool-names.ts +++ b/packages/translate/src/composition/request/tool-names.ts @@ -1,6 +1,7 @@ import type { IRInputRequestHeader, IRInputTool, IRInputToolChoice, IRInputToolSelector, IRInputTurn } from '../../ir/input/types.ts'; import { TranslateInputException } from '../../shared/input-error.ts'; -import type { RestoredAssistantTurn, SourceCallIdentity } from '../reading.ts'; +import type { RestoredAssistantTurn } from '../reading.ts'; +import type { IRInputTurnWithCallIds } from './call-ids.ts'; /** Records allocated custom-call names and their original identities for the target event reader. */ export interface ToolNameContext { @@ -15,35 +16,9 @@ const customNamesOf = (request: IRInputRequestHeader): ReadonlySet => const callableKey = ({ type, namespace, name }: IRInputToolSelector): string => JSON.stringify([type, namespace, name]); export interface NativeCallNames { - readonly ids: (turn: Turn) => readonly (string | undefined)[]; - readonly rename: (turn: Turn, names: readonly string[]) => Turn; + readonly names: (turn: Turn) => readonly string[]; } -const nativeCallIdentities = ( - turn: RestoredAssistantTurn, - native: NativeCallNames, -): readonly SourceCallIdentity[] => { - const calls = native.ids(turn.turn); - const remaining = new Set(turn.callIdentities); - const byId = new Map(turn.callIdentities.flatMap(identity => identity.call_id === undefined ? [] : [[identity.call_id, identity] as const])); - const matched = calls.map(callId => { - const identity = callId === undefined ? undefined : byId.get(callId); - if (identity !== undefined) remaining.delete(identity); - return identity; - }); - const unmatched = matched.flatMap((identity, index) => identity === undefined ? [index] : []); - // Legacy Chat names no id. Once modern ids are removed, the one remaining - // source call is its identity; no wire spelling or position invents a match. - if (unmatched.length === 1 && remaining.size === 1) { - const [identity] = remaining; - if (calls[unmatched[0]] === undefined || identity.call_id === undefined) matched[unmatched[0]] = identity; - } - return matched.map(identity => { - if (identity === undefined) throw new Error('Authenticated native call has no unique source identity.'); - return identity; - }); -}; - /** * States this target's request and conversation with wireNames callable names. * @@ -53,20 +28,31 @@ const nativeCallIdentities = ( * returned context carries is what the same spelling decided: which * declarations were freeform, and which identity each allocated name came from. */ -export const allocateToolNames = ( +export function allocateToolNames( + request: IRInputRequestHeader, + inputTurns: readonly (IRInputTurnWithCallIds | RestoredAssistantTurn)[], + native: NativeCallNames, +): { readonly request: IRInputRequestHeader; readonly turns: readonly (IRInputTurnWithCallIds | RestoredAssistantTurn)[]; readonly context: ToolNameContext }; +export function allocateToolNames( request: IRInputRequestHeader, inputTurns: readonly (IRInputTurn | RestoredAssistantTurn)[], native: NativeCallNames, -): { readonly request: IRInputRequestHeader; readonly turns: readonly (IRInputTurn | RestoredAssistantTurn)[]; readonly context: ToolNameContext } => { +): { readonly request: IRInputRequestHeader; readonly turns: readonly (IRInputTurn | RestoredAssistantTurn)[]; readonly context: ToolNameContext }; +export function allocateToolNames( + request: IRInputRequestHeader, + inputTurns: readonly (IRInputTurn | RestoredAssistantTurn)[], + native: NativeCallNames, +): { readonly request: IRInputRequestHeader; readonly turns: readonly (IRInputTurn | RestoredAssistantTurn)[]; readonly context: ToolNameContext } { const callables = [ ...(request.tools ?? []), - ...inputTurns.flatMap(turn => turn.kind === 'restored' ? turn.callIdentities : turn.items.flatMap(item => + ...inputTurns.flatMap(turn => turn.kind === 'restored' ? [] : turn.items.flatMap(item => item.type === 'function_call' || item.type === 'custom_tool_call' ? [{ type: item.type === 'function_call' ? 'function' as const : 'custom' as const, namespace: item.namespace, name: item.name }] : [])), ]; + const nativeNames = new Set(inputTurns.flatMap(turn => turn.kind === 'restored' ? native.names(turn.turn) : [])); const reserved = new Set(callables.flatMap(callable => callable.namespace === undefined ? [callable.name] : [])); - const wireNames = assignToolNames(callables, reserved); + const wireNames = assignToolNames(callables, reserved, nativeNames); const originalToolNames = new Map>(); for (const callable of callables) { const wireName = wireNames.get(callableKey(callable))!; @@ -104,10 +90,7 @@ export const allocateToolNames = ( request: allocatedRequest, context: { customToolNames: customNamesOf(allocatedRequest), originalToolNames }, turns: inputTurns.map((turn): IRInputTurn | RestoredAssistantTurn => { - if (turn.kind === 'restored') { - const names = nativeCallIdentities(turn, native).map(wireNameOf); - return names.length === 0 ? turn : { ...turn, turn: native.rename(turn.turn, names) }; - } + if (turn.kind === 'restored') return turn; if (turn.kind === 'assistant') { return { kind: 'assistant', @@ -119,7 +102,7 @@ export const allocateToolNames = ( return turn; }), }; -}; +} /** * Assigns names independently of tool choice. Both callable kinds become function @@ -134,8 +117,9 @@ export const allocateToolNames = ( const assignToolNames = ( callables: readonly IRInputToolSelector[], reserved: ReadonlySet, + occupied: ReadonlySet, ): Map => { - const taken = new Set(reserved); + const taken = new Set([...reserved, ...occupied]); const nextSuffixes = new Map(); const assigned = new Map(); const bareNames = new Set(); @@ -143,7 +127,7 @@ const assignToolNames = ( const { namespace, name } = callable; const key = callableKey(callable); if (assigned.has(key)) continue; - if (namespace === undefined && !bareNames.has(name)) { + if (namespace === undefined && !bareNames.has(name) && !occupied.has(name)) { bareNames.add(name); assigned.set(key, name); continue; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts index 5441d2999b..1ed1429f26 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts @@ -423,9 +423,6 @@ export const readAnthropicMessagesRequest = (payload: AnthropicMessagesPayload): turns.push({ kind: 'assistant', assistantTurn: turn.items, - readCallIdentities: () => turn.items.flatMap(message => typeof message.content === 'string' ? [] : message.content.flatMap(block => block.type === 'tool_use' || block.type === 'server_tool_use' - ? [{ type: 'function' as const, name: block.name, call_id: block.id }] - : [])), toIRInputTurns: () => anthropicMessagesTurnToIR(turn, firstMessageIndex), }); continue; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target.ts b/packages/translate/src/protocols/anthropic-messages/as-target.ts index 308b7e71eb..297b1f9ecd 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target.ts @@ -22,18 +22,12 @@ export const anthropicMessagesTargetProtocol: TargetProtocol< write: (request: IRInputRequestHeader, turns: readonly (IRInputTurn | RestoredAssistantTurn>)[], options: TranslateOptions) => { // This wire addresses a callable by one bare name, so every grouped // declaration and call is stated under the flat spelling it computes. - const flattened = allocateToolNames(request, turns, { - ids: turn => typeof turn.content === 'string' ? [] : turn.content.flatMap(block => block.type === 'tool_use' ? [block.id] : []), - rename: (turn, names) => { - let index = 0; - return { - ...turn, - content: typeof turn.content === 'string' ? turn.content : turn.content.map(block => block.type === 'tool_use' ? { ...block, name: names[index++]! } : block), - }; - }, + const assigned = assignCallIds(turns, callsOf); + const flattened = allocateToolNames(request, assigned, { + names: turn => typeof turn.content === 'string' ? [] : turn.content.flatMap(block => block.type === 'tool_use' ? [block.name] : []), }); return { - payload: anthropicMessagesPayloadFromSegments(flattened.request, assignCallIds(flattened.turns, callsOf), { + payload: anthropicMessagesPayloadFromSegments(flattened.request, flattened.turns, { fallbackMaxOutputTokens: options.fallbackMaxOutputTokens, }), // Both facts come from the same flattening that produced the request, so diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts index bd3eb90b66..a7378f2cfa 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts @@ -218,13 +218,7 @@ export const readGeminiGenerateContentRequest = (payload: GeminiGenerateContentR contentIndex += turn.items.length; const toIRInputTurns = () => turnToIRTurns(turn, at); if (turn.role !== 'assistant') return { kind: 'client', toIRInputTurns }; - return { - kind: 'assistant', assistantTurn: turn.items, toIRInputTurns, - readCallIdentities: () => turn.items.flatMap(content => (content.parts ?? []).flatMap(part => part.functionCall === undefined ? [] : [{ - type: 'function' as const, name: part.functionCall.name, - ...(part.functionCall.id === undefined ? {} : { call_id: part.functionCall.id }), - }])), - }; + return { kind: 'assistant', assistantTurn: turn.items, toIRInputTurns }; }; return { diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts index 3549c0cfbd..741d35ed44 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts @@ -324,15 +324,6 @@ export const readOpenAIChatCompletionsRequest = (payload: OpenAIChatCompletionsP ? { kind: 'assistant' as const, assistantTurn: turn.items[0], - readCallIdentities: () => { - const message = turn.items[0]; - return [ - ...(message.function_call == null ? [] : [{ type: 'function' as const, name: message.function_call.name, call_id: context.legacyIds[at]! }]), - ...(message.tool_calls ?? []).map(call => call.type === 'custom' - ? { type: 'custom' as const, name: call.custom.name, call_id: call.id } - : { type: 'function' as const, name: call.function.name, call_id: call.id }), - ]; - }, toIRInputTurns: () => [openaiChatCompletionsTurnToIR(turn, context, at)], } : { kind: 'client' as const, toIRInputTurns: () => [openaiChatCompletionsTurnToIR(turn, context, at)] }; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target.ts b/packages/translate/src/protocols/openai-chat-completions/as-target.ts index 42fcb4fa65..6fdc27756b 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target.ts @@ -34,29 +34,18 @@ export const openaiChatCompletionsTargetProtocol: TargetProtocol< // This wire addresses a callable by one bare name, so every grouped // declaration and call is stated under the flat spelling it computes. write: (request, turns) => { - const flattened = allocateToolNames(request, turns, { - ids: turn => [ - ...(turn.function_call == null ? [] : [undefined]), - ...(turn.tool_calls ?? []).map(call => call.id), + const legacy = chatLegacyCallsOf(turns); + const assigned = assignCallIds(turns, turn => callsOf(turn, legacy.callIds)); + const flattened = allocateToolNames(request, assigned, { + names: turn => [ + ...(turn.function_call == null ? [] : [turn.function_call.name]), + ...(turn.tool_calls ?? []).map(call => call.type === 'function' ? call.function.name : call.custom.name), ], - rename: (turn, names) => { - let index = 0; - return { - ...turn, - ...(turn.function_call == null ? {} : { function_call: { ...turn.function_call, name: names[index++]! } }), - ...(turn.tool_calls === undefined ? {} : { - tool_calls: turn.tool_calls.map(call => call.type === 'function' - ? { ...call, function: { ...call.function, name: names[index++]! } } - : { ...call, custom: { ...call.custom, name: names[index++]! } }), - }), - }; - }, }); - const legacy = chatLegacyCallsOf(flattened.turns); return { payload: openaiChatCompletionsPayloadFromSegments( flattened.request, - assignCallIds(flattened.turns, turn => callsOf(turn, legacy.callIds)), + flattened.turns, legacy.names, ), context: flattened.context, diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts index b0fcd0e245..a469380036 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts @@ -1,5 +1,5 @@ import type { RestoredAssistantTurn } from '../../../composition/reading.ts'; -import type { IRInputTurn } from '../../../ir/input/types.ts'; +import type { IRInputToolResultItem, IRInputTurn } from '../../../ir/input/types.ts'; import type { InputAssistantTurnFor } from '../../../round-trip/input-assistant-turn.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; @@ -15,7 +15,7 @@ interface ChatLegacyCalls { readonly names: ReadonlyMap; } -/** Every call id this conversation states, which no legacy answer can name. */ +/** Every call id this conversation states, which an internal legacy pairing id must avoid. */ const statedCallIds = ( turns: readonly (IRInputTurn | RestoredAssistantTurn)[], ): ReadonlySet => { @@ -33,9 +33,9 @@ const statedCallIds = ( }; /** - * Native legacy function_call has no ID. Match its result within the following client turn by - * eliminating stated modern call IDs; id-less results follow pending-call order. - * Require one unambiguous answer before another round begins. A tail call may remain pending. + * Native legacy function_call has no ID. It consumes the unique later id-less + * result that states the same function name before modern calls consume the + * remaining results. A tail call may remain pending. * Internal pairing IDs never change the legacy wire's name-based result correlation. */ export const chatLegacyCallsOf = ( @@ -45,33 +45,30 @@ export const chatLegacyCallsOf = ( const claimed = new Set(stated); const callIds = new Map(); const names = new Map(); + const consumed = new Set(); let allocated = 0; for (const [index, piece] of turns.entries()) { if (piece.kind !== 'restored') continue; const name = piece.turn.function_call?.name; if (name === undefined) continue; - const next = turns[index + 1]; - const results = next?.kind === 'client' - ? next.items.flatMap(item => item.type === 'tool_result' ? [item] : []) - : []; - const unnamed = results.filter(item => item.call_id === undefined || !stated.has(item.call_id)); - if (unnamed.length > 1) { - throw new TranslateInputException(`Cannot translate the answer to the legacy call '${name}': the turn after it states ${unnamed.length} results that name no call of this conversation, so nothing says which one answers it.`); + const candidates = turns.slice(index + 1).flatMap(turn => turn.kind !== 'client' ? [] : turn.items.flatMap(item => + item.type === 'tool_result' && item.call_id === undefined && item.name === name && !consumed.has(item) ? [item] : [])); + if (candidates.length > 1) { + throw new TranslateInputException(`Cannot translate the answer to the legacy call '${name}': ${candidates.length} id-less results state that function name.`); } - const [answer] = unnamed; + const [answer] = candidates; if (answer === undefined) { if (index < turns.length - 1) { - throw new TranslateInputException(`Cannot translate the legacy call '${name}': the turn after it does not answer it, and a wire that states no id for the call cannot say which later answer means it.`); + throw new TranslateInputException(`Cannot translate the legacy call '${name}': no unique id-less result states that function name.`); } continue; } - let callId = answer.call_id; - if (callId === undefined) { - do { - allocated += 1; - callId = `legacy_call_${allocated}`; - } while (claimed.has(callId)); - } + consumed.add(answer); + let callId: string; + do { + allocated += 1; + callId = `legacy_call_${allocated}`; + } while (claimed.has(callId)); claimed.add(callId); callIds.set(piece.turn, callId); names.set(callId, name); diff --git a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts index b7efd88779..9762afca2c 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts @@ -375,11 +375,6 @@ export const readOpenAIResponsesRequest = (source: OpenAIResponsesRequestPayload ? { kind: 'assistant' as const, assistantTurn: turn.items, - readCallIdentities: () => turn.items.flatMap(item => item.type === 'function_call' || item.type === 'custom_tool_call' ? [{ - type: item.type === 'function_call' ? 'function' as const : 'custom' as const, - name: item.name, call_id: item.call_id, - ...(item.namespace === undefined ? {} : { namespace: item.namespace }), - }] : []), toIRInputTurns: () => openaiResponsesTurnToIR(turn), } : { kind: 'client' as const, toIRInputTurns: () => openaiResponsesTurnToIR(turn) }), From ebc762a30dc87e96172ef8c86d3fb1a7d88c5895 Mon Sep 17 00:00:00 2001 From: Menci Date: Fri, 13 Nov 2026 01:39:50 +0800 Subject: [PATCH 51/66] fix(translate): keep Gemini thought visibility source-side Stop translating includeThoughts into an upstream reasoning-summary request. Record complete target reasoning for sidecar replay while emitting plaintext thought text only when the Gemini source requested it, using a separate visible digest so authenticated replay retains the original reasoning. --- .../as-source/native-reassembly_test.ts | 11 +++--- .../gemini-thinking-round-trip_test.ts | 38 +++++++++++++++++++ ...ontent-to-openai-responses-request_test.ts | 12 +++--- .../gemini-generate-content/as-source.ts | 5 ++- .../as-source/events.ts | 25 +++++++++--- .../as-source/input-to.ts | 4 -- 6 files changed, 74 insertions(+), 21 deletions(-) create mode 100644 packages/translate/__tests__/protocols/gemini-thinking-round-trip_test.ts 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 75f32524c6..e79e08132b 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 @@ -52,9 +52,10 @@ test.each([2, 32])('reads only the ending candidate for %i interleaved choices', sourceTurnDigest: await computeGeminiGenerateContentTurnDigest([{ role: 'model', parts: [{ text: `answer-${index}` }] }]), }); } - expect(createGeminiGenerateContentEventAccumulator).toHaveBeenCalledTimes(1); - const creation = vi.mocked(createGeminiGenerateContentEventAccumulator).mock.results[0]; - if (creation.type !== 'return') throw new Error('Expected the native accumulator'); - expect(creation.value.result).not.toHaveBeenCalled(); - expect(creation.value.candidate).toHaveBeenCalledTimes(count); + expect(createGeminiGenerateContentEventAccumulator).toHaveBeenCalledTimes(2); + for (const creation of vi.mocked(createGeminiGenerateContentEventAccumulator).mock.results) { + if (creation.type !== 'return') throw new Error('Expected a Gemini accumulator'); + expect(creation.value.result).not.toHaveBeenCalled(); + expect(creation.value.candidate).toHaveBeenCalledTimes(count); + } }); diff --git a/packages/translate/__tests__/protocols/gemini-thinking-round-trip_test.ts b/packages/translate/__tests__/protocols/gemini-thinking-round-trip_test.ts new file mode 100644 index 0000000000..f88540f2e0 --- /dev/null +++ b/packages/translate/__tests__/protocols/gemini-thinking-round-trip_test.ts @@ -0,0 +1,38 @@ +import { expect, test } from 'vitest'; + +import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { expectTrip } from '../trip.ts'; +import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; +import { collectGeminiGenerateContentProtocolEventsToResult } from '@floway-dev/protocols/gemini-generate-content'; +import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; +import { routeGeminiGenerateContentViaOpenAIChatCompletions } from '@floway-dev/translate'; + +const upstream = async function* (): AsyncGenerator> | ReturnType> { + yield eventFrame({ + id: 'chat', model: 'm', object: 'chat.completion.chunk', created: 1, + choices: [{ index: 0, delta: { role: 'assistant', reasoning_content: 'secret' }, finish_reason: null }], + }); + yield eventFrame({ + id: 'chat', model: 'm', object: 'chat.completion.chunk', created: 1, + choices: [{ index: 0, delta: { content: 'answer' }, finish_reason: 'stop' }], + }); + yield doneFrame(); +}; + +test.each([false, true])('includeThoughts=%s controls plaintext without changing sidecar replay', async includeThoughts => { + const options = { sidecarCodec: createTestSidecarCodec() }; + const trip = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions({ + model: 'm', contents: [{ role: 'user', parts: [{ text: 'question' }] }], + generationConfig: { thinkingConfig: { includeThoughts } }, + }, options)); + const result = await collectGeminiGenerateContentProtocolEventsToResult(trip.events(upstream())); + const content = result.candidates?.[0]?.content; + if (content === undefined) throw new Error('Expected translated Gemini content.'); + const thoughts = content.parts?.filter(part => part.thought === true) ?? []; + expect(thoughts.map(part => part.text)).toContain(includeThoughts ? 'secret' : ''); + expect(thoughts.map(part => part.text)).not.toContain(includeThoughts ? '' : 'secret'); + expect(content.parts?.some(part => part.thoughtSignature !== undefined)).toBe(true); + + const replay = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions({ model: 'm', contents: [content] }, options)); + expect(replay.target.messages).toContainEqual(expect.objectContaining({ role: 'assistant', content: 'answer', reasoning_content: 'secret' })); +}); diff --git a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts index ad6be7921b..4b9812bbd0 100644 --- a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts @@ -37,7 +37,7 @@ test('buildTargetPayload translates LOW to reasoning.effort', async () => { generationConfig: { thinkingConfig: { thinkingLevel: 'LOW', includeThoughts: true } }, }, 'gpt-test'); - assertEquals(request.reasoning, { effort: 'low', summary: 'detailed' }); + assertEquals(request.reasoning, { effort: 'low' }); }); test('buildTargetPayload forwards an empty thinkingLevel verbatim', async () => { @@ -46,7 +46,7 @@ test('buildTargetPayload forwards an empty thinkingLevel verbatim', async () => generationConfig: { thinkingConfig: { thinkingLevel: '', includeThoughts: true } }, }, 'gpt-test'); - assertEquals(request.reasoning, { effort: '', summary: 'detailed' }); + assertEquals(request.reasoning, { effort: '' }); }); test('buildTargetPayload preserves an explicit empty thinkingLevel beside a budget', async () => { @@ -55,7 +55,7 @@ test('buildTargetPayload preserves an explicit empty thinkingLevel beside a budg generationConfig: { thinkingConfig: { thinkingBudget: 2048, thinkingLevel: '', includeThoughts: true } }, }, 'gpt-test'); - assertEquals(request.reasoning, { effort: '', summary: 'detailed' }); + assertEquals(request.reasoning, { effort: '' }); }); test('buildTargetPayload maps instructions and multimodal user input without defaults', async () => { @@ -247,7 +247,7 @@ test('buildTargetPayload maps generation config, JSON schema, and reasoning cont name: 'gemini_response', schema, }, }, - reasoning: { effort: 'medium', summary: 'detailed' }, + reasoning: { effort: 'medium' }, }); assertEquals((await buildWithTestSidecar({ contents: [], generationConfig: { responseMimeType: 'application/json' } }, 'gpt-test')).text, { format: { type: 'json_object' } }); @@ -261,7 +261,7 @@ test('buildTargetPayload never invents reasoning.context from Gemini generateCon }, }, 'gpt-test'); - assertEquals(result.reasoning, { effort: 'high', summary: 'detailed' }); + assertEquals(result.reasoning, { effort: 'high' }); assertEquals(result.reasoning?.context, undefined); assertFalse('context' in (result.reasoning ?? {})); }); @@ -534,5 +534,5 @@ test('an orphaned tool choice is not stated', async () => { test.each([undefined, 1024, -1])('preserves includeThoughts without a thinking level for budget %s', async thinkingBudget => { const request = await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget, includeThoughts: true } } }, 'm'); - assertEquals(request.reasoning, { ...(thinkingBudget === 1024 ? { effort: 'low' } : {}), summary: 'detailed' }); + assertEquals(request.reasoning, thinkingBudget === 1024 ? { effort: 'low' } : undefined); }); diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source.ts b/packages/translate/src/protocols/gemini-generate-content/as-source.ts index 4d44a5d34a..47638e6540 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source.ts @@ -16,5 +16,8 @@ export const geminiGenerateContentSourceProtocol: SourceProtocol< collectCandidates: collectGeminiGenerateContentReplayContents, }, error: restateGeminiGenerateContentError, - writeEvents: (_payload, records, roundTrip) => writeGeminiGenerateContentEvents(records, { roundTrip }), + writeEvents: (payload, records, roundTrip) => writeGeminiGenerateContentEvents(records, { + includeThoughts: payload.generationConfig?.thinkingConfig?.includeThoughts === true, + roundTrip, + }), }; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts index 69591f76e0..4789cf9361 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts @@ -49,6 +49,7 @@ interface TextSegment { } interface GeminiGenerateContentEventWriterOptions extends SourceEventWriterOptions<'geminiGenerateContent'> { + includeThoughts?: boolean; parseToolArguments?: (text: string) => IRJSONObject; } @@ -67,6 +68,7 @@ export const writeGeminiGenerateContentEvents = async function* ( const pendingText = new Map(); const sourceLengths = new Map(); const nativeAccumulator = createGeminiGenerateContentEventAccumulator(); + const visibleAccumulator = createGeminiGenerateContentEventAccumulator(); type GeminiContentState = NonNullable[number]>; type PartsState = NonNullable; const partStates = new Map(); @@ -80,13 +82,24 @@ export const writeGeminiGenerateContentEvents = async function* ( const appendPendingContent = (content: EmittedFieldState, text: string, spans: readonly TextProvenanceSpan[]): void => replayTextProvenance(text, spans, (value, textFragmentIds) => appendEmittedField(options.roundTrip.builder, content, value, textFragmentIds)); + const frameOf = (event: GeminiGenerateContentResult): EventFrame => + eventFrame({ responseId: metadata.id, modelVersion: metadata.model, ...event }); const emit = (event: GeminiGenerateContentResult): EventFrame => { - const frame = eventFrame({ responseId: metadata.id, modelVersion: metadata.model, ...event }); + const frame = frameOf(event); nativeAccumulator.accept(frame.event); + visibleAccumulator.accept(frame.event); return frame; }; const emitPart = (choice: number, part: GeminiGenerateContentPart): EventFrame => emit({ candidates: [{ index: choice, content: { role: 'model', parts: [part] } }] }); + const renderPart = (choice: number, part: GeminiGenerateContentPart): EventFrame => { + if (part.thought !== true || options.includeThoughts === true) return emitPart(choice, part); + const full = frameOf({ candidates: [{ index: choice, content: { role: 'model', parts: [part] } }] }); + nativeAccumulator.accept(full.event); + const visible = frameOf({ candidates: [{ index: choice, content: { role: 'model', parts: [{ text: '', thought: true }] } }] }); + visibleAccumulator.accept(visible.event); + return visible; + }; const addUnit = (choice: number, unit: GenerateContentUnit): void => { let queue = queues.get(choice); if (queue === undefined) { queue = createIRUnitQueue(); queues.set(choice, queue); } @@ -284,8 +297,8 @@ export const writeGeminiGenerateContentEvents = async function* ( if (wait) break; queue.dropHead(); unit.opened = true; - if (part !== undefined) yield emitPart(choice, part); - for (const pendingPart of drainPendingText(choice, collector)) yield emitPart(choice, pendingPart); + if (part !== undefined) yield renderPart(choice, part); + for (const pendingPart of drainPendingText(choice, collector)) yield renderPart(choice, pendingPart); if (closed) units.delete(unit.path); } if (queue.head === undefined) queues.delete(choice); @@ -395,7 +408,7 @@ export const writeGeminiGenerateContentEvents = async function* ( }); } else { const part = appendText(choice, record.path, record.value, kind === 'thought', record.referencePieceIds); - if (part !== undefined) yield emitPart(choice, part); + if (part !== undefined) yield renderPart(choice, part); } } } @@ -409,10 +422,12 @@ export const writeGeminiGenerateContentEvents = async function* ( if (record.type === 'choice_end') { const nativeCandidate = nativeAccumulator.candidate(record.choice); const targetTurn: GeminiGenerateContentInputAssistantTurn = [{ role: 'model', parts: nativeCandidate?.content?.parts ?? [] }]; + const visibleCandidate = visibleAccumulator.candidate(record.choice); + const sourceTurn: GeminiGenerateContentInputAssistantTurn = [{ role: 'model', parts: visibleCandidate?.content?.parts ?? [] }]; const stateParts = partsState(record.choice); stateParts.length = targetTurn[0].parts?.length ?? 0; const assistantState: GeminiGenerateContentInputAssistantTurnState = [{ parts: stateParts }]; - const sourceTurnDigest = await computeGeminiGenerateContentTurnDigest(targetTurn); + const sourceTurnDigest = await computeGeminiGenerateContentTurnDigest(sourceTurn); const data = await options.roundTrip.encodeSidecar(record.choice, sourceTurnDigest, targetTurn, assistantState, collectGeminiGenerateContentTextPlacements(targetTurn)); yield emitPart(record.choice, createGeminiGenerateContentSidecarPart(data)); yield emit({ candidates: [terminalCandidate(record, state, collector)] }); diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts index a7378f2cfa..7409a1877d 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts @@ -271,10 +271,6 @@ const geminiGenerateContentSettingsToIR = (payload: GeminiGenerateContentRequest // A negative budget asks the model to choose its own and states no number. ...(budget === undefined || budget <= 0 ? {} : { reasoning_budget: budget }), ...(budget === undefined || budget >= 0 ? {} : { reasoning_budget_dynamic: true }), - // GenerateContent states readable reasoning as a boolean; the neutral - // spelling names how much of it the caller wants. A request that asked for - // no reasoning at all has nothing to summarize. - ...(effort !== 'none' && thinkingConfig?.includeThoughts === true ? { reasoning_summary: 'detailed' } : {}), }; }; From dcaae603503e5376d903ccf251a1ad372157c257 Mon Sep 17 00:00:00 2001 From: Menci Date: Fri, 13 Nov 2026 01:42:07 +0800 Subject: [PATCH 52/66] test(translate): align result projection regressions Pin the restored no-tool choice behavior and distinguish malformed argument fallback from valid non-object JSON arguments after the input IR fixes. --- ...ponses-to-anthropic-messages-request_test.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts index fd6adf6a7e..b9c6fcaca3 100644 --- a/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts @@ -149,7 +149,7 @@ test('buildTargetPayload wires OpenAI Responses tooling guards', async () => { test('buildTargetPayload accepts null tool_choice', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, tool_choice: null }); - assertEquals(result.target.tool_choice, { type: 'auto', disable_parallel_tool_use: false }); + assertEquals(result.target.tool_choice, undefined); }); test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetPayload rejects input_file in %s', async type => { @@ -745,7 +745,8 @@ test('buildTargetPayload preserves out-of-range numeric tool arguments as raw JS assertEquals(JSON.stringify(toolUse.input), '{"value":1e400}'); }); -test.each(['not json', '[1,2,3]'])('buildTargetPayload keeps raw_arguments fallback for invalid or non-object tool arguments', async argumentsJson => { +test('buildTargetPayload keeps raw_arguments fallback for malformed tool arguments', async () => { + const argumentsJson = 'not json'; const result = await buildWithTestSidecar({ ...minimalPayload, input: [{ type: 'function_call', call_id: 'call_1', name: 'tool', arguments: argumentsJson, status: 'completed' }], @@ -759,6 +760,18 @@ test.each(['not json', '[1,2,3]'])('buildTargetPayload keeps raw_arguments fallb assertEquals(toolUse.input, { raw_arguments: argumentsJson }); }); +test('buildTargetPayload preserves a valid non-object JSON argument value', async () => { + const result = await buildWithTestSidecar({ + ...minimalPayload, + input: [{ type: 'function_call', call_id: 'call_1', name: 'tool', arguments: '[1,2,3]', status: 'completed' }], + }); + const assistantMessage = result.target.messages[0]; + assert(assistantMessage.role === 'assistant' && Array.isArray(assistantMessage.content)); + const toolUse = assistantMessage.content[0]; + assert(toolUse.type === 'tool_use'); + assertEquals(toolUse.input, [1, 2, 3]); +}); + test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetPayload maps multimodal %s into tool_result image and text blocks', async type => { const result = await buildWithTestSidecar({ model: 'claude-test', From e71cbef57a684fb5ab7939f2123f49faa1dae963 Mon Sep 17 00:00:00 2001 From: Menci Date: Fri, 13 Nov 2026 01:54:48 +0800 Subject: [PATCH 53/66] test(translate): align fixtures with finalized input semantics Make reasoning-output fixtures request Gemini thought plaintext explicitly, assert restored turns remain target-native, retain ProtoJSON null arguments, and update gateway instruction expectations after the finalized IR behavior. --- .../chat/anthropic-messages/attempt_test.ts | 12 +-- .../protocols/chat-legacy-round-trip_test.ts | 93 +------------------ .../__tests__/protocols/lifecycle_test.ts | 4 +- ...ntent-to-anthropic-messages-events_test.ts | 2 +- ...-to-openai-chat-completions-events_test.ts | 2 +- ...content-to-openai-responses-events_test.ts | 2 +- .../pairs/media-values-request_test.ts | 2 +- .../protocols/round-trip-receive_test.ts | 8 +- .../as-target/legacy-calls.ts | 4 +- 9 files changed, 21 insertions(+), 108 deletions(-) diff --git a/packages/gateway/__tests__/data-plane/chat/anthropic-messages/attempt_test.ts b/packages/gateway/__tests__/data-plane/chat/anthropic-messages/attempt_test.ts index 95f1854bee..916089d1c1 100644 --- a/packages/gateway/__tests__/data-plane/chat/anthropic-messages/attempt_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/anthropic-messages/attempt_test.ts @@ -288,7 +288,7 @@ test('generate lets the target system-to-developer rewrite take precedence over assertEquals(input[1], { type: 'message', role: 'developer', content: 'inline instructions' }); }); -test('generate translate-to-responses branch rewrites a multi-block system prefix to developer', async () => { +test('generate translate-to-responses keeps top-level instructions apart from rewritten inline system messages', async () => { installRepo(); const observedBodies: Omit[] = []; const callOpenAIResponses = vi.fn(async (_model, body): Promise => { @@ -340,13 +340,9 @@ test('generate translate-to-responses branch rewrites a multi-block system prefi if (!observedBody) throw new Error('expected observed OpenAI Responses body'); const input = observedBody.input; if (!Array.isArray(input)) throw new Error('expected OpenAI Responses input array'); - assertEquals(input[0], { - type: 'message', - role: 'developer', - content: [{ type: 'input_text', text: 'base A' }, { type: 'input_text', text: 'base B' }], - }); - assertEquals(input[1], { type: 'message', role: 'user', content: 'hello' }); - assertEquals(input[2], { type: 'message', role: 'developer', content: 'inline instructions' }); + assertEquals(observedBody.instructions, 'base Abase B'); + assertEquals(input[0], { type: 'message', role: 'user', content: 'hello' }); + assertEquals(input[1], { type: 'message', role: 'developer', content: 'inline instructions' }); }); test('countTokens proxies the upstream response as a plain result', async () => { 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 bc616fe768..50ea3d42dc 100644 --- a/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts +++ b/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts @@ -7,9 +7,7 @@ import { collectAnthropicMessagesProtocolEventsToResult, type AnthropicMessagesP import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { collectGeminiGenerateContentProtocolEventsToResult, type GeminiGenerateContentRequest } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIChatCompletionsAssistantDeltaEx, OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; -import { toOpenAIResponsesInputItems } from '@floway-dev/protocols/openai-responses'; -import { collectOpenAIResponsesProtocolEventsToResult, type OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; -import { routeAnthropicMessagesViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIChatCompletions, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; +import { routeAnthropicMessagesViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIChatCompletions } 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({ @@ -56,49 +54,6 @@ const assertNativeHistory = ( expect(assistants[2].tool_calls).toBeUndefined(); }; -test('Messages returns matching legacy function results while modern calls remain tool messages across turns', async () => { - const context = { sidecarCodec: createTestSidecarCodec() }; - const payload: AnthropicMessagesPayload = { - model: 'public-model', max_tokens: 64, stream: true, - messages: [{ role: 'user', content: 'Call lookup' }], - tools: [{ name: 'lookup', input_schema: { type: 'object', properties: { x: { type: 'integer' } } } }], - }; - for (const [index, mode] of (['legacy', 'modern', 'legacy'] as const).entries()) { - const trip = expectTrip(await 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 = expectTrip(await routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)); - assertNativeHistory(replay.target, 'lookup', ['result-0', 'result-1', 'result-2']); -}); - -test('Responses binds public namespaced call IDs to the native legacy function name', async () => { - const context = { sidecarCodec: createTestSidecarCodec() }; - const payload: OpenAIResponsesRequestPayloadEx = { - model: 'public-model', stream: true, input: [{ type: 'message', role: 'user', content: 'Call lookup' }], - tools: [{ type: 'namespace', name: 'public', description: 'Public lookup tools', tools: [{ type: 'function', name: 'lookup', parameters: { type: 'object', properties: { x: { type: 'integer' } } } }] }], - }; - let nativeName = ''; - for (const [index, mode] of (['legacy', 'modern', 'legacy'] as const).entries()) { - const trip = expectTrip(await 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; - expect(nativeName).not.toBe('lookup'); - const result = await collectOpenAIResponsesProtocolEventsToResult(trip.events(iterate(nativeFrames(mode, nativeName)))); - const call = result.output.find(item => item.type === 'function_call'); - if (call === undefined) throw new Error('Expected a public Responses function invocation'); - expect(call.name).toBe('lookup'); - expect(call.namespace).toBe('public'); - if (!Array.isArray(payload.input)) throw new Error('Expected item-array history'); - payload.input.push(...toOpenAIResponsesInputItems(result.output), { type: 'function_call_output', call_id: call.call_id, output: `result-${index}` }); - } - const replay = expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions(payload, context)); - assertNativeHistory(replay.target, nativeName, ['result-0', 'result-1', 'result-2']); -}); - test('Gemini matches ID-less legacy responses by public name and explicit modern responses by ID', async () => { const context = { sidecarCodec: createTestSidecarCodec() }; const payload: GeminiGenerateContentRequest = { @@ -118,7 +73,6 @@ test('Gemini matches ID-less legacy responses by public name and explicit modern const replay = expectTrip(await 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 = { sidecarCodec: createTestSidecarCodec() }; const source: GeminiGenerateContentRequest = { model: 'model', contents: [{ role: 'user', parts: [{ text: 'Call lookup' }] }] }; @@ -151,23 +105,8 @@ test('Gemini keeps modern IDs separate when a vendor stream includes both call f expect(results[0]).toMatchObject({ role: 'tool', tool_call_id: 'native-modern' }); expect(results[1]).toMatchObject({ role: 'function', name: 'lookup' }); }); - -test('restored legacy results retain the ordinary Responses input_file rejection', async () => { - const context = { sidecarCodec: createTestSidecarCodec() }; - const trip = expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'public-model', stream: true, 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 = toOpenAIResponsesInputItems(result.output); - for (const history of [input, input.filter(item => item.type !== 'reasoning')]) { - await expect(routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'public-model', stream: true, input: [...history, output] }, context)).resolves.toMatchObject({ type: 'input-error', message: expect.stringContaining('Cannot translate file tool output to OpenAI Chat Completions.') }); - } -}); - -test('a legacy call answered after another assistant turn is refused', async () => { - // A legacy call states no id, so nothing names it across an assistant turn - // that follows it: the answer could pair with either turn's calls. +test('a source-ID-only result cannot answer a restored legacy call', async () => { + // The restored native call states no id, and source-side ids are not consulted. const context = { sidecarCodec: createTestSidecarCodec() }; const payload: AnthropicMessagesPayload = { model: 'public-model', max_tokens: 64, stream: true, @@ -184,29 +123,5 @@ test('a legacy call answered after another assistant turn is refused', async () { role: 'assistant', content: 'meanwhile' }, { role: 'user', content: [{ type: 'tool_result', tool_use_id: call.id, content: 'late' }] }, ); - await expect(routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)).resolves.toMatchObject({ type: 'input-error', message: expect.stringContaining('does not answer it') }); -}); - -test('a legacy call and its answer share one client turn without being refused', async () => { - // The result may sit anywhere inside the turn that follows the call: it is - // the turn, not the item's position, that pairs them. - const context = { sidecarCodec: createTestSidecarCodec() }; - for (const order of ['before', 'after'] as const) { - const payload: AnthropicMessagesPayload = { - model: 'public-model', max_tokens: 64, stream: true, - messages: [{ role: 'user', content: 'Call lookup' }], - tools: [{ name: 'lookup', input_schema: { type: 'object', properties: { x: { type: 'integer' } } } }], - }; - const trip = expectTrip(await routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)); - const result = await collectAnthropicMessagesProtocolEventsToResult(trip.events(iterate(nativeFrames('legacy', 'lookup')))); - const call = result.content.find(block => block.type === 'tool_use'); - if (call === undefined) throw new Error('Expected a public Messages tool invocation'); - const answer: { role: 'user'; content: string } | { role: 'user'; content: [{ type: 'tool_result'; tool_use_id: string; content: string }] } = order === 'before' - ? { role: 'user', content: [{ type: 'tool_result', tool_use_id: call.id, content: 'the-answer' }] } - : { role: 'user', content: 'chatter' }; - const rest = order === 'before' ? { role: 'user' as const, content: 'chatter' } : { role: 'user' as const, content: [{ type: 'tool_result' as const, tool_use_id: call.id, content: 'the-answer' }] }; - payload.messages.push({ role: 'assistant', content: result.content }, answer, rest); - const replay = expectTrip(await routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)); - expect(replay.target.messages.filter(message => message.role === 'function')).toEqual([{ role: 'function', name: 'lookup', content: 'the-answer' }]); - } + await expect(routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)).resolves.toMatchObject({ type: 'input-error', message: expect.stringContaining('no unique id-less result') }); }); diff --git a/packages/translate/__tests__/protocols/lifecycle_test.ts b/packages/translate/__tests__/protocols/lifecycle_test.ts index aeac038d33..10f5db2932 100644 --- a/packages/translate/__tests__/protocols/lifecycle_test.ts +++ b/packages/translate/__tests__/protocols/lifecycle_test.ts @@ -205,7 +205,7 @@ test('Messages closes an earlier concurrent block immediately and the frontier b ]); }); -test.each([['ChatCompletions', writeOpenAIChatCompletionsEvents], ['Messages', writeAnthropicMessagesEvents], ['GenerateContent', writeGeminiGenerateContentEvents]] as const)('%s flattens reasoning content and summary in arrival order', async (name, writer) => { +test.each([['ChatCompletions', writeOpenAIChatCompletionsEvents], ['Messages', writeAnthropicMessagesEvents], ['GenerateContent', (records: AsyncIterable) => writeGeminiGenerateContentEvents(records, { includeThoughts: true })]] as const)('%s flattens reasoning content and summary in arrival order', async (name, writer) => { const item = { type: 'reasoning', id: 'r', summary: [] }; const source = [responseStart, add(0, item), eventFrame({ type: 'response.content_part.added', output_index: 0, content_index: 0, part: { type: 'reasoning_text', text: '' } } as any), eventFrame({ type: 'response.reasoning_text.delta', output_index: 0, content_index: 0, delta: 'C' } as any), eventFrame({ type: 'response.reasoning_summary_part.added', output_index: 0, summary_index: 0, part: { type: 'summary_text', text: '' } } as any), eventFrame({ type: 'response.reasoning_summary_text.delta', output_index: 0, summary_index: 0, delta: 'S' } as any), eventFrame({ type: 'response.completed', response: response([{ ...item, content: [{ type: 'reasoning_text', text: 'C' }], summary: [{ type: 'summary_text', text: 'S' }] }]) } as any)]; const frames: any[] = await collect(writer(readOpenAIResponsesEvents(iterate(source)))); @@ -263,7 +263,7 @@ test('Responses closes only the addressed text part while a refusal remains open test('GenerateContent retains reasoning arrival order while it waits for a prior tool', async () => { const reasoning = { type: 'reasoning', id: 'r', content: [{ type: 'reasoning_text', text: 'C' }], summary: [] }; const source = [responseStart, add(0, call(0)), args(0, '{"x":'), add(1, reasoning), eventFrame({ type: 'response.reasoning_summary_part.added', output_index: 1, summary_index: 0, part: { type: 'summary_text', text: 'S' } } as any), eventFrame({ type: 'response.output_item.done', output_index: 1, item: { ...reasoning, summary: [{ type: 'summary_text', text: 'S' }] } } as any), args(0, '1}'), endCall(0, '{"x":1}'), eventFrame({ type: 'response.completed', response: response([call(0, '{"x":1}', 'completed'), { ...reasoning, summary: [{ type: 'summary_text', text: 'S' }] }]) } as any)]; - const frames = await collect(writeGeminiGenerateContentEvents(readOpenAIResponsesEvents(iterate(source)))); + const frames = await collect(writeGeminiGenerateContentEvents(readOpenAIResponsesEvents(iterate(source)), { includeThoughts: true })); const parts = frames.flatMap(frame => (frame.event as any).candidates?.flatMap((candidate: any) => candidate.content?.parts ?? []) ?? []); expect(parts[0].functionCall.id).toBe('call_0'); expect(parts.filter((part: any) => part.thought).map((part: any) => part.text).join('')).toBe('CS'); diff --git a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-events_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-events_test.ts index e7a374fb98..22ba640384 100644 --- a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-events_test.ts +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-events_test.ts @@ -3,5 +3,5 @@ import { testOutputTranslation } from '../fixtures.ts'; testOutputTranslation('gemini-generate-content', 'anthropic-messages', (frames, codec) => { const [source, target] = pairs.geminiToMessages; - return buildSourceEventsFor(source, target, emptyPayload.gemini, { sidecarCodec: codec }, frames); + return buildSourceEventsFor(source, target, { ...emptyPayload.gemini, generationConfig: { thinkingConfig: { includeThoughts: true } } }, { sidecarCodec: codec }, frames); }); diff --git a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-events_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-events_test.ts index c0246ae1fe..e6dbf6cfe2 100644 --- a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-events_test.ts +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-events_test.ts @@ -3,5 +3,5 @@ import { testOutputTranslation } from '../fixtures.ts'; testOutputTranslation('gemini-generate-content', 'openai-chat-completions', (frames, codec) => { const [source, target] = pairs.geminiToChat; - return buildSourceEventsFor(source, target, emptyPayload.gemini, { sidecarCodec: codec }, frames); + return buildSourceEventsFor(source, target, { ...emptyPayload.gemini, generationConfig: { thinkingConfig: { includeThoughts: true } } }, { sidecarCodec: codec }, frames); }); diff --git a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-events_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-events_test.ts index e64ab42a21..24a0eaadbe 100644 --- a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-events_test.ts +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-events_test.ts @@ -3,5 +3,5 @@ import { testOutputTranslation } from '../fixtures.ts'; testOutputTranslation('gemini-generate-content', 'openai-responses', (frames, codec) => { const [source, target] = pairs.geminiToResponses; - return buildSourceEventsFor(source, target, emptyPayload.gemini, { sidecarCodec: codec }, frames); + return buildSourceEventsFor(source, target, { ...emptyPayload.gemini, generationConfig: { thinkingConfig: { includeThoughts: true } } }, { sidecarCodec: codec }, frames); }); diff --git a/packages/translate/__tests__/protocols/pairs/media-values-request_test.ts b/packages/translate/__tests__/protocols/pairs/media-values-request_test.ts index c94e3c6498..4c39139922 100644 --- a/packages/translate/__tests__/protocols/pairs/media-values-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/media-values-request_test.ts @@ -1,11 +1,11 @@ import { expect, test } from 'vitest'; -import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; import { routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses, routeOpenAIResponsesViaOpenAIChatCompletions, } from '../../../src/index.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; const options = { sidecarCodec: createTestSidecarCodec(), fallbackMaxOutputTokens: 16 }; diff --git a/packages/translate/__tests__/protocols/round-trip-receive_test.ts b/packages/translate/__tests__/protocols/round-trip-receive_test.ts index 1153ea3b68..eb0bda567a 100644 --- a/packages/translate/__tests__/protocols/round-trip-receive_test.ts +++ b/packages/translate/__tests__/protocols/round-trip-receive_test.ts @@ -90,7 +90,7 @@ test('hydrates a native JSON field from a replayed GenerateContent argument cand sourceTurnDigest: await hashRoundTripContent(JSON.stringify(checkMaterial)), }); const trip = expectTrip(await routeGeminiGenerateContentViaAnthropicMessages({ model: 'model', 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: 'lookup', input: args }] }]); + expect(trip.target.messages).toEqual([{ role: 'assistant', content: [{ type: 'tool_use', id: 'native-call', name: 'native-tool', input: args }] }]); }); test('exposes an authenticated missing reference as an internal error after replay verification', async () => { @@ -147,10 +147,10 @@ test('preserves ordinary GenerateContent null-argument handling', async () => { { type: 'function_call', name: 'lookup', arguments: 'null' }, ]); const messagesTrip = expectTrip(await routeGeminiGenerateContentViaAnthropicMessages(payload, context)); - // Messages needs an argument object, so a `null` argument list keeps its raw - // text rather than becoming `input: null`. + // ProtoJSON null remains the source value instead of being wrapped in an + // invented argument object. expect(messagesTrip.target.messages).toMatchObject([ - { role: 'assistant', content: [{ type: 'tool_use', name: 'lookup', input: { raw_arguments: 'null' } }] }, + { role: 'assistant', content: [{ type: 'tool_use', name: 'lookup', input: null }] }, ]); }); diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts index a469380036..800e88af88 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts @@ -51,7 +51,9 @@ export const chatLegacyCallsOf = ( if (piece.kind !== 'restored') continue; const name = piece.turn.function_call?.name; if (name === undefined) continue; - const candidates = turns.slice(index + 1).flatMap(turn => turn.kind !== 'client' ? [] : turn.items.flatMap(item => + const nextLegacyOffset = turns.slice(index + 1).findIndex(turn => turn.kind === 'restored' && turn.turn.function_call !== undefined); + const end = nextLegacyOffset < 0 ? turns.length : index + 1 + nextLegacyOffset; + const candidates = turns.slice(index + 1, end).flatMap(turn => turn.kind !== 'client' ? [] : turn.items.flatMap(item => item.type === 'tool_result' && item.call_id === undefined && item.name === name && !consumed.has(item) ? [item] : [])); if (candidates.length > 1) { throw new TranslateInputException(`Cannot translate the answer to the legacy call '${name}': ${candidates.length} id-less results state that function name.`); From be96b77b0302e5ccd72eb376090b813d4b92dde7 Mon Sep 17 00:00:00 2001 From: Menci Date: Fri, 13 Nov 2026 02:11:55 +0800 Subject: [PATCH 54/66] fix(translate): preserve input semantics across targets Keep historical roles and empty turns intact, preserve nullable schema fields and required tool choices at the correct target boundaries, and associate legacy Chat results before modern calls.\n\nRepresent Messages documents formally in tool results, move data URL decoding to its target owner, and remove restored-turn name reservations and duplicate Gemini visibility state. --- .gitignore | 2 - .../protocols/src/anthropic-messages/index.ts | 22 +++++--- .../composition/request/tool-names_test.ts | 31 ++++++----- .../as-target/data-url_test.ts} | 20 ++++---- .../protocols/call-association_test.ts | 6 +-- .../gemini-thinking-round-trip_test.ts | 36 ++++++++----- .../as-target/legacy-calls_test.ts | 15 +++++- ...ssages-to-openai-responses-request_test.ts | 51 +++++++++++++++---- .../pairs/media-values-request_test.ts | 16 ++++++ ...ions-to-anthropic-messages-request_test.ts | 5 ++ ...etions-to-openai-responses-request_test.ts | 18 ++++--- ...nses-to-anthropic-messages-request_test.ts | 37 +++++++++----- ...to-openai-chat-completions-request_test.ts | 27 ++++++++++ packages/translate/src/composition/reading.ts | 2 +- .../src/composition/request/call-ids.ts | 3 +- .../src/composition/request/tool-names.ts | 35 ++++--------- packages/translate/src/ir/input/types.ts | 4 +- .../anthropic-messages/as-source/input-to.ts | 30 ++++++----- .../protocols/anthropic-messages/as-target.ts | 4 +- .../anthropic-messages/as-target/data-url.ts} | 28 +++++----- .../as-target/input-from.ts | 22 ++++---- .../as-source/events.ts | 10 +--- .../as-source/input-to.ts | 4 +- .../as-source/conversation-turns.ts | 7 ++- .../as-source/input-to.ts | 7 ++- .../openai-chat-completions/as-target.ts | 9 +--- .../as-target/input-from.ts | 4 +- .../as-target/legacy-calls.ts | 10 ++-- .../as-target/segmented-request.ts | 2 +- .../as-source/conversation-turns.ts | 9 ++-- .../openai-responses/as-source/input-to.ts | 23 +++++---- .../openai-responses/as-target/input-from.ts | 29 +++-------- 32 files changed, 302 insertions(+), 226 deletions(-) rename packages/translate/__tests__/{ir/input/media_test.ts => protocols/anthropic-messages/as-target/data-url_test.ts} (53%) rename packages/translate/src/{ir/input/media.ts => protocols/anthropic-messages/as-target/data-url.ts} (62%) diff --git a/.gitignore b/.gitignore index 3646a7f651..d9a3c228b4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,8 +12,6 @@ apps/web/.react-router/ .env .pnpm-store/ /wrangler.jsonc -.tmp/ -.research/ # Local node-target dev runtime (sqlite db + uploaded files). One under the # repo root for `pnpm run dev:node` invoked at the root, one under the package diff --git a/packages/protocols/src/anthropic-messages/index.ts b/packages/protocols/src/anthropic-messages/index.ts index 42755faee3..136789b600 100644 --- a/packages/protocols/src/anthropic-messages/index.ts +++ b/packages/protocols/src/anthropic-messages/index.ts @@ -73,10 +73,10 @@ export type AnthropicMessagesImageSource = | { type: 'file'; file_id: string }; export interface AnthropicMessagesImageBlock { type: 'image'; source: AnthropicMessagesImageSource; cache_control?: AnthropicMessagesCacheControl | null; transformations?: { oversized_image?: 'downsize' | 'error' } | null } -// The snapshot pins a binary document's `media_type` to `'application/pdf'`, -// while the wire treats it as a MIME string: an upstream decides what it -// accepts. Translation forwards the caller's media type verbatim, which the -// snapshot cannot express without claiming the bytes are a PDF they are not. +// The official snapshot pins a binary document's `media_type` to +// `'application/pdf'`. Floway keeps this extension open so translation can +// forward caller-provided MIME values for upstream validation without +// relabelling non-PDF bytes as PDF. export type AnthropicMessagesDocumentBlockEx = { type: 'document'; source: Omit, 'media_type'> & { media_type: string }; @@ -90,9 +90,17 @@ export type AnthropicMessagesSearchResultBlock = Native.SearchResultBlockParam | export type AnthropicMessagesWebSearchResultBlock = Native.WebSearchResultBlock | Beta.BetaWebSearchResultBlock; -export type AnthropicMessagesToolResultContentBlock = Exclude, string>[number]; +type AnthropicMessagesNativeToolResultContentBlock = + | Exclude, string>[number] + | Exclude, string>[number]; -export type AnthropicMessagesToolResultBlock = Native.ToolResultBlockParam | Beta.BetaToolResultBlockParam; +export type AnthropicMessagesToolResultContentBlock = AnthropicMessagesNativeToolResultContentBlock | AnthropicMessagesDocumentBlockEx; + +export type AnthropicMessagesToolResultBlockEx = Omit & { + content?: string | AnthropicMessagesToolResultContentBlock[]; +}; + +export type AnthropicMessagesToolResultBlock = Native.ToolResultBlockParam | Beta.BetaToolResultBlockParam | AnthropicMessagesToolResultBlockEx; export type AnthropicMessagesToolUseBlock = Native.ToolUseBlock; export type AnthropicMessagesToolUseBlockParam = Native.ToolUseBlockParam | Beta.BetaToolUseBlockParam; @@ -145,7 +153,7 @@ export type AnthropicMessagesUserContentBlock = AnthropicMessagesInputContentBlo export type AnthropicMessagesAssistantContentBlock = Native.ContentBlock | Beta.BetaContentBlock; export type AnthropicMessagesAssistantInputContentBlock = Native.ContentBlockParam | Beta.BetaContentBlockParam; -export type AnthropicMessagesInputContentBlock = AnthropicMessagesAssistantInputContentBlock | AnthropicMessagesDocumentBlockEx; +export type AnthropicMessagesInputContentBlock = AnthropicMessagesAssistantInputContentBlock | AnthropicMessagesDocumentBlockEx | AnthropicMessagesToolResultBlockEx; export interface AnthropicMessagesUserMessage extends Omit { role: 'user'; diff --git a/packages/translate/__tests__/composition/request/tool-names_test.ts b/packages/translate/__tests__/composition/request/tool-names_test.ts index 03ba056c99..3a6e7f7198 100644 --- a/packages/translate/__tests__/composition/request/tool-names_test.ts +++ b/packages/translate/__tests__/composition/request/tool-names_test.ts @@ -1,17 +1,16 @@ import { test } from 'vitest'; -import { allocateToolNames, type NativeCallNames } from '../../../src/composition/request/tool-names.ts'; -import type { IRInputRequestHeader, IRInputTool, IRInputTurn } from '../../../src/ir/input/types.ts'; +import type { IRInputTurnWithCallIds } from '../../../src/composition/request/call-ids.ts'; +import { allocateToolNames } from '../../../src/composition/request/tool-names.ts'; +import type { IRInputRequestHeader, IRInputTool } from '../../../src/ir/input/types.ts'; import { TranslateInputException } from '../../../src/shared/input-error.ts'; import { assertThrows, assertEquals } from '@floway-dev/test-utils'; -const native: NativeCallNames = { names: turn => turn }; - const request = (tools: readonly IRInputTool[]): IRInputRequestHeader => ({ settings: { model: 'm', stream: true }, tools }); test('a grouped declaration keeps its pair when its namespace contains the flatten separator', () => { // Keep the full callable identity beside its allocated wire name; never reconstruct it by parsing that spelling. - const flattened = allocateToolNames(request([{ type: 'function', namespace: 'a.b', name: 'read', parameters: { type: 'object' } }]), [], native); + const flattened = allocateToolNames(request([{ type: 'function', namespace: 'a.b', name: 'read', parameters: { type: 'object' } }]), []); const flatName = flattened.context.originalToolNames.keys().next().value; assertEquals(typeof flatName, 'string'); @@ -19,7 +18,7 @@ test('a grouped declaration keeps its pair when its namespace contains the flatt }); test('a grouped declaration keeps its pair when its name contains the flatten separator', () => { - const flattened = allocateToolNames(request([{ type: 'function', namespace: 'files', name: 'read.one', parameters: { type: 'object' } }]), [], native); + const flattened = allocateToolNames(request([{ type: 'function', namespace: 'files', name: 'read.one', parameters: { type: 'object' } }]), []); const flatName = flattened.context.originalToolNames.keys().next().value; assertEquals(typeof flatName, 'string'); @@ -27,7 +26,7 @@ test('a grouped declaration keeps its pair when its name contains the flatten se }); test('custom declarations are reported under the flat names this wire writes', () => { - const flattened = allocateToolNames(request([{ type: 'custom', namespace: 'files', name: 'edit' }]), [], native); + const flattened = allocateToolNames(request([{ type: 'custom', namespace: 'files', name: 'edit' }]), []); // The reader that recognises these calls back sees the wire name, not the // client's, so the set has to be stated after flattening. @@ -36,11 +35,11 @@ test('custom declarations are reported under the flat names this wire writes', ( }); test('a call naming an undeclared group still gets a flat spelling and its pair', () => { - const turns: IRInputTurn[] = [{ + const turns: IRInputTurnWithCallIds[] = [{ kind: 'assistant', items: [{ type: 'function_call', call_id: 'c1', namespace: 'ghost', name: 'run', arguments: '{}' }], }]; - const flattened = allocateToolNames(request([]), turns, native); + const flattened = allocateToolNames(request([]), turns); const assistantTurn = flattened.turns[0]; assertEquals(assistantTurn.kind, 'assistant'); @@ -58,7 +57,7 @@ test('a selector naming a group the request never declared is a caller error', ( settings: { model: 'm', stream: true }, tools: [{ type: 'function', namespace: 'files', name: 'read', parameters: { type: 'object' } }], tool_choice: { type: 'function', namespace: 'ghost', name: 'run' }, - }, [], native), + }, []), TranslateInputException, 'undeclared grouped tool', ); @@ -68,7 +67,7 @@ test('distinct structured identities with the same dotted spelling get distinct const flattened = allocateToolNames(request([ { type: 'function', namespace: 'a', name: 'b.c', parameters: { type: 'object' } }, { type: 'function', namespace: 'a.b', name: 'c', parameters: { type: 'object' } }, - ]), [], native); + ]), []); assertEquals(flattened.request.tools?.map(tool => tool.name), ['a_b_c', 'a_b_c_2']); assertEquals([...flattened.context.originalToolNames.values()], [{ namespace: 'a', name: 'b.c' }, { namespace: 'a.b', name: 'c' }]); }); @@ -76,13 +75,13 @@ test('distinct structured identities with the same dotted spelling get distinct test('a selector naming a group this request does not declare is refused', () => { // No declaration or history call allocates this grouped identity, so the selector cannot name an upstream callable. assertThrows( - () => allocateToolNames({ settings: { model: 'm', stream: true }, tools: [{ type: 'function', name: 'web.run', parameters: { type: 'object' } }], tool_choice: { type: 'function', name: 'run', namespace: 'web' } }, [], native), + () => allocateToolNames({ settings: { model: 'm', stream: true }, tools: [{ type: 'function', name: 'web.run', parameters: { type: 'object' } }], tool_choice: { type: 'function', name: 'run', namespace: 'web' } }, []), TranslateInputException, ); }); test('a plain selector passes through untouched', () => { - const flattened = allocateToolNames({ settings: { model: 'm', stream: true }, tools: [{ type: 'function', name: 'plain', parameters: { type: 'object' } }], tool_choice: { type: 'function', name: 'plain' } }, [], native); + const flattened = allocateToolNames({ settings: { model: 'm', stream: true }, tools: [{ type: 'function', name: 'plain', parameters: { type: 'object' } }], tool_choice: { type: 'function', name: 'plain' } }, []); assertEquals(flattened.request.tool_choice, { type: 'function', name: 'plain' }); }); @@ -92,15 +91,15 @@ test.each([undefined, 'files'])('allocates distinct names per callable kind in n { type: 'custom', namespace, name: 'edit' }, { type: 'function', name: 'edit_2', parameters: { type: 'object' } }, ]; - const turns: IRInputTurn[] = [{ + const turns: IRInputTurnWithCallIds[] = [{ kind: 'assistant', items: [ { type: 'function_call', namespace, name: 'edit', call_id: 'f', arguments: '{}' }, { type: 'custom_tool_call', namespace, name: 'edit', call_id: 'c', input: 'raw' }, { type: 'custom_tool_call', namespace, name: 'edit', call_id: 'c2', input: 'again' }, ], }]; - const baseline = allocateToolNames({ ...request(tools), tool_choice: 'auto' }, turns, native); - const selected = allocateToolNames({ ...request(tools), tool_choice: { type: 'allowed_tools', mode: 'required', tools: [{ type: 'custom', namespace, name: 'edit' }] } }, turns, native); + const baseline = allocateToolNames({ ...request(tools), tool_choice: 'auto' }, turns); + const selected = allocateToolNames({ ...request(tools), tool_choice: { type: 'allowed_tools', mode: 'required', tools: [{ type: 'custom', namespace, name: 'edit' }] } }, turns); const first = namespace === undefined ? 'edit' : 'files_edit'; const second = namespace === undefined ? 'edit_3' : 'files_edit_2'; assertEquals(baseline.request.tools?.map(tool => tool.name), [first, second, 'edit_2']); diff --git a/packages/translate/__tests__/ir/input/media_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-target/data-url_test.ts similarity index 53% rename from packages/translate/__tests__/ir/input/media_test.ts rename to packages/translate/__tests__/protocols/anthropic-messages/as-target/data-url_test.ts index b096ae2d11..34017691df 100644 --- a/packages/translate/__tests__/ir/input/media_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-target/data-url_test.ts @@ -1,28 +1,26 @@ import { test } from 'vitest'; -import { mediaFromURL, parseDataURL } from '../../../src/ir/input/media.ts'; +import { parseDataURL } from '../../../../src/protocols/anthropic-messages/as-target/data-url.ts'; import { assertEquals } from '@floway-dev/test-utils'; -test.each([ - 'https://example.com/a.png', - 'data:image/png;charset=utf-8;base64,AAAA', - 'data:image/svg+xml,%3Csvg%2F%3E', -])('a URL remains exactly the source value %s', url => { - assertEquals(mediaFromURL(url), { source_kind: 'url', url }); -}); - -test('target-boundary data URL parsing retains media-type parameters', () => { +test('data URL parsing retains media-type parameters', () => { assertEquals(parseDataURL('data:text/csv;charset=utf-8;base64,YQ=='), { media_type: 'text/csv;charset=utf-8', data: 'YQ==', }); assertEquals(parseDataURL('data:;base64,AAAA'), { data: 'AAAA' }); + assertEquals(parseDataURL('data:;charset=utf-8,hi'), { + media_type: 'text/plain;charset=utf-8', data: 'aGk=', + }); }); -test('target-boundary data URL parsing encodes percent-escaped bytes', () => { +test('data URL parsing decodes percent-escaped bytes', () => { assertEquals(parseDataURL('data:image/svg+xml,%3Csvg%2F%3E'), { media_type: 'image/svg+xml', data: 'PHN2Zy8+', }); assertEquals(parseDataURL('data:text/plain;charset=iso-8859-1,%E9'), { media_type: 'text/plain;charset=iso-8859-1', data: '6Q==', }); + assertEquals(parseDataURL('data:text/plain;base64,SGk%3D'), { + media_type: 'text/plain', data: 'SGk=', + }); }); diff --git a/packages/translate/__tests__/protocols/call-association_test.ts b/packages/translate/__tests__/protocols/call-association_test.ts index 3469d9ecdd..341c67e96f 100644 --- a/packages/translate/__tests__/protocols/call-association_test.ts +++ b/packages/translate/__tests__/protocols/call-association_test.ts @@ -86,7 +86,7 @@ test.each([['chat', 'function'], ['chat', 'custom'], ['messages', 'function'], [ try { if (target === 'chat') { const trip = expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions(request, options)); - expect(trip.target.tools?.map(tool => tool.type === 'function' ? tool.function.name : tool.custom.name)).toEqual(['web_run_2', 'web_run_3']); + expect(trip.target.tools?.map(tool => tool.type === 'function' ? tool.function.name : tool.custom.name)).toEqual(['web_run_2', 'web_run']); expect(trip.target.tool_choice).toEqual({ type: 'allowed_tools', allowed_tools: { mode: 'required', tools: [{ type: 'function', function: { name: 'web_run_2' } }] } }); const calls = trip.target.messages.flatMap(message => message.role === 'assistant' ? message.tool_calls ?? [] : []); expect(calls.map(item => item.type === 'function' ? item.function.name : item.custom.name)).toEqual(['web_run', 'web_run_2']); @@ -95,7 +95,7 @@ test.each([['chat', 'function'], ['chat', 'custom'], ['messages', 'function'], [ expect(returned.output.find(item => item.type === 'function_call' || item.type === 'custom_tool_call')).toMatchObject({ type: kind === 'function' ? 'function_call' : 'custom_tool_call', name: 'run', namespace: 'web' }); } else { const trip = expectTrip(await routeOpenAIResponsesViaAnthropicMessages(request, options)); - expect(trip.target.tools?.map(tool => 'name' in tool ? tool.name : undefined)).toEqual(['web_run_2', 'web_run_3']); + expect(trip.target.tools?.map(tool => 'name' in tool ? tool.name : undefined)).toEqual(['web_run_2', 'web_run']); expect(trip.target.tool_choice).toEqual({ type: 'tool', name: 'web_run_2' }); const calls = trip.target.messages.flatMap(message => message.role === 'assistant' && Array.isArray(message.content) ? message.content.flatMap(block => block.type === 'tool_use' ? [block] : []) : []); expect(calls.map(item => item.name)).toEqual(['web_run', 'web_run_2']); @@ -214,7 +214,7 @@ test('Chat source custom identity renames native Messages calls independently fr messages: [result.choices[0].message, { role: 'tool', tool_call_id: 'native', content: 'ok' }, { role: 'assistant', content: null, tool_calls: [{ type: 'custom', id: 'later', custom: { name: 'edit', input: 'later' } }] }], }, options)); const calls = replay.target.messages.flatMap(message => message.role === 'assistant' && Array.isArray(message.content) ? message.content.flatMap(block => block.type === 'tool_use' ? [block] : []) : []); - expect(calls.map(call => call.name)).toEqual(['edit', 'edit_3']); + expect(calls.map(call => call.name)).toEqual(['edit', 'edit_2']); expect(calls.map(call => call.input)).toEqual([{ input: 'raw' }, { input: 'later' }]); expect(replay.target.messages[0].content).toContainEqual({ type: 'thinking', thinking: 'native reasoning', signature: 'native signature' }); }); diff --git a/packages/translate/__tests__/protocols/gemini-thinking-round-trip_test.ts b/packages/translate/__tests__/protocols/gemini-thinking-round-trip_test.ts index f88540f2e0..9b09e245be 100644 --- a/packages/translate/__tests__/protocols/gemini-thinking-round-trip_test.ts +++ b/packages/translate/__tests__/protocols/gemini-thinking-round-trip_test.ts @@ -1,6 +1,7 @@ import { expect, test } from 'vitest'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { iterate } from '../shared/iterables.ts'; import { expectTrip } from '../trip.ts'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; import { collectGeminiGenerateContentProtocolEventsToResult } from '@floway-dev/protocols/gemini-generate-content'; @@ -19,20 +20,27 @@ const upstream = async function* (): AsyncGenerator { +test('includeThoughts controls only visible plaintext', async () => { const options = { sidecarCodec: createTestSidecarCodec() }; - const trip = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions({ - model: 'm', contents: [{ role: 'user', parts: [{ text: 'question' }] }], - generationConfig: { thinkingConfig: { includeThoughts } }, - }, options)); - const result = await collectGeminiGenerateContentProtocolEventsToResult(trip.events(upstream())); - const content = result.candidates?.[0]?.content; - if (content === undefined) throw new Error('Expected translated Gemini content.'); - const thoughts = content.parts?.filter(part => part.thought === true) ?? []; - expect(thoughts.map(part => part.text)).toContain(includeThoughts ? 'secret' : ''); - expect(thoughts.map(part => part.text)).not.toContain(includeThoughts ? '' : 'secret'); - expect(content.parts?.some(part => part.thoughtSignature !== undefined)).toBe(true); + const runs = await Promise.all([false, true].map(async includeThoughts => { + const trip = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions({ + model: 'm', contents: [{ role: 'user', parts: [{ text: 'question' }] }], + generationConfig: { thinkingConfig: { includeThoughts } }, + }, options)); + const frames = []; + for await (const frame of trip.events(upstream())) frames.push(frame); + const result = await collectGeminiGenerateContentProtocolEventsToResult(iterate(frames)); + const content = result.candidates?.[0]?.content; + if (content === undefined) throw new Error('Expected translated Gemini content.'); + return { includeThoughts, target: trip.target, frames, content }; + })); - const replay = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions({ model: 'm', contents: [content] }, options)); - expect(replay.target.messages).toContainEqual(expect.objectContaining({ role: 'assistant', content: 'answer', reasoning_content: 'secret' })); + expect(runs[0].target).toEqual(runs[1].target); + expect(JSON.stringify(runs[0].frames)).not.toContain('secret'); + expect(JSON.stringify(runs[1].frames)).toContain('secret'); + for (const run of runs) { + expect(run.content.parts?.some(part => part.thoughtSignature !== undefined)).toBe(true); + const replay = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions({ model: 'm', contents: [run.content] }, options)); + expect(replay.target.messages).toContainEqual(expect.objectContaining({ role: 'assistant', content: 'answer', reasoning_content: 'secret' })); + } }); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts index 40f05f46f4..0d7d59ebee 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts @@ -66,7 +66,20 @@ test('legacy matching consumes its uniquely named id-less result before modern m const assigned = assignCallIds([native, results], turn => [ { callId: legacy.callIds.get(turn)!, name: 'legacy' }, { callId: 'modern-id', name: 'modern' }, - ]); + ], legacy.results); const client = assigned[1]; assertEquals(client.kind === 'client' ? client.items.map(item => item.type === 'tool_result' ? item.call_id : undefined) : [], ['modern-id', legacy.callIds.get(native.turn)]); }); + +test('legacy matching wins over an earlier pending modern call of the same name', () => { + const legacyTurn = legacy('lookup'); + const answer = namedResult('lookup'); + const turns = [modern('modern-id'), legacyTurn, answer]; + const legacyCalls = chatLegacyCallsOf(turns); + const assigned = assignCallIds(turns, turn => [ + ...(turn.tool_calls ?? []).map(call => ({ callId: call.id, name: call.type === 'function' ? call.function.name : call.custom.name })), + ...(turn.function_call == null ? [] : [{ callId: legacyCalls.callIds.get(turn)!, name: turn.function_call.name }]), + ], legacyCalls.results); + const client = assigned[2]; + assertEquals(client.kind === 'client' && client.items[0].type === 'tool_result' ? client.items[0].call_id : undefined, legacyCalls.callIds.get(legacyTurn.turn)); +}); diff --git a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts index 79e336b9a0..3c1eccb24d 100644 --- a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts @@ -33,6 +33,40 @@ test('document tool results enter the IR as files', async () => { assertEquals(output?.type === 'function_call_output' ? output.output : undefined, [{ type: 'input_file', file_data: 'data:text/plain;base64,aGVsbG8=' }]); }); +test('mixed tool-result blocks keep documents formal and unknown blocks opaque', async () => { + const unknown = { type: 'tool_reference' as const, tool_name: 'read' }; + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [ + { role: 'assistant', content: [{ type: 'tool_use', id: 'c', name: 'read', input: {} }] }, + { + role: 'user', + content: [{ + type: 'tool_result', tool_use_id: 'c', content: [ + { type: 'document', source: { type: 'text', media_type: 'text/plain', data: 'hello' } }, + unknown, + ], + }], + }, + ], + }); + const output = result.input.find(item => item.type === 'function_call_output'); + assertEquals(output?.type === 'function_call_output' ? output.output : undefined, [ + { type: 'input_file', file_data: 'data:text/plain;base64,aGVsbG8=' }, + { type: 'input_text', text: JSON.stringify(unknown) }, + ]); +}); + +test('inline text content documents enter the IR as text files', async () => { + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [{ + role: 'user', content: [{ type: 'document', source: { type: 'content', content: 'hello' } }], + }], + }); + assertEquals(result.input, [{ + type: 'message', role: 'user', content: [{ type: 'input_file', file_data: 'data:text/plain;base64,aGVsbG8=' }], + }]); +}); + test('keeps search result runs between the text and images around them', async () => { const first = { type: 'search_result' as const, source: 'https://example.com/1', title: 'First', content: [{ type: 'text' as const, text: 'one' }] }; const second = { ...first, source: 'https://example.com/2', title: 'Second' }; @@ -573,7 +607,7 @@ test('buildTargetPayload preserves in-array system text blocks as separate input }); }); -test('buildTargetPayload hoists a leading in-array system message and keeps later ones in place', async () => { +test('buildTargetPayload keeps in-array system messages in place', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -586,15 +620,14 @@ test('buildTargetPayload hoists a leading in-array system message and keeps late ], }); - // The leading run is what the request states before its first turn, so it is - // the prompt; a system message after a turn happened stays where it was. - assertEquals(result.instructions, 'mid-array A'); + assertEquals(result.instructions, undefined); const input = result.input as Array<{ type: string; role?: string }>; - assertEquals(input.length, 4); - assertEquals(input[0].role, 'user'); - assertEquals(input[1].role, 'assistant'); - assertEquals(input[2].role, 'system'); - assertEquals(input[3].role, 'user'); + assertEquals(input.length, 5); + assertEquals(input[0].role, 'system'); + assertEquals(input[1].role, 'user'); + assertEquals(input[2].role, 'assistant'); + assertEquals(input[3].role, 'system'); + assertEquals(input[4].role, 'user'); }); test('buildTargetPayload rejects an unknown message role', async () => { diff --git a/packages/translate/__tests__/protocols/pairs/media-values-request_test.ts b/packages/translate/__tests__/protocols/pairs/media-values-request_test.ts index 4c39139922..096140857e 100644 --- a/packages/translate/__tests__/protocols/pairs/media-values-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/media-values-request_test.ts @@ -48,3 +48,19 @@ test('Messages decodes a data URL only at its target boundary', async () => { if (result.type !== 'trip') throw new Error(result.message); expect(result.target.messages).toEqual([{ role: 'user', content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png;charset=utf-8', data: 'AAAA' }, cache_control: { type: 'ephemeral' } }] }]); }); + +test.each([ + ['data:;charset=utf-8,hi', 'text/plain;charset=utf-8', 'aGk='], + ['data:text/plain;base64,SGk%3D', 'text/plain', 'SGk='], +] as const)('Messages decodes target-boundary data URL %s', async (url, mediaType, data) => { + const result = await routeOpenAIChatCompletionsViaAnthropicMessages({ + model: 'm', stream: true, messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url } }] }], + }, options); + if (result.type !== 'trip') throw new Error(result.message); + expect(result.target.messages).toEqual([{ + role: 'user', + content: [{ + type: 'image', source: { type: 'base64', media_type: mediaType, data }, cache_control: { type: 'ephemeral' }, + }], + }]); +}); diff --git a/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-request_test.ts index 945bd05f84..31d1b7da4f 100644 --- a/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-request_test.ts @@ -1214,6 +1214,11 @@ test('translates legacy declarations and forced choice into Messages tooling', a assertEquals(result.tool_choice, { type: 'tool', name: 'lookup' }); }); +test('preserves required tool choice without declarations', async () => { + const result = await buildWithTestSidecar({ model: 'model', stream: true, messages: [], tool_choice: 'required' }); + assertEquals(result.tool_choice, { type: 'any' }); +}); + test.each([true, false])('maps parallel_tool_calls %s into Messages tool choice', async parallel => { for (const tool_choice of [undefined, 'auto', 'required', 'none'] as const) { const result = await buildWithTestSidecar(mkPayload({ messages: [], tools: [{ type: 'function', function: { name: 'f', parameters: { type: 'object', properties: {} } } }], tool_choice, parallel_tool_calls: parallel })); diff --git a/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-openai-responses-request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-openai-responses-request_test.ts index 135bc4ac53..4ae804f3be 100644 --- a/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-openai-responses-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-openai-responses-request_test.ts @@ -166,7 +166,7 @@ test('buildTargetPayload preserves explicit null prompt cache and safety fields' assertEquals(result.safety_identifier, null); }); -test('buildTargetPayload hoists only the initial contiguous system prefix', async () => { +test('buildTargetPayload keeps historical system and developer messages inline', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [ @@ -179,8 +179,10 @@ test('buildTargetPayload hoists only the initial contiguous system prefix', asyn ], }); - assertEquals(result.instructions, 'sys-1\n\nsys-2'); + assertEquals(result.instructions, undefined); assertEquals(result.input, [ + { type: 'message', role: 'system', content: 'sys-1' }, + { type: 'message', role: 'system', content: 'sys-2' }, { type: 'message', role: 'user', content: 'u1' }, { type: 'message', role: 'developer', content: 'dev-late' }, { type: 'message', role: 'system', content: 'sys-late' }, @@ -192,7 +194,7 @@ test('buildTargetPayload hoists only the initial contiguous system prefix', asyn ]); }); -test('buildTargetPayload hoists a leading developer prefix beside a system one', async () => { +test('buildTargetPayload preserves leading developer and system message roles', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [ @@ -202,10 +204,12 @@ test('buildTargetPayload hoists a leading developer prefix beside a system one', ], }); - // A developer message is the same prompt boundary a system message is, so a - // leading run of either kind is what `instructions` states. - assertEquals(result.instructions, 'dev-1\n\nsys-1'); - assertEquals(result.input, [{ type: 'message', role: 'user', content: 'u1' }]); + assertEquals(result.instructions, undefined); + assertEquals(result.input, [ + { type: 'message', role: 'developer', content: 'dev-1' }, + { type: 'message', role: 'system', content: 'sys-1' }, + { type: 'message', role: 'user', content: 'u1' }, + ]); }); test('buildTargetPayload preserves explicit tool strict and defaults omission to false', async () => { diff --git a/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts index b9c6fcaca3..b519a587a5 100644 --- a/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts @@ -152,20 +152,29 @@ test('buildTargetPayload accepts null tool_choice', async () => { assertEquals(result.target.tool_choice, undefined); }); -test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetPayload rejects input_file in %s', async type => { - await assertRejects( - () => buildWithTestSidecar({ - ...minimalPayload, - input: [ - ...(type === 'function_call_output' - ? [{ type: 'function_call' as const, call_id: 'call_1', name: 'lookup', arguments: '{}' }] - : [{ type: 'custom_tool_call' as const, call_id: 'call_1', name: 'lookup', input: '{}' }]), - { type, call_id: 'call_1', output: [{ type: 'input_file', file_id: 'file_1' }] }, - ], - }), - Error, - 'file tool output', - ); +test('buildTargetPayload preserves required tool choice without declarations', async () => { + const result = await buildWithTestSidecar({ ...minimalPayload, tools: null, tool_choice: 'required' }); + assertEquals(result.target.tool_choice, { type: 'any' }); +}); + +test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetPayload maps input_file in %s to a document', async type => { + const result = await buildWithTestSidecar({ + ...minimalPayload, + input: [ + ...(type === 'function_call_output' + ? [{ type: 'function_call' as const, call_id: 'call_1', name: 'lookup', arguments: '{}' }] + : [{ type: 'custom_tool_call' as const, call_id: 'call_1', name: 'lookup', input: '{}' }]), + { type, call_id: 'call_1', output: [{ type: 'input_file', file_id: 'file_1' }] }, + ], + }); + const user = result.target.messages.find(message => message.role === 'user'); + assertEquals(user, { + role: 'user', + content: [{ + type: 'tool_result', tool_use_id: 'call_1', content: [{ type: 'document', source: { type: 'file', file_id: 'file_1' } }], + cache_control: { type: 'ephemeral' }, + }], + }); }); test.each([ diff --git a/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts index 89b98a9a5d..644ffb2fb0 100644 --- a/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts @@ -28,6 +28,21 @@ test('preserves a refusal-only assistant turn between client turns', async () => ]); }); +test('preserves an empty assistant turn between client turns', async () => { + const { target } = await buildWithTestSidecar({ + model: 'model', input: [ + { type: 'message', role: 'user', content: 'A' }, + { type: 'message', role: 'assistant', content: [] }, + { type: 'message', role: 'user', content: 'B' }, + ], + }); + assertEquals(target.messages, [ + { role: 'user', content: 'A' }, + { role: 'assistant', content: '' }, + { role: 'user', content: 'B' }, + ]); +}); + test('buildTargetPayload accepts an implicit message discriminator', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', @@ -212,6 +227,18 @@ test('buildTargetPayload reshapes flat json_schema text format into OpenAI Chat }); }); +test('buildTargetPayload preserves null json_schema strictness', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + input: 'Hi', + text: { format: { type: 'json_schema', name: 'result', schema: {}, strict: null } }, + }); + assertEquals(result.target.response_format, { + type: 'json_schema', + json_schema: { name: 'result', schema: {}, strict: null }, + }); +}); + test('buildTargetPayload passes through plain text format without wrapping', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', diff --git a/packages/translate/src/composition/reading.ts b/packages/translate/src/composition/reading.ts index 81623a41d5..fcdb20cdc4 100644 --- a/packages/translate/src/composition/reading.ts +++ b/packages/translate/src/composition/reading.ts @@ -17,7 +17,7 @@ export type SourceTurnSlot = /** * A request read into what a writer consumes. * - * The request's own prompt field opens `turns` when the protocol has one. It is + * The request's own instruction field opens `turns` when the protocol has one. It is * a client turn rather than a field of its own, which is what lets a writer * place it by position the same way it places every other turn. */ diff --git a/packages/translate/src/composition/request/call-ids.ts b/packages/translate/src/composition/request/call-ids.ts index c412f4662e..0942eadb55 100644 --- a/packages/translate/src/composition/request/call-ids.ts +++ b/packages/translate/src/composition/request/call-ids.ts @@ -32,6 +32,7 @@ export interface PendingCall { export const assignCallIds = ( turns: readonly (IRInputTurn | RestoredAssistantTurn)[], restoredCalls: (turn: Restored) => readonly PendingCall[], + resultCallIds?: ReadonlyMap, ): readonly (IRInputTurnWithCallIds | RestoredAssistantTurn)[] => { const pending: PendingCall[] = []; let allocated = 0; @@ -82,7 +83,7 @@ export const assignCallIds = ( if (piece.kind === 'client') { const items: IRInputClientItemWithCallIds[] = piece.items.map((item): IRInputClientItemWithCallIds => { if (item.type !== 'tool_result') return item; - return { ...item, call_id: answer(item.call_id, item.name).callId }; + return { ...item, call_id: answer(resultCallIds?.get(item) ?? item.call_id, item.name).callId }; }); return { kind: 'client', items }; } diff --git a/packages/translate/src/composition/request/tool-names.ts b/packages/translate/src/composition/request/tool-names.ts index c277c2f178..e121ead99f 100644 --- a/packages/translate/src/composition/request/tool-names.ts +++ b/packages/translate/src/composition/request/tool-names.ts @@ -1,7 +1,7 @@ -import type { IRInputRequestHeader, IRInputTool, IRInputToolChoice, IRInputToolSelector, IRInputTurn } from '../../ir/input/types.ts'; +import type { IRInputTurnWithCallIds } from './call-ids.ts'; +import type { IRInputRequestHeader, IRInputTool, IRInputToolChoice, IRInputToolSelector } from '../../ir/input/types.ts'; import { TranslateInputException } from '../../shared/input-error.ts'; import type { RestoredAssistantTurn } from '../reading.ts'; -import type { IRInputTurnWithCallIds } from './call-ids.ts'; /** Records allocated custom-call names and their original identities for the target event reader. */ export interface ToolNameContext { @@ -15,10 +15,6 @@ const customNamesOf = (request: IRInputRequestHeader): ReadonlySet => const callableKey = ({ type, namespace, name }: IRInputToolSelector): string => JSON.stringify([type, namespace, name]); -export interface NativeCallNames { - readonly names: (turn: Turn) => readonly string[]; -} - /** * States this target's request and conversation with wireNames callable names. * @@ -28,21 +24,10 @@ export interface NativeCallNames { * returned context carries is what the same spelling decided: which * declarations were freeform, and which identity each allocated name came from. */ -export function allocateToolNames( +export const allocateToolNames = ( request: IRInputRequestHeader, inputTurns: readonly (IRInputTurnWithCallIds | RestoredAssistantTurn)[], - native: NativeCallNames, -): { readonly request: IRInputRequestHeader; readonly turns: readonly (IRInputTurnWithCallIds | RestoredAssistantTurn)[]; readonly context: ToolNameContext }; -export function allocateToolNames( - request: IRInputRequestHeader, - inputTurns: readonly (IRInputTurn | RestoredAssistantTurn)[], - native: NativeCallNames, -): { readonly request: IRInputRequestHeader; readonly turns: readonly (IRInputTurn | RestoredAssistantTurn)[]; readonly context: ToolNameContext }; -export function allocateToolNames( - request: IRInputRequestHeader, - inputTurns: readonly (IRInputTurn | RestoredAssistantTurn)[], - native: NativeCallNames, -): { readonly request: IRInputRequestHeader; readonly turns: readonly (IRInputTurn | RestoredAssistantTurn)[]; readonly context: ToolNameContext } { +): { readonly request: IRInputRequestHeader; readonly turns: readonly (IRInputTurnWithCallIds | RestoredAssistantTurn)[]; readonly context: ToolNameContext } => { const callables = [ ...(request.tools ?? []), ...inputTurns.flatMap(turn => turn.kind === 'restored' ? [] : turn.items.flatMap(item => @@ -50,9 +35,8 @@ export function allocateToolNames( ? [{ type: item.type === 'function_call' ? 'function' as const : 'custom' as const, namespace: item.namespace, name: item.name }] : [])), ]; - const nativeNames = new Set(inputTurns.flatMap(turn => turn.kind === 'restored' ? native.names(turn.turn) : [])); const reserved = new Set(callables.flatMap(callable => callable.namespace === undefined ? [callable.name] : [])); - const wireNames = assignToolNames(callables, reserved, nativeNames); + const wireNames = assignToolNames(callables, reserved); const originalToolNames = new Map>(); for (const callable of callables) { const wireName = wireNames.get(callableKey(callable))!; @@ -89,7 +73,7 @@ export function allocateToolNames( return { request: allocatedRequest, context: { customToolNames: customNamesOf(allocatedRequest), originalToolNames }, - turns: inputTurns.map((turn): IRInputTurn | RestoredAssistantTurn => { + turns: inputTurns.map((turn): IRInputTurnWithCallIds | RestoredAssistantTurn => { if (turn.kind === 'restored') return turn; if (turn.kind === 'assistant') { return { @@ -102,7 +86,7 @@ export function allocateToolNames( return turn; }), }; -} +}; /** * Assigns names independently of tool choice. Both callable kinds become function @@ -117,9 +101,8 @@ export function allocateToolNames( const assignToolNames = ( callables: readonly IRInputToolSelector[], reserved: ReadonlySet, - occupied: ReadonlySet, ): Map => { - const taken = new Set([...reserved, ...occupied]); + const taken = new Set(reserved); const nextSuffixes = new Map(); const assigned = new Map(); const bareNames = new Set(); @@ -127,7 +110,7 @@ const assignToolNames = ( const { namespace, name } = callable; const key = callableKey(callable); if (assigned.has(key)) continue; - if (namespace === undefined && !bareNames.has(name) && !occupied.has(name)) { + if (namespace === undefined && !bareNames.has(name)) { bareNames.add(name); assigned.set(key, name); continue; diff --git a/packages/translate/src/ir/input/types.ts b/packages/translate/src/ir/input/types.ts index e3bb87b000..da586a38df 100644 --- a/packages/translate/src/ir/input/types.ts +++ b/packages/translate/src/ir/input/types.ts @@ -122,12 +122,12 @@ export interface IRInputToolSelector { } // A turn groups one conversation branch. Assistant messages keep their own -// boundaries; top-level instructions and inline prompts retain distinct client +// boundaries; top-level instructions and inline system/developer messages retain distinct client // boundaries. Items stay ordered while each // target chooses its native message grouping. export type IRInputTurn = IRInputClientTurn | IRInputAssistantTurn; -// Client turns contain all nonassistant input, including prompts and tool results. +// Client turns contain all nonassistant input, including instructions, messages, and tool results. // Kind identifies the conversation branch; item role identifies the speaker. export interface IRInputClientTurn { readonly kind: 'client'; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts index 1ed1429f26..504d9730ca 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts @@ -3,7 +3,6 @@ import { klona } from 'klona/json'; import { partitionAnthropicMessagesTurns, type AnthropicMessagesConversationTurn } from './conversation-turns.ts'; import type { AnthropicMessagesSourceAssistantTurn } from './round-trip.ts'; import type { SourceReading, SourceTurnSlot } from '../../../composition/reading.ts'; -import { mediaFromURL } from '../../../ir/input/media.ts'; import type { IRInputAssistantContentPart, IRInputRequestHeader, @@ -37,12 +36,12 @@ import { FAST_SERVICE_TIER } from '@floway-dev/protocols/common'; const imageMedia = (source: AnthropicMessagesImageBlock['source']): IRInputMedia => { switch (source.type) { case 'base64': return { source_kind: 'base64', media_type: source.media_type, data: source.data }; - case 'url': return mediaFromURL(source.url); + case 'url': return { source_kind: 'url', url: source.url }; case 'file': return { source_kind: 'file', file_id: source.file_id }; } }; -const promptPartsToIR = (content: string | readonly AnthropicMessagesTextBlockParam[]): IRInputTextPart[] => typeof content === 'string' +const textPartsToIR = (content: string | readonly AnthropicMessagesTextBlockParam[]): IRInputTextPart[] => typeof content === 'string' ? content === '' ? [] : [{ type: 'text', text: content }] : content.map(block => { if (block.type !== 'text') throw new TranslateInputException('Only text system content is supported.'); @@ -66,8 +65,14 @@ const documentMedia = (block: Extract { if (block === undefined) return []; if (typeof block === 'string') return [{ type: 'text', text: block }]; - if (block.some(part => part.type !== 'text' && part.type !== 'image' && part.type !== 'document' && part.type !== 'search_result')) { - return [{ type: 'text', text: JSON.stringify(block) }]; - } const parts: IRInputToolResultContentPart[] = []; const searchResults: AnthropicMessagesSearchResultBlock[] = []; // Only consecutive search results share an array, so text and images retain @@ -112,7 +114,7 @@ const toolResultPart = (block: AnthropicMessagesToolResultBlock['content']): IRI if (part.type === 'text') parts.push({ type: 'text', text: part.text }); else if (part.type === 'image') parts.push({ type: 'image', image: imageMedia(part.source) }); else if (part.type === 'document') parts.push({ type: 'file', file: documentMedia(part) }); - else throw new TranslateInputException(`'${(part as { type: string }).type}' tool result content blocks are not supported.`); + else parts.push({ type: 'text', text: JSON.stringify(part) }); } flushSearchResults(); return parts; @@ -126,7 +128,7 @@ const clientItems = ( if (typeof message.content !== 'string' && message.content.some(block => block.type !== 'text')) { throw new TranslateInputException('Only text system content is supported.'); } - return [{ type: 'message', role: 'system', content: promptPartsToIR(message.content as string | readonly AnthropicMessagesTextBlockParam[]) }]; + return [{ type: 'message', role: 'system', content: textPartsToIR(message.content as string | readonly AnthropicMessagesTextBlockParam[]) }]; } if (typeof message.content === 'string') { return [{ type: 'message', role: 'user', content: message.content === '' ? [] : [{ type: 'text', text: message.content }] }]; @@ -257,7 +259,7 @@ const assistantSegments = (message: Extract { if (system === undefined) return undefined; - const content = promptPartsToIR(system); + const content = textPartsToIR(system); return content.length === 0 ? undefined : { kind: 'client', items: [{ type: 'instruction', content }] }; }; @@ -395,7 +397,7 @@ const reasoningEffortToIR = (payload: AnthropicMessagesPayload): string | undefi /** * A Messages request read into the turns a writer consumes. * - * Messages states its prompt in a top-level field and its conversation in + * Messages states its instruction in a top-level field and its conversation in * `messages`; both enter the IR, and the request-wide fields come from the same * payload. * @@ -413,8 +415,8 @@ export const readAnthropicMessagesRequest = (payload: AnthropicMessagesPayload): const turns: SourceTurnSlot[] = []; let messageIndex = 0; - const prompt = anthropicMessagesSystemToIR(payload.system); - if (prompt !== undefined) turns.push({ kind: 'client', toIRInputTurns: () => [prompt] }); + const instruction = anthropicMessagesSystemToIR(payload.system); + if (instruction !== undefined) turns.push({ kind: 'client', toIRInputTurns: () => [instruction] }); for (const turn of partitionAnthropicMessagesTurns(payload.messages)) { const firstMessageIndex = messageIndex; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target.ts b/packages/translate/src/protocols/anthropic-messages/as-target.ts index 297b1f9ecd..3601226046 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target.ts @@ -23,9 +23,7 @@ export const anthropicMessagesTargetProtocol: TargetProtocol< // This wire addresses a callable by one bare name, so every grouped // declaration and call is stated under the flat spelling it computes. const assigned = assignCallIds(turns, callsOf); - const flattened = allocateToolNames(request, assigned, { - names: turn => typeof turn.content === 'string' ? [] : turn.content.flatMap(block => block.type === 'tool_use' ? [block.name] : []), - }); + const flattened = allocateToolNames(request, assigned); return { payload: anthropicMessagesPayloadFromSegments(flattened.request, flattened.turns, { fallbackMaxOutputTokens: options.fallbackMaxOutputTokens, diff --git a/packages/translate/src/ir/input/media.ts b/packages/translate/src/protocols/anthropic-messages/as-target/data-url.ts similarity index 62% rename from packages/translate/src/ir/input/media.ts rename to packages/translate/src/protocols/anthropic-messages/as-target/data-url.ts index 0bba7b9f45..9b1b5a1da5 100644 --- a/packages/translate/src/ir/input/media.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/data-url.ts @@ -1,23 +1,13 @@ -import type { IRInputMedia } from './types.ts'; -import { TranslateInputException } from '../../shared/input-error.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; const DATA_URL = /^data:([^,]*),([\s\S]*)$/; const BASE64_MARKER = ';base64'; -/** URLs remain source values. A target decides whether it accepts the scheme or needs inline bytes. */ -export const mediaFromURL = (url: string): IRInputMedia => ({ source_kind: 'url', url }); - export interface ParsedDataURL { readonly media_type?: string; readonly data: string; } -const base64OfBytes = (bytes: readonly number[]): string => { - let binary = ''; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary); -}; - const bytesOfPercentEncodedData = (value: string): number[] => { const bytes: number[] = []; for (let index = 0; index < value.length;) { @@ -34,7 +24,15 @@ const bytesOfPercentEncodedData = (value: string): number[] => { return bytes; }; -/** Parse a data URL at a target boundary that requires inline bytes. */ +const stringOfBytes = (bytes: readonly number[]): string => { + let value = ''; + for (const byte of bytes) value += String.fromCharCode(byte); + return value; +}; + +const base64OfBytes = (bytes: readonly number[]): string => btoa(stringOfBytes(bytes)); + +/** Parse a data URL at the Messages target boundary that requires inline bytes. */ export const parseDataURL = (url: string): ParsedDataURL => { const match = DATA_URL.exec(url); if (match === null) throw new TranslateInputException(`Expected a data URL, got '${url.slice(0, 32)}'.`); @@ -42,8 +40,10 @@ export const parseDataURL = (url: string): ParsedDataURL => { const body = match[2]!; const encodedAsBase64 = header.toLowerCase().endsWith(BASE64_MARKER); const mediaType = encodedAsBase64 ? header.slice(0, -BASE64_MARKER.length) : header; + const normalizedMediaType = mediaType.startsWith(';') ? `text/plain${mediaType}` : mediaType; + const bytes = bytesOfPercentEncodedData(body); return { - ...(mediaType === '' ? {} : { media_type: mediaType }), - data: encodedAsBase64 ? body : base64OfBytes(bytesOfPercentEncodedData(body)), + ...(normalizedMediaType === '' ? {} : { media_type: normalizedMediaType }), + data: encodedAsBase64 ? stringOfBytes(bytes) : base64OfBytes(bytes), }; }; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts index 69b1f008cf..947db2c2fc 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts @@ -1,5 +1,5 @@ +import { parseDataURL, type ParsedDataURL } from './data-url.ts'; import type { IRInputAssistantItemWithCallIds, IRInputTurnWithCallIds } from '../../../composition/request/call-ids.ts'; -import { parseDataURL, type ParsedDataURL } from '../../../ir/input/media.ts'; import { requireStreaming } from '../../../ir/input/streaming.ts'; import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputMedia, IRInputTextPart, IRInputToolResultContentPart, IRInputClientItem } from '../../../ir/input/types.ts'; import { buildCustomToolInputSchema } from '../../../shared/custom-tool.ts'; @@ -55,7 +55,7 @@ const imageBlock = (media: IRInputMedia): AnthropicMessagesImageBlock => ({ type * Inline documents remain bytes. The target requires a media type, so an * untyped raw base64 value receives the generic binary type at this boundary. */ -const documentBlock = (media: IRInputMedia): AnthropicMessagesUserContentBlock => { +const documentBlock = (media: IRInputMedia): Extract => { switch (media.source_kind) { case 'url': { if (!media.url.startsWith('data:')) return { type: 'document', source: { type: 'url', url: media.url } }; @@ -99,7 +99,7 @@ const toolResultContent = ( const blocks: AnthropicMessagesToolResultContentBlock[] = []; for (const part of output) { if (part.type === 'text') { blocks.push({ type: 'text', text: part.text }); continue; } - if (part.type === 'file') throw new TranslateInputException('Cannot translate file tool output to Anthropic Messages.'); + if (part.type === 'file') { blocks.push(documentBlock(part.file)); continue; } blocks.push(imageBlock(part.image)); } // Messages spells an empty tool result as the empty string. @@ -137,10 +137,8 @@ const assistantBlocks = (items: readonly IRInputAssistantItemWithCallIds[]): Ant return blocks; }; -// Messages needs an object, so a call whose arguments are not an object keeps -// the raw text under `raw_arguments` rather than dropping the history. A JSON -// `null`, an array, and malformed text all take that path: none of them is an -// argument object, and discarding the call would lose what the model said. +// Messages accepts any JSON tool input. Malformed JSON keeps its raw text under +// `raw_arguments` so the historical call is not discarded. const parseArguments = (argumentsJson: string): unknown => { // An out-of-range JSON number such as `1e400` parses to `Infinity` and would // serialize back as `null`. Parsing with raw-number preservation keeps the @@ -185,20 +183,20 @@ const toolChoiceFromIR = (choice: IRInputToolChoice, tools: readonly IRInputTool const toolChoiceForTools = (choice: IRInputToolChoice | undefined, tools: readonly IRInputTool[] | undefined): IRInputToolChoice | undefined => { if (tools !== undefined && tools.length > 0) return choice; - if (choice === 'none' || choice === 'auto') return choice; + if (choice === 'none' || choice === 'auto' || choice === 'required') return choice; if (typeof choice === 'object' && choice.type === 'allowed_tools' && choice.mode === 'auto') return 'none'; return undefined; }; /** - * One segment's messages and the system blocks a leading prompt run lifts. + * One segment's messages and the system blocks lifted from its instruction. * - * Messages keeps the system prompt in its own top-level field. A leading system - * run is lifted there, one block per source text part so the boundaries + * Messages keeps instructions in its top-level system field, one block per + * source text part so the boundaries * survive; later system messages stay inline at their position. * * Only the segment opening the whole conversation may hoist. Splitting at a - * restored assistant turn must not promote a later inline prompt merely because + * restored assistant turn must not promote a later system message merely because * it opens another segment. */ export interface AnthropicMessagesConversation { diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts index 4789cf9361..c8a18146e1 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts @@ -68,7 +68,6 @@ export const writeGeminiGenerateContentEvents = async function* ( const pendingText = new Map(); const sourceLengths = new Map(); const nativeAccumulator = createGeminiGenerateContentEventAccumulator(); - const visibleAccumulator = createGeminiGenerateContentEventAccumulator(); type GeminiContentState = NonNullable[number]>; type PartsState = NonNullable; const partStates = new Map(); @@ -87,7 +86,6 @@ export const writeGeminiGenerateContentEvents = async function* ( const emit = (event: GeminiGenerateContentResult): EventFrame => { const frame = frameOf(event); nativeAccumulator.accept(frame.event); - visibleAccumulator.accept(frame.event); return frame; }; const emitPart = (choice: number, part: GeminiGenerateContentPart): EventFrame => @@ -96,9 +94,7 @@ export const writeGeminiGenerateContentEvents = async function* ( if (part.thought !== true || options.includeThoughts === true) return emitPart(choice, part); const full = frameOf({ candidates: [{ index: choice, content: { role: 'model', parts: [part] } }] }); nativeAccumulator.accept(full.event); - const visible = frameOf({ candidates: [{ index: choice, content: { role: 'model', parts: [{ text: '', thought: true }] } }] }); - visibleAccumulator.accept(visible.event); - return visible; + return frameOf({ candidates: [{ index: choice, content: { role: 'model', parts: [{ text: '', thought: true }] } }] }); }; const addUnit = (choice: number, unit: GenerateContentUnit): void => { let queue = queues.get(choice); @@ -422,12 +418,10 @@ export const writeGeminiGenerateContentEvents = async function* ( if (record.type === 'choice_end') { const nativeCandidate = nativeAccumulator.candidate(record.choice); const targetTurn: GeminiGenerateContentInputAssistantTurn = [{ role: 'model', parts: nativeCandidate?.content?.parts ?? [] }]; - const visibleCandidate = visibleAccumulator.candidate(record.choice); - const sourceTurn: GeminiGenerateContentInputAssistantTurn = [{ role: 'model', parts: visibleCandidate?.content?.parts ?? [] }]; const stateParts = partsState(record.choice); stateParts.length = targetTurn[0].parts?.length ?? 0; const assistantState: GeminiGenerateContentInputAssistantTurnState = [{ parts: stateParts }]; - const sourceTurnDigest = await computeGeminiGenerateContentTurnDigest(sourceTurn); + const sourceTurnDigest = await computeGeminiGenerateContentTurnDigest(targetTurn); const data = await options.roundTrip.encodeSidecar(record.choice, sourceTurnDigest, targetTurn, assistantState, collectGeminiGenerateContentTextPlacements(targetTurn)); yield emitPart(record.choice, createGeminiGenerateContentSidecarPart(data)); yield emit({ candidates: [terminalCandidate(record, state, collector)] }); diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts index 7409a1877d..b085886848 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts @@ -211,7 +211,7 @@ const toolsToIR = (payload: GeminiGenerateContentPayload): Pick => { let contentIndex = 0; - const prompt = geminiGenerateContentSystemToIR(payload.systemInstruction); + const instruction = geminiGenerateContentSystemToIR(payload.systemInstruction); const turnSlotFor = (turn: GeminiGenerateContentTurn): SourceTurnSlot => { const at = contentIndex; @@ -227,7 +227,7 @@ export const readGeminiGenerateContentRequest = (payload: GeminiGenerateContentR ...toolsToIR(payload), }, turns: [ - ...(prompt === undefined ? [] : [{ kind: 'client' as const, toIRInputTurns: () => [prompt] }]), + ...(instruction === undefined ? [] : [{ kind: 'client' as const, toIRInputTurns: () => [instruction] }]), ...partitionGeminiGenerateContentTurns(payload.contents ?? []).map(turnSlotFor), ], }; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/conversation-turns.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/conversation-turns.ts index c8bf346e92..0e02b560d4 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/conversation-turns.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/conversation-turns.ts @@ -4,14 +4,13 @@ export type OpenAIChatCompletionsConversationMessage = OpenAIChatCompletionsMess export type OpenAIChatCompletionsConversationTurn = | { role: 'assistant'; items: [OpenAIChatCompletionsAssistantMessage | OpenAIChatCompletionsAssistantMessageEx] } | { role: 'client'; items: Exclude[] }; -// Each assistant message forms its own turn; adjacent nonprompt client messages form one client run. +// Each assistant message forms its own turn; adjacent user/tool/function messages form one client run. export const partitionOpenAIChatCompletionsTurns = (messages: readonly OpenAIChatCompletionsConversationMessage[]): OpenAIChatCompletionsConversationTurn[] => { const turns: OpenAIChatCompletionsConversationTurn[] = []; for (const message of messages) { const previous = turns.at(-1); - // A system or developer message is a prompt boundary of its own: a target - // that hoists it into a top-level slot, or keeps it inline, needs it to stay - // separate from the client content around it. + // A system or developer message keeps its own boundary and role instead of + // joining the client content around it. if (message.role === 'assistant') { turns.push({ role: 'assistant', items: [message] }); continue; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts index 741d35ed44..ebee36d3e7 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts @@ -3,7 +3,6 @@ import { klona } from 'klona/json'; import { partitionOpenAIChatCompletionsTurns, type OpenAIChatCompletionsConversationMessage, type OpenAIChatCompletionsConversationTurn } from './conversation-turns.ts'; import type { OpenAIChatCompletionsSourceAssistantTurn } from './round-trip.ts'; import type { SourceReading } from '../../../composition/reading.ts'; -import { mediaFromURL } from '../../../ir/input/media.ts'; import type { IRInputAssistantContentPart, IRInputAssistantItem, @@ -34,7 +33,7 @@ import type { const AUDIO_MEDIA_TYPES: Readonly> = { wav: 'audio/wav', mp3: 'audio/mp3' }; // Explicit empty user and assistant messages retain their turn boundaries. -// Empty prompt text contributes no text to the prompt. +// A caller can explicitly keep or omit an empty text part. const textPartsToIR = (value: string, options: { keepEmpty?: boolean } = {}): IRInputTextPart[] => value === '' && options.keepEmpty === false ? [] : [{ type: 'text', text: value }]; interface OpenAIChatCompletionsInputContext { @@ -78,7 +77,7 @@ const userContentPart = (part: OpenAIChatCompletionsUserContentPart): IRInputUse case 'image_url': return { type: 'image', - image: mediaFromURL(part.image_url.url), + image: { source_kind: 'url', url: part.image_url.url }, ...(part.image_url.detail === undefined ? {} : { detail: part.image_url.detail }), }; case 'input_audio': @@ -255,7 +254,7 @@ const openaiChatCompletionsToolsToIR = (payload: OpenAIChatCompletionsPayload): : legacyChoice === undefined ? undefined : { type: 'function' as const, function: legacyChoice }); return { ...(tools === undefined || tools.length === 0 ? {} : { tools }), - ...(choice == null || tools === undefined || tools.length === 0 ? {} : { tool_choice: toolChoiceToIR(choice) }), + ...(choice == null ? {} : { tool_choice: toolChoiceToIR(choice) }), }; }; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target.ts b/packages/translate/src/protocols/openai-chat-completions/as-target.ts index 6fdc27756b..fb27202179 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target.ts @@ -35,13 +35,8 @@ export const openaiChatCompletionsTargetProtocol: TargetProtocol< // declaration and call is stated under the flat spelling it computes. write: (request, turns) => { const legacy = chatLegacyCallsOf(turns); - const assigned = assignCallIds(turns, turn => callsOf(turn, legacy.callIds)); - const flattened = allocateToolNames(request, assigned, { - names: turn => [ - ...(turn.function_call == null ? [] : [turn.function_call.name]), - ...(turn.tool_calls ?? []).map(call => call.type === 'function' ? call.function.name : call.custom.name), - ], - }); + const assigned = assignCallIds(turns, turn => callsOf(turn, legacy.callIds), legacy.results); + const flattened = allocateToolNames(request, assigned); return { payload: openaiChatCompletionsPayloadFromSegments( flattened.request, diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts index 0950efa0fa..e158e79022 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts @@ -88,9 +88,11 @@ const assistantMessage = (items: readonly IRInputAssistantItemWithCallIds[]): Op const refusals: string[] = []; const reasoning: string[] = []; const toolCalls: OpenAIChatCompletionsToolCall[] = []; + let hasMessage = false; for (const item of items) { switch (item.type) { case 'message': + hasMessage = true; for (const part of item.content) if (part.type === 'text') text.push(part.text); else refusals.push(part.refusal); break; case 'reasoning': @@ -112,7 +114,7 @@ const assistantMessage = (items: readonly IRInputAssistantItemWithCallIds[]): Op break; } } - if (text.length === 0 && refusals.length === 0 && toolCalls.length === 0 && reasoning.length === 0) return []; + if (!hasMessage && toolCalls.length === 0 && reasoning.length === 0) return []; // A turn carrying reasoning or tool calls with no text spells its content as // null; a turn with neither is an explicitly empty message and keeps ''. return [{ diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts index 800e88af88..671b9c78cc 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts @@ -13,6 +13,8 @@ interface ChatLegacyCalls { readonly callIds: ReadonlyMap; /** The legacy call each of those ids answers, by that id. */ readonly names: ReadonlyMap; + /** The selected id-less result for each legacy call, before modern matching. */ + readonly results: ReadonlyMap; } /** Every call id this conversation states, which an internal legacy pairing id must avoid. */ @@ -45,7 +47,7 @@ export const chatLegacyCallsOf = ( const claimed = new Set(stated); const callIds = new Map(); const names = new Map(); - const consumed = new Set(); + const results = new Map(); let allocated = 0; for (const [index, piece] of turns.entries()) { if (piece.kind !== 'restored') continue; @@ -54,7 +56,7 @@ export const chatLegacyCallsOf = ( const nextLegacyOffset = turns.slice(index + 1).findIndex(turn => turn.kind === 'restored' && turn.turn.function_call !== undefined); const end = nextLegacyOffset < 0 ? turns.length : index + 1 + nextLegacyOffset; const candidates = turns.slice(index + 1, end).flatMap(turn => turn.kind !== 'client' ? [] : turn.items.flatMap(item => - item.type === 'tool_result' && item.call_id === undefined && item.name === name && !consumed.has(item) ? [item] : [])); + item.type === 'tool_result' && item.call_id === undefined && item.name === name ? [item] : [])); if (candidates.length > 1) { throw new TranslateInputException(`Cannot translate the answer to the legacy call '${name}': ${candidates.length} id-less results state that function name.`); } @@ -65,7 +67,6 @@ export const chatLegacyCallsOf = ( } continue; } - consumed.add(answer); let callId: string; do { allocated += 1; @@ -74,6 +75,7 @@ export const chatLegacyCallsOf = ( claimed.add(callId); callIds.set(piece.turn, callId); names.set(callId, name); + results.set(answer, callId); } - return { callIds, names }; + return { callIds, names, results }; }; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/segmented-request.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/segmented-request.ts index 4d90129b5f..d0de8ba332 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/segmented-request.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/segmented-request.ts @@ -7,7 +7,7 @@ import type { OpenAIChatCompletionsMessage, OpenAIChatCompletionsPayload } from /** * Preserve restored native Chat turns verbatim and compute request-wide fields once. - * Chat has no top-level prompt slot, so prompts retain their inline positions. + * Chat has no top-level instruction slot, so instructions become inline messages. */ export const openaiChatCompletionsPayloadFromSegments = ( request: IRInputRequestHeader, diff --git a/packages/translate/src/protocols/openai-responses/as-source/conversation-turns.ts b/packages/translate/src/protocols/openai-responses/as-source/conversation-turns.ts index f9eb69fb8f..1cbd36a4ba 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/conversation-turns.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/conversation-turns.ts @@ -5,10 +5,9 @@ export type OpenAIResponsesConversationTurn = { readonly items: CanonicalOpenAIResponsesInputItem[]; }; -// A system or developer message is a prompt boundary of its own: a target that -// hoists it into a top-level slot, or keeps it inline, needs it to stay separate -// from the client content around it. -const isPromptBoundary = (item: CanonicalOpenAIResponsesInputItem): boolean => +// A system or developer message keeps its own boundary and role instead of +// joining the client content around it. +const isRoleMessageBoundary = (item: CanonicalOpenAIResponsesInputItem): boolean => item.type === 'message' && (item.role === 'system' || item.role === 'developer'); export const partitionOpenAIResponsesTurns = (input: readonly CanonicalOpenAIResponsesInputItem[]): OpenAIResponsesConversationTurn[] => { @@ -16,7 +15,7 @@ export const partitionOpenAIResponsesTurns = (input: readonly CanonicalOpenAIRes for (const item of input) { const role = isOpenAIResponsesAssistantInputItem(item) ? 'assistant' : 'client'; const previous = turns.at(-1); - if (isPromptBoundary(item) || previous?.role !== role || previous.items.some(isPromptBoundary)) { + if (isRoleMessageBoundary(item) || previous?.role !== role || previous.items.some(isRoleMessageBoundary)) { turns.push({ role, items: [item] }); continue; } diff --git a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts index 9762afca2c..7ef1121b14 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts @@ -5,7 +5,6 @@ import { partitionOpenAIResponsesTurns, type OpenAIResponsesConversationTurn } f import { rejectProgramCaller, rejectProgrammaticOpenAIResponsesPayload } from './programmatic-tooling.ts'; import type { OpenAIResponsesSourceAssistantTurn } from './round-trip.ts'; import type { SourceReading } from '../../../composition/reading.ts'; -import { mediaFromURL } from '../../../ir/input/media.ts'; import type { IRInputAssistantContentPart, IRInputAssistantItem, @@ -35,7 +34,7 @@ import type { } from '@floway-dev/protocols/openai-responses'; const imageMedia = (part: Extract): IRInputMedia => { - if (typeof part.image_url === 'string') return mediaFromURL(part.image_url); + if (typeof part.image_url === 'string') return { source_kind: 'url', url: part.image_url }; if (typeof part.file_id === 'string') return { source_kind: 'file', file_id: part.file_id }; throw new TranslateInputException('An OpenAI Responses input_image requires image_url or file_id.'); }; @@ -67,7 +66,7 @@ const userContent = (content: string | OpenAIResponsesInputContent[]): IRInputUs ? content === '' ? [] : [{ type: 'text', text: content }] : content.map(userContentPart); -const promptPartsToIR = (message: OpenAIResponsesInputMessageEx): IRInputTextPart[] => typeof message.content === 'string' +const textPartsToIR = (message: OpenAIResponsesInputMessageEx): IRInputTextPart[] => typeof message.content === 'string' ? message.content === '' ? [] : [{ type: 'text', text: message.content }] : message.content.map(part => { if (part.type !== 'input_text' && part.type !== 'output_text') { @@ -139,7 +138,7 @@ const clientItems = (items: readonly CanonicalOpenAIResponsesInputItem[]): IRInp switch (item.role) { case 'user': return [{ type: 'message' as const, role: 'user' as const, content: userContent(item.content) }]; case 'system': - case 'developer': return [{ type: 'message' as const, role: item.role, content: promptPartsToIR(item) }]; + case 'developer': return [{ type: 'message' as const, role: item.role, content: textPartsToIR(item) }]; default: throw new TranslateInputException(`Invalid role '${item.role}' in input message.`); } case 'function_call_output': { @@ -237,9 +236,11 @@ const selectorOf = (payload: CanonicalOpenAIResponsesPayload, selector: Record { - const choice = payload.tool_choice; - if (choice == null) throw new TranslateInputException('Cannot translate a missing tool choice.'); +const toolChoiceToIR = ( + payload: CanonicalOpenAIResponsesPayload, + choice: NonNullable, + declarations: readonly IRInputTool[], +): IRInputToolChoice => { if (typeof choice === 'string') { if (choice === 'auto' || choice === 'none' || choice === 'required') return choice; // Any other bare string is a hosted selector, which names a callable this @@ -300,7 +301,7 @@ const openaiResponsesToolsToIR = (payload: CanonicalOpenAIResponsesPayload): Pic const declared = payload.tools?.flatMap(toolGroupToIR) ?? []; const deferred = deferredInventories(payload).flatMap(toolGroupToIR); const tools = declared.length === 0 && deferred.length === 0 ? undefined : [...declared, ...deferred]; - const toolChoice = payload.tool_choice == null ? undefined : toolChoiceToIR(payload, tools ?? []); + const toolChoice = payload.tool_choice == null ? undefined : toolChoiceToIR(payload, payload.tool_choice, tools ?? []); return { ...(tools === undefined ? {} : { tools }), ...(toolChoice === undefined ? {} : { tool_choice: toolChoice }), @@ -323,7 +324,7 @@ const openaiResponsesSettingsToIR = (payload: CanonicalOpenAIResponsesPayload): ...('schema' in format && format.schema !== undefined && format.schema !== null && typeof format.schema === 'object' ? { schema: klona(format.schema) as Record } : {}), - ...('strict' in format && typeof format.strict === 'boolean' ? { strict: format.strict } : {}), + ...('strict' in format && format.strict !== undefined ? { strict: format.strict } : {}), } : { type: format.type }; @@ -363,14 +364,14 @@ export const readOpenAIResponsesRequest = (source: OpenAIResponsesRequestPayload if (!('input' in canonical)) throw new TranslateInputException(canonical.error.message, canonical.error); rejectProgrammaticOpenAIResponsesPayload(canonical); - const prompt = openaiResponsesInstructionsToIR(canonical.instructions); + const instruction = openaiResponsesInstructionsToIR(canonical.instructions); return { request: { settings: openaiResponsesSettingsToIR(canonical), ...openaiResponsesToolsToIR(canonical), }, turns: [ - ...(prompt === undefined ? [] : [{ kind: 'client' as const, toIRInputTurns: () => [prompt] }]), + ...(instruction === undefined ? [] : [{ kind: 'client' as const, toIRInputTurns: () => [instruction] }]), ...partitionOpenAIResponsesTurns(canonical.input).map(turn => turn.role === 'assistant' ? { kind: 'assistant' as const, diff --git a/packages/translate/src/protocols/openai-responses/as-target/input-from.ts b/packages/translate/src/protocols/openai-responses/as-target/input-from.ts index 1673e1696c..688dd8833a 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/input-from.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/input-from.ts @@ -1,6 +1,6 @@ import type { IRInputAssistantItemWithCallIds, IRInputTurnWithCallIds, IRInputClientItemWithCallIds } from '../../../composition/request/call-ids.ts'; import { requireStreaming } from '../../../ir/input/streaming.ts'; -import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputOutputFormat, IRInputMedia, IRInputToolResultContentPart, IRInputClientItem, IRInputInstructionItem, IRInputClientMessageItem } from '../../../ir/input/types.ts'; +import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputOutputFormat, IRInputMedia, IRInputToolResultContentPart, IRInputClientItem, IRInputInstructionItem } from '../../../ir/input/types.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import { reasoningEffortForBudget } from '../../../shared/reasoning-budget.ts'; import type { @@ -124,21 +124,18 @@ const clientItems = (items: readonly IRInputClientItemWithCallIds[]): CanonicalO } }); -// A leading system or developer run states the request's prompt, which -// Responses keeps in its own top-level slot. Every later system message stays -// inline as an ordinary input item. +// Only the IR's top-level instruction belongs in Responses `instructions`. +// Historical system and developer messages remain input messages at their +// original positions. const isInstruction = (entry: IRInputClientItem): entry is IRInputInstructionItem => entry.type === 'instruction'; -const isPrompt = (entry: IRInputClientItem): entry is Extract => - entry.type === 'message' && entry.role !== 'user'; export const openaiResponsesConversationFromIR = ( turns: readonly IRInputTurnWithCallIds[], hoistSystem: boolean, ): Pick => { const instructions: string[] = []; - // A conversation's own leading prompt is the first turn of the first segment. - // Later segments continue an exchange that already started, so their leading - // prompt (if any) stays inline where the caller placed it. + // Only the first segment can contain the request's top-level instruction. + // A later segment continues conversation history, so every item stays inline. let hoisting = hoistSystem; const input = turns.flatMap(item => { @@ -152,18 +149,6 @@ export const openaiResponsesConversationFromIR = ( hoisting = false; return []; } - if (hoisting && isPrompt(entry)) { - const parts = entry.content.filter(part => part.text !== ''); - if (parts.length === 1) { - instructions.push(parts[0].text); - return []; - } - if (parts.length > 1) { - hoisting = false; - return [{ type: 'message', role: entry.role, content: parts.map(part => ({ type: 'input_text' as const, text: part.text })) }]; - } - return []; - } hoisting = false; return clientItems([entry]); }); @@ -236,7 +221,7 @@ export const openaiResponsesHeaderFromIR = (request: IRInputRequestHeader): Omit // This wire states a permitted subset natively, so the declarations stay // whole: narrowing them as well would also narrow what the model may see. const tools = request.tools; - const choice = tools === undefined || tools.length === 0 ? request.tool_choice === 'required' ? 'required' : undefined : request.tool_choice; + const choice = tools === undefined || tools.length === 0 ? undefined : request.tool_choice; return { ...openaiResponsesSettingsFromIR(settings), model: settings.model, From b51fcff60b24eb2f1ecf371641fdcac524f054a5 Mon Sep 17 00:00:00 2001 From: Menci Date: Fri, 13 Nov 2026 02:18:27 +0800 Subject: [PATCH 55/66] fix(translate): complete input edge semantics Treat ProtoJSON null message fields as unset, preserve text-only content documents as formal files, and let distinct legacy function calls match their named results.\n\nSimplify the Responses instruction projection and remove the remaining stale prompt and strict-state wording. --- .../as-target/legacy-calls_test.ts | 14 ++++++++++++++ ...-messages-to-openai-responses-request_test.ts | 12 ++++++++---- ...nt-to-openai-chat-completions-request_test.ts | 10 ++++++++++ ...e-content-to-openai-responses-request_test.ts | 16 ++++++++++++++++ packages/translate/src/ir/input/types.ts | 3 +-- .../anthropic-messages/as-source/input-to.ts | 9 +++++---- .../as-target/segmented-request.ts | 4 ++-- .../as-source/input-to.ts | 8 ++++---- .../gemini-generate-content/as-source/schema.ts | 8 ++++---- .../as-target/legacy-calls.ts | 2 +- .../openai-responses/as-target/input-from.ts | 10 +++++----- .../as-target/segmented-request.ts | 2 +- 12 files changed, 71 insertions(+), 27 deletions(-) diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts index 0d7d59ebee..a7bac2578d 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts @@ -83,3 +83,17 @@ test('legacy matching wins over an earlier pending modern call of the same name' const client = assigned[2]; assertEquals(client.kind === 'client' && client.items[0].type === 'tool_result' ? client.items[0].call_id : undefined, legacyCalls.callIds.get(legacyTurn.turn)); }); + +test('a different legacy function does not end the result search', () => { + const first = legacy('first'); + const second = legacy('second'); + const answers: IRInputTurn = { + kind: 'client', + items: [ + { type: 'tool_result', name: 'first', output: 'FIRST' }, + { type: 'tool_result', name: 'second', output: 'SECOND' }, + ], + }; + const legacyCalls = chatLegacyCallsOf([first, message('between'), second, answers]); + assertEquals([...legacyCalls.names.values()], ['first', 'second']); +}); diff --git a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts index 3c1eccb24d..ae307ecb81 100644 --- a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts @@ -42,7 +42,7 @@ test('mixed tool-result blocks keep documents formal and unknown blocks opaque', role: 'user', content: [{ type: 'tool_result', tool_use_id: 'c', content: [ - { type: 'document', source: { type: 'text', media_type: 'text/plain', data: 'hello' } }, + { type: 'document', source: { type: 'content', content: [{ type: 'text', text: 'hel' }, { type: 'text', text: 'lo' }] } }, unknown, ], }], @@ -56,14 +56,18 @@ test('mixed tool-result blocks keep documents formal and unknown blocks opaque', ]); }); -test('inline text content documents enter the IR as text files', async () => { +test.each([ + ['string', 'hello', 'aGVsbG8='], + ['text blocks', [{ type: 'text' as const, text: 'hel' }, { type: 'text' as const, text: 'lo' }], 'aGVsbG8='], + ['empty text blocks', [], ''], +] as const)('inline content document %s enters the IR as a text file', async (_name, content, data) => { const result = await buildWithTestSidecar({ model: 'm', max_tokens: 32, messages: [{ - role: 'user', content: [{ type: 'document', source: { type: 'content', content: 'hello' } }], + role: 'user', content: [{ type: 'document', source: { type: 'content', content: typeof content === 'string' ? content : [...content] } }], }], }); assertEquals(result.input, [{ - type: 'message', role: 'user', content: [{ type: 'input_file', file_data: 'data:text/plain;base64,aGVsbG8=' }], + type: 'message', role: 'user', content: [{ type: 'input_file', file_data: `data:text/plain;base64,${data}` }], }]); }); diff --git a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts index e928ff187c..7655a19e18 100644 --- a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts @@ -11,6 +11,16 @@ const buildWithTestSidecar = ( model: string, ) => buildTargetPayloadFor(pairs.geminiToChat[0], pairs.geminiToChat[1], { ...payload, model }, { sidecarCodec: createTestSidecarCodec() }); +test('ProtoJSON null scalar members are treated as unset', async () => { + const request = await buildWithTestSidecar({ + contents: [], + generationConfig: { candidateCount: null as never, presencePenalty: null as never, frequencyPenalty: null as never }, + }, 'gpt-test'); + assertEquals(request.n, undefined); + assertEquals(request.presence_penalty, undefined); + assertEquals(request.frequency_penalty, undefined); +}); + test('buildTargetPayload translates LOW to reasoning_effort', async () => { const request = await buildWithTestSidecar({ contents: [], diff --git a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts index 4b9812bbd0..b6f8dc8809 100644 --- a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts @@ -29,6 +29,22 @@ test('ProtoJSON null configuration members are treated as unset', async () => { contents: [], generationConfig: { thinkingConfig: { thinkingLevel: null as never, thinkingBudget: 1024 } }, }, 'gpt-test'); assertEquals(nullLevel.reasoning, { effort: 'low' }); + + const nullScalars = await buildWithTestSidecar({ + contents: [{ role: null as never, parts: [{ text: 'hello' }] }], + generationConfig: { + candidateCount: null as never, + presencePenalty: null as never, + frequencyPenalty: null as never, + responseSchema: null as never, + }, + tools: [{ functionDeclarations: [{ name: 'lookup', parameters: null as never }] }], + }, 'gpt-test'); + assertEquals(nullScalars.input, [{ type: 'message', role: 'user', content: 'hello' }]); + assertFalse('presence_penalty' in nullScalars); + assertFalse('frequency_penalty' in nullScalars); + assertEquals(nullScalars.text, undefined); + assertEquals(nullScalars.tools?.[0]?.type === 'function' ? nullScalars.tools[0].parameters : undefined, { type: 'object', properties: {} }); }); test('buildTargetPayload translates LOW to reasoning.effort', async () => { diff --git a/packages/translate/src/ir/input/types.ts b/packages/translate/src/ir/input/types.ts index da586a38df..4f7064c844 100644 --- a/packages/translate/src/ir/input/types.ts +++ b/packages/translate/src/ir/input/types.ts @@ -51,8 +51,7 @@ export interface IRInputPromptCacheOptions { readonly ttl?: string; } -// Structured output. `strict` is three-state: absent means the source did not -// say, which is not the same as `false`. +// Structured output. `strict` keeps absent, null, false, and true distinct. export interface IRInputOutputFormat { readonly type: 'text' | 'json_object' | 'json_schema'; readonly name?: string; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts index 504d9730ca..daf1695cbb 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts @@ -66,10 +66,11 @@ const documentMedia = (block: Extract content.type === 'text')) { + return { source_kind: 'base64', media_type: 'text/plain', data: base64Of(source.content.map(content => content.text).join('')) }; + } + const content = source.content.length === 1 ? source.content[0] : undefined; if (content?.type === 'image') return imageMedia(content.source); throw new TranslateInputException('Cannot translate a multi-part inline content document as one file.'); } diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/segmented-request.ts b/packages/translate/src/protocols/anthropic-messages/as-target/segmented-request.ts index 7bdfed0b48..c5a1141a7d 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/segmented-request.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/segmented-request.ts @@ -25,8 +25,8 @@ export const anthropicMessagesPayloadFromSegments = ( restored.add(piece.turn); continue; } - // Only a conversation's first segment may lift its leading prompt into the - // top-level `system` slot, and segmentation is what says which that is. + // Only the first segment may project its instruction or leading + // system/developer messages into the top-level `system` slot. const segment = anthropicMessagesConversationFromIR(piece.turns, piece.leading); system.push(...segment.system); messages.push(...segment.messages); diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts index b085886848..bcf4dfc7a8 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts @@ -158,7 +158,7 @@ const turnToIRTurns = ( : [{ kind: 'client', items: turn.items.flatMap(content => { - if (content.role !== 'user' && content.role !== undefined) { + if (content.role !== 'user' && content.role != null) { throw new TranslateInputException(`"${(content as { role: string }).role}" is not a supported content role.`); } return (content.parts?.length ?? 0) === 0 @@ -264,9 +264,9 @@ const geminiGenerateContentSettingsToIR = (payload: GeminiGenerateContentRequest ...(config?.seed == null ? {} : { seed: config.seed }), ...(config?.maxOutputTokens == null ? {} : { max_output_tokens: config.maxOutputTokens }), ...(outputFormat === undefined ? {} : { output_format: outputFormat }), - ...(config?.candidateCount === undefined ? {} : { choice_count: config.candidateCount }), - ...(config?.presencePenalty === undefined ? {} : { presence_penalty: config.presencePenalty }), - ...(config?.frequencyPenalty === undefined ? {} : { frequency_penalty: config.frequencyPenalty }), + ...(config?.candidateCount == null ? {} : { choice_count: config.candidateCount }), + ...(config?.presencePenalty == null ? {} : { presence_penalty: config.presencePenalty }), + ...(config?.frequencyPenalty == null ? {} : { frequency_penalty: config.frequencyPenalty }), ...(effort === undefined ? {} : { reasoning_effort: effort }), // A negative budget asks the model to choose its own and states no number. ...(budget === undefined || budget <= 0 ? {} : { reasoning_budget: budget }), 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 a452afbfac..14b4e4e39d 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 @@ -36,11 +36,11 @@ const jsonSchemaObject = (schema: unknown): Record => { }; export const geminiFunctionParameters = (declaration: GeminiGenerateContentFunctionDeclaration): Record | undefined => { - 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); + if (declaration.parameters != null && declaration.parametersJsonSchema !== undefined) throw new TranslateInputException('Gemini parameters and parametersJsonSchema are mutually exclusive.'); + return declaration.parametersJsonSchema !== undefined ? jsonSchemaObject(declaration.parametersJsonSchema) : declaration.parameters == null ? undefined : geminiSchemaToJsonSchema(declaration.parameters); }; export const geminiResponseSchema = (config: GeminiGenerateContentGenerationConfig): Record | undefined => { - 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); + if (config.responseSchema != null && config.responseJsonSchema !== undefined) throw new TranslateInputException('Gemini responseSchema and responseJsonSchema are mutually exclusive.'); + return config.responseJsonSchema !== undefined ? jsonSchemaObject(config.responseJsonSchema) : config.responseSchema == null ? undefined : geminiSchemaToJsonSchema(config.responseSchema); }; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts index 671b9c78cc..75a9756cf6 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts @@ -53,7 +53,7 @@ export const chatLegacyCallsOf = ( if (piece.kind !== 'restored') continue; const name = piece.turn.function_call?.name; if (name === undefined) continue; - const nextLegacyOffset = turns.slice(index + 1).findIndex(turn => turn.kind === 'restored' && turn.turn.function_call !== undefined); + const nextLegacyOffset = turns.slice(index + 1).findIndex(turn => turn.kind === 'restored' && turn.turn.function_call?.name === name); const end = nextLegacyOffset < 0 ? turns.length : index + 1 + nextLegacyOffset; const candidates = turns.slice(index + 1, end).flatMap(turn => turn.kind !== 'client' ? [] : turn.items.flatMap(item => item.type === 'tool_result' && item.call_id === undefined && item.name === name ? [item] : [])); diff --git a/packages/translate/src/protocols/openai-responses/as-target/input-from.ts b/packages/translate/src/protocols/openai-responses/as-target/input-from.ts index 688dd8833a..8eca1b0976 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/input-from.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/input-from.ts @@ -131,12 +131,12 @@ const isInstruction = (entry: IRInputClientItem): entry is IRInputInstructionIte export const openaiResponsesConversationFromIR = ( turns: readonly IRInputTurnWithCallIds[], - hoistSystem: boolean, + leading: boolean, ): Pick => { - const instructions: string[] = []; + let instructions: string | undefined; // Only the first segment can contain the request's top-level instruction. // A later segment continues conversation history, so every item stays inline. - let hoisting = hoistSystem; + let hoisting = leading; const input = turns.flatMap(item => { if (item.kind === 'assistant') { @@ -145,7 +145,7 @@ export const openaiResponsesConversationFromIR = ( } return item.items.flatMap((entry): CanonicalOpenAIResponsesInputItem[] => { if (hoisting && isInstruction(entry)) { - instructions.push(entry.content.map(part => part.text).join('')); + instructions = entry.content.map(part => part.text).join(''); hoisting = false; return []; } @@ -154,7 +154,7 @@ export const openaiResponsesConversationFromIR = ( }); }); - return { instructions: instructions.length === 0 ? undefined : instructions.join('\n\n'), input }; + return { instructions, input }; }; const requireSchema = (format: IRInputOutputFormat): Readonly> => { diff --git a/packages/translate/src/protocols/openai-responses/as-target/segmented-request.ts b/packages/translate/src/protocols/openai-responses/as-target/segmented-request.ts index 6ea5015029..0412a981e1 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/segmented-request.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/segmented-request.ts @@ -18,7 +18,7 @@ export const openaiResponsesPayloadFromSegments = ( for (const piece of segmentTurns(turns)) { if (piece.kind === 'restored') { input.push(...piece.turn); continue; } const segment = openaiResponsesConversationFromIR(piece.turns, piece.leading); - if (segment.instructions != null) instructions ??= segment.instructions; + if (segment.instructions != null) instructions = segment.instructions; input.push(...segment.input); } From 0f4fad204871000e834b3675e835efafd563bcd4 Mon Sep 17 00:00:00 2001 From: Menci Date: Fri, 13 Nov 2026 02:28:57 +0800 Subject: [PATCH 56/66] fix(translate): close cross-protocol behavior gaps Preserve text-block boundaries without changing transport-fragment concatenation, retain nullable Responses controls and immutable request snapshots, and constrain generated Responses call IDs at the target.\n\nKeep leading Messages system content hoisted, order tool results legally, drop foreign encrypted reasoning, and carry Gemini function-response media through formal IR parts. --- .../as-target/legacy-calls_test.ts | 4 +- ...to-openai-chat-completions-request_test.ts | 17 ++++++++- ...tent-to-anthropic-messages-request_test.ts | 30 +++++++++++++++ ...to-openai-chat-completions-request_test.ts | 13 +++++++ ...ontent-to-openai-responses-request_test.ts | 14 +++++++ ...nses-to-anthropic-messages-request_test.ts | 37 ++++++++++++++++--- ...to-openai-chat-completions-request_test.ts | 33 +++++++++++++++++ .../src/composition/request/call-ids.ts | 15 ++++++-- packages/translate/src/ir/input/types.ts | 11 ++++-- .../anthropic-messages/as-source/input-to.ts | 8 ++-- .../as-target/input-from.ts | 16 ++++---- .../as-source/input-to.ts | 18 +++++++-- .../openai-chat-completions/as-target.ts | 2 +- .../as-target/input-from.ts | 19 +++++----- .../openai-responses/as-source/input-to.ts | 7 ++-- .../protocols/openai-responses/as-target.ts | 6 ++- .../openai-responses/as-target/input-from.ts | 2 +- 17 files changed, 206 insertions(+), 46 deletions(-) diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts index a7bac2578d..83f75bf65a 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts @@ -66,7 +66,7 @@ test('legacy matching consumes its uniquely named id-less result before modern m const assigned = assignCallIds([native, results], turn => [ { callId: legacy.callIds.get(turn)!, name: 'legacy' }, { callId: 'modern-id', name: 'modern' }, - ], legacy.results); + ], { resultCallIds: legacy.results }); const client = assigned[1]; assertEquals(client.kind === 'client' ? client.items.map(item => item.type === 'tool_result' ? item.call_id : undefined) : [], ['modern-id', legacy.callIds.get(native.turn)]); }); @@ -79,7 +79,7 @@ test('legacy matching wins over an earlier pending modern call of the same name' const assigned = assignCallIds(turns, turn => [ ...(turn.tool_calls ?? []).map(call => ({ callId: call.id, name: call.type === 'function' ? call.function.name : call.custom.name })), ...(turn.function_call == null ? [] : [{ callId: legacyCalls.callIds.get(turn)!, name: turn.function_call.name }]), - ], legacyCalls.results); + ], { resultCallIds: legacyCalls.results }); const client = assigned[2]; assertEquals(client.kind === 'client' && client.items[0].type === 'tool_result' ? client.items[0].call_id : undefined, legacyCalls.callIds.get(legacyTurn.turn)); }); diff --git a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts index fad54ceac4..f2e3567b37 100644 --- a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts @@ -10,7 +10,20 @@ import { assertEquals, assertFalse, assertRejects } from '@floway-dev/test-utils const buildWithTestSidecar = (payload: AnthropicMessagesPayload) => buildTargetPayloadFor(pairs.messagesToChat[0], pairs.messagesToChat[1], { ...payload, stream: true }, { sidecarCodec: createTestSidecarCodec() }); -test('unmodeled structured tool-result blocks retain main opaque JSON behavior', async () => { +test('independent text blocks retain paragraph boundaries when Chat flattens them', async () => { + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [ + { role: 'user', content: [{ type: 'text', text: 'alpha' }, { type: 'text', text: 'beta' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'gamma' }, { type: 'text', text: 'delta' }] }, + ], + }); + assertEquals(result.messages, [ + { role: 'user', content: 'alpha\n\nbeta' }, + { role: 'assistant', content: 'gamma\n\ndelta' }, + ]); +}); + +test('unmodeled structured tool-result blocks remain individually opaque', async () => { const content = [{ type: 'browser_state', tabs: [] }]; const result = await buildWithTestSidecar({ model: 'm', max_tokens: 32, messages: [ @@ -18,7 +31,7 @@ test('unmodeled structured tool-result blocks retain main opaque JSON behavior', { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'c', content: content as never }] }, ], }); - assertEquals(result.messages.at(-1), { role: 'tool', tool_call_id: 'c', content: JSON.stringify(content) }); + assertEquals(result.messages.at(-1), { role: 'tool', tool_call_id: 'c', content: JSON.stringify(content[0]) }); }); test('buildTargetPayload maps thinking.disabled to reasoning_effort none', async () => { diff --git a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-request_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-request_test.ts index e2cd8270e1..7f192a694b 100644 --- a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-request_test.ts @@ -25,6 +25,36 @@ test('a ProtoJSON null function-call args value reaches Messages unchanged', asy assertEquals(message.role === 'assistant' && Array.isArray(message.content) && message.content[0]?.type === 'tool_use' ? message.content[0].input : undefined, null); }); +test('function response media enters Messages tool results and tool results precede user text', async () => { + const result = await buildWithTestSidecar({ + contents: [ + { role: 'model', parts: [{ functionCall: { name: 'fetch', args: {} } }] }, + { + role: 'user', + parts: [ + { text: 'Here is the result' }, + { functionResponse: { name: 'fetch', response: { status: 'ok' }, parts: [{ inlineData: { mimeType: 'image/png', data: 'aGVsbG8=' } }] } }, + ], + }, + ], + }, 'claude-test', noOptions); + const user = result.messages[1]; + assertEquals(user, { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'fetch_call_1', + content: [ + { type: 'text', text: '{"status":"ok"}' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'aGVsbG8=' } }, + ], + }, + { type: 'text', text: 'Here is the result', cache_control: { type: 'ephemeral' } }, + ], + }); +}); + test('buildTargetPayload preserves an empty thought as a thinking turn', async () => { const result = await buildWithTestSidecar({ contents: [{ role: 'model', parts: [{ text: '', thought: true }] }] }, 'claude-test', noOptions); assertEquals(result.messages, [{ role: 'assistant', content: [{ type: 'thinking', thinking: '', signature: '' }] }]); diff --git a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts index 7655a19e18..6c06b05f4a 100644 --- a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts @@ -21,6 +21,19 @@ test('ProtoJSON null scalar members are treated as unset', async () => { assertEquals(request.frequency_penalty, undefined); }); +test('independent text parts retain paragraph boundaries when Chat flattens them', async () => { + const request = await buildWithTestSidecar({ + contents: [ + { role: 'user', parts: [{ text: 'alpha' }, { text: 'beta' }] }, + { role: 'model', parts: [{ text: 'gamma' }, { text: 'delta' }] }, + ], + }, 'gpt-test'); + assertEquals(request.messages, [ + { role: 'user', content: 'alpha\n\nbeta' }, + { role: 'assistant', content: 'gamma\n\ndelta' }, + ]); +}); + test('buildTargetPayload translates LOW to reasoning_effort', async () => { const request = await buildWithTestSidecar({ contents: [], diff --git a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts index b6f8dc8809..02c937b364 100644 --- a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts @@ -47,6 +47,20 @@ test('ProtoJSON null configuration members are treated as unset', async () => { assertEquals(nullScalars.tools?.[0]?.type === 'function' ? nullScalars.tools[0].parameters : undefined, { type: 'object', properties: {} }); }); +test('generated Responses call IDs stay within the wire limit', async () => { + const name = 'f'.repeat(64); + const result = await buildWithTestSidecar({ + contents: [ + { role: 'model', parts: [{ functionCall: { name, args: {} } }] }, + { role: 'user', parts: [{ functionResponse: { name, response: { ok: true } } }] }, + ], + }, 'gpt-test'); + const call = result.input.find(item => item.type === 'function_call'); + const output = result.input.find(item => item.type === 'function_call_output'); + assertEquals(call?.type === 'function_call' ? call.call_id.length : undefined, 64); + assertEquals(output?.type === 'function_call_output' ? output.call_id : undefined, call?.type === 'function_call' ? call.call_id : undefined); +}); + test('buildTargetPayload translates LOW to reasoning.effort', async () => { const request = await buildWithTestSidecar({ contents: [], diff --git a/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts index b519a587a5..2a7a9bd0ef 100644 --- a/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts @@ -46,6 +46,31 @@ test('buildTargetPayload preserves unsafe JSON number tokens in function history assertEquals(JSON.stringify(tool.input), '{"n":1e400}'); }); +test('top-level instructions and leading role messages share the Messages system field', async () => { + const result = await buildWithTestSidecar({ + ...minimalPayload, + instructions: 'base', + input: [ + { type: 'message', role: 'system', content: 'rule' }, + { type: 'message', role: 'developer', content: 'dev' }, + { type: 'message', role: 'user', content: 'question' }, + ], + }); + assertEquals(Array.isArray(result.target.system) ? result.target.system.map(block => block.text) : undefined, ['base', 'rule', 'dev']); + assertEquals(result.target.messages, [{ role: 'user', content: [{ type: 'text', text: 'question', cache_control: { type: 'ephemeral' } }] }]); +}); + +test('foreign encrypted reasoning is dropped instead of creating an unsigned thinking block', async () => { + const result = await buildWithTestSidecar({ + ...minimalPayload, + input: [ + { type: 'reasoning', id: 'rs_foreign', summary: [{ type: 'summary_text', text: 'trace' }], encrypted_content: 'foreign' }, + { type: 'message', role: 'assistant', content: 'answer' }, + ], + }); + assertEquals(result.target.messages, [{ role: 'assistant', content: [{ type: 'text', text: 'answer', cache_control: { type: 'ephemeral' } }] }]); +}); + test('buildTargetPayload preserves OpenAI Responses assistant refusal history as Anthropic Messages text', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, @@ -1162,13 +1187,13 @@ test('buildTargetPayload preserves payload.instructions and leading system as se ); assertEquals(result.target.system, [ - { type: 'text', text: 'canonical top-level instructions', cache_control: { type: 'ephemeral' } }, + { type: 'text', text: 'canonical top-level instructions' }, + { type: 'text', text: 'leading note', cache_control: { type: 'ephemeral' } }, ]); - assertEquals(result.target.messages.length, 4); - assertEquals(result.target.messages[0], { role: 'system', content: [{ type: 'text', text: 'leading note' }] }); - assertEquals(result.target.messages[1].role, 'user'); - assertEquals(result.target.messages[2], { role: 'system', content: [{ type: 'text', text: 'mid-array note' }] }); - assertEquals(result.target.messages[3].role, 'user'); + assertEquals(result.target.messages.length, 3); + assertEquals(result.target.messages[0].role, 'user'); + assertEquals(result.target.messages[1], { role: 'system', content: [{ type: 'text', text: 'mid-array note' }] }); + assertEquals(result.target.messages[2].role, 'user'); }); test('buildTargetPayload throws when a system input message contains an image part', async () => { diff --git a/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts index 644ffb2fb0..29c6592f0e 100644 --- a/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts @@ -43,6 +43,39 @@ test('preserves an empty assistant turn between client turns', async () => { ]); }); +test('Responses text fragments remain contiguous when Chat flattens them', async () => { + const { target } = await buildWithTestSidecar({ + model: 'model', input: [ + { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'alpha' }, { type: 'input_text', text: 'beta' }] }, + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'gamma' }, { type: 'output_text', text: 'delta' }] }, + ], + }); + assertEquals(target.messages, [ + { role: 'user', content: 'alphabeta' }, + { role: 'assistant', content: 'gammadelta' }, + ]); +}); + +test('preserves nullable controls and snapshots mutable source objects', async () => { + const payload: OpenAIResponsesRequestPayloadEx = { + model: 'model', input: 'hi', metadata: { trace: 'before' }, service_tier: null, + text: { format: { type: 'json_schema', json_schema: { name: 'shape', schema: { type: 'object', properties: { name: { type: 'string' } } } } } as never }, + }; + const building = buildWithTestSidecar(payload); + payload.metadata!.trace = 'after'; + const nested = payload.text!.format as unknown as { json_schema: { schema: { properties: { name: { type: string } } } } }; + nested.json_schema.schema.properties.name.type = 'number'; + const { target } = await building; + assertEquals(target.metadata, { trace: 'before' }); + assertEquals(target.service_tier, null); + assertEquals(target.response_format?.type === 'json_schema' ? target.response_format.json_schema.schema : undefined, { + type: 'object', properties: { name: { type: 'string' } }, + }); + + const nullMetadata = await buildWithTestSidecar({ model: 'model', input: 'hi', metadata: null }); + assertEquals(nullMetadata.target.metadata, null); +}); + test('buildTargetPayload accepts an implicit message discriminator', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', diff --git a/packages/translate/src/composition/request/call-ids.ts b/packages/translate/src/composition/request/call-ids.ts index 0942eadb55..60ecf3799f 100644 --- a/packages/translate/src/composition/request/call-ids.ts +++ b/packages/translate/src/composition/request/call-ids.ts @@ -24,6 +24,11 @@ export interface PendingCall { readonly callId: string; } +export interface AssignCallIdsOptions { + readonly resultCallIds?: ReadonlyMap; + readonly maxGeneratedCallIdLength?: number; +} + /** * Allocate missing IDs in conversation order so id-less results answer pending calls. * Explicit result IDs must refer to a call present in the conversation. Restored native @@ -32,7 +37,7 @@ export interface PendingCall { export const assignCallIds = ( turns: readonly (IRInputTurn | RestoredAssistantTurn)[], restoredCalls: (turn: Restored) => readonly PendingCall[], - resultCallIds?: ReadonlyMap, + options: AssignCallIdsOptions = {}, ): readonly (IRInputTurnWithCallIds | RestoredAssistantTurn)[] => { const pending: PendingCall[] = []; let allocated = 0; @@ -53,7 +58,11 @@ export const assignCallIds = ( let candidate: string; do { allocated += 1; - candidate = `${name}_call_${allocated}`; + const suffix = `_call_${allocated}`; + const prefix = options.maxGeneratedCallIdLength === undefined + ? name + : name.slice(0, Math.max(0, options.maxGeneratedCallIdLength - suffix.length)); + candidate = `${prefix}${suffix}`; } while (claimed.has(candidate)); claimed.add(candidate); return candidate; @@ -83,7 +92,7 @@ export const assignCallIds = ( if (piece.kind === 'client') { const items: IRInputClientItemWithCallIds[] = piece.items.map((item): IRInputClientItemWithCallIds => { if (item.type !== 'tool_result') return item; - return { ...item, call_id: answer(resultCallIds?.get(item) ?? item.call_id, item.name).callId }; + return { ...item, call_id: answer(options.resultCallIds?.get(item) ?? item.call_id, item.name).callId }; }); return { kind: 'client', items }; } diff --git a/packages/translate/src/ir/input/types.ts b/packages/translate/src/ir/input/types.ts index 4f7064c844..085428b639 100644 --- a/packages/translate/src/ir/input/types.ts +++ b/packages/translate/src/ir/input/types.ts @@ -16,11 +16,11 @@ export interface IRInputSettings { readonly max_visible_output_tokens?: number | null; readonly max_output_tokens?: number | null; readonly output_format?: IRInputOutputFormat; - // Readers normalize metadata to string pairs; target writers do not coerce it again. - readonly metadata?: Readonly>; + // Readers normalize non-null metadata to string pairs; target writers do not coerce it again. + readonly metadata?: Readonly> | null; readonly store?: boolean | null; - // Preserve service-tier strings; each target owns vocabulary mapping. - readonly service_tier?: string; + // Preserve service-tier strings and explicit null; each target owns vocabulary mapping. + readonly service_tier?: string | null; readonly parallel_tool_calls?: boolean | null; // Keep effort as an open string, independently of numeric budget. Protocol readers and writers own their thinking-mode mapping. readonly reasoning_effort?: string; @@ -181,6 +181,9 @@ export type IRInputAssistantContentPart = IRInputTextPart | IRInputRefusalPart; export interface IRInputTextPart { readonly type: 'text'; readonly text: string; + // Independently submitted text blocks retain their boundary when a target + // must flatten them into one string. Transport fragments leave this absent. + readonly block?: true; } // A refusal is model output, so only assistant history carries one. No protocol diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts index daf1695cbb..5e85e649ea 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts @@ -45,7 +45,7 @@ const textPartsToIR = (content: string | readonly AnthropicMessagesTextBlockPara ? content === '' ? [] : [{ type: 'text', text: content }] : content.map(block => { if (block.type !== 'text') throw new TranslateInputException('Only text system content is supported.'); - return { type: 'text', text: block.text }; + return { type: 'text', text: block.text, block: true }; }); const base64Of = (text: string): string => { @@ -87,7 +87,7 @@ const userContentPart = ( at: MessagePosition, ): IRInputUserContentPart => { switch (block.type) { - case 'text': return { type: 'text', text: block.text }; + case 'text': return { type: 'text', text: block.text, block: true }; case 'image': return { type: 'image', image: imageMedia(block.source) }; case 'document': return { type: 'file', file: documentMedia(block) }; default: throw new TranslateInputException(`messages.${at.messageIdx}.content.${at.blockIdx}.type: '${(block as { type: string }).type}' content blocks are not supported in user content.`); @@ -112,7 +112,7 @@ const toolResultPart = (block: AnthropicMessagesToolResultBlock['content']): IRI continue; } flushSearchResults(); - if (part.type === 'text') parts.push({ type: 'text', text: part.text }); + if (part.type === 'text') parts.push({ type: 'text', text: part.text, block: true }); else if (part.type === 'image') parts.push({ type: 'image', image: imageMedia(part.source) }); else if (part.type === 'document') parts.push({ type: 'file', file: documentMedia(part) }); else parts.push({ type: 'text', text: JSON.stringify(part) }); @@ -232,7 +232,7 @@ const assistantSegments = (message: Extract))); + if (item.type === 'tool_result') { + toolResultBlocks.push({ type: 'tool_result', tool_use_id: item.call_id, content: toolResultContent(item.output), ...(item.is_error === true ? { is_error: true } : {}) }); + } else { + userBlocks.push(...userContent(item as Extract)); + } } - append(turnBlocks); + append([...toolResultBlocks, ...userBlocks]); } return { messages, system }; }; @@ -331,6 +333,6 @@ export const anthropicMessagesSettingsFromIR = (settings: IRInputSettings): Omit // Anthropic spells the accelerated lane as `speed: 'fast'`; every other tier // stays an open string on `service_tier`. // https://docs.claude.com/en/build-with-claude/fast-mode - ...(settings.service_tier === undefined ? {} : isFastServiceTier(settings.service_tier) ? { speed: 'fast' as const } : { service_tier: settings.service_tier }), + ...(settings.service_tier == null ? {} : isFastServiceTier(settings.service_tier) ? { speed: 'fast' as const } : { service_tier: settings.service_tier }), }; }; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts index bcf4dfc7a8..f932960a17 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts @@ -16,6 +16,7 @@ import type { IRInputTurn, IRInputUserContentPart, IRInputClientItem, + IRInputToolResultContentPart, } from '../../../ir/input/types.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import { geminiGenerateContentPartKind } from '@floway-dev/protocols/gemini-generate-content'; @@ -50,6 +51,15 @@ const inlinePart = (part: GeminiGenerateContentPart): IRInputUserContentPart => } }; +const functionResponsePart = ( + part: NonNullable['parts']>[number], +): IRInputToolResultContentPart => { + const media = { source_kind: 'base64' as const, media_type: part.inlineData.mimeType, data: part.inlineData.data }; + return geminiGenerateContentInlineData({ inlineData: part.inlineData })?.kind === 'image' + ? { type: 'image', image: media } + : { type: 'file', file: media }; +}; + const userParts = (content: GeminiGenerateContentContent): IRInputClientItem[] => { const items: IRInputClientItem[] = []; const pending: IRInputUserContentPart[] = []; @@ -71,13 +81,15 @@ const userParts = (content: GeminiGenerateContentContent): IRInputClientItem[] = // The target matches id-less results against both ordinary and native calls. name: response.name, ...(response.id === undefined ? {} : { call_id: response.id }), - output: JSON.stringify(response.response), + output: response.parts === undefined + ? JSON.stringify(response.response) + : [{ type: 'text', text: JSON.stringify(response.response) }, ...response.parts.map(functionResponsePart)], }); return; } case 'text': { const text = geminiGenerateContentPartText(part); - if (text !== null) pending.push({ type: 'text', text }); + if (text !== null) pending.push({ type: 'text', text, block: true }); return; } case 'inline_data': @@ -125,7 +137,7 @@ const assistantItems = (content: GeminiGenerateContentContent, contentIndex: num return; } const visible = geminiGenerateContentVisibleText(part); - if (visible !== null) pending.push({ type: 'text', text: visible }); + if (visible !== null) pending.push({ type: 'text', text: visible, block: true }); return; } case 'inline_data': diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target.ts b/packages/translate/src/protocols/openai-chat-completions/as-target.ts index fb27202179..10cc9dd4c5 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target.ts @@ -35,7 +35,7 @@ export const openaiChatCompletionsTargetProtocol: TargetProtocol< // declaration and call is stated under the flat spelling it computes. write: (request, turns) => { const legacy = chatLegacyCallsOf(turns); - const assigned = assignCallIds(turns, turn => callsOf(turn, legacy.callIds), legacy.results); + const assigned = assignCallIds(turns, turn => callsOf(turn, legacy.callIds), { resultCallIds: legacy.results }); const flattened = allocateToolNames(request, assigned); return { payload: openaiChatCompletionsPayloadFromSegments( diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts index e158e79022..8a80981a47 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts @@ -1,6 +1,6 @@ import type { IRInputAssistantItemWithCallIds, IRInputTurnWithCallIds, IRInputClientItemWithCallIds } from '../../../composition/request/call-ids.ts'; import { requireStreaming } from '../../../ir/input/streaming.ts'; -import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputMedia, IRInputToolResultContentPart, IRInputClientItem } from '../../../ir/input/types.ts'; +import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputMedia, IRInputToolResultContentPart, IRInputClientItem, IRInputTextPart } from '../../../ir/input/types.ts'; import { buildCustomToolInputSchema } from '../../../shared/custom-tool.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import { reasoningEffortForBudget } from '../../../shared/reasoning-budget.ts'; @@ -45,10 +45,11 @@ const filePart = (media: IRInputMedia, filename?: string | null): OpenAIChatComp } }; +const flattenTextParts = (parts: readonly IRInputTextPart[]): string => parts.reduce((text, part, index) => + `${text}${index > 0 && (parts[index - 1]?.block === true || part.block === true) ? '\n\n' : ''}${part.text}`, ''); + const userContent = (item: Extract): string | OpenAIChatCompletionsUserContentPart[] => { - // Adjacent text parts join with no separator: the parts are one run of text, - // and a separator would invent characters the caller never wrote. - if (item.content.every(part => part.type === 'text')) return item.content.map(part => part.text).join(''); + if (item.content.every((part): part is IRInputTextPart => part.type === 'text')) return flattenTextParts(item.content); return item.content.map(part => { switch (part.type) { case 'text': return { type: 'text' as const, text: part.text }; @@ -73,7 +74,7 @@ export const openaiChatCompletionsToolResultContent = ( ): { readonly content: string; readonly lifted: OpenAIChatCompletionsUserContentPart[] } => { if (typeof output === 'string') return { content: output, lifted: [] }; if (output.some(part => part.type === 'file')) throw new TranslateInputException('Cannot translate file tool output to OpenAI Chat Completions.'); - const text = output.flatMap(part => part.type === 'text' ? [part.text] : []).join(''); + const text = flattenTextParts(output.flatMap(part => part.type === 'text' ? [part] : [])); const images = output.flatMap(part => part.type === 'image' ? [imageUrlPart(part.image, part.detail)] : []); return images.length === 0 ? { content: text, lifted: [] } @@ -84,7 +85,7 @@ export const openaiChatCompletionsToolResultContent = ( }; const assistantMessage = (items: readonly IRInputAssistantItemWithCallIds[]): OpenAIChatCompletionsMessage[] => { - const text: string[] = []; + const text: IRInputTextPart[] = []; const refusals: string[] = []; const reasoning: string[] = []; const toolCalls: OpenAIChatCompletionsToolCall[] = []; @@ -93,7 +94,7 @@ const assistantMessage = (items: readonly IRInputAssistantItemWithCallIds[]): Op switch (item.type) { case 'message': hasMessage = true; - for (const part of item.content) if (part.type === 'text') text.push(part.text); else refusals.push(part.refusal); + for (const part of item.content) if (part.type === 'text') text.push(part); else refusals.push(part.refusal); break; case 'reasoning': // We write readable reasoning as reasoning_text; input IR does not retain the source alias spelling. @@ -119,7 +120,7 @@ const assistantMessage = (items: readonly IRInputAssistantItemWithCallIds[]): Op // null; a turn with neither is an explicitly empty message and keeps ''. return [{ role: 'assistant', - content: text.length === 0 && (toolCalls.length > 0 || reasoning.length > 0) ? null : text.join(''), + content: text.length === 0 && (toolCalls.length > 0 || reasoning.length > 0) ? null : flattenTextParts(text), ...(refusals.length === 0 ? {} : { refusal: refusals.join('') }), ...(reasoning.length === 0 ? {} : { reasoning_text: reasoning.join('') }), ...(toolCalls.length === 0 ? {} : { tool_calls: toolCalls }), @@ -274,7 +275,7 @@ export const openaiChatCompletionsSettingsFromIR = (settings: IRInputSettings): ...(settings.top_p === undefined ? {} : { top_p: settings.top_p }), ...(settings.stop === undefined ? {} : { stop: [...settings.stop] }), ...(settings.seed === undefined ? {} : { seed: settings.seed }), - ...(settings.metadata === undefined ? {} : { metadata: { ...settings.metadata } }), + ...(settings.metadata === undefined ? {} : { metadata: settings.metadata === null ? null : { ...settings.metadata } }), ...(settings.store === undefined ? {} : { store: settings.store }), ...(settings.service_tier === undefined ? {} : { service_tier: settings.service_tier }), ...(settings.parallel_tool_calls == null ? {} : { parallel_tool_calls: settings.parallel_tool_calls }), diff --git a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts index 7ef1121b14..40533f17e4 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts @@ -112,6 +112,7 @@ const assistantItems = (items: readonly CanonicalOpenAIResponsesInputItem[]): IR } case 'reasoning': { + if (typeof item.encrypted_content === 'string') return undefined; const summary = item.summary.map(part => part.text); const content = (item.content ?? []).map(part => part.text); // No readable text means nothing any target could replay. @@ -316,7 +317,7 @@ const openaiResponsesSettingsToIR = (payload: CanonicalOpenAIResponsesPayload): // A caller may hand the Chat-Completions-nested spelling to the // Responses field; its details are still the same schema. ...('json_schema' in format && typeof format.json_schema === 'object' && format.json_schema !== null - ? format.json_schema as Record + ? klona(format.json_schema as Record) : {}), type: 'json_schema', ...('name' in format && typeof format.name === 'string' ? { name: format.name } : {}), @@ -337,9 +338,9 @@ const openaiResponsesSettingsToIR = (payload: CanonicalOpenAIResponsesPayload): ...(payload.frequency_penalty === undefined ? {} : { frequency_penalty: payload.frequency_penalty }), ...(payload.max_output_tokens === undefined ? {} : { max_output_tokens: payload.max_output_tokens }), ...(outputFormat === undefined ? {} : { output_format: outputFormat }), - ...(payload.metadata == null ? {} : { metadata: payload.metadata }), + ...(payload.metadata === undefined ? {} : { metadata: payload.metadata === null ? null : klona(payload.metadata) }), ...(payload.store === undefined ? {} : { store: payload.store }), - ...(payload.service_tier == null ? {} : { service_tier: payload.service_tier }), + ...(payload.service_tier === undefined ? {} : { service_tier: payload.service_tier }), ...(payload.parallel_tool_calls === undefined ? {} : { parallel_tool_calls: payload.parallel_tool_calls }), ...(payload.reasoning?.effort == null ? {} : { reasoning_effort: payload.reasoning.effort }), ...(payload.reasoning?.summary == null ? {} : { reasoning_summary: payload.reasoning.summary }), diff --git a/packages/translate/src/protocols/openai-responses/as-target.ts b/packages/translate/src/protocols/openai-responses/as-target.ts index 9f6641ecad..f09cf220e1 100644 --- a/packages/translate/src/protocols/openai-responses/as-target.ts +++ b/packages/translate/src/protocols/openai-responses/as-target.ts @@ -6,6 +6,10 @@ import type { CanonicalOpenAIResponsesPayload, CanonicalOpenAIResponsesInputItem type ResponsesRestoredAssistantTurn = readonly CanonicalOpenAIResponsesInputItem[]; +// Responses call and result IDs accept at most 64 characters. +// https://github.com/openresponses/openresponses/blob/92c12d96d7b61d6d15e2214daa5e9c6000ab6e1c/public/openapi/openapi.json +const RESPONSES_MAX_CALL_ID_LENGTH = 64; + const callsOf = (turn: ResponsesRestoredAssistantTurn): readonly PendingCall[] => turn.flatMap(item => item.type === 'function_call' || item.type === 'custom_tool_call' ? [{ callId: item.call_id, name: item.name }] @@ -17,7 +21,7 @@ export const openaiResponsesTargetProtocol: TargetProtocol< > = { name: 'openaiResponses', write: (request, turns) => ({ - payload: openaiResponsesPayloadFromSegments(request, assignCallIds(turns, callsOf)), + payload: openaiResponsesPayloadFromSegments(request, assignCallIds(turns, callsOf, { maxGeneratedCallIdLength: RESPONSES_MAX_CALL_ID_LENGTH })), context: undefined, }), readEvents: (frames, _context, roundTripReader) => readOpenAIResponsesEvents(frames, { roundTrip: roundTripReader }), diff --git a/packages/translate/src/protocols/openai-responses/as-target/input-from.ts b/packages/translate/src/protocols/openai-responses/as-target/input-from.ts index 8eca1b0976..7b9f5caf31 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/input-from.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/input-from.ts @@ -244,7 +244,7 @@ export const openaiResponsesSettingsFromIR = (settings: IRInputSettings): Partia ...(settings.top_p === undefined ? {} : { top_p: settings.top_p }), ...(settings.presence_penalty === undefined ? {} : { presence_penalty: settings.presence_penalty }), ...(settings.frequency_penalty === undefined ? {} : { frequency_penalty: settings.frequency_penalty }), - ...(settings.metadata === undefined ? {} : { metadata: { ...settings.metadata } }), + ...(settings.metadata === undefined ? {} : { metadata: settings.metadata === null ? null : { ...settings.metadata } }), ...(settings.store === undefined ? {} : { store: settings.store }), ...(settings.service_tier === undefined ? {} : { service_tier: settings.service_tier }), ...(settings.parallel_tool_calls === undefined ? {} : { parallel_tool_calls: settings.parallel_tool_calls }), From f9007986cb3075d312e60725b47a80fd4dcbf303 Mon Sep 17 00:00:00 2001 From: Menci Date: Fri, 13 Nov 2026 02:33:36 +0800 Subject: [PATCH 57/66] test(translate): align suite with finalized input semantics Use a truly unrepresentable content document in input-error coverage, assert the single Gemini accumulator, and encode the final decisions for foreign encrypted reasoning and inline historical role messages. --- .../chat/openai-chat-completions/attempt_test.ts | 7 ++++--- packages/translate/__tests__/input-error_test.ts | 11 ++++++++++- .../as-source/native-reassembly_test.ts | 2 +- .../openai-responses/as-source/input-to_test.ts | 8 +++----- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/attempt_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/attempt_test.ts index 97ae48aa46..5ffc87a4c9 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/attempt_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/attempt_test.ts @@ -287,11 +287,12 @@ test('generate preserves translated instructions before rewriting inline system await collectEvents(result.events); const observedBody = observedBodies[0]; if (!observedBody) throw new Error('expected observed OpenAI Responses body'); - assertEquals(observedBody.instructions, 'base instructions'); + assertEquals(observedBody.instructions, undefined); const input = observedBody.input; if (!Array.isArray(input)) throw new Error('expected OpenAI Responses input array'); - assertEquals(input[0], { type: 'message', role: 'user', content: 'hello' }); - assertEquals(input[1], { type: 'message', role: 'developer', content: 'inline instructions' }); + assertEquals(input[0], { type: 'message', role: 'developer', content: 'base instructions' }); + assertEquals(input[1], { type: 'message', role: 'user', content: 'hello' }); + assertEquals(input[2], { type: 'message', role: 'developer', content: 'inline instructions' }); }); test('generate propagates upstream response headers onto the EventResult so respond can forward them', async () => { diff --git a/packages/translate/__tests__/input-error_test.ts b/packages/translate/__tests__/input-error_test.ts index 85fa1e03f2..a2d6bda217 100644 --- a/packages/translate/__tests__/input-error_test.ts +++ b/packages/translate/__tests__/input-error_test.ts @@ -8,7 +8,16 @@ import * as translate from '@floway-dev/translate'; const options = { sidecarCodec: createTestSidecarCodec() }; const messages = { model: 'm', stream: true, max_tokens: 32, - messages: [{ role: 'user' as const, content: [{ type: 'document' as const, source: { type: 'content' as const, content: [] } }] }], + messages: [{ + role: 'user' as const, + content: [{ + type: 'document' as const, + source: { + type: 'content' as const, + content: [{ type: 'text' as const, text: 'caption' }, { type: 'image' as const, source: { type: 'base64' as const, media_type: 'image/png' as const, data: 'AAAA' } }], + }, + }], + }], }; const chat = { model: 'm', stream: true, 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 e79e08132b..703d3cd5bd 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 @@ -52,7 +52,7 @@ test.each([2, 32])('reads only the ending candidate for %i interleaved choices', sourceTurnDigest: await computeGeminiGenerateContentTurnDigest([{ role: 'model', parts: [{ text: `answer-${index}` }] }]), }); } - expect(createGeminiGenerateContentEventAccumulator).toHaveBeenCalledTimes(2); + expect(createGeminiGenerateContentEventAccumulator).toHaveBeenCalledTimes(1); for (const creation of vi.mocked(createGeminiGenerateContentEventAccumulator).mock.results) { if (creation.type !== 'return') throw new Error('Expected a Gemini accumulator'); expect(creation.value.result).not.toHaveBeenCalled(); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/input-to_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/input-to_test.ts index 123ab79ec4..eecc36f375 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/input-to_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/input-to_test.ts @@ -13,14 +13,12 @@ const source: OpenAIResponsesRequestPayloadEx = { }], }; -test('readable reasoning stays separate in IR beside an opaque encrypted field', async () => { +test('foreign encrypted reasoning is dropped as one opaque item', async () => { const slot = readOpenAIResponsesRequest(source).turns[0]; - expect(slot.toIRInputTurns()).toEqual([{ - kind: 'assistant', items: [{ type: 'reasoning', summary: ['S'], content: ['C'] }], - }]); + expect(slot.toIRInputTurns()).toEqual([{ kind: 'assistant', items: [] }]); const trip = await routeOpenAIResponsesViaOpenAIChatCompletions(source, { sidecarCodec: createTestSidecarCodec() }); if (trip.type !== 'trip') throw new Error(trip.message); - expect(trip.target.messages).toEqual([{ role: 'assistant', content: null, reasoning_text: 'SC' }]); + expect(trip.target.messages).toEqual([]); expect(JSON.stringify(trip.target)).not.toContain('opaque-vendor-data'); }); From 7c293022912e961697f10b059facda7b1ae1f777 Mon Sep 17 00:00:00 2001 From: Menci Date: Fri, 13 Nov 2026 02:53:56 +0800 Subject: [PATCH 58/66] revert(translate): remove text block boundary marker Remove the unsupported input-IR block distinction and restore direct text-part concatenation. The previous change inferred desired behavior from main without a user decision. --- ...ges-to-openai-chat-completions-request_test.ts | 13 ------------- ...ent-to-openai-chat-completions-request_test.ts | 13 ------------- ...ses-to-openai-chat-completions-request_test.ts | 13 ------------- packages/translate/src/ir/input/types.ts | 3 --- .../anthropic-messages/as-source/input-to.ts | 8 ++++---- .../gemini-generate-content/as-source/input-to.ts | 4 ++-- .../as-target/input-from.ts | 15 ++++++--------- 7 files changed, 12 insertions(+), 57 deletions(-) diff --git a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts index f2e3567b37..d0652f7d9f 100644 --- a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts @@ -10,19 +10,6 @@ import { assertEquals, assertFalse, assertRejects } from '@floway-dev/test-utils const buildWithTestSidecar = (payload: AnthropicMessagesPayload) => buildTargetPayloadFor(pairs.messagesToChat[0], pairs.messagesToChat[1], { ...payload, stream: true }, { sidecarCodec: createTestSidecarCodec() }); -test('independent text blocks retain paragraph boundaries when Chat flattens them', async () => { - const result = await buildWithTestSidecar({ - model: 'm', max_tokens: 32, messages: [ - { role: 'user', content: [{ type: 'text', text: 'alpha' }, { type: 'text', text: 'beta' }] }, - { role: 'assistant', content: [{ type: 'text', text: 'gamma' }, { type: 'text', text: 'delta' }] }, - ], - }); - assertEquals(result.messages, [ - { role: 'user', content: 'alpha\n\nbeta' }, - { role: 'assistant', content: 'gamma\n\ndelta' }, - ]); -}); - test('unmodeled structured tool-result blocks remain individually opaque', async () => { const content = [{ type: 'browser_state', tabs: [] }]; const result = await buildWithTestSidecar({ diff --git a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts index 6c06b05f4a..7655a19e18 100644 --- a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts @@ -21,19 +21,6 @@ test('ProtoJSON null scalar members are treated as unset', async () => { assertEquals(request.frequency_penalty, undefined); }); -test('independent text parts retain paragraph boundaries when Chat flattens them', async () => { - const request = await buildWithTestSidecar({ - contents: [ - { role: 'user', parts: [{ text: 'alpha' }, { text: 'beta' }] }, - { role: 'model', parts: [{ text: 'gamma' }, { text: 'delta' }] }, - ], - }, 'gpt-test'); - assertEquals(request.messages, [ - { role: 'user', content: 'alpha\n\nbeta' }, - { role: 'assistant', content: 'gamma\n\ndelta' }, - ]); -}); - test('buildTargetPayload translates LOW to reasoning_effort', async () => { const request = await buildWithTestSidecar({ contents: [], diff --git a/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts index 29c6592f0e..6e5376b895 100644 --- a/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts @@ -43,19 +43,6 @@ test('preserves an empty assistant turn between client turns', async () => { ]); }); -test('Responses text fragments remain contiguous when Chat flattens them', async () => { - const { target } = await buildWithTestSidecar({ - model: 'model', input: [ - { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'alpha' }, { type: 'input_text', text: 'beta' }] }, - { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'gamma' }, { type: 'output_text', text: 'delta' }] }, - ], - }); - assertEquals(target.messages, [ - { role: 'user', content: 'alphabeta' }, - { role: 'assistant', content: 'gammadelta' }, - ]); -}); - test('preserves nullable controls and snapshots mutable source objects', async () => { const payload: OpenAIResponsesRequestPayloadEx = { model: 'model', input: 'hi', metadata: { trace: 'before' }, service_tier: null, diff --git a/packages/translate/src/ir/input/types.ts b/packages/translate/src/ir/input/types.ts index 085428b639..fd3e652df0 100644 --- a/packages/translate/src/ir/input/types.ts +++ b/packages/translate/src/ir/input/types.ts @@ -181,9 +181,6 @@ export type IRInputAssistantContentPart = IRInputTextPart | IRInputRefusalPart; export interface IRInputTextPart { readonly type: 'text'; readonly text: string; - // Independently submitted text blocks retain their boundary when a target - // must flatten them into one string. Transport fragments leave this absent. - readonly block?: true; } // A refusal is model output, so only assistant history carries one. No protocol diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts index 5e85e649ea..daf1695cbb 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts @@ -45,7 +45,7 @@ const textPartsToIR = (content: string | readonly AnthropicMessagesTextBlockPara ? content === '' ? [] : [{ type: 'text', text: content }] : content.map(block => { if (block.type !== 'text') throw new TranslateInputException('Only text system content is supported.'); - return { type: 'text', text: block.text, block: true }; + return { type: 'text', text: block.text }; }); const base64Of = (text: string): string => { @@ -87,7 +87,7 @@ const userContentPart = ( at: MessagePosition, ): IRInputUserContentPart => { switch (block.type) { - case 'text': return { type: 'text', text: block.text, block: true }; + case 'text': return { type: 'text', text: block.text }; case 'image': return { type: 'image', image: imageMedia(block.source) }; case 'document': return { type: 'file', file: documentMedia(block) }; default: throw new TranslateInputException(`messages.${at.messageIdx}.content.${at.blockIdx}.type: '${(block as { type: string }).type}' content blocks are not supported in user content.`); @@ -112,7 +112,7 @@ const toolResultPart = (block: AnthropicMessagesToolResultBlock['content']): IRI continue; } flushSearchResults(); - if (part.type === 'text') parts.push({ type: 'text', text: part.text, block: true }); + if (part.type === 'text') parts.push({ type: 'text', text: part.text }); else if (part.type === 'image') parts.push({ type: 'image', image: imageMedia(part.source) }); else if (part.type === 'document') parts.push({ type: 'file', file: documentMedia(part) }); else parts.push({ type: 'text', text: JSON.stringify(part) }); @@ -232,7 +232,7 @@ const assistantSegments = (message: Extract parts.reduce((text, part, index) => - `${text}${index > 0 && (parts[index - 1]?.block === true || part.block === true) ? '\n\n' : ''}${part.text}`, ''); - const userContent = (item: Extract): string | OpenAIChatCompletionsUserContentPart[] => { - if (item.content.every((part): part is IRInputTextPart => part.type === 'text')) return flattenTextParts(item.content); + if (item.content.every(part => part.type === 'text')) return item.content.map(part => part.text).join(''); return item.content.map(part => { switch (part.type) { case 'text': return { type: 'text' as const, text: part.text }; @@ -74,7 +71,7 @@ export const openaiChatCompletionsToolResultContent = ( ): { readonly content: string; readonly lifted: OpenAIChatCompletionsUserContentPart[] } => { if (typeof output === 'string') return { content: output, lifted: [] }; if (output.some(part => part.type === 'file')) throw new TranslateInputException('Cannot translate file tool output to OpenAI Chat Completions.'); - const text = flattenTextParts(output.flatMap(part => part.type === 'text' ? [part] : [])); + const text = output.flatMap(part => part.type === 'text' ? [part.text] : []).join(''); const images = output.flatMap(part => part.type === 'image' ? [imageUrlPart(part.image, part.detail)] : []); return images.length === 0 ? { content: text, lifted: [] } @@ -85,7 +82,7 @@ export const openaiChatCompletionsToolResultContent = ( }; const assistantMessage = (items: readonly IRInputAssistantItemWithCallIds[]): OpenAIChatCompletionsMessage[] => { - const text: IRInputTextPart[] = []; + const text: string[] = []; const refusals: string[] = []; const reasoning: string[] = []; const toolCalls: OpenAIChatCompletionsToolCall[] = []; @@ -94,7 +91,7 @@ const assistantMessage = (items: readonly IRInputAssistantItemWithCallIds[]): Op switch (item.type) { case 'message': hasMessage = true; - for (const part of item.content) if (part.type === 'text') text.push(part); else refusals.push(part.refusal); + for (const part of item.content) if (part.type === 'text') text.push(part.text); else refusals.push(part.refusal); break; case 'reasoning': // We write readable reasoning as reasoning_text; input IR does not retain the source alias spelling. @@ -120,7 +117,7 @@ const assistantMessage = (items: readonly IRInputAssistantItemWithCallIds[]): Op // null; a turn with neither is an explicitly empty message and keeps ''. return [{ role: 'assistant', - content: text.length === 0 && (toolCalls.length > 0 || reasoning.length > 0) ? null : flattenTextParts(text), + content: text.length === 0 && (toolCalls.length > 0 || reasoning.length > 0) ? null : text.join(''), ...(refusals.length === 0 ? {} : { refusal: refusals.join('') }), ...(reasoning.length === 0 ? {} : { reasoning_text: reasoning.join('') }), ...(toolCalls.length === 0 ? {} : { tool_calls: toolCalls }), From 7038bee507aa4971ae302af8c1238abd8561ce19 Mon Sep 17 00:00:00 2001 From: Menci Date: Fri, 13 Nov 2026 03:07:09 +0800 Subject: [PATCH 59/66] fix(translate): restore authorized instruction and reasoning semantics Keep top-level instructions distinct from historical role messages, preserve readable reasoning beside opaque encrypted data, and remove speculative request snapshots.\n\nDrop the implementation-coupled Gemini accumulator test. --- .../as-source/native-reassembly_test.ts | 61 ------------------- .../as-source/input-to_test.ts | 8 ++- ...nses-to-anthropic-messages-request_test.ts | 33 ++++------ ...to-openai-chat-completions-request_test.ts | 20 +----- .../as-target/input-from.ts | 1 + .../openai-responses/as-source/input-to.ts | 5 +- 6 files changed, 24 insertions(+), 104 deletions(-) delete mode 100644 packages/translate/__tests__/protocols/gemini-generate-content/as-source/native-reassembly_test.ts 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 deleted file mode 100644 index 703d3cd5bd..0000000000 --- a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/native-reassembly_test.ts +++ /dev/null @@ -1,61 +0,0 @@ -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 { expectTrip } from '../../../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 { routeGeminiGenerateContentViaOpenAIChatCompletions } from '@floway-dev/translate'; - -vi.mock('@floway-dev/protocols/gemini-generate-content', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - createGeminiGenerateContentEventAccumulator: vi.fn(() => { - const accumulator = actual.createGeminiGenerateContentEventAccumulator(); - return { ...accumulator, candidate: vi.fn(accumulator.candidate), result: vi.fn(accumulator.result) }; - }), - }; -}); - -const chatFrames = async function* (count: number): AsyncGenerator> { - for (let index = 0; index < count; index++) yield eventFrame({ - id: 'chat', object: 'chat.completion.chunk', created: 1, model: 'target', - choices: [{ index, delta: { role: 'assistant', content: `answer-${index}` }, finish_reason: null }], - }); - for (let index = count - 1; index >= 0; index--) yield eventFrame({ - id: 'chat', object: 'chat.completion.chunk', created: 1, model: 'target', - choices: [{ index, delta: {}, finish_reason: 'stop' }], - }); - yield doneFrame(); -}; - -test.each([2, 32])('reads only the ending candidate for %i interleaved choices', async count => { - vi.mocked(createGeminiGenerateContentEventAccumulator).mockClear(); - const codec = createTestSidecarCodec(); - const trip = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions({ - model: 'model', contents: [{ role: 'user', parts: [{ text: 'question' }] }], generationConfig: { candidateCount: count }, - }, { sidecarCodec: codec })); - const events = async function* () { - for await (const frame of trip.events(chatFrames(count))) if (frame.type === 'event') yield frame.event; - }; - const result = await reassembleGeminiGenerateContentEvents(events()); - expect(result.candidates).toHaveLength(count); - for (const [index, candidate] of (result.candidates ?? []).entries()) { - expect(candidate.index).toBe(index); - expect(candidate.finishReason).toBe('STOP'); - expect(candidate.content?.parts?.filter(part => part.text !== undefined)).toEqual([{ text: `answer-${index}` }]); - const carrier = candidate.content?.parts?.find(part => part.thoughtSignature !== undefined)?.thoughtSignature; - if (carrier === undefined) throw new Error('Expected a round-trip sidecar'); - expect(await codec.decode(carrier)).toMatchObject({ - sourceTurnDigest: await computeGeminiGenerateContentTurnDigest([{ role: 'model', parts: [{ text: `answer-${index}` }] }]), - }); - } - expect(createGeminiGenerateContentEventAccumulator).toHaveBeenCalledTimes(1); - for (const creation of vi.mocked(createGeminiGenerateContentEventAccumulator).mock.results) { - if (creation.type !== 'return') throw new Error('Expected a Gemini accumulator'); - expect(creation.value.result).not.toHaveBeenCalled(); - expect(creation.value.candidate).toHaveBeenCalledTimes(count); - } -}); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/input-to_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/input-to_test.ts index eecc36f375..123ab79ec4 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/input-to_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/input-to_test.ts @@ -13,12 +13,14 @@ const source: OpenAIResponsesRequestPayloadEx = { }], }; -test('foreign encrypted reasoning is dropped as one opaque item', async () => { +test('readable reasoning stays separate in IR beside an opaque encrypted field', async () => { const slot = readOpenAIResponsesRequest(source).turns[0]; - expect(slot.toIRInputTurns()).toEqual([{ kind: 'assistant', items: [] }]); + expect(slot.toIRInputTurns()).toEqual([{ + kind: 'assistant', items: [{ type: 'reasoning', summary: ['S'], content: ['C'] }], + }]); const trip = await routeOpenAIResponsesViaOpenAIChatCompletions(source, { sidecarCodec: createTestSidecarCodec() }); if (trip.type !== 'trip') throw new Error(trip.message); - expect(trip.target.messages).toEqual([]); + expect(trip.target.messages).toEqual([{ role: 'assistant', content: null, reasoning_text: 'SC' }]); expect(JSON.stringify(trip.target)).not.toContain('opaque-vendor-data'); }); diff --git a/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts index 2a7a9bd0ef..383917b786 100644 --- a/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts @@ -46,7 +46,7 @@ test('buildTargetPayload preserves unsafe JSON number tokens in function history assertEquals(JSON.stringify(tool.input), '{"n":1e400}'); }); -test('top-level instructions and leading role messages share the Messages system field', async () => { +test('top-level instructions stay separate from leading historical role messages', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, instructions: 'base', @@ -56,19 +56,12 @@ test('top-level instructions and leading role messages share the Messages system { type: 'message', role: 'user', content: 'question' }, ], }); - assertEquals(Array.isArray(result.target.system) ? result.target.system.map(block => block.text) : undefined, ['base', 'rule', 'dev']); - assertEquals(result.target.messages, [{ role: 'user', content: [{ type: 'text', text: 'question', cache_control: { type: 'ephemeral' } }] }]); -}); - -test('foreign encrypted reasoning is dropped instead of creating an unsigned thinking block', async () => { - const result = await buildWithTestSidecar({ - ...minimalPayload, - input: [ - { type: 'reasoning', id: 'rs_foreign', summary: [{ type: 'summary_text', text: 'trace' }], encrypted_content: 'foreign' }, - { type: 'message', role: 'assistant', content: 'answer' }, - ], - }); - assertEquals(result.target.messages, [{ role: 'assistant', content: [{ type: 'text', text: 'answer', cache_control: { type: 'ephemeral' } }] }]); + assertEquals(result.target.system, [{ type: 'text', text: 'base', cache_control: { type: 'ephemeral' } }]); + assertEquals(result.target.messages, [ + { role: 'system', content: [{ type: 'text', text: 'rule' }] }, + { role: 'system', content: [{ type: 'text', text: 'dev' }] }, + { role: 'user', content: [{ type: 'text', text: 'question', cache_control: { type: 'ephemeral' } }] }, + ]); }); test('buildTargetPayload preserves OpenAI Responses assistant refusal history as Anthropic Messages text', async () => { @@ -1187,13 +1180,13 @@ test('buildTargetPayload preserves payload.instructions and leading system as se ); assertEquals(result.target.system, [ - { type: 'text', text: 'canonical top-level instructions' }, - { type: 'text', text: 'leading note', cache_control: { type: 'ephemeral' } }, + { type: 'text', text: 'canonical top-level instructions', cache_control: { type: 'ephemeral' } }, ]); - assertEquals(result.target.messages.length, 3); - assertEquals(result.target.messages[0].role, 'user'); - assertEquals(result.target.messages[1], { role: 'system', content: [{ type: 'text', text: 'mid-array note' }] }); - assertEquals(result.target.messages[2].role, 'user'); + assertEquals(result.target.messages.length, 4); + assertEquals(result.target.messages[0], { role: 'system', content: [{ type: 'text', text: 'leading note' }] }); + assertEquals(result.target.messages[1].role, 'user'); + assertEquals(result.target.messages[2], { role: 'system', content: [{ type: 'text', text: 'mid-array note' }] }); + assertEquals(result.target.messages[3].role, 'user'); }); test('buildTargetPayload throws when a system input message contains an image part', async () => { diff --git a/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts index 6e5376b895..1bf9327d8b 100644 --- a/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts @@ -43,24 +43,10 @@ test('preserves an empty assistant turn between client turns', async () => { ]); }); -test('preserves nullable controls and snapshots mutable source objects', async () => { - const payload: OpenAIResponsesRequestPayloadEx = { - model: 'model', input: 'hi', metadata: { trace: 'before' }, service_tier: null, - text: { format: { type: 'json_schema', json_schema: { name: 'shape', schema: { type: 'object', properties: { name: { type: 'string' } } } } } as never }, - }; - const building = buildWithTestSidecar(payload); - payload.metadata!.trace = 'after'; - const nested = payload.text!.format as unknown as { json_schema: { schema: { properties: { name: { type: string } } } } }; - nested.json_schema.schema.properties.name.type = 'number'; - const { target } = await building; - assertEquals(target.metadata, { trace: 'before' }); +test('preserves nullable metadata and service tier controls', async () => { + const { target } = await buildWithTestSidecar({ model: 'model', input: 'hi', metadata: null, service_tier: null }); + assertEquals(target.metadata, null); assertEquals(target.service_tier, null); - assertEquals(target.response_format?.type === 'json_schema' ? target.response_format.json_schema.schema : undefined, { - type: 'object', properties: { name: { type: 'string' } }, - }); - - const nullMetadata = await buildWithTestSidecar({ model: 'model', input: 'hi', metadata: null }); - assertEquals(nullMetadata.target.metadata, null); }); test('buildTargetPayload accepts an implicit message discriminator', async () => { diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts index f2c734fb1e..3291424210 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts @@ -239,6 +239,7 @@ export const anthropicMessagesConversationFromIR = ( if (item.type === 'instruction') { if (hoisting) { system.push(...systemContent(item.content)); + hoisting = false; continue; } const blocks = systemContent(item.content); diff --git a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts index 40533f17e4..14c63e0af1 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts @@ -112,7 +112,6 @@ const assistantItems = (items: readonly CanonicalOpenAIResponsesInputItem[]): IR } case 'reasoning': { - if (typeof item.encrypted_content === 'string') return undefined; const summary = item.summary.map(part => part.text); const content = (item.content ?? []).map(part => part.text); // No readable text means nothing any target could replay. @@ -317,7 +316,7 @@ const openaiResponsesSettingsToIR = (payload: CanonicalOpenAIResponsesPayload): // A caller may hand the Chat-Completions-nested spelling to the // Responses field; its details are still the same schema. ...('json_schema' in format && typeof format.json_schema === 'object' && format.json_schema !== null - ? klona(format.json_schema as Record) + ? format.json_schema as Record : {}), type: 'json_schema', ...('name' in format && typeof format.name === 'string' ? { name: format.name } : {}), @@ -338,7 +337,7 @@ const openaiResponsesSettingsToIR = (payload: CanonicalOpenAIResponsesPayload): ...(payload.frequency_penalty === undefined ? {} : { frequency_penalty: payload.frequency_penalty }), ...(payload.max_output_tokens === undefined ? {} : { max_output_tokens: payload.max_output_tokens }), ...(outputFormat === undefined ? {} : { output_format: outputFormat }), - ...(payload.metadata === undefined ? {} : { metadata: payload.metadata === null ? null : klona(payload.metadata) }), + ...(payload.metadata === undefined ? {} : { metadata: payload.metadata }), ...(payload.store === undefined ? {} : { store: payload.store }), ...(payload.service_tier === undefined ? {} : { service_tier: payload.service_tier }), ...(payload.parallel_tool_calls === undefined ? {} : { parallel_tool_calls: payload.parallel_tool_calls }), From eb1134f65ee24d3b6c71d56d0fe78d281786bf4b Mon Sep 17 00:00:00 2001 From: Menci Date: Fri, 13 Nov 2026 03:33:42 +0800 Subject: [PATCH 60/66] feat(translate): preserve document structure in input IR Represent Messages documents separately from files, retain content-backed text and image sequences, and project unsupported document semantics through provenance tags.\n\nRestore native Messages documents, emit tagged Responses content, and lift Chat tool-result files and documents alongside images. --- .../translate/__tests__/input-error_test.ts | 2 +- .../__tests__/ir/input/types_test.ts | 16 ++++- .../as-target/input-from_test.ts | 46 +++++++++++++ ...to-openai-chat-completions-request_test.ts | 65 ++++++++++++++++++- ...ssages-to-openai-responses-request_test.ts | 49 ++++++++++++-- ...to-openai-chat-completions-request_test.ts | 28 ++++---- packages/translate/src/ir/input/types.ts | 23 ++++++- .../anthropic-messages/as-source/input-to.ts | 41 +++++------- .../as-target/input-from.ts | 29 +++++++-- .../as-target/input-from.ts | 59 +++++++++++------ .../openai-responses/as-target/input-from.ts | 26 ++++++-- packages/translate/src/shared/document.ts | 9 +++ 12 files changed, 315 insertions(+), 78 deletions(-) create mode 100644 packages/translate/__tests__/protocols/anthropic-messages/as-target/input-from_test.ts create mode 100644 packages/translate/src/shared/document.ts diff --git a/packages/translate/__tests__/input-error_test.ts b/packages/translate/__tests__/input-error_test.ts index a2d6bda217..4424c87f24 100644 --- a/packages/translate/__tests__/input-error_test.ts +++ b/packages/translate/__tests__/input-error_test.ts @@ -14,7 +14,7 @@ const messages = { type: 'document' as const, source: { type: 'content' as const, - content: [{ type: 'text' as const, text: 'caption' }, { type: 'image' as const, source: { type: 'base64' as const, media_type: 'image/png' as const, data: 'AAAA' } }], + content: [{ type: 'unknown' } as never], }, }], }], diff --git a/packages/translate/__tests__/ir/input/types_test.ts b/packages/translate/__tests__/ir/input/types_test.ts index 4dad8c090c..dfede359fd 100644 --- a/packages/translate/__tests__/ir/input/types_test.ts +++ b/packages/translate/__tests__/ir/input/types_test.ts @@ -9,6 +9,19 @@ describe('input IR shape', () => { { type: 'image', image: { source_kind: 'base64', media_type: 'image/png', data: 'AAAA' } }, { type: 'file', file: { source_kind: 'file', file_id: 'file_1' }, filename: 'a.pdf' }, { type: 'file', file: { source_kind: 'base64', data: 'AAAA' } }, + { type: 'document', document: { source: { source_kind: 'media', media: { source_kind: 'file', file_id: 'file_2' } } } }, + { + type: 'document', + document: { + source: { + source_kind: 'content', + content: [ + { type: 'text', text: 'caption' }, + { type: 'image', image: { source_kind: 'base64', media_type: 'image/png', data: 'AAAA' } }, + ], + }, + }, + }, { type: 'audio', audio: { media_type: 'audio/wav', data: 'AAAA' } }, ]; @@ -45,9 +58,10 @@ describe('input IR shape', () => { const parts: IRInputToolResultContentPart[] = [ { type: 'text', text: 'caption' }, { type: 'file', file: { source_kind: 'base64', media_type: 'application/pdf', data: 'AAAA' }, filename: 'r.pdf' }, + { type: 'document', document: { source: { source_kind: 'text', media_type: 'text/plain', text: 'quoted' } } }, ]; const result: IRInputToolResultItem = { type: 'tool_result', call_id: 'call_1', output: parts }; - expect(Array.isArray(result.output) && result.output.map(part => part.type)).toEqual(['text', 'file']); + expect(Array.isArray(result.output) && result.output.map(part => part.type)).toEqual(['text', 'file', 'document']); }); test('a file may omit media type, while audio must state one', () => { diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-target/input-from_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-target/input-from_test.ts new file mode 100644 index 0000000000..63d64868a5 --- /dev/null +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-target/input-from_test.ts @@ -0,0 +1,46 @@ +import { test } from 'vitest'; + +import type { IRInputTurnWithCallIds } from '../../../../src/composition/request/call-ids.ts'; +import { anthropicMessagesConversationFromIR } from '../../../../src/protocols/anthropic-messages/as-target/input-from.ts'; +import { assertEquals } from '@floway-dev/test-utils'; + +test('writes a content-backed IR document as a native Messages document', () => { + const turns: IRInputTurnWithCallIds[] = [{ + kind: 'client', + items: [{ + type: 'message', role: 'user', content: [ + { + type: 'document', + document: { + source: { + source_kind: 'content', + content: [ + { type: 'text', text: 'caption' }, + { type: 'image', image: { source_kind: 'base64', media_type: 'image/png', data: 'AAAA' } }, + ], + }, + }, + }, + ], + }], + }]; + + assertEquals(anthropicMessagesConversationFromIR(turns, true), { + system: [], + messages: [{ + role: 'user', + content: [ + { + type: 'document', + source: { + type: 'content', + content: [ + { type: 'text', text: 'caption' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } }, + ], + }, + }, + ], + }], + }); +}); diff --git a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts index d0652f7d9f..79de3d9841 100644 --- a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts @@ -21,6 +21,37 @@ test('unmodeled structured tool-result blocks remain individually opaque', async assertEquals(result.messages.at(-1), { role: 'tool', tool_call_id: 'c', content: JSON.stringify(content[0]) }); }); +test('content-backed document tool output is lifted with its document tags', async () => { + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [ + { role: 'assistant', content: [{ type: 'tool_use', id: 'c', name: 'read', input: {} }] }, + { + role: 'user', + content: [{ + type: 'tool_result', tool_use_id: 'c', content: [{ + type: 'document', + source: { + type: 'content', + content: [ + { type: 'text', text: 'caption' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } }, + ], + }, + }], + }], + }, + ], + }); + assertEquals(result.messages.at(-2), { role: 'tool', tool_call_id: 'c', content: 'Document output is attached in the following user message.' }); + assertEquals(result.messages.at(-1)?.content, [ + { type: 'text', text: 'Document output from tool call c:' }, + { type: 'text', text: '' }, + { type: 'text', text: 'caption' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } }, + { type: 'text', text: '' }, + ]); +}); + test('buildTargetPayload maps thinking.disabled to reasoning_effort none', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', @@ -672,7 +703,7 @@ test('rejects a metadata value the neutral model cannot state as a string pair', ); }); -test('buildTargetPayload encodes a plain-text document source as base64', async () => { +test('buildTargetPayload marks a plain-text document around its file part', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -683,7 +714,37 @@ test('buildTargetPayload encodes a plain-text document source as base64', async }); const part = result.messages[0].content; if (!Array.isArray(part)) throw new Error('expected a content array'); - assertEquals(part[0], { type: 'file', file: { file_data: 'data:text/plain;base64,aGVsbG8gd29ybGQ=' } }); + assertEquals(part, [ + { type: 'text', text: '' }, + { type: 'file', file: { file_data: 'data:text/plain;base64,aGVsbG8gd29ybGQ=' } }, + { type: 'text', text: '' }, + ]); +}); + +test('buildTargetPayload keeps mixed document content inside provenance tags', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', max_tokens: 256, messages: [ + { + role: 'user', + content: [{ + type: 'document', + source: { + type: 'content', + content: [ + { type: 'text', text: 'caption' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } }, + ], + }, + }], + }, + ], + }); + assertEquals(result.messages[0].content, [ + { type: 'text', text: '' }, + { type: 'text', text: 'caption' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } }, + { type: 'text', text: '' }, + ]); }); test.each([true, false])('maps disable_parallel_tool_use %s without changing declarations', async disabled => { diff --git a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts index ae307ecb81..a85e2f5ad9 100644 --- a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts @@ -51,23 +51,60 @@ test('mixed tool-result blocks keep documents formal and unknown blocks opaque', }); const output = result.input.find(item => item.type === 'function_call_output'); assertEquals(output?.type === 'function_call_output' ? output.output : undefined, [ - { type: 'input_file', file_data: 'data:text/plain;base64,aGVsbG8=' }, + { type: 'input_text', text: '' }, + { type: 'input_text', text: 'hel' }, + { type: 'input_text', text: 'lo' }, + { type: 'input_text', text: '' }, { type: 'input_text', text: JSON.stringify(unknown) }, ]); }); test.each([ - ['string', 'hello', 'aGVsbG8='], - ['text blocks', [{ type: 'text' as const, text: 'hel' }, { type: 'text' as const, text: 'lo' }], 'aGVsbG8='], - ['empty text blocks', [], ''], -] as const)('inline content document %s enters the IR as a text file', async (_name, content, data) => { + ['string', 'hello', [{ type: 'input_text' as const, text: 'hello' }]], + ['text blocks', [{ type: 'text' as const, text: 'hel' }, { type: 'text' as const, text: 'lo' }], [ + { type: 'input_text' as const, text: 'hel' }, { type: 'input_text' as const, text: 'lo' }, + ]], + ['empty text blocks', [], []], +] as const)('inline content document %s retains its nested parts', async (_name, content, expected) => { const result = await buildWithTestSidecar({ model: 'm', max_tokens: 32, messages: [{ role: 'user', content: [{ type: 'document', source: { type: 'content', content: typeof content === 'string' ? content : [...content] } }], }], }); assertEquals(result.input, [{ - type: 'message', role: 'user', content: [{ type: 'input_file', file_data: `data:text/plain;base64,${data}` }], + type: 'message', role: 'user', content: [ + { type: 'input_text', text: '' }, + ...expected, + { type: 'input_text', text: '' }, + ], + }]); +}); + +test('inline mixed document content retains text and image order inside tags', async () => { + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [ + { + role: 'user', + content: [{ + type: 'document', + source: { + type: 'content', + content: [ + { type: 'text', text: 'caption' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } }, + ], + }, + }], + }, + ], + }); + assertEquals(result.input, [{ + type: 'message', role: 'user', content: [ + { type: 'input_text', text: '' }, + { type: 'input_text', text: 'caption' }, + { type: 'input_image', image_url: 'data:image/png;base64,AAAA' }, + { type: 'input_text', text: '' }, + ], }]); }); diff --git a/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts index 1bf9327d8b..cc7ebfec75 100644 --- a/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts @@ -516,20 +516,20 @@ test('buildTargetPayload accepts null tool_choice', async () => { assertEquals(result.target.tool_choice, undefined); }); -test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetPayload rejects input_file in %s', async type => { - await assertRejects( - () => buildWithTestSidecar({ - model: 'gpt-test', - input: [ - ...(type === 'function_call_output' - ? [{ type: 'function_call' as const, call_id: 'call_1', name: 'lookup', arguments: '{}' }] - : [{ type: 'custom_tool_call' as const, call_id: 'call_1', name: 'lookup', input: '{}' }]), - { type, call_id: 'call_1', output: [{ type: 'input_file', file_id: 'file_1' }] }, - ], - }), - Error, - 'file tool output', - ); +test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetPayload lifts input_file from %s', async type => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + input: [ + ...(type === 'function_call_output' + ? [{ type: 'function_call' as const, call_id: 'call_1', name: 'lookup', arguments: '{}' }] + : [{ type: 'custom_tool_call' as const, call_id: 'call_1', name: 'lookup', input: '{}' }]), + { type, call_id: 'call_1', output: [{ type: 'input_file', file_id: 'file_1', filename: 'result.txt' }] }, + ], + }); + assertEquals(result.target.messages.at(-1)?.content, [ + { type: 'text', text: 'File output from tool call call_1:' }, + { type: 'file', file: { file_id: 'file_1', filename: 'result.txt' } }, + ]); }); test('buildTargetPayload rejects file assistant content', async () => { diff --git a/packages/translate/src/ir/input/types.ts b/packages/translate/src/ir/input/types.ts index fd3e652df0..f73565b536 100644 --- a/packages/translate/src/ir/input/types.ts +++ b/packages/translate/src/ir/input/types.ts @@ -174,6 +174,7 @@ export type IRInputUserContentPart = | IRInputTextPart | IRInputImagePart | IRInputFilePart + | IRInputDocumentPart | IRInputAudioPart; export type IRInputAssistantContentPart = IRInputTextPart | IRInputRefusalPart; @@ -203,6 +204,26 @@ export interface IRInputFilePart { readonly filename?: string | null; } +export interface IRInputDocumentPart { + readonly type: 'document'; + readonly document: IRInputDocument; +} + +export interface IRInputDocument { + readonly source: IRInputDocumentSource; +} + +export type IRInputDocumentSource = + | { readonly source_kind: 'media'; readonly media: IRInputMedia } + | { readonly source_kind: 'text'; readonly media_type: 'text/plain'; readonly text: string } + | { readonly source_kind: 'content'; readonly content: readonly IRInputDocumentContentPart[] }; + +// Anthropic content-backed documents admit text and images only. Keeping the +// nested sequence lets non-document targets mark its provenance without +// pretending it was a byte file; a future GenerateContent target can project +// the same sequence to text and inlineData parts. +export type IRInputDocumentContentPart = IRInputTextPart | IRInputImagePart; + export interface IRInputAudioPart { readonly type: 'audio'; readonly audio: IRInputAudio; @@ -263,4 +284,4 @@ export interface IRInputToolResultItem { readonly is_error?: boolean; } -export type IRInputToolResultContentPart = IRInputTextPart | IRInputImagePart | IRInputFilePart; +export type IRInputToolResultContentPart = IRInputTextPart | IRInputImagePart | IRInputFilePart | IRInputDocumentPart; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts index daf1695cbb..966f228232 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts @@ -11,6 +11,8 @@ import type { IRInputToolChoice, IRInputOutputFormat, IRInputAssistantItem, + IRInputDocument, + IRInputDocumentContentPart, IRInputMedia, IRInputTextPart, IRInputToolResultContentPart, @@ -48,31 +50,22 @@ const textPartsToIR = (content: string | readonly AnthropicMessagesTextBlockPara return { type: 'text', text: block.text }; }); -const base64Of = (text: string): string => { - const bytes = new TextEncoder().encode(text); - let binary = ''; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary); -}; - -const documentMedia = (block: Extract): IRInputMedia => { +const documentFromBlock = (block: Extract): IRInputDocument => { const source = block.source; switch (source.type) { - case 'base64': return { source_kind: 'base64', media_type: source.media_type, data: source.data }; - case 'url': return { source_kind: 'url', url: source.url }; - case 'file': return { source_kind: 'file', file_id: source.file_id }; - // A `text` document source carries the document's bytes as plain text, while - // every target that accepts a file expects base64. Encoding here keeps the - // IR's media arms uniform. - case 'text': return { source_kind: 'base64', media_type: source.media_type, data: base64Of(source.data) }; + case 'base64': return { source: { source_kind: 'media', media: { source_kind: 'base64', media_type: source.media_type, data: source.data } } }; + case 'url': return { source: { source_kind: 'media', media: { source_kind: 'url', url: source.url } } }; + case 'file': return { source: { source_kind: 'media', media: { source_kind: 'file', file_id: source.file_id } } }; + case 'text': return { source: { source_kind: 'text', media_type: source.media_type, text: source.data } }; case 'content': { - if (typeof source.content === 'string') return { source_kind: 'base64', media_type: 'text/plain', data: base64Of(source.content) }; - if (source.content.every(content => content.type === 'text')) { - return { source_kind: 'base64', media_type: 'text/plain', data: base64Of(source.content.map(content => content.text).join('')) }; - } - const content = source.content.length === 1 ? source.content[0] : undefined; - if (content?.type === 'image') return imageMedia(content.source); - throw new TranslateInputException('Cannot translate a multi-part inline content document as one file.'); + const content: IRInputDocumentContentPart[] = typeof source.content === 'string' + ? [{ type: 'text', text: source.content }] + : source.content.map(part => { + if (part.type === 'text') return { type: 'text' as const, text: part.text }; + if (part.type === 'image') return { type: 'image' as const, image: imageMedia(part.source) }; + throw new TranslateInputException(`'${(part as { type: string }).type}' document content block is not supported.`); + }); + return { source: { source_kind: 'content', content } }; } } }; @@ -89,7 +82,7 @@ const userContentPart = ( switch (block.type) { case 'text': return { type: 'text', text: block.text }; case 'image': return { type: 'image', image: imageMedia(block.source) }; - case 'document': return { type: 'file', file: documentMedia(block) }; + case 'document': return { type: 'document', document: documentFromBlock(block) }; default: throw new TranslateInputException(`messages.${at.messageIdx}.content.${at.blockIdx}.type: '${(block as { type: string }).type}' content blocks are not supported in user content.`); } }; @@ -114,7 +107,7 @@ const toolResultPart = (block: AnthropicMessagesToolResultBlock['content']): IRI flushSearchResults(); if (part.type === 'text') parts.push({ type: 'text', text: part.text }); else if (part.type === 'image') parts.push({ type: 'image', image: imageMedia(part.source) }); - else if (part.type === 'document') parts.push({ type: 'file', file: documentMedia(part) }); + else if (part.type === 'document') parts.push({ type: 'document', document: documentFromBlock(part) }); else parts.push({ type: 'text', text: JSON.stringify(part) }); } flushSearchResults(); diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts index 3291424210..4afc6e2987 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts @@ -1,7 +1,7 @@ import { parseDataURL, type ParsedDataURL } from './data-url.ts'; import type { IRInputAssistantItemWithCallIds, IRInputTurnWithCallIds } from '../../../composition/request/call-ids.ts'; import { requireStreaming } from '../../../ir/input/streaming.ts'; -import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputMedia, IRInputTextPart, IRInputToolResultContentPart, IRInputClientItem } from '../../../ir/input/types.ts'; +import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputDocument, IRInputMedia, IRInputTextPart, IRInputToolResultContentPart, IRInputClientItem } from '../../../ir/input/types.ts'; import { buildCustomToolInputSchema } from '../../../shared/custom-tool.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import { type TranslateOptions } from '../../../types.ts'; @@ -55,7 +55,7 @@ const imageBlock = (media: IRInputMedia): AnthropicMessagesImageBlock => ({ type * Inline documents remain bytes. The target requires a media type, so an * untyped raw base64 value receives the generic binary type at this boundary. */ -const documentBlock = (media: IRInputMedia): Extract => { +const mediaDocumentBlock = (media: IRInputMedia): Extract => { switch (media.source_kind) { case 'url': { if (!media.url.startsWith('data:')) return { type: 'document', source: { type: 'url', url: media.url } }; @@ -71,6 +71,23 @@ const documentBlock = (media: IRInputMedia): Extract => { + const source = document.source; + if (source.source_kind === 'media') return mediaDocumentBlock(source.media); + if (source.source_kind === 'text') { + return { type: 'document', source: { type: 'text', media_type: source.media_type, data: source.text } }; + } + return { + type: 'document', + source: { + type: 'content', + content: source.content.map(part => part.type === 'text' + ? { type: 'text' as const, text: part.text } + : imageBlock(part.image)), + }, + }; +}; + const systemContent = (content: readonly IRInputTextPart[]): AnthropicMessagesTextBlockParam[] => content.map(part => ({ type: 'text', text: part.text })); const userContent = ( @@ -82,7 +99,10 @@ const userContent = ( case 'text': blocks.push({ type: 'text', text: part.text }); break; case 'image': blocks.push(imageBlock(part.image)); break; case 'file': - blocks.push(documentBlock(part.file)); + blocks.push(mediaDocumentBlock(part.file)); + break; + case 'document': + blocks.push(documentBlock(part.document)); break; case 'audio': throw new TranslateInputException(`Cannot translate an audio part of MIME type '${part.audio.media_type}' to Anthropic Messages.`); default: throw new TranslateInputException(`Invalid '${(part as { type: string }).type}' content part.`); @@ -99,7 +119,8 @@ const toolResultContent = ( const blocks: AnthropicMessagesToolResultContentBlock[] = []; for (const part of output) { if (part.type === 'text') { blocks.push({ type: 'text', text: part.text }); continue; } - if (part.type === 'file') { blocks.push(documentBlock(part.file)); continue; } + if (part.type === 'file') { blocks.push(mediaDocumentBlock(part.file)); continue; } + if (part.type === 'document') { blocks.push(documentBlock(part.document)); continue; } blocks.push(imageBlock(part.image)); } // Messages spells an empty tool result as the empty string. diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts index 2bd57ac437..262a2d9ccd 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts @@ -1,7 +1,8 @@ import type { IRInputAssistantItemWithCallIds, IRInputTurnWithCallIds, IRInputClientItemWithCallIds } from '../../../composition/request/call-ids.ts'; import { requireStreaming } from '../../../ir/input/streaming.ts'; -import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputMedia, IRInputToolResultContentPart, IRInputClientItem } from '../../../ir/input/types.ts'; +import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputDocument, IRInputMedia, IRInputToolResultContentPart, IRInputClientItem } from '../../../ir/input/types.ts'; import { buildCustomToolInputSchema } from '../../../shared/custom-tool.ts'; +import { DOCUMENT_CLOSE_TAG, DOCUMENT_OPEN_TAG, utf8Base64 } from '../../../shared/document.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import { reasoningEffortForBudget } from '../../../shared/reasoning-budget.ts'; import type { @@ -45,13 +46,26 @@ const filePart = (media: IRInputMedia, filename?: string | null): OpenAIChatComp } }; +const documentParts = (document: IRInputDocument): OpenAIChatCompletionsUserContentPart[] => { + const source = document.source; + const content = source.source_kind === 'media' + ? [filePart(source.media)] + : source.source_kind === 'text' + ? [filePart({ source_kind: 'base64', media_type: source.media_type, data: utf8Base64(source.text) })] + : source.content.map(part => part.type === 'text' + ? { type: 'text' as const, text: part.text } + : imageUrlPart(part.image, part.detail)); + return [{ type: 'text', text: DOCUMENT_OPEN_TAG }, ...content, { type: 'text', text: DOCUMENT_CLOSE_TAG }]; +}; + const userContent = (item: Extract): string | OpenAIChatCompletionsUserContentPart[] => { if (item.content.every(part => part.type === 'text')) return item.content.map(part => part.text).join(''); - return item.content.map(part => { + return item.content.flatMap(part => { switch (part.type) { case 'text': return { type: 'text' as const, text: part.text }; case 'image': return imageUrlPart(part.image, part.detail); case 'file': return filePart(part.file, part.filename); + case 'document': return documentParts(part.document); case 'audio': return { type: 'input_audio' as const, input_audio: { data: part.audio.data, format: audioFormat(part.audio.media_type) } }; } }); @@ -59,8 +73,8 @@ const userContent = (item: Extract { if (typeof output === 'string') return { content: output, lifted: [] }; - if (output.some(part => part.type === 'file')) throw new TranslateInputException('Cannot translate file tool output to OpenAI Chat Completions.'); const text = output.flatMap(part => part.type === 'text' ? [part.text] : []).join(''); - const images = output.flatMap(part => part.type === 'image' ? [imageUrlPart(part.image, part.detail)] : []); - return images.length === 0 - ? { content: text, lifted: [] } - : { - content: text === '' ? 'Image output is attached in the following user message.' : text, - lifted: [{ type: 'text', text: `Image output from tool call ${callId}:` }, ...images], - }; + const lifted = output.flatMap(part => part.type === 'image' + ? [imageUrlPart(part.image, part.detail)] + : part.type === 'file' ? [filePart(part.file, part.filename)] + : part.type === 'document' ? documentParts(part.document) : []); + if (lifted.length === 0) return { content: text, lifted: [] }; + const kinds = new Set(output.flatMap(part => part.type === 'image' || part.type === 'file' || part.type === 'document' ? [part.type] : [])); + const noun = kinds.size > 1 ? 'Attachments' : kinds.has('image') ? 'Image output' : kinds.has('file') ? 'File output' : 'Document output'; + return { + content: text === '' ? `${noun} is attached in the following user message.` : text, + lifted: [{ type: 'text', text: `${noun} from tool call ${callId}:` }, ...lifted], + }; }; const assistantMessage = (items: readonly IRInputAssistantItemWithCallIds[]): OpenAIChatCompletionsMessage[] => { @@ -128,20 +145,20 @@ const clientMessages = ( items: readonly IRInputClientItemWithCallIds[], legacyCallNames: ReadonlyMap, ): OpenAIChatCompletionsMessage[] => { - // Images lifted out of consecutive tool results gather into one user message + // Attachments lifted out of consecutive tool results gather into one user message // after the run, so a tool-call group stays contiguous. const messages: OpenAIChatCompletionsMessage[] = []; - let pendingImages: OpenAIChatCompletionsUserContentPart[] = []; - const flushImages = (): void => { - if (pendingImages.length === 0) return; - messages.push({ role: 'user', content: pendingImages }); - pendingImages = []; + let pendingAttachments: OpenAIChatCompletionsUserContentPart[] = []; + const flushAttachments = (): void => { + if (pendingAttachments.length === 0) return; + messages.push({ role: 'user', content: pendingAttachments }); + pendingAttachments = []; }; for (const item of items) { - if (item.type !== 'tool_result') flushImages(); - messages.push(...openaiChatCompletionsClientItem(item, lifted => pendingImages.push(...lifted), legacyCallNames)); + if (item.type !== 'tool_result') flushAttachments(); + messages.push(...openaiChatCompletionsClientItem(item, lifted => pendingAttachments.push(...lifted), legacyCallNames)); } - flushImages(); + flushAttachments(); return messages; }; diff --git a/packages/translate/src/protocols/openai-responses/as-target/input-from.ts b/packages/translate/src/protocols/openai-responses/as-target/input-from.ts index 7b9f5caf31..9758a5cdaa 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/input-from.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/input-from.ts @@ -1,6 +1,7 @@ import type { IRInputAssistantItemWithCallIds, IRInputTurnWithCallIds, IRInputClientItemWithCallIds } from '../../../composition/request/call-ids.ts'; import { requireStreaming } from '../../../ir/input/streaming.ts'; -import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputOutputFormat, IRInputMedia, IRInputToolResultContentPart, IRInputClientItem, IRInputInstructionItem } from '../../../ir/input/types.ts'; +import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputOutputFormat, IRInputDocument, IRInputMedia, IRInputToolResultContentPart, IRInputClientItem, IRInputInstructionItem } from '../../../ir/input/types.ts'; +import { DOCUMENT_CLOSE_TAG, DOCUMENT_OPEN_TAG, utf8Base64 } from '../../../shared/document.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import { reasoningEffortForBudget } from '../../../shared/reasoning-budget.ts'; import type { @@ -44,22 +45,39 @@ const fileContent = (media: IRInputMedia, filename?: string | null): OpenAIRespo } }; +const documentContent = (document: IRInputDocument): OpenAIResponsesInputContent[] => { + const source = document.source; + if (source.source_kind === 'media') return [fileContent(source.media)]; + if (source.source_kind === 'text') { + return [fileContent({ source_kind: 'base64', media_type: source.media_type, data: utf8Base64(source.text) })]; + } + return [ + { type: 'input_text', text: DOCUMENT_OPEN_TAG }, + ...source.content.map(part => part.type === 'text' + ? { type: 'input_text' as const, text: part.text } + : inputContent(part.image, part.detail)), + { type: 'input_text', text: DOCUMENT_CLOSE_TAG }, + ]; +}; + // Responses tool output admits text, images, and files, but not refusals, so it // is projected from the tool-result parts rather than the message-part union. const toolResultOutput = (output: string | readonly IRInputToolResultContentPart[]): string | OpenAIResponsesToolOutputContent[] => typeof output === 'string' ? output - : [...output].map(part => { + : [...output].flatMap(part => { if (part.type === 'text') return { type: 'input_text' as const, text: part.text }; if (part.type === 'image') return inputContent(part.image, part.detail); - return fileContent(part.file, part.filename); + if (part.type === 'file') return fileContent(part.file, part.filename); + return documentContent(part.document); }); // The content-array projection preserves IR part boundaries. -const userContent = (item: Extract): OpenAIResponsesInputContent[] => item.content.map(part => { +const userContent = (item: Extract): OpenAIResponsesInputContent[] => item.content.flatMap(part => { switch (part.type) { case 'text': return { type: 'input_text' as const, text: part.text }; case 'image': return inputContent(part.image, part.detail); case 'file': return fileContent(part.file, part.filename); + case 'document': return documentContent(part.document); case 'audio': throw new TranslateInputException(`Cannot translate an audio part of MIME type '${part.audio.media_type}' to OpenAI Responses.`); } }); diff --git a/packages/translate/src/shared/document.ts b/packages/translate/src/shared/document.ts new file mode 100644 index 0000000000..449532e30f --- /dev/null +++ b/packages/translate/src/shared/document.ts @@ -0,0 +1,9 @@ +export const DOCUMENT_OPEN_TAG = ''; +export const DOCUMENT_CLOSE_TAG = ''; + +export const utf8Base64 = (text: string): string => { + const bytes = new TextEncoder().encode(text); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary); +}; From cfcbaef87f89c1252a0e6a721fdad8a49ad84dfd Mon Sep 17 00:00:00 2001 From: Menci Date: Fri, 13 Nov 2026 03:38:56 +0800 Subject: [PATCH 61/66] refactor(translate): trim input IR commentary Remove comments that restate field names and types, retaining only cross-protocol invariants and non-obvious representation decisions. --- packages/translate/src/ir/input/streaming.ts | 1 - packages/translate/src/ir/input/types.ts | 88 +++----------------- 2 files changed, 11 insertions(+), 78 deletions(-) diff --git a/packages/translate/src/ir/input/streaming.ts b/packages/translate/src/ir/input/streaming.ts index f61113cc6a..c178049ad7 100644 --- a/packages/translate/src/ir/input/streaming.ts +++ b/packages/translate/src/ir/input/streaming.ts @@ -1,6 +1,5 @@ import type { IRInputSettings } from './types.ts'; -/** Translation trips consume upstream streams. A false stream setting violates the request boundary's contract. */ export const requireStreaming = (settings: IRInputSettings): true => { if (settings.stream !== true) throw new Error('A translation target requires a streaming request, but the IR states a non-streaming one.'); return true; diff --git a/packages/translate/src/ir/input/types.ts b/packages/translate/src/ir/input/types.ts index f73565b536..f3d660b683 100644 --- a/packages/translate/src/ir/input/types.ts +++ b/packages/translate/src/ir/input/types.ts @@ -1,42 +1,29 @@ -// Input Fit IR carries concepts translatable by at least one protocol pair. -// Authenticated native replay bypasses it; vendor-native residuals remain in protocol readers and sidecars. -// Requests arrive complete, so input IR is an immutable value rather than an operation stream. -// Target writers project the fields their wire can represent. +// Input Fit IR contains cross-protocol request semantics; authenticated native replay bypasses it. export interface IRInputSettings { readonly model: string; - // The return path is a stream, so the request path asks the upstream for one - // and a target writer rejects an IR that does not say `true`. readonly stream: boolean; readonly temperature?: number | null; readonly top_p?: number | null; readonly top_k?: number; readonly stop?: readonly string[]; readonly seed?: number; - // Preserve the visible-output ceiling independently of the cap that includes reasoning. + // The visible-output ceiling is distinct from a cap that includes reasoning. readonly max_visible_output_tokens?: number | null; readonly max_output_tokens?: number | null; readonly output_format?: IRInputOutputFormat; - // Readers normalize non-null metadata to string pairs; target writers do not coerce it again. readonly metadata?: Readonly> | null; readonly store?: boolean | null; - // Preserve service-tier strings and explicit null; each target owns vocabulary mapping. readonly service_tier?: string | null; readonly parallel_tool_calls?: boolean | null; - // Keep effort as an open string, independently of numeric budget. Protocol readers and writers own their thinking-mode mapping. + // Effort and numeric budget are independent source controls. readonly reasoning_effort?: string; - // Budget and effort remain independent; a source may cap reasoning tokens without naming an effort level. readonly reasoning_budget?: number; - // Dynamic budgeting represents model-selected reasoning limits: a negative GenerateContent budget or adaptive Messages thinking. readonly reasoning_budget_dynamic?: boolean; - // Preserve the summary scale while normalizing boolean source preferences at their reader. readonly reasoning_summary?: string; readonly verbosity?: string; - // Preserve an explicit nullable cache key exactly as supplied, independently of field omission. readonly prompt_cache_key?: string | null; - // Retention and breakpoint lifetime are independent controls. readonly prompt_cache_retention?: string | null; readonly prompt_cache_options?: IRInputPromptCacheOptions | null; - // Preserve an explicit nullable safety identifier exactly as supplied. readonly safety_identifier?: string | null; readonly user?: string | null; readonly logprobs?: boolean | null; @@ -51,7 +38,6 @@ export interface IRInputPromptCacheOptions { readonly ttl?: string; } -// Structured output. `strict` keeps absent, null, false, and true distinct. export interface IRInputOutputFormat { readonly type: 'text' | 'json_object' | 'json_schema'; readonly name?: string; @@ -60,9 +46,7 @@ export interface IRInputOutputFormat { readonly strict?: boolean | null; } -// One request's own fields, from the source's wire to the target's. The -// conversation is not part of it: turns travel separately as turns, because a -// writer needs them interleaved with the assistant turns a sidecar restored. +// Turns stay separate so restored native assistant turns can be interleaved. export interface IRInputRequestHeader { readonly settings: IRInputSettings; // Absent and empty are the same thing: a request that declares no tool. @@ -70,26 +54,16 @@ export interface IRInputRequestHeader { readonly tool_choice?: IRInputToolChoice; } -// A callable the model may invoke. Hosted tools (web search, code execution, -// file search, computer use) have no cross-vendor meaning and stay in the -// native reader, matching the Fit rule for hosted history. -// -// Messages expresses both arms as one declaration shape; the distinction -// survives only where the target distinguishes it. +// Hosted tools have no cross-protocol IR form. export type IRInputTool = IRInputFunctionTool | IRInputCustomTool; export interface IRInputFunctionTool extends IRInputToolSelector { readonly type: 'function'; readonly description?: string; - // Preserve omitted source schemas; each target owns any schema its wire requires. readonly parameters?: Readonly>; - // Preserve strict as true, false or source-unspecified; each target applies its wire requirement. readonly strict?: boolean; } -// A freeform tool: the model returns the string the declaration describes. -// Responses has a native arm for it; Chat Completions and Messages wrap it in a -// single-`input`-string function schema. export interface IRInputCustomTool extends IRInputToolSelector { readonly type: 'custom'; readonly description?: string; @@ -100,10 +74,7 @@ export type IRInputCustomToolFormat = | { readonly type: 'text' } | { readonly type: 'grammar'; readonly definition: string; readonly syntax: string }; -// What the model may call, and how freely. `allowed_tools` states a mode with a -// restricted callable set, so it stays a distinct arm rather than being flattened -// into that mode plus a filtered declaration list: widening or narrowing the -// declaration list changes what the model can see, not what it may choose. +// `allowed_tools` restricts calls without changing the visible declarations. export type IRInputToolChoice = | 'auto' | 'none' @@ -111,23 +82,14 @@ export type IRInputToolChoice = | IRInputToolSelector | { readonly type: 'allowed_tools'; readonly mode: 'auto' | 'required'; readonly tools: readonly IRInputToolSelector[] }; -// A selector names one declared callable by kind and its logical identity, which -// is what every protocol's selector arm states. A protocol that groups its -// declarations states the group here too. export interface IRInputToolSelector { readonly type: 'function' | 'custom'; readonly name: string; readonly namespace?: string; } -// A turn groups one conversation branch. Assistant messages keep their own -// boundaries; top-level instructions and inline system/developer messages retain distinct client -// boundaries. Items stay ordered while each -// target chooses its native message grouping. export type IRInputTurn = IRInputClientTurn | IRInputAssistantTurn; -// Client turns contain all nonassistant input, including instructions, messages, and tool results. -// Kind identifies the conversation branch; item role identifies the speaker. export interface IRInputClientTurn { readonly kind: 'client'; readonly items: readonly IRInputClientItem[]; @@ -138,13 +100,7 @@ export interface IRInputAssistantTurn { readonly items: readonly IRInputAssistantItem[]; } -// Items stay flat and ordered because protocols disagree about where a message -// boundary sits: Messages keeps tool results inside a user message, Chat -// Completions gives them a `tool` message, Responses makes them an input item, -// and GenerateContent makes them a part of a user content. Keeping a source -// message's own parts together while tool results and tool calls remain -// separate items lets every writer reproduce its own grouping without this IR -// prejudging one protocol's shape. +// Flat ordered items let each target reconstruct its own message grouping. export type IRInputClientItem = IRInputInstructionItem | IRInputClientMessageItem | IRInputToolResultItem; export type IRInputAssistantItem = @@ -153,18 +109,15 @@ export type IRInputAssistantItem = | IRInputFunctionCallItem | IRInputCustomToolCallItem; -/** A source protocol's top-level instruction field, distinct from conversation history. */ export interface IRInputInstructionItem { readonly type: 'instruction'; readonly content: readonly IRInputTextPart[]; } -/** A client-side conversation message at its source position. */ export type IRInputClientMessageItem = | { readonly type: 'message'; readonly role: 'user'; readonly content: readonly IRInputUserContentPart[] } | { readonly type: 'message'; readonly role: 'system' | 'developer'; readonly content: readonly IRInputTextPart[] }; -// Assistant IR projects text and refusals. Source readers reject assistant media that this model cannot represent. export interface IRInputAssistantMessageItem { readonly type: 'message'; readonly content: readonly IRInputAssistantContentPart[]; @@ -184,8 +137,6 @@ export interface IRInputTextPart { readonly text: string; } -// A refusal is model output, so only assistant history carries one. No protocol -// accepts it in client-produced content. export interface IRInputRefusalPart { readonly type: 'refusal'; readonly refusal: string; @@ -194,7 +145,6 @@ export interface IRInputRefusalPart { export interface IRInputImagePart { readonly type: 'image'; readonly image: IRInputMedia; - // Preserve the open-string image detail value; each target owns its supported projection. readonly detail?: string | null; } @@ -218,10 +168,7 @@ export type IRInputDocumentSource = | { readonly source_kind: 'text'; readonly media_type: 'text/plain'; readonly text: string } | { readonly source_kind: 'content'; readonly content: readonly IRInputDocumentContentPart[] }; -// Anthropic content-backed documents admit text and images only. Keeping the -// nested sequence lets non-document targets mark its provenance without -// pretending it was a byte file; a future GenerateContent target can project -// the same sequence to text and inlineData parts. +// Content-backed documents contain text and images, not file parts. export type IRInputDocumentContentPart = IRInputTextPart | IRInputImagePart; export interface IRInputAudioPart { @@ -229,24 +176,18 @@ export interface IRInputAudioPart { readonly audio: IRInputAudio; } -// Chat Completions names a two-value `format` enum and GenerateContent names a -// MIME type; the MIME type is the neutral spelling, so readers normalize into -// it and the Chat writer derives its enum. export interface IRInputAudio { readonly media_type: string; readonly data: string; } -// Preserve URL and file references exactly as stated. Inline media retains its supplied MIME type; -// bare base64 remains untyped. Target writers choose the wire representation. export type IRInputMedia = | { readonly source_kind: 'url'; readonly url: string } | { readonly source_kind: 'data'; readonly data: string } | { readonly source_kind: 'base64'; readonly media_type?: string; readonly data: string } | { readonly source_kind: 'file'; readonly file_id: string }; -// Only readable reasoning enters IR; opaque signed replay state remains in authenticated sidecars. -// Summary and content stay separate so each target can project the fields it supports. +// Opaque signed reasoning remains in authenticated sidecars. export interface IRInputReasoningItem { readonly type: 'reasoning'; readonly id?: string; @@ -256,10 +197,8 @@ export interface IRInputReasoningItem { export interface IRInputFunctionCallItem extends Pick { readonly type: 'function_call'; - // Call IDs may be absent in input IR; target request preparation supplies those required by its wire. readonly call_id?: string; - // Keep argument JSON as raw text, including malformed input. Each target parses at its own boundary. - // Absence means the source supplied no argument object and remains distinct from explicit null or {}. + // Raw JSON preserves malformed input and distinguishes omission from null or {}. readonly arguments?: string; } @@ -271,16 +210,11 @@ export interface IRInputCustomToolCallItem extends Pick Date: Fri, 13 Nov 2026 03:47:20 +0800 Subject: [PATCH 62/66] docs(translate): mark input IR protocol coverage Annotate every input IR type and field with the source protocols that can populate it, matching the compact coverage style used by output IR. --- packages/translate/src/ir/input/types.ts | 282 ++++++++++++++--------- 1 file changed, 179 insertions(+), 103 deletions(-) diff --git a/packages/translate/src/ir/input/types.ts b/packages/translate/src/ir/input/types.ts index f3d660b683..2cbbdb4483 100644 --- a/packages/translate/src/ir/input/types.ts +++ b/packages/translate/src/ir/input/types.ts @@ -1,128 +1,164 @@ // Input Fit IR contains cross-protocol request semantics; authenticated native replay bypasses it. +// Coverage comments name the source protocols that can populate each type or field. +// All export interface IRInputSettings { - readonly model: string; - readonly stream: boolean; - readonly temperature?: number | null; - readonly top_p?: number | null; - readonly top_k?: number; - readonly stop?: readonly string[]; - readonly seed?: number; + readonly model: string; // All + readonly stream: boolean; // All + readonly temperature?: number | null; // All + readonly top_p?: number | null; // All + readonly top_k?: number; // Messages, GenerateContent + readonly stop?: readonly string[]; // ChatCompletions, Messages, GenerateContent + readonly seed?: number; // ChatCompletions, GenerateContent // The visible-output ceiling is distinct from a cap that includes reasoning. - readonly max_visible_output_tokens?: number | null; - readonly max_output_tokens?: number | null; - readonly output_format?: IRInputOutputFormat; - readonly metadata?: Readonly> | null; - readonly store?: boolean | null; - readonly service_tier?: string | null; - readonly parallel_tool_calls?: boolean | null; + readonly max_visible_output_tokens?: number | null; // ChatCompletions + readonly max_output_tokens?: number | null; // All + readonly output_format?: IRInputOutputFormat; // All + readonly metadata?: Readonly> | null; // ChatCompletions, Responses, Messages + readonly store?: boolean | null; // ChatCompletions, Responses + readonly service_tier?: string | null; // ChatCompletions, Responses, Messages + readonly parallel_tool_calls?: boolean | null; // ChatCompletions, Responses, Messages // Effort and numeric budget are independent source controls. - readonly reasoning_effort?: string; - readonly reasoning_budget?: number; - readonly reasoning_budget_dynamic?: boolean; - readonly reasoning_summary?: string; - readonly verbosity?: string; - readonly prompt_cache_key?: string | null; - readonly prompt_cache_retention?: string | null; - readonly prompt_cache_options?: IRInputPromptCacheOptions | null; - readonly safety_identifier?: string | null; - readonly user?: string | null; - readonly logprobs?: boolean | null; - readonly top_logprobs?: number | null; - readonly choice_count?: number; - readonly presence_penalty?: number | null; - readonly frequency_penalty?: number | null; + readonly reasoning_effort?: string; // All + readonly reasoning_budget?: number; // Messages, GenerateContent + readonly reasoning_budget_dynamic?: boolean; // Messages, GenerateContent + readonly reasoning_summary?: string; // Responses + readonly verbosity?: string; // ChatCompletions, Responses + readonly prompt_cache_key?: string | null; // ChatCompletions, Responses + readonly prompt_cache_retention?: string | null; // ChatCompletions, Responses + readonly prompt_cache_options?: IRInputPromptCacheOptions | null; // ChatCompletions, Responses + readonly safety_identifier?: string | null; // ChatCompletions, Responses + readonly user?: string | null; // ChatCompletions, Responses + readonly logprobs?: boolean | null; // ChatCompletions, Responses + readonly top_logprobs?: number | null; // ChatCompletions, Responses + readonly choice_count?: number; // ChatCompletions, GenerateContent + readonly presence_penalty?: number | null; // ChatCompletions, Responses, GenerateContent + readonly frequency_penalty?: number | null; // ChatCompletions, Responses, GenerateContent } +// ChatCompletions, Responses export interface IRInputPromptCacheOptions { - readonly mode?: string; - readonly ttl?: string; + readonly mode?: string; // ChatCompletions, Responses + readonly ttl?: string; // ChatCompletions, Responses } +// All export interface IRInputOutputFormat { - readonly type: 'text' | 'json_object' | 'json_schema'; - readonly name?: string; - readonly description?: string; - readonly schema?: Readonly>; - readonly strict?: boolean | null; + readonly type: 'text' | 'json_object' | 'json_schema'; // All + readonly name?: string; // All + readonly description?: string; // ChatCompletions, Responses + readonly schema?: Readonly>; // All + readonly strict?: boolean | null; // ChatCompletions, Responses, Messages } // Turns stay separate so restored native assistant turns can be interleaved. +// All export interface IRInputRequestHeader { - readonly settings: IRInputSettings; + readonly settings: IRInputSettings; // All // Absent and empty are the same thing: a request that declares no tool. - readonly tools?: readonly IRInputTool[]; - readonly tool_choice?: IRInputToolChoice; + readonly tools?: readonly IRInputTool[]; // All + readonly tool_choice?: IRInputToolChoice; // All } // Hosted tools have no cross-protocol IR form. +// All export type IRInputTool = IRInputFunctionTool | IRInputCustomTool; +// All export interface IRInputFunctionTool extends IRInputToolSelector { - readonly type: 'function'; - readonly description?: string; - readonly parameters?: Readonly>; - readonly strict?: boolean; + readonly type: 'function'; // All + readonly description?: string; // All + readonly parameters?: Readonly>; // All + readonly strict?: boolean; // ChatCompletions, Responses, Messages } +// ChatCompletions, Responses export interface IRInputCustomTool extends IRInputToolSelector { - readonly type: 'custom'; - readonly description?: string; - readonly format?: IRInputCustomToolFormat; + readonly type: 'custom'; // ChatCompletions, Responses + readonly description?: string; // ChatCompletions, Responses + readonly format?: IRInputCustomToolFormat; // ChatCompletions, Responses } +// ChatCompletions, Responses export type IRInputCustomToolFormat = - | { readonly type: 'text' } - | { readonly type: 'grammar'; readonly definition: string; readonly syntax: string }; + | { readonly type: 'text' } // ChatCompletions, Responses + | { + readonly type: 'grammar'; // ChatCompletions, Responses + readonly definition: string; // ChatCompletions, Responses + readonly syntax: string; // ChatCompletions, Responses + }; // `allowed_tools` restricts calls without changing the visible declarations. +// All export type IRInputToolChoice = | 'auto' | 'none' | 'required' | IRInputToolSelector - | { readonly type: 'allowed_tools'; readonly mode: 'auto' | 'required'; readonly tools: readonly IRInputToolSelector[] }; + | { + readonly type: 'allowed_tools'; // ChatCompletions, Responses, GenerateContent + readonly mode: 'auto' | 'required'; // ChatCompletions, Responses, GenerateContent + readonly tools: readonly IRInputToolSelector[]; // ChatCompletions, Responses, GenerateContent + }; +// All export interface IRInputToolSelector { - readonly type: 'function' | 'custom'; - readonly name: string; - readonly namespace?: string; + readonly type: 'function' | 'custom'; // All + readonly name: string; // All + readonly namespace?: string; // Responses } +// All export type IRInputTurn = IRInputClientTurn | IRInputAssistantTurn; +// All export interface IRInputClientTurn { - readonly kind: 'client'; - readonly items: readonly IRInputClientItem[]; + readonly kind: 'client'; // All + readonly items: readonly IRInputClientItem[]; // All } +// All export interface IRInputAssistantTurn { - readonly kind: 'assistant'; - readonly items: readonly IRInputAssistantItem[]; + readonly kind: 'assistant'; // All + readonly items: readonly IRInputAssistantItem[]; // All } // Flat ordered items let each target reconstruct its own message grouping. +// All export type IRInputClientItem = IRInputInstructionItem | IRInputClientMessageItem | IRInputToolResultItem; +// All export type IRInputAssistantItem = | IRInputAssistantMessageItem | IRInputReasoningItem | IRInputFunctionCallItem | IRInputCustomToolCallItem; +// Messages, Responses, GenerateContent export interface IRInputInstructionItem { - readonly type: 'instruction'; - readonly content: readonly IRInputTextPart[]; + readonly type: 'instruction'; // Messages, Responses, GenerateContent + readonly content: readonly IRInputTextPart[]; // Messages, Responses, GenerateContent } +// All export type IRInputClientMessageItem = - | { readonly type: 'message'; readonly role: 'user'; readonly content: readonly IRInputUserContentPart[] } - | { readonly type: 'message'; readonly role: 'system' | 'developer'; readonly content: readonly IRInputTextPart[] }; - + | { + readonly type: 'message'; // All + readonly role: 'user'; // All + readonly content: readonly IRInputUserContentPart[]; // All + } + | { + readonly type: 'message'; // ChatCompletions, Responses, Messages + readonly role: 'system' | 'developer'; // ChatCompletions, Responses, Messages + readonly content: readonly IRInputTextPart[]; // ChatCompletions, Responses, Messages + }; + +// All export interface IRInputAssistantMessageItem { - readonly type: 'message'; - readonly content: readonly IRInputAssistantContentPart[]; + readonly type: 'message'; // All + readonly content: readonly IRInputAssistantContentPart[]; // All } +// All export type IRInputUserContentPart = | IRInputTextPart | IRInputImagePart @@ -130,92 +166,132 @@ export type IRInputUserContentPart = | IRInputDocumentPart | IRInputAudioPart; +// All export type IRInputAssistantContentPart = IRInputTextPart | IRInputRefusalPart; +// All export interface IRInputTextPart { - readonly type: 'text'; - readonly text: string; + readonly type: 'text'; // All + readonly text: string; // All } +// ChatCompletions, Responses export interface IRInputRefusalPart { - readonly type: 'refusal'; - readonly refusal: string; + readonly type: 'refusal'; // ChatCompletions, Responses + readonly refusal: string; // ChatCompletions, Responses } +// All export interface IRInputImagePart { - readonly type: 'image'; - readonly image: IRInputMedia; - readonly detail?: string | null; + readonly type: 'image'; // All + readonly image: IRInputMedia; // All + readonly detail?: string | null; // ChatCompletions, Responses } +// ChatCompletions, Responses, GenerateContent export interface IRInputFilePart { - readonly type: 'file'; - readonly file: IRInputMedia; - readonly filename?: string | null; + readonly type: 'file'; // ChatCompletions, Responses, GenerateContent + readonly file: IRInputMedia; // ChatCompletions, Responses, GenerateContent + readonly filename?: string | null; // ChatCompletions, Responses } +// Messages export interface IRInputDocumentPart { - readonly type: 'document'; - readonly document: IRInputDocument; + readonly type: 'document'; // Messages + readonly document: IRInputDocument; // Messages } +// Messages export interface IRInputDocument { - readonly source: IRInputDocumentSource; + readonly source: IRInputDocumentSource; // Messages } +// Messages export type IRInputDocumentSource = - | { readonly source_kind: 'media'; readonly media: IRInputMedia } - | { readonly source_kind: 'text'; readonly media_type: 'text/plain'; readonly text: string } - | { readonly source_kind: 'content'; readonly content: readonly IRInputDocumentContentPart[] }; + | { + readonly source_kind: 'media'; // Messages + readonly media: IRInputMedia; // Messages + } + | { + readonly source_kind: 'text'; // Messages + readonly media_type: 'text/plain'; // Messages + readonly text: string; // Messages + } + | { + readonly source_kind: 'content'; // Messages + readonly content: readonly IRInputDocumentContentPart[]; // Messages + }; // Content-backed documents contain text and images, not file parts. +// Messages export type IRInputDocumentContentPart = IRInputTextPart | IRInputImagePart; +// ChatCompletions, GenerateContent export interface IRInputAudioPart { - readonly type: 'audio'; - readonly audio: IRInputAudio; + readonly type: 'audio'; // ChatCompletions, GenerateContent + readonly audio: IRInputAudio; // ChatCompletions, GenerateContent } +// ChatCompletions, GenerateContent export interface IRInputAudio { - readonly media_type: string; - readonly data: string; + readonly media_type: string; // ChatCompletions, GenerateContent + readonly data: string; // ChatCompletions, GenerateContent } +// All export type IRInputMedia = - | { readonly source_kind: 'url'; readonly url: string } - | { readonly source_kind: 'data'; readonly data: string } - | { readonly source_kind: 'base64'; readonly media_type?: string; readonly data: string } - | { readonly source_kind: 'file'; readonly file_id: string }; + | { + readonly source_kind: 'url'; // ChatCompletions, Responses, Messages + readonly url: string; // ChatCompletions, Responses, Messages + } + | { + readonly source_kind: 'data'; // ChatCompletions, Responses + readonly data: string; // ChatCompletions, Responses + } + | { + readonly source_kind: 'base64'; // Messages, GenerateContent + readonly media_type?: string; // Messages, GenerateContent + readonly data: string; // Messages, GenerateContent + } + | { + readonly source_kind: 'file'; // ChatCompletions, Responses, Messages + readonly file_id: string; // ChatCompletions, Responses, Messages + }; // Opaque signed reasoning remains in authenticated sidecars. +// Messages, Responses, GenerateContent export interface IRInputReasoningItem { - readonly type: 'reasoning'; - readonly id?: string; - readonly summary?: readonly string[]; - readonly content?: readonly string[]; + readonly type: 'reasoning'; // Messages, Responses, GenerateContent + readonly id?: string; // Responses, GenerateContent + readonly summary?: readonly string[]; // Messages, Responses, GenerateContent + readonly content?: readonly string[]; // Responses } +// All export interface IRInputFunctionCallItem extends Pick { - readonly type: 'function_call'; - readonly call_id?: string; + readonly type: 'function_call'; // All + readonly call_id?: string; // All // Raw JSON preserves malformed input and distinguishes omission from null or {}. - readonly arguments?: string; + readonly arguments?: string; // All } +// ChatCompletions, Responses export interface IRInputCustomToolCallItem extends Pick { - readonly type: 'custom_tool_call'; - readonly call_id?: string; - readonly input: string; + readonly type: 'custom_tool_call'; // ChatCompletions, Responses + readonly call_id?: string; // ChatCompletions, Responses + readonly input: string; // ChatCompletions, Responses } +// All export interface IRInputToolResultItem { - readonly type: 'tool_result'; - readonly call_id?: string; - readonly name?: string; + readonly type: 'tool_result'; // All + readonly call_id?: string; // All + readonly name?: string; // ChatCompletions, GenerateContent // Strings stay opaque; arrays preserve structured multimodal output. - readonly output: string | readonly IRInputToolResultContentPart[]; - readonly tool_kind?: 'function' | 'custom'; - readonly is_error?: boolean; + readonly output: string | readonly IRInputToolResultContentPart[]; // All + readonly tool_kind?: 'function' | 'custom'; // ChatCompletions, Responses + readonly is_error?: boolean; // Messages, Responses } +// All export type IRInputToolResultContentPart = IRInputTextPart | IRInputImagePart | IRInputFilePart | IRInputDocumentPart; From fc2d2cb74481198b8ed92ed45e2baf596dd6746f Mon Sep 17 00:00:00 2001 From: Menci Date: Fri, 13 Nov 2026 05:00:12 +0800 Subject: [PATCH 63/66] refactor(translate): simplify input translation Remove redundant validation and indirection while preserving request projections. Correct protocol coverage annotations and current translation ownership notes. --- packages/translate/AGENTS.md | 10 ++++----- .../composition/request/tool-names_test.ts | 2 -- .../as-target/legacy-calls_test.ts | 3 +-- ...tent-to-anthropic-messages-request_test.ts | 2 -- ...to-openai-chat-completions-request_test.ts | 3 --- ...ontent-to-openai-responses-request_test.ts | 3 --- ...ions-to-anthropic-messages-request_test.ts | 2 -- ...nses-to-anthropic-messages-request_test.ts | 4 ---- ...to-openai-chat-completions-request_test.ts | 2 -- packages/translate/src/composition/index.ts | 1 - .../src/composition/request/call-ids.ts | 2 +- packages/translate/src/ir/input/types.ts | 4 ++-- .../anthropic-messages/as-source/input-to.ts | 5 +---- .../protocols/anthropic-messages/as-target.ts | 2 +- .../anthropic-messages/as-target/data-url.ts | 5 +---- .../as-target/input-from.ts | 21 ++++++------------ .../as-target/segmented-request.ts | 2 +- .../as-source/input-to.ts | 9 +++----- .../openai-chat-completions/as-source.ts | 4 ++-- .../as-source/input-to.ts | 7 ------ .../as-target/input-from.ts | 11 +++++----- .../protocols/openai-responses/as-source.ts | 1 - .../openai-responses/as-source/events.ts | 2 -- .../openai-responses/as-target/input-from.ts | 22 ++++++------------- 24 files changed, 37 insertions(+), 92 deletions(-) diff --git a/packages/translate/AGENTS.md b/packages/translate/AGENTS.md index 85280f4a9c..16f60b6342 100644 --- a/packages/translate/AGENTS.md +++ b/packages/translate/AGENTS.md @@ -17,7 +17,7 @@ Protocol-native request normalization and Part data classification belong to Only routes whose source is that protocol may import it. 3. **Target-only helper** — keep it in `protocols//as-target/`. Only routes whose target is that protocol may import it. -4. **Output IR** — keep private types, its path schema, stream operations, +4. **IR** — keep private types, its path schema, stream operations, validation, accumulation, operation paths, and native unit index allocation under `ir/output/`, and the input model under `ir/input/`. A reading holds the source's native assistant turns beside the IR it would produce, so it belongs @@ -48,10 +48,10 @@ Protocol-native request normalization and Part data classification belong to - `protocols/anthropic-messages/as-source/` — interprets Messages requests and emits Messages SSE. - `protocols/anthropic-messages/as-target/` — maps requests to Messages and consumes Messages SSE. - `protocols/gemini-generate-content/as-source/` — interprets GenerateContent parts and schemas and emits GenerateContent SSE. -- `protocols/openai-chat-completions/as-source/` — emits ChatCompletions SSE. -- `protocols/openai-chat-completions/as-target/` — consumes ChatCompletions SSE. -- `protocols/openai-responses/as-source/` — interprets Responses tools, restores event identities, and emits Responses SSE. -- `protocols/openai-responses/as-target/` — consumes Responses SSE. +- `protocols/openai-chat-completions/as-source/` — interprets ChatCompletions requests and emits ChatCompletions SSE. +- `protocols/openai-chat-completions/as-target/` — maps requests to ChatCompletions and consumes ChatCompletions SSE. +- `protocols/openai-responses/as-source/` — interprets Responses requests, restores event identities, and emits Responses SSE. +- `protocols/openai-responses/as-target/` — maps requests to Responses and consumes Responses SSE. - `composition/index.ts` — pairs one source protocol with one target protocol into a trip; the generic composition boundary. - `protocols//as-source.ts` — states that protocol as the side a request comes from. - `protocols//as-target.ts` — states that protocol as the side a request goes to. diff --git a/packages/translate/__tests__/composition/request/tool-names_test.ts b/packages/translate/__tests__/composition/request/tool-names_test.ts index 3a6e7f7198..2ce4538ba7 100644 --- a/packages/translate/__tests__/composition/request/tool-names_test.ts +++ b/packages/translate/__tests__/composition/request/tool-names_test.ts @@ -51,7 +51,6 @@ test('a call naming an undeclared group still gets a flat spelling and its pair' }); test('a selector naming a group the request never declared is a caller error', () => { - // A selector must name a callable allocated from the request and conversation; an unmatched grouped identity is a caller input error. assertThrows( () => allocateToolNames({ settings: { model: 'm', stream: true }, @@ -73,7 +72,6 @@ test('distinct structured identities with the same dotted spelling get distinct }); test('a selector naming a group this request does not declare is refused', () => { - // No declaration or history call allocates this grouped identity, so the selector cannot name an upstream callable. assertThrows( () => allocateToolNames({ settings: { model: 'm', stream: true }, tools: [{ type: 'function', name: 'web.run', parameters: { type: 'object' } }], tool_choice: { type: 'function', name: 'run', namespace: 'web' } }, []), TranslateInputException, diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts index 83f75bf65a..8d5ddff91d 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts @@ -38,8 +38,7 @@ test('two id-less answers naming the legacy function are refused as ambiguous', assertThrows(() => chatLegacyCallsOf([legacy('lookup'), twoAnswers]), TranslateInputException); }); -test('a legacy call the next turn does not answer is refused', () => { - // A wire that states no id for the call cannot say which later answer means it. +test('a non-tail legacy call without a matching result is refused', () => { assertThrows(() => chatLegacyCallsOf([legacy('lookup'), message('later')]), TranslateInputException); }); diff --git a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-request_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-request_test.ts index 7f192a694b..ff4f238db2 100644 --- a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-request_test.ts @@ -188,8 +188,6 @@ test('buildTargetPayload maps generation config and thinking controls', async () }); test('buildTargetPayload refuses a stated budget beside a stated level', async () => { - // GenerateContent rejects the pair itself, so translating it would have to - // pick a winner that a different model family disagrees with. await assertRejects( () => buildWithTestSidecar({ contents: [], diff --git a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts index 7655a19e18..d37fcc7a9a 100644 --- a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts @@ -632,9 +632,6 @@ test('VALIDATED restricts calls without changing declarations while leaving the }); test('a call with no argument object is written with an empty one', async () => { - // GenerateContent states no `args` for a call that takes none, and the IR - // keeps it absent. Chat requires `arguments`, so the empty object is how that - // call's argument list is spelled on the wire. const request = await buildWithTestSidecar({ contents: [{ role: 'model', parts: [{ functionCall: { name: 'f' } }] }] }, 'gpt-test'); const call = request.messages.flatMap(message => message.role === 'assistant' && message.tool_calls !== undefined ? message.tool_calls : [])[0]; diff --git a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts index 02c937b364..24dd7c7f79 100644 --- a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts @@ -544,9 +544,6 @@ test.each([undefined, []])('preserves an empty model turn with parts %j', async }); test('a call with no argument object is written with an empty one', async () => { - // GenerateContent states no `args` for a call that takes none, and the IR - // keeps it absent. This wire requires `arguments`, so the empty object is how - // that call's argument list is spelled on the wire. const request = await buildWithTestSidecar({ contents: [{ role: 'model', parts: [{ functionCall: { name: 'f' } }] }] }, 'gpt-test'); const call = request.input.find(item => item.type === 'function_call'); diff --git a/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-request_test.ts index 31d1b7da4f..1cf0eedb32 100644 --- a/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-request_test.ts @@ -668,8 +668,6 @@ test('a data URL is stated with the media type it declares', async () => { }); test('content with a value that is not a URL is rejected rather than written', async () => { - // Messages states a URL as a URL, so a caller that named neither a URL nor - // inline bytes has stated something the target cannot carry. await assertRejects( () => buildWithTestSidecar(mkPayload({ messages: [{ diff --git a/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts index 383917b786..461a867844 100644 --- a/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts @@ -1257,10 +1257,6 @@ test('buildTargetPayload throws when a non-leading developer input message conta ); }); -// (Native native↔native only — OpenAI Responses no longer carries thinking-mode -// extension fields; alias overlays land on the Anthropic Messages target payload at the wire -// call via `applyRulesToUpstreamAnthropicMessages`.) - test('keeps an empty assistant message as the turn that separates two client runs', async () => { // Dropping it would leave two adjacent user messages, which this wire merges // into one turn, losing the boundary the client stated. diff --git a/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts index cc7ebfec75..4411cb904f 100644 --- a/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts @@ -886,8 +886,6 @@ test.each(['system', 'developer'] as const)('preserves %s text part boundaries', }); test.each(['system', 'developer'] as const)('collapses an empty %s message to the empty string', async role => { - // An empty part array is not a legal Chat body; the empty string states the - // same message in the spelling this wire accepts. const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role, content: '' }] }); assertEquals(result.target.messages, [{ role, content: '' }]); }); diff --git a/packages/translate/src/composition/index.ts b/packages/translate/src/composition/index.ts index f31dc2558b..82f604d9e9 100644 --- a/packages/translate/src/composition/index.ts +++ b/packages/translate/src/composition/index.ts @@ -54,7 +54,6 @@ export interface TargetProtocol AsyncIterable; } -/** Compose request and event pipelines with shared replay coordination. */ export const composeTranslation = < SrcName extends AssistantTurnProtocol, SrcPayload, SrcEvent, TgtName extends AssistantTurnProtocol, TgtPayload extends { model: string }, TgtEvent, Context, diff --git a/packages/translate/src/composition/request/call-ids.ts b/packages/translate/src/composition/request/call-ids.ts index 60ecf3799f..bdc0ab7641 100644 --- a/packages/translate/src/composition/request/call-ids.ts +++ b/packages/translate/src/composition/request/call-ids.ts @@ -47,7 +47,7 @@ export const assignCallIds = ( const claimed = new Set(); for (const piece of turns) { if (piece.kind === 'restored') { - for (const call of restoredCalls(piece.turn)) if (call.callId !== undefined) claimed.add(call.callId); + for (const call of restoredCalls(piece.turn)) claimed.add(call.callId); } else if (piece.kind === 'assistant') { for (const item of piece.items) { if ((item.type === 'function_call' || item.type === 'custom_tool_call') && item.call_id !== undefined) claimed.add(item.call_id); diff --git a/packages/translate/src/ir/input/types.ts b/packages/translate/src/ir/input/types.ts index 2cbbdb4483..02c5ca2262 100644 --- a/packages/translate/src/ir/input/types.ts +++ b/packages/translate/src/ir/input/types.ts @@ -262,7 +262,7 @@ export type IRInputMedia = // Messages, Responses, GenerateContent export interface IRInputReasoningItem { readonly type: 'reasoning'; // Messages, Responses, GenerateContent - readonly id?: string; // Responses, GenerateContent + readonly id?: string; // GenerateContent readonly summary?: readonly string[]; // Messages, Responses, GenerateContent readonly content?: readonly string[]; // Responses } @@ -286,7 +286,7 @@ export interface IRInputCustomToolCallItem extends Pick { if (message.role === 'system') { - if (typeof message.content !== 'string' && message.content.some(block => block.type !== 'text')) { - throw new TranslateInputException('Only text system content is supported.'); - } return [{ type: 'message', role: 'system', content: textPartsToIR(message.content as string | readonly AnthropicMessagesTextBlockParam[]) }]; } if (typeof message.content === 'string') { @@ -259,7 +256,7 @@ const anthropicMessagesSystemToIR = (system: AnthropicMessagesPayload['system']) const anthropicMessagesTurnToIR = ( turn: AnthropicMessagesConversationTurn, - firstMessageIndex = 0, + firstMessageIndex: number, ): IRInputTurn[] => { if (turn.role === 'assistant') { const turns: IRInputTurn[] = []; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target.ts b/packages/translate/src/protocols/anthropic-messages/as-target.ts index 3601226046..2ed96800b0 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target.ts @@ -9,12 +9,12 @@ import type { InputAssistantTurnFor } from '../../round-trip/input-assistant-tur import type { TranslateOptions } from '../../types.ts'; import type { AnthropicMessagesMessage, AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; +// Restored tool calls supply IDs for matching later results. const callsOf = (turn: Extract): readonly PendingCall[] => Array.isArray(turn.content) ? turn.content.flatMap(block => block.type === 'tool_use' || block.type === 'server_tool_use' ? [{ callId: block.id, name: block.name }] : []) : []; -/** Read pending call IDs from restored Messages tool_use blocks before writing later results. */ export const anthropicMessagesTargetProtocol: TargetProtocol< 'anthropicMessages', AnthropicMessagesPayload, AnthropicMessagesStreamEventEx, ToolNameContext > = { diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/data-url.ts b/packages/translate/src/protocols/anthropic-messages/as-target/data-url.ts index 9b1b5a1da5..6eb88636f8 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/data-url.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/data-url.ts @@ -30,9 +30,6 @@ const stringOfBytes = (bytes: readonly number[]): string => { return value; }; -const base64OfBytes = (bytes: readonly number[]): string => btoa(stringOfBytes(bytes)); - -/** Parse a data URL at the Messages target boundary that requires inline bytes. */ export const parseDataURL = (url: string): ParsedDataURL => { const match = DATA_URL.exec(url); if (match === null) throw new TranslateInputException(`Expected a data URL, got '${url.slice(0, 32)}'.`); @@ -44,6 +41,6 @@ export const parseDataURL = (url: string): ParsedDataURL => { const bytes = bytesOfPercentEncodedData(body); return { ...(normalizedMediaType === '' ? {} : { media_type: normalizedMediaType }), - data: encodedAsBase64 ? stringOfBytes(bytes) : base64OfBytes(bytes), + data: encodedAsBase64 ? stringOfBytes(bytes) : btoa(stringOfBytes(bytes)), }; }; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts index 4afc6e2987..35ac19c3f9 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts @@ -108,7 +108,6 @@ const userContent = ( default: throw new TranslateInputException(`Invalid '${(part as { type: string }).type}' content part.`); } } - // Messages' user content is a non-empty block list in the same way. return blocks.length === 0 ? [{ type: 'text', text: '' }] : blocks; }; @@ -165,8 +164,7 @@ const parseArguments = (argumentsJson: string): unknown => { // serialize back as `null`. Parsing with raw-number preservation keeps the // caller's exact token. try { - const parsed = parseJSONWithRawNumbers(argumentsJson); - return parsed; + return parseJSONWithRawNumbers(argumentsJson); } catch { return { raw_arguments: argumentsJson }; } @@ -210,17 +208,12 @@ const toolChoiceForTools = (choice: IRInputToolChoice | undefined, tools: readon }; /** - * One segment's messages and the system blocks lifted from its instruction. - * - * Messages keeps instructions in its top-level system field, one block per - * source text part so the boundaries - * survive; later system messages stay inline at their position. - * - * Only the segment opening the whole conversation may hoist. Splitting at a - * restored assistant turn must not promote a later system message merely because - * it opens another segment. + * Only the first conversation segment may hoist an instruction or leading + * system/developer messages into top-level system blocks. Their text-part + * boundaries are preserved; later system messages stay in place, including + * after a restored assistant turn. */ -export interface AnthropicMessagesConversation { +interface AnthropicMessagesConversation { readonly messages: AnthropicMessagesMessage[]; readonly system: AnthropicMessagesTextBlockParam[]; } @@ -299,7 +292,7 @@ export const anthropicMessagesConversationFromIR = ( */ export const anthropicMessagesHeaderFromIR = ( request: IRInputRequestHeader, - options: Pick = {}, + options: Pick, ): Omit => { const tools = request.tools; const choice = toolChoiceForTools(request.tool_choice, tools); diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/segmented-request.ts b/packages/translate/src/protocols/anthropic-messages/as-target/segmented-request.ts index c5a1141a7d..a9a453434e 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/segmented-request.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/segmented-request.ts @@ -14,7 +14,7 @@ import type { AnthropicMessagesMessage, AnthropicMessagesPayload, AnthropicMessa export const anthropicMessagesPayloadFromSegments = ( request: IRInputRequestHeader, turns: readonly (IRInputTurnWithCallIds | RestoredAssistantTurn)[], - options: Pick = {}, + options: Pick, ): AnthropicMessagesPayload => { const system: AnthropicMessagesTextBlockParam[] = []; const messages: AnthropicMessagesMessage[] = []; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts index 81f9b82c93..22672a0366 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts @@ -103,10 +103,6 @@ const userParts = (content: GeminiGenerateContentContent): IRInputClientItem[] = return items; }; -// A thought part states no id of its own, so its IR identity comes from the -// position it occupies in the request. -const reasoningId = (contentIndex: number, partIndex: number): string => `gemini_reasoning_${contentIndex}_${partIndex}`; - const assistantItems = (content: GeminiGenerateContentContent, contentIndex: number): IRInputAssistantItem[] => { const items: IRInputAssistantItem[] = []; const pending: IRInputAssistantContentPart[] = []; @@ -133,7 +129,8 @@ const assistantItems = (content: GeminiGenerateContentContent, contentIndex: num const thoughtText = geminiGenerateContentThoughtText(part); if (thoughtText !== null) { flush(); - items.push({ type: 'reasoning', id: reasoningId(contentIndex, partIndex), summary: [thoughtText] }); + // Thought parts have no ID; their IR identity comes from their position. + items.push({ type: 'reasoning', id: `gemini_reasoning_${contentIndex}_${partIndex}`, summary: [thoughtText] }); return; } const visible = geminiGenerateContentVisibleText(part); @@ -184,7 +181,7 @@ const geminiGenerateContentSystemToIR = (content: GeminiGenerateContentContent | return parts.length === 0 ? undefined : { kind: 'client', items: [{ type: 'instruction', content: parts }] }; }; -// GenerateContent restricts selectable functions through allowed_tools while retaining the complete declarations. +// GenerateContent's allowedFunctionNames becomes an IR allowed_tools choice while retaining all declarations. // https://github.com/googleapis/googleapis/blob/bc7e3baa28fbb223fa93782e130260fab8205bfc/google/ai/generativelanguage/v1beta/content.proto const toolsToIR = (payload: GeminiGenerateContentPayload): Pick => { const declarations = payload.tools?.flatMap(group => group.functionDeclarations ?? []) ?? []; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source.ts b/packages/translate/src/protocols/openai-chat-completions/as-source.ts index f975ffa132..e2dd886741 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source.ts @@ -1,6 +1,6 @@ import { restateOpenAIChatCompletionsError } from './as-source/error-body.ts'; import { writeOpenAIChatCompletionsEvents } from './as-source/events.ts'; -import { continuousUsageOf, readOpenAIChatCompletionsRequest } from './as-source/input-to.ts'; +import { readOpenAIChatCompletionsRequest } from './as-source/input-to.ts'; import { collectOpenAIChatCompletionsReplayContents, findOpenAIChatCompletionsSidecarOccurrences, verifyOpenAIChatCompletionsTurnDigest } from './as-source/round-trip.ts'; import type { SourceProtocol } from '../../composition/index.ts'; import type { OpenAIChatCompletionsPayloadEx, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; @@ -18,7 +18,7 @@ export const openaiChatCompletionsSourceProtocol: SourceProtocol< }, error: restateOpenAIChatCompletionsError, writeEvents: (payload, records, roundTrip) => writeOpenAIChatCompletionsEvents(records, { - continuousUsageStats: continuousUsageOf(payload), + continuousUsageStats: payload.stream_options?.include_usage === true && payload.stream_options?.continuous_usage_stats === true, roundTrip, }), }; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts index ebee36d3e7..b9668dabb2 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts @@ -26,10 +26,8 @@ import type { OpenAIChatCompletionsPayload, OpenAIChatCompletionsTool, OpenAIChatCompletionsUserContentPart, - OpenAIChatCompletionsPayloadEx, } from '@floway-dev/protocols/openai-chat-completions'; -// Normalize Chat audio container names to the input IR's MIME representation. const AUDIO_MEDIA_TYPES: Readonly> = { wav: 'audio/wav', mp3: 'audio/mp3' }; // Explicit empty user and assistant messages retain their turn boundaries. @@ -165,8 +163,6 @@ const openaiChatCompletionsTurnToIR = ( ): IRInputTurn => { if (turn.role === 'assistant') return { kind: 'assistant', items: assistantItems(turn, context, messageIndex) }; - // Every client message in one turn contributes its items, in order, to the - // same IR turn; a target that groups them differently reads the item list. return { kind: 'client', items: turn.items.flatMap((message, index) => clientItems(message, context, messageIndex + index)) }; }; @@ -263,9 +259,6 @@ const openaiChatCompletionsRequestToIR = (payload: OpenAIChatCompletionsPayload) ...openaiChatCompletionsToolsToIR(payload), }); -export const continuousUsageOf = (payload: OpenAIChatCompletionsPayloadEx): boolean => - payload.stream_options?.include_usage === true && payload.stream_options?.continuous_usage_stats === true; - const openaiChatCompletionsSettingsToIR = (payload: OpenAIChatCompletionsPayload): IRInputSettings => { const format = payload.response_format; const outputFormat: IRInputOutputFormat | undefined = format === undefined ? undefined diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts index 262a2d9ccd..4b746e2796 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts @@ -79,7 +79,7 @@ const userContent = (item: Extract { @@ -169,11 +169,10 @@ const openaiChatCompletionsClientItem = ( ): OpenAIChatCompletionsMessage[] => { switch (item.type) { case 'message': - return item.role === 'user' - ? [{ role: 'user', content: userContent(item) }] - : [{ role: item.role, content: item.content.length === 1 ? item.content[0].text : item.content.length === 0 ? '' : item.content.map(part => ({ type: 'text' as const, text: part.text })) }]; - case 'instruction': - return [{ role: 'system', content: item.content.length === 1 ? item.content[0].text : item.content.length === 0 ? '' : item.content.map(part => ({ type: 'text' as const, text: part.text })) }]; + case 'instruction': { + if (item.type === 'message' && item.role === 'user') return [{ role: 'user', content: userContent(item) }]; + return [{ role: item.type === 'instruction' ? 'system' : item.role, content: item.content.length === 1 ? item.content[0].text : item.content.length === 0 ? '' : item.content.map(part => ({ type: 'text' as const, text: part.text })) }]; + } case 'tool_result': { const projected = openaiChatCompletionsToolResultContent(item.call_id, item.output); lift(projected.lifted); diff --git a/packages/translate/src/protocols/openai-responses/as-source.ts b/packages/translate/src/protocols/openai-responses/as-source.ts index 8168e7fb4e..ad6c040463 100644 --- a/packages/translate/src/protocols/openai-responses/as-source.ts +++ b/packages/translate/src/protocols/openai-responses/as-source.ts @@ -19,7 +19,6 @@ export const openaiResponsesSourceProtocol: SourceProtocol< }, error: restateOpenAIResponsesError, writeEvents: (payload, records, roundTrip) => { - // Mint the client-visible translated Responses ID for this event stream. const responseId = createRandomOpenAIResponsesResponseId(); // The response objects echo the client's own declarations, which only the // payload states as the client wrote them: the IR holds a normalized form diff --git a/packages/translate/src/protocols/openai-responses/as-source/events.ts b/packages/translate/src/protocols/openai-responses/as-source/events.ts index fa19188db8..9a8f0eeb80 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/events.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/events.ts @@ -329,8 +329,6 @@ export const writeOpenAIResponsesEvents = async function* ( }; } if (unit.callId === undefined) throw new TypeError('Responses tool unit has no call ID'); - // A call the request stated grouped is written as that pair; one without a - // group keeps its single name. if (unit.kind === 'custom_tool_call') { if (item.type !== 'custom_tool_call') throw new TypeError('Responses tool unit has the wrong IR item'); return { diff --git a/packages/translate/src/protocols/openai-responses/as-target/input-from.ts b/packages/translate/src/protocols/openai-responses/as-target/input-from.ts index 9758a5cdaa..d9c43ef61b 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/input-from.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/input-from.ts @@ -1,6 +1,6 @@ import type { IRInputAssistantItemWithCallIds, IRInputTurnWithCallIds, IRInputClientItemWithCallIds } from '../../../composition/request/call-ids.ts'; import { requireStreaming } from '../../../ir/input/streaming.ts'; -import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputOutputFormat, IRInputDocument, IRInputMedia, IRInputToolResultContentPart, IRInputClientItem, IRInputInstructionItem } from '../../../ir/input/types.ts'; +import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputOutputFormat, IRInputDocument, IRInputMedia, IRInputToolResultContentPart, IRInputClientItem } from '../../../ir/input/types.ts'; import { DOCUMENT_CLOSE_TAG, DOCUMENT_OPEN_TAG, utf8Base64 } from '../../../shared/document.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import { reasoningEffortForBudget } from '../../../shared/reasoning-budget.ts'; @@ -71,7 +71,6 @@ const toolResultOutput = (output: string | readonly IRInputToolResultContentPart return documentContent(part.document); }); -// The content-array projection preserves IR part boundaries. const userContent = (item: Extract): OpenAIResponsesInputContent[] => item.content.flatMap(part => { switch (part.type) { case 'text': return { type: 'input_text' as const, text: part.text }; @@ -88,8 +87,7 @@ const assistantItems = (items: readonly IRInputAssistantItemWithCallIds[]): Cano return { type: 'message', role: 'assistant', - // Responses spelling of an empty assistant history message. - content: item.content.length === 0 || item.content.every(part => part.type === 'text' && part.text === '') + content: item.content.every(part => part.type === 'text' && part.text === '') ? '' : item.content.map(part => part.type === 'text' ? { type: 'output_text' as const, text: part.text } @@ -116,7 +114,7 @@ const assistantItems = (items: readonly IRInputAssistantItemWithCallIds[]): Cano } }); -const clientItems = (items: readonly IRInputClientItemWithCallIds[]): CanonicalOpenAIResponsesInputItem[] => items.map(item => { +const clientItem = (item: IRInputClientItemWithCallIds): CanonicalOpenAIResponsesInputItem => { switch (item.type) { case 'instruction': return { type: 'message', role: 'system', content: item.content.length === 1 ? item.content[0].text : item.content.map(part => ({ type: 'input_text' as const, text: part.text })) }; @@ -127,7 +125,6 @@ const clientItems = (items: readonly IRInputClientItemWithCallIds[]): CanonicalO : item.content.map(part => ({ type: 'input_text' as const, text: part.text })); return { type: 'message', role: item.role, content }; } - // A single text part uses bare-string content; multipart user content retains its part boundaries. if (item.content.length === 1 && item.content[0].type === 'text') { return { type: 'message', role: 'user', content: item.content[0].text }; } @@ -140,12 +137,7 @@ const clientItems = (items: readonly IRInputClientItemWithCallIds[]): CanonicalO : { type: 'function_call_output', call_id: item.call_id, output, ...(item.is_error === true ? { status: 'incomplete' as const } : { status: 'completed' as const }) }; } } -}); - -// Only the IR's top-level instruction belongs in Responses `instructions`. -// Historical system and developer messages remain input messages at their -// original positions. -const isInstruction = (entry: IRInputClientItem): entry is IRInputInstructionItem => entry.type === 'instruction'; +}; export const openaiResponsesConversationFromIR = ( turns: readonly IRInputTurnWithCallIds[], @@ -162,13 +154,14 @@ export const openaiResponsesConversationFromIR = ( return assistantItems(item.items); } return item.items.flatMap((entry): CanonicalOpenAIResponsesInputItem[] => { - if (hoisting && isInstruction(entry)) { + // Only a top-level instruction is hoisted; historical system and developer messages stay in place. + if (hoisting && entry.type === 'instruction') { instructions = entry.content.map(part => part.text).join(''); hoisting = false; return []; } hoisting = false; - return clientItems([entry]); + return [clientItem(entry)]; }); }); @@ -214,7 +207,6 @@ const toolFromIR = (tool: IRInputTool): OpenAIResponsesTool => { const toolChoiceFromIR = (choice: IRInputToolChoice): NonNullable => { if (typeof choice === 'string') return choice; - // Responses expresses the permitted callable subset in tool_choice.allowed_tools. if (choice.type === 'allowed_tools') { return { type: 'allowed_tools', From 750b4bda3044a95ea71c5ee537cb05d643a17ede Mon Sep 17 00:00:00 2001 From: Menci Date: Fri, 13 Nov 2026 14:23:00 +0800 Subject: [PATCH 64/66] fix(translate): preserve document and tool format semantics Carry document metadata and nested attributes through input IR, projecting citation and cache controls where supported. Preserve unrecognized custom tool formats until target writing and return an input error when they cannot be translated. --- packages/protocols/SOURCES.md | 2 +- .../translate/__tests__/input-error_test.ts | 21 ++++++ .../as-source/input-to_test.ts | 61 +++++++++++++++ .../as-target/input-from_test.ts | 54 +++++++++++++- .../as-source/tool-kind_test.ts | 19 ++++- .../as-source/input-to_test.ts | 34 +++++++++ ...to-openai-chat-completions-request_test.ts | 30 +++++++- ...ssages-to-openai-responses-request_test.ts | 74 ++++++++++++++++++- packages/translate/src/ir/input/types.ts | 26 ++++++- .../anthropic-messages/as-source/input-to.ts | 40 +++++++--- .../as-target/input-from.ts | 23 +++++- .../as-source/input-to.ts | 8 +- .../as-target/input-from.ts | 8 +- .../openai-responses/as-source/input-to.ts | 12 +-- .../openai-responses/as-target/input-from.ts | 35 +++++---- packages/translate/src/shared/custom-tool.ts | 12 ++- packages/translate/src/shared/document.ts | 23 ++++-- 17 files changed, 421 insertions(+), 61 deletions(-) create mode 100644 packages/translate/__tests__/protocols/anthropic-messages/as-source/input-to_test.ts diff --git a/packages/protocols/SOURCES.md b/packages/protocols/SOURCES.md index 08fe981bd6..c45f484a48 100644 --- a/packages/protocols/SOURCES.md +++ b/packages/protocols/SOURCES.md @@ -22,7 +22,7 @@ Provider additions and broader wire views use `Ex`. Chat assistant and tool-call Messages collectors follow the pinned SDK's cumulative usage counters, citation events, complete signature replacement, and compaction final-value assignment, including null. Standard usage deltas exclude fixed metadata. Incoming gateway TTL deltas and Floway's late fixed metadata use `AnthropicMessagesUsageDeltaEx`, following the complete-usage convention used by [new-api](https://github.com/QuantumNous/new-api/blob/6370b29424168039e94d40d610191e7d2e65dbf4/relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go#L215-L235). This preserves realtime streaming without a new carrier field. Floway's collector and accounting reader understand the extension; the official SDK's standard accumulator does not interpret those extra fields. Accounting snapshots remain separate from wire types. -Recognizing an official block does not add a translation implementation. Unimplemented Messages-to-Chat blocks and compaction stops fail explicitly; context-window exhaustion maps to Chat `length`. Display-only alias rules update existing enabled/adaptive thinking and preserve other modes. System/developer text part boundaries and empty history turns remain represented. Native Chat custom tools, calls, and choices map to Responses. Messages preserves tool declarations and accepts only `allowed_tools` selections its native choice modes can express. Unrepresentable inputs fail instead of receiving invented defaults. +Recognizing an official block does not add a translation implementation. Unimplemented Messages-to-Chat blocks and compaction stops fail explicitly; context-window exhaustion maps to Chat `length`. Display-only alias rules update existing enabled/adaptive thinking and preserve other modes. System/developer text part boundaries and empty history turns remain represented. Native Chat custom tools, calls, and choices map to Responses. Messages preserves tool declarations and accepts only `allowed_tools` selections its native choice modes can express. Unrepresentable request semantics fail instead of receiving invented defaults; citation and cache controls can degrade when no target equivalent exists. Gemini Schema conversion preserves constraints, reorders properties before removing `propertyOrdering`, and emits int64 constraints as exact JSON numeric tokens. The `klona/json` and streaming JSON serializer patches retain immutable raw JSON values; ordered schema properties retain their enumeration order through request cloning and serialization, including integer-named keys. Boolean JSON schemas use exact object equivalents; destination schema restrictions remain enforced by the destination boundary or upstream. Mutually exclusive schema fields fail explicitly. Omitted Chat schema is not replaced with an unconstrained schema. diff --git a/packages/translate/__tests__/input-error_test.ts b/packages/translate/__tests__/input-error_test.ts index 4424c87f24..d90fe61a27 100644 --- a/packages/translate/__tests__/input-error_test.ts +++ b/packages/translate/__tests__/input-error_test.ts @@ -58,3 +58,24 @@ test('an unexpected codec failure retains its original error and cause', async ( }; await expect(translate.routeOpenAIChatCompletionsViaAnthropicMessages({ model: 'm', stream: true, messages: [turn] }, { sidecarCodec: codec })).rejects.toBe(failure); }); + +test.each([ + translate.routeAnthropicMessagesViaOpenAIChatCompletions, + translate.routeAnthropicMessagesViaOpenAIResponses, +])('a target that cannot express document image transformations returns an input error', async route => { + const result = await route({ + model: 'm', max_tokens: 32, stream: true, messages: [{ + role: 'user', content: [{ + type: 'document', + source: { + type: 'content', content: [{ + type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' }, + transformations: { oversized_image: 'downsize' }, + }], + }, + }], + }], + }, options); + expect(result).toMatchObject({ type: 'input-error', message: 'Cannot translate document image transformations to this protocol.' }); + expect(result).not.toBeInstanceOf(Error); +}); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-source/input-to_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/input-to_test.ts new file mode 100644 index 0000000000..7f10a75fea --- /dev/null +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-source/input-to_test.ts @@ -0,0 +1,61 @@ +import { expect, test } from 'vitest'; + +import { readAnthropicMessagesRequest } from '../../../../src/protocols/anthropic-messages/as-source/input-to.ts'; + +test('reads document metadata and nested part attributes into the IR', () => { + const citation = { + type: 'char_location' as const, cited_text: 'caption', document_index: 0, document_title: null, + start_char_index: 0, end_char_index: 7, + }; + const reading = readAnthropicMessagesRequest({ + model: 'm', max_tokens: 32, messages: [{ + role: 'user', + content: [{ + type: 'document', title: 'Report', context: 'USD', citations: { enabled: true }, + cache_control: { type: 'ephemeral', ttl: '1h' }, + source: { + type: 'content', content: [ + { type: 'text', text: 'caption', citations: [citation], cache_control: null }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' }, transformations: { oversized_image: 'downsize' }, cache_control: { type: 'ephemeral' } }, + ], + }, + }], + }], + }); + + expect(reading.turns[0].toIRInputTurns()).toStrictEqual([{ + kind: 'client', items: [{ + type: 'message', role: 'user', content: [{ + type: 'document', document: { + title: 'Report', context: 'USD', citations: { enabled: true }, cache_control: { type: 'ephemeral', ttl: '1h' }, + source: { + source_kind: 'content', content: [ + { type: 'text', text: 'caption', citations: [citation], cache_control: null }, + { type: 'image', image: { source_kind: 'base64', media_type: 'image/png', data: 'AAAA' }, transformations: { oversized_image: 'downsize' }, cache_control: { type: 'ephemeral' } }, + ], + }, + }, + }], + }], + }]); +}); + +test('distinguishes null document metadata from absent metadata', () => { + const document = { type: 'document' as const, source: { type: 'text' as const, media_type: 'text/plain' as const, data: 'body' } }; + const reading = readAnthropicMessagesRequest({ + model: 'm', max_tokens: 32, messages: [{ + role: 'user', content: [ + document, + { ...document, title: null, context: null, citations: null, cache_control: null }, + { ...document, citations: {} }, + ], + }], + }); + const [turn] = reading.turns[0].toIRInputTurns(); + if (turn.kind !== 'client' || turn.items[0].type !== 'message') throw new Error('Expected a user message'); + expect(turn.items[0].content).toStrictEqual([ + { type: 'document', document: { source: { source_kind: 'text', media_type: 'text/plain', text: 'body' } } }, + { type: 'document', document: { source: { source_kind: 'text', media_type: 'text/plain', text: 'body' }, title: null, context: null, citations: null, cache_control: null } }, + { type: 'document', document: { source: { source_kind: 'text', media_type: 'text/plain', text: 'body' }, citations: {} } }, + ]); +}); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-target/input-from_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-target/input-from_test.ts index 63d64868a5..00ab11966f 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-target/input-from_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-target/input-from_test.ts @@ -5,6 +5,10 @@ import { anthropicMessagesConversationFromIR } from '../../../../src/protocols/a import { assertEquals } from '@floway-dev/test-utils'; test('writes a content-backed IR document as a native Messages document', () => { + const citation = { + type: 'char_location' as const, cited_text: 'caption', document_index: 0, document_title: null, + start_char_index: 0, end_char_index: 7, + }; const turns: IRInputTurnWithCallIds[] = [{ kind: 'client', items: [{ @@ -12,11 +16,15 @@ test('writes a content-backed IR document as a native Messages document', () => { type: 'document', document: { + title: 'Report', + context: 'USD', + citations: { enabled: true }, + cache_control: { type: 'ephemeral', ttl: '1h' }, source: { source_kind: 'content', content: [ - { type: 'text', text: 'caption' }, - { type: 'image', image: { source_kind: 'base64', media_type: 'image/png', data: 'AAAA' } }, + { type: 'text', text: 'caption', citations: [citation], cache_control: null }, + { type: 'image', image: { source_kind: 'base64', media_type: 'image/png', data: 'AAAA' }, transformations: { oversized_image: 'downsize' }, cache_control: { type: 'ephemeral' } }, ], }, }, @@ -32,11 +40,15 @@ test('writes a content-backed IR document as a native Messages document', () => content: [ { type: 'document', + title: 'Report', + context: 'USD', + citations: { enabled: true }, + cache_control: { type: 'ephemeral', ttl: '1h' }, source: { type: 'content', content: [ - { type: 'text', text: 'caption' }, - { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } }, + { type: 'text', text: 'caption', citations: [citation], cache_control: null }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' }, transformations: { oversized_image: 'downsize' }, cache_control: { type: 'ephemeral' } }, ], }, }, @@ -44,3 +56,37 @@ test('writes a content-backed IR document as a native Messages document', () => }], }); }); + +test('writes null document metadata and leaves absent metadata absent', () => { + const turns: IRInputTurnWithCallIds[] = [{ + kind: 'client', items: [{ + type: 'message', role: 'user', content: [ + { type: 'document', document: { source: { source_kind: 'text', media_type: 'text/plain', text: 'a' } } }, + { type: 'document', document: { source: { source_kind: 'text', media_type: 'text/plain', text: 'b' }, title: null, context: null, citations: null, cache_control: null } }, + ], + }], + }]; + assertEquals(anthropicMessagesConversationFromIR(turns, true).messages, [{ + role: 'user', content: [ + { type: 'document', source: { type: 'text', media_type: 'text/plain', data: 'a' } }, + { type: 'document', source: { type: 'text', media_type: 'text/plain', data: 'b' }, title: null, context: null, citations: null, cache_control: null }, + ], + }]); +}); + +test('writes metadata for both text and media document sources', () => { + const turns: IRInputTurnWithCallIds[] = [{ + kind: 'client', items: [{ + type: 'message', role: 'user', content: [ + { type: 'document', document: { source: { source_kind: 'text', media_type: 'text/plain', text: 'body' }, title: 'Text', citations: {} } }, + { type: 'document', document: { source: { source_kind: 'media', media: { source_kind: 'file', file_id: 'file_1' } }, title: 'File', cache_control: { type: 'ephemeral', ttl: '5m' } } }, + ], + }], + }]; + assertEquals(anthropicMessagesConversationFromIR(turns, true).messages, [{ + role: 'user', content: [ + { type: 'document', source: { type: 'text', media_type: 'text/plain', data: 'body' }, title: 'Text', citations: {} }, + { type: 'document', source: { type: 'file', file_id: 'file_1' }, title: 'File', cache_control: { type: 'ephemeral', ttl: '5m' } }, + ], + }]); +}); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/tool-kind_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/tool-kind_test.ts index f36437008e..b4f4da03e9 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/tool-kind_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/tool-kind_test.ts @@ -1,7 +1,9 @@ -import { test } from 'vitest'; +import { expect, test } from 'vitest'; +import { routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses } from '../../../../src/index.ts'; import type { IRInputTurn } from '../../../../src/ir/input/types.ts'; import { readOpenAIChatCompletionsRequest } from '../../../../src/protocols/openai-chat-completions/as-source/input-to.ts'; +import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; import { assertEquals } from '@floway-dev/test-utils'; const itemsOf = (turns: readonly IRInputTurn[]) => turns.flatMap(turn => [...turn.items]); @@ -24,3 +26,18 @@ test('a tool result keeps the call kind even when a sidecar states the turn that const result = itemsOf(turns).find(item => item.type === 'tool_result'); assertEquals(result?.type === 'tool_result' ? result.tool_kind : undefined, 'custom'); }); + +test('preserves unrecognized Chat custom format until target writing', async () => { + const format = { type: 'future_format', definition: 'future syntax' }; + const payload = { + model: 'm', stream: true, messages: [], + tools: [{ type: 'custom' as const, custom: { name: 'edit', format: format as never } }], + }; + const tool = readOpenAIChatCompletionsRequest(payload).request.tools?.[0]; + expect(tool).toEqual({ type: 'custom', name: 'edit', format: { type: 'unrecognized', value: format } }); + for (const route of [routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses]) { + const result = await route(payload, { sidecarCodec: createTestSidecarCodec() }); + expect(result).toMatchObject({ type: 'input-error', message: 'Cannot translate an unrecognized custom tool format.' }); + expect(result).not.toBeInstanceOf(Error); + } +}); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/input-to_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/input-to_test.ts index 123ab79ec4..fddaf3b18c 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/input-to_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/input-to_test.ts @@ -59,3 +59,37 @@ test('native Chat custom results preserve their result shape on Responses', asyn if (trip.type !== 'trip') throw new Error(trip.message); expect(trip.target.input.at(-1)).toEqual({ type: 'custom_tool_call_output', call_id: 'c', output: 'done' }); }); + +test.each([ + { type: 'future_format', syntax: 'lark', definition: 'start: "ok"' }, + { type: 'grammar', syntax: 'lark' }, +])('preserves unrecognized custom format in IR and rejects it at target writing: %j', async format => { + const payload: OpenAIResponsesRequestPayloadEx = { + model: 'm', stream: true, input: [], + tools: [{ type: 'custom', name: 'edit', format }], + }; + const tool = readOpenAIResponsesRequest(payload).request.tools?.[0]; + expect(tool).toEqual({ type: 'custom', name: 'edit', format: { type: 'unrecognized', value: format } }); + + for (const route of [routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions]) { + const result = await route(payload, { sidecarCodec: createTestSidecarCodec() }); + expect(result).toMatchObject({ type: 'input-error', message: 'Cannot translate an unrecognized custom tool format.' }); + expect(result).not.toBeInstanceOf(Error); + } +}); + +test('an allowed-tools subset does not hide an unrecognized tool declaration', async () => { + const payload: OpenAIResponsesRequestPayloadEx = { + model: 'm', stream: true, input: [], + tools: [ + { type: 'function', name: 'read', parameters: { type: 'object', properties: {} }, strict: false }, + { type: 'custom', name: 'edit', format: { type: 'future_format' } }, + ], + tool_choice: { type: 'allowed_tools', mode: 'required', tools: [{ type: 'function', name: 'read' }] }, + }; + expect(readOpenAIResponsesRequest(payload).request.tools).toHaveLength(2); + for (const route of [routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions]) { + const result = await route(payload, { sidecarCodec: createTestSidecarCodec() }); + expect(result).toMatchObject({ type: 'input-error', message: 'Cannot translate an unrecognized custom tool format.' }); + } +}); diff --git a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts index 79de3d9841..55cf5ba326 100644 --- a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts @@ -52,6 +52,32 @@ test('content-backed document tool output is lifted with its document tags', asy ]); }); +test('lifts a tool-result document with metadata while dropping unsupported cache and citation controls', async () => { + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [ + { role: 'assistant', content: [{ type: 'tool_use', id: 'c', name: 'read', input: {} }] }, + { + role: 'user', content: [{ + type: 'tool_result', tool_use_id: 'c', content: [{ + type: 'document', title: 'Report <1>', context: 'A & B', + citations: { enabled: true }, cache_control: { type: 'ephemeral', ttl: '1h' }, + source: { type: 'base64', media_type: 'application/pdf', data: 'AAAA' }, + }], + }], + }, + ], + }); + assertEquals(result.messages.at(-2), { role: 'tool', tool_call_id: 'c', content: 'Document output is attached in the following user message.' }); + assertEquals(result.messages.at(-1), { + role: 'user', content: [ + { type: 'text', text: 'Document output from tool call c:' }, + { type: 'text', text: '\nReport <1>\nA & B' }, + { type: 'file', file: { file_data: 'data:application/pdf;base64,AAAA' } }, + { type: 'text', text: '' }, + ], + }); +}); + test('buildTargetPayload maps thinking.disabled to reasoning_effort none', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', @@ -703,7 +729,7 @@ test('rejects a metadata value the neutral model cannot state as a string pair', ); }); -test('buildTargetPayload marks a plain-text document around its file part', async () => { +test('buildTargetPayload marks a plain-text document around its text', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -716,7 +742,7 @@ test('buildTargetPayload marks a plain-text document around its file part', asyn if (!Array.isArray(part)) throw new Error('expected a content array'); assertEquals(part, [ { type: 'text', text: '' }, - { type: 'file', file: { file_data: 'data:text/plain;base64,aGVsbG8gd29ybGQ=' } }, + { type: 'text', text: 'hello world' }, { type: 'text', text: '' }, ]); }); diff --git a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts index a85e2f5ad9..9aa4646a9d 100644 --- a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts @@ -22,7 +22,7 @@ test('server tool errors become incomplete Responses results', async () => { assertEquals(output?.type === 'function_call_output' ? output.status : undefined, 'incomplete'); }); -test('document tool results enter the IR as files', async () => { +test('plain-text document tool results keep their document boundary', async () => { const result = await buildWithTestSidecar({ model: 'm', max_tokens: 32, messages: [ { role: 'assistant', content: [{ type: 'tool_use', id: 'c', name: 'read', input: {} }] }, @@ -30,7 +30,34 @@ test('document tool results enter the IR as files', async () => { ], }); const output = result.input.find(item => item.type === 'function_call_output'); - assertEquals(output?.type === 'function_call_output' ? output.output : undefined, [{ type: 'input_file', file_data: 'data:text/plain;base64,aGVsbG8=' }]); + assertEquals(output?.type === 'function_call_output' ? output.output : undefined, [ + { type: 'input_text', text: '' }, + { type: 'input_text', text: 'hello' }, + { type: 'input_text', text: '' }, + ]); +}); + +test('preserves metadata on a document inside a Responses tool result', async () => { + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [ + { role: 'assistant', content: [{ type: 'tool_use', id: 'c', name: 'read', input: {} }] }, + { + role: 'user', content: [{ + type: 'tool_result', tool_use_id: 'c', content: [{ + type: 'document', title: 'Report', context: 'USD', + citations: { enabled: true }, cache_control: { type: 'ephemeral', ttl: '5m' }, + source: { type: 'content', content: [{ type: 'text', text: 'revenue' }] }, + }], + }], + }, + ], + }); + const output = result.input.find(item => item.type === 'function_call_output'); + assertEquals(output?.type === 'function_call_output' ? output.output : undefined, [ + { type: 'input_text', text: '\nReport\nUSD' }, + { type: 'input_text', text: 'revenue' }, + { type: 'input_text', text: '', prompt_cache_breakpoint: { mode: 'explicit' } }, + ]); }); test('mixed tool-result blocks keep documents formal and unknown blocks opaque', async () => { @@ -108,6 +135,49 @@ test('inline mixed document content retains text and image order inside tags', a }]); }); +test('projects document metadata and cache breakpoints while dropping unsupported citation controls', async () => { + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [{ + role: 'user', content: [{ + type: 'document', title: 'Report <1>', context: 'A & B', + citations: { enabled: true }, cache_control: { type: 'ephemeral', ttl: '1h' }, + source: { + type: 'content', content: [ + { type: 'text', text: 'caption', cache_control: { type: 'ephemeral', ttl: '5m' } }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' }, cache_control: null }, + ], + }, + }], + }], + }); + assertEquals(result.input, [{ + type: 'message', role: 'user', content: [ + { type: 'input_text', text: '\nReport <1>\nA & B' }, + { type: 'input_text', text: 'caption', prompt_cache_breakpoint: { mode: 'explicit' } }, + { type: 'input_image', image_url: 'data:image/png;base64,AAAA', prompt_cache_breakpoint: null }, + { type: 'input_text', text: '', prompt_cache_breakpoint: { mode: 'explicit' } }, + ], + }]); +}); + +test('keeps metadata around a Responses file without inventing a filename', async () => { + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [{ + role: 'user', content: [{ + type: 'document', title: 'Report', + source: { type: 'base64', media_type: 'application/pdf', data: 'AAAA' }, + }], + }], + }); + assertEquals(result.input, [{ + type: 'message', role: 'user', content: [ + { type: 'input_text', text: '\nReport' }, + { type: 'input_file', file_data: 'data:application/pdf;base64,AAAA' }, + { type: 'input_text', text: '' }, + ], + }]); +}); + test('keeps search result runs between the text and images around them', async () => { const first = { type: 'search_result' as const, source: 'https://example.com/1', title: 'First', content: [{ type: 'text' as const, text: 'one' }] }; const second = { ...first, source: 'https://example.com/2', title: 'Second' }; diff --git a/packages/translate/src/ir/input/types.ts b/packages/translate/src/ir/input/types.ts index 02c5ca2262..289de14082 100644 --- a/packages/translate/src/ir/input/types.ts +++ b/packages/translate/src/ir/input/types.ts @@ -85,6 +85,10 @@ export type IRInputCustomToolFormat = readonly type: 'grammar'; // ChatCompletions, Responses readonly definition: string; // ChatCompletions, Responses readonly syntax: string; // ChatCompletions, Responses + } + | { + readonly type: 'unrecognized'; // ChatCompletions, Responses + readonly value: object; // ChatCompletions, Responses }; // `allowed_tools` restricts calls without changing the visible declarations. @@ -204,6 +208,16 @@ export interface IRInputDocumentPart { // Messages export interface IRInputDocument { readonly source: IRInputDocumentSource; // Messages + readonly title?: string | null; // Messages + readonly context?: string | null; // Messages + readonly citations?: { readonly enabled?: boolean } | null; // Messages + readonly cache_control?: IRInputCacheControl | null; // Messages +} + +// Messages +export interface IRInputCacheControl { + readonly type: 'ephemeral'; // Messages + readonly ttl?: '5m' | '1h'; // Messages } // Messages @@ -222,9 +236,17 @@ export type IRInputDocumentSource = readonly content: readonly IRInputDocumentContentPart[]; // Messages }; -// Content-backed documents contain text and images, not file parts. // Messages -export type IRInputDocumentContentPart = IRInputTextPart | IRInputImagePart; +export type IRInputDocumentContentPart = ( + | (IRInputTextPart & { + readonly citations?: readonly object[] | null; // Messages + }) + | (IRInputImagePart & { + readonly transformations?: { readonly oversized_image?: 'downsize' | 'error' } | null; // Messages + }) +) & { + readonly cache_control?: IRInputCacheControl | null; // Messages +}; // ChatCompletions, GenerateContent export interface IRInputAudioPart { diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts index 98676a1eea..46437f5105 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts @@ -51,23 +51,39 @@ const textPartsToIR = (content: string | readonly AnthropicMessagesTextBlockPara }); const documentFromBlock = (block: Extract): IRInputDocument => { - const source = block.source; - switch (source.type) { - case 'base64': return { source: { source_kind: 'media', media: { source_kind: 'base64', media_type: source.media_type, data: source.data } } }; - case 'url': return { source: { source_kind: 'media', media: { source_kind: 'url', url: source.url } } }; - case 'file': return { source: { source_kind: 'media', media: { source_kind: 'file', file_id: source.file_id } } }; - case 'text': return { source: { source_kind: 'text', media_type: source.media_type, text: source.data } }; + const native = block.source; + let source: IRInputDocument['source']; + switch (native.type) { + case 'base64': source = { source_kind: 'media', media: { source_kind: 'base64', media_type: native.media_type, data: native.data } }; break; + case 'url': source = { source_kind: 'media', media: { source_kind: 'url', url: native.url } }; break; + case 'file': source = { source_kind: 'media', media: { source_kind: 'file', file_id: native.file_id } }; break; + case 'text': source = { source_kind: 'text', media_type: native.media_type, text: native.data }; break; case 'content': { - const content: IRInputDocumentContentPart[] = typeof source.content === 'string' - ? [{ type: 'text', text: source.content }] - : source.content.map(part => { - if (part.type === 'text') return { type: 'text' as const, text: part.text }; - if (part.type === 'image') return { type: 'image' as const, image: imageMedia(part.source) }; + const content: IRInputDocumentContentPart[] = typeof native.content === 'string' + ? [{ type: 'text', text: native.content }] + : native.content.map(part => { + const cache = part.cache_control === undefined ? {} : { cache_control: part.cache_control }; + if (part.type === 'text') return { + type: 'text', text: part.text, ...cache, + ...(part.citations === undefined ? {} : { citations: part.citations }), + }; + if (part.type === 'image') return { + type: 'image', image: imageMedia(part.source), ...cache, + ...(part.transformations === undefined ? {} : { transformations: part.transformations }), + }; throw new TranslateInputException(`'${(part as { type: string }).type}' document content block is not supported.`); }); - return { source: { source_kind: 'content', content } }; + source = { source_kind: 'content', content }; + break; } } + return { + source, + ...(block.title === undefined ? {} : { title: block.title }), + ...(block.context === undefined ? {} : { context: block.context }), + ...(block.citations === undefined ? {} : { citations: block.citations }), + ...(block.cache_control === undefined ? {} : { cache_control: block.cache_control }), + }; }; interface MessagePosition { diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts index 35ac19c3f9..e32e058502 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts @@ -73,18 +73,33 @@ const mediaDocumentBlock = (media: IRInputMedia): Extract => { const source = document.source; - if (source.source_kind === 'media') return mediaDocumentBlock(source.media); + const properties = { + ...(document.title === undefined ? {} : { title: document.title }), + ...(document.context === undefined ? {} : { context: document.context }), + ...(document.citations === undefined ? {} : { citations: document.citations }), + ...(document.cache_control === undefined ? {} : { cache_control: document.cache_control }), + }; + if (source.source_kind === 'media') return { ...mediaDocumentBlock(source.media), ...properties }; if (source.source_kind === 'text') { - return { type: 'document', source: { type: 'text', media_type: source.media_type, data: source.text } }; + return { type: 'document', source: { type: 'text', media_type: source.media_type, data: source.text }, ...properties }; } return { type: 'document', source: { type: 'content', content: source.content.map(part => part.type === 'text' - ? { type: 'text' as const, text: part.text } - : imageBlock(part.image)), + ? { + type: 'text' as const, text: part.text, + ...(part.citations === undefined ? {} : { citations: part.citations as AnthropicMessagesTextBlockParam['citations'] }), + ...(part.cache_control === undefined ? {} : { cache_control: part.cache_control }), + } + : { + ...imageBlock(part.image), + ...(part.transformations === undefined ? {} : { transformations: part.transformations }), + ...(part.cache_control === undefined ? {} : { cache_control: part.cache_control }), + }), }, + ...properties, }; }; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts index b9668dabb2..49a2f4766b 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts @@ -214,9 +214,11 @@ const toolToIR = (tool: OpenAIChatCompletionsTool): IRInputTool => { name: tool.custom.name, ...(tool.custom.description === undefined ? {} : { description: tool.custom.description }), ...(format === undefined ? {} : { - format: format.type === 'grammar' - ? { type: 'grammar', definition: format.grammar.definition, syntax: format.grammar.syntax } - : { type: 'text' }, + format: format.type === 'text' + ? { type: 'text' } + : format.type === 'grammar' + ? { type: 'grammar', definition: format.grammar.definition, syntax: format.grammar.syntax } + : { type: 'unrecognized', value: format }, }), }; } diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts index 4b746e2796..5baba9ec7b 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts @@ -2,7 +2,7 @@ import type { IRInputAssistantItemWithCallIds, IRInputTurnWithCallIds, IRInputCl import { requireStreaming } from '../../../ir/input/streaming.ts'; import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputDocument, IRInputMedia, IRInputToolResultContentPart, IRInputClientItem } from '../../../ir/input/types.ts'; import { buildCustomToolInputSchema } from '../../../shared/custom-tool.ts'; -import { DOCUMENT_CLOSE_TAG, DOCUMENT_OPEN_TAG, utf8Base64 } from '../../../shared/document.ts'; +import { DOCUMENT_CLOSE_TAG, documentImage, documentOpening } from '../../../shared/document.ts'; import { TranslateInputException } from '../../../shared/input-error.ts'; import { reasoningEffortForBudget } from '../../../shared/reasoning-budget.ts'; import type { @@ -51,11 +51,11 @@ const documentParts = (document: IRInputDocument): OpenAIChatCompletionsUserCont const content = source.source_kind === 'media' ? [filePart(source.media)] : source.source_kind === 'text' - ? [filePart({ source_kind: 'base64', media_type: source.media_type, data: utf8Base64(source.text) })] + ? [{ type: 'text' as const, text: source.text }] : source.content.map(part => part.type === 'text' ? { type: 'text' as const, text: part.text } - : imageUrlPart(part.image, part.detail)); - return [{ type: 'text', text: DOCUMENT_OPEN_TAG }, ...content, { type: 'text', text: DOCUMENT_CLOSE_TAG }]; + : imageUrlPart(documentImage(part), part.detail)); + return [{ type: 'text', text: documentOpening(document) }, ...content, { type: 'text', text: DOCUMENT_CLOSE_TAG }]; }; const userContent = (item: Extract): string | OpenAIChatCompletionsUserContentPart[] => { diff --git a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts index 14c63e0af1..37f21fa612 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts @@ -184,16 +184,18 @@ const toolToIR = (tool: Extract + cache === undefined ? part : { ...part, prompt_cache_breakpoint: cache === null ? null : { mode: 'explicit' } }; + const documentContent = (document: IRInputDocument): OpenAIResponsesInputContent[] => { const source = document.source; - if (source.source_kind === 'media') return [fileContent(source.media)]; - if (source.source_kind === 'text') { - return [fileContent({ source_kind: 'base64', media_type: source.media_type, data: utf8Base64(source.text) })]; + if (source.source_kind === 'media' && document.title == null && document.context == null) { + return [withDocumentCache(fileContent(source.media), document.cache_control)]; } + const content: OpenAIResponsesInputContent[] = source.source_kind === 'media' + ? [fileContent(source.media)] + : source.source_kind === 'text' + ? [{ type: 'input_text', text: source.text }] + : source.content.map(part => withDocumentCache(part.type === 'text' + ? { type: 'input_text', text: part.text } + : inputContent(documentImage(part), part.detail), part.cache_control)); return [ - { type: 'input_text', text: DOCUMENT_OPEN_TAG }, - ...source.content.map(part => part.type === 'text' - ? { type: 'input_text' as const, text: part.text } - : inputContent(part.image, part.detail)), - { type: 'input_text', text: DOCUMENT_CLOSE_TAG }, + { type: 'input_text', text: documentOpening(document) }, + ...content, + withDocumentCache({ type: 'input_text', text: DOCUMENT_CLOSE_TAG }, document.cache_control), ]; }; @@ -183,13 +191,14 @@ const normalizeParameters = (schema: Readonly> | undefin const toolFromIR = (tool: IRInputTool): OpenAIResponsesTool => { if (tool.type === 'custom') { + const format = requireKnownCustomToolFormat(tool.format); return { type: 'custom', name: tool.name, ...(tool.description === undefined ? {} : { description: tool.description }), - ...(tool.format === undefined ? {} : { - format: tool.format.type === 'grammar' - ? { type: 'grammar', definition: tool.format.definition, syntax: tool.format.syntax } + ...(format === undefined ? {} : { + format: format.type === 'grammar' + ? { type: 'grammar', definition: format.definition, syntax: format.syntax } : { type: 'text' }, }), }; diff --git a/packages/translate/src/shared/custom-tool.ts b/packages/translate/src/shared/custom-tool.ts index 67376fabfc..d5948f9f85 100644 --- a/packages/translate/src/shared/custom-tool.ts +++ b/packages/translate/src/shared/custom-tool.ts @@ -1,11 +1,19 @@ +import { TranslateInputException } from './input-error.ts'; import type { IRInputCustomToolFormat } from '../ir/input/types.ts'; + +export const requireKnownCustomToolFormat = (format: IRInputCustomToolFormat | undefined): Exclude | undefined => { + if (format?.type === 'unrecognized') throw new TranslateInputException('Cannot translate an unrecognized custom tool format.'); + return format; +}; + // Translation paths wrap Responses `custom` tools as single-string JSON-schema // function tools. Their grammar definition becomes a syntax-labeled prompt hint; // downstream enforcement remains provider-dependent. // https://github.com/openai/openai-node/blob/7423ac3e9351c46300cd094479cded5551a72eb4/src/resources/responses/responses.ts export const buildCustomToolInputSchema = (format?: IRInputCustomToolFormat): Record & { type: 'object' } => { - const definition = format?.type === 'grammar' ? format.definition : undefined; - const sourceSyntax = format?.type === 'grammar' && format.syntax !== '' ? format.syntax : undefined; + const known = requireKnownCustomToolFormat(format); + const definition = known?.type === 'grammar' ? known.definition : undefined; + const sourceSyntax = known?.type === 'grammar' && known.syntax !== '' ? known.syntax : undefined; const syntax = sourceSyntax === 'lark' ? 'Lark grammar' : sourceSyntax === 'regex' ? 'Regex' : sourceSyntax === undefined ? 'Grammar' : `Grammar (${sourceSyntax})`; return { type: 'object', diff --git a/packages/translate/src/shared/document.ts b/packages/translate/src/shared/document.ts index 449532e30f..38d5144dae 100644 --- a/packages/translate/src/shared/document.ts +++ b/packages/translate/src/shared/document.ts @@ -1,9 +1,20 @@ -export const DOCUMENT_OPEN_TAG = ''; +import { TranslateInputException } from './input-error.ts'; +import type { IRInputDocument, IRInputDocumentContentPart, IRInputMedia } from '../ir/input/types.ts'; + export const DOCUMENT_CLOSE_TAG = ''; -export const utf8Base64 = (text: string): string => { - const bytes = new TextEncoder().encode(text); - let binary = ''; - for (const byte of bytes) binary += String.fromCharCode(byte); - return btoa(binary); +export const documentOpening = (document: IRInputDocument): string => { + const escape = (value: string): string => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); + return [ + '', + ...(document.title == null ? [] : [`${escape(document.title)}`]), + ...(document.context == null ? [] : [`${escape(document.context)}`]), + ].join('\n'); +}; + +export const documentImage = (part: Extract): IRInputMedia => { + if (part.transformations?.oversized_image !== undefined) { + throw new TranslateInputException('Cannot translate document image transformations to this protocol.'); + } + return part.image; }; From 0e0c1f93f3446ae8cd311043eec366abf1c9f5d5 Mon Sep 17 00:00:00 2001 From: Menci Date: Fri, 13 Nov 2026 15:13:50 +0800 Subject: [PATCH 65/66] fix(translate): update streamed callable identities Clear stale namespaces when a Chat tool call changes its name, and apply the updated identity before a delayed call ID opens the Responses item. --- .../protocols/call-association_test.ts | 78 +++++++++++++++++++ packages/translate/src/ir/output/schema.ts | 4 +- packages/translate/src/ir/output/types.ts | 4 +- .../as-target/events.ts | 13 ++-- .../openai-responses/as-source/events.ts | 8 +- 5 files changed, 92 insertions(+), 15 deletions(-) diff --git a/packages/translate/__tests__/protocols/call-association_test.ts b/packages/translate/__tests__/protocols/call-association_test.ts index 341c67e96f..7334812ea3 100644 --- a/packages/translate/__tests__/protocols/call-association_test.ts +++ b/packages/translate/__tests__/protocols/call-association_test.ts @@ -56,6 +56,84 @@ const group = (kind: 'function' | 'custom'): OpenAIResponsesTool => ({ tools: [kind === 'function' ? { type: 'function', name: 'run', parameters: { type: 'object', properties: {} } } : { type: 'custom', name: 'run', format: { type: 'text' } }], }); +test.each([ + { initial: 'web_run', final: 'plain', name: 'plain', namespace: undefined }, + { initial: 'plain', final: 'web_run', name: 'run', namespace: 'web' }, + { initial: 'web_run', final: 'files_run', name: 'run', namespace: 'files' }, + { initial: 'plain', final: 'other', name: 'other', namespace: undefined }, + { initial: 'web_run', final: 'plain', name: 'plain', namespace: undefined, delayedId: true }, + { initial: 'plain', final: 'web_run', name: 'run', namespace: 'web', delayedId: true }, + { initial: 'web_run', final: 'files_run', name: 'run', namespace: 'files', delayedId: true }, +])('keeps callable identity when a Chat tool name changes from $initial to $final', async ({ initial, final, name, namespace, delayedId }) => { + const source: OpenAIResponsesRequestPayloadEx = { + model: 'm', stream: true, input: 'start', + tools: [ + group('function'), + { type: 'namespace', name: 'files', description: '', tools: [{ type: 'function', name: 'run', parameters: { type: 'object', properties: {} } }] }, + { type: 'function', name: 'plain', parameters: { type: 'object', properties: {} } }, + { type: 'function', name: 'other', parameters: { type: 'object', properties: {} } }, + ], + }; + const trip = expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions(source, { sidecarCodec: createTestSidecarCodec() })); + const frames: ProtocolFrame[] = [ + eventFrame({ + id: 'chat', object: 'chat.completion.chunk', created: 1, model: 'm', + choices: [{ index: 0, delta: { tool_calls: [{ index: 0, ...(delayedId ? {} : { id: 'c' }), type: 'function', function: { name: initial, arguments: '' } }] }, finish_reason: null }], + }), + eventFrame({ + id: 'chat', object: 'chat.completion.chunk', created: 1, model: 'm', + choices: [{ index: 0, delta: { tool_calls: [{ index: 0, ...(delayedId ? { id: 'c' } : {}), function: { name: final, arguments: '{}' } }] }, finish_reason: 'tool_calls' }], + }), + doneFrame(), + ]; + const events: OpenAIResponsesStreamEventEx[] = []; + for await (const frame of trip.events(iterate(frames))) if (frame.type === 'event') events.push(frame.event); + const done = events.find(event => event.type === 'response.output_item.done' && event.item.type === 'function_call'); + const completed = events.find(event => event.type === 'response.completed'); + if (done?.type !== 'response.output_item.done' || completed?.type !== 'response.completed') throw new Error('Expected completed tool call'); + if (delayedId) { + const added = events.find(event => event.type === 'response.output_item.added' && event.item.type === 'function_call'); + expect(added?.type === 'response.output_item.added' ? added.item : undefined).toMatchObject({ type: 'function_call', name }); + if (namespace === undefined) expect(added?.type === 'response.output_item.added' ? added.item : undefined).not.toHaveProperty('namespace'); + else expect(added?.type === 'response.output_item.added' ? added.item : undefined).toHaveProperty('namespace', namespace); + } + const finalCall = completed.response.output.find(item => item.type === 'function_call'); + for (const call of [done.item, finalCall]) { + expect(call).toMatchObject({ type: 'function_call', name, arguments: '{}' }); + if (namespace === undefined) expect(call).not.toHaveProperty('namespace'); + else expect(call).toHaveProperty('namespace', namespace); + } +}); + +test('clears a grouped custom-tool namespace when its Chat name becomes ungrouped', async () => { + const source: OpenAIResponsesRequestPayloadEx = { + model: 'm', stream: true, input: 'start', + tools: [group('custom'), { type: 'custom', name: 'plain', format: { type: 'text' } }], + }; + const trip = expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions(source, { sidecarCodec: createTestSidecarCodec() })); + const frames: ProtocolFrame[] = [ + eventFrame({ + id: 'chat', object: 'chat.completion.chunk', created: 1, model: 'm', + choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'c', type: 'function', function: { name: 'web_run', arguments: '' } }] }, finish_reason: null }], + }), + eventFrame({ + id: 'chat', object: 'chat.completion.chunk', created: 1, model: 'm', + choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { name: 'plain', arguments: '{"input":"raw"}' } }] }, finish_reason: 'tool_calls' }], + }), + doneFrame(), + ]; + const events: OpenAIResponsesStreamEventEx[] = []; + for await (const frame of trip.events(iterate(frames))) if (frame.type === 'event') events.push(frame.event); + const done = events.find(event => event.type === 'response.output_item.done' && event.item.type === 'custom_tool_call'); + const completed = events.find(event => event.type === 'response.completed'); + if (done?.type !== 'response.output_item.done' || completed?.type !== 'response.completed') throw new Error('Expected completed custom tool call'); + const finalCall = completed.response.output.find(item => item.type === 'custom_tool_call'); + for (const call of [done.item, finalCall]) { + expect(call).toMatchObject({ type: 'custom_tool_call', name: 'plain', input: 'raw' }); + expect(call).not.toHaveProperty('namespace'); + } +}); + test.each([['chat', 'function'], ['chat', 'custom'], ['messages', 'function'], ['messages', 'custom']] as const)('native %s %s calls share logical aliases with declarations, selectors and ordinary history', async (target, kind) => { const options = { sidecarCodec: createTestSidecarCodec() }; const source: OpenAIResponsesRequestPayloadEx = { model: 'm', stream: true, input: 'start', tools: [group(kind)] }; diff --git a/packages/translate/src/ir/output/schema.ts b/packages/translate/src/ir/output/schema.ts index f11a9b1501..f7d5dc651c 100644 --- a/packages/translate/src/ir/output/schema.ts +++ b/packages/translate/src/ir/output/schema.ts @@ -69,8 +69,8 @@ const item = union('type', { summary: array(appendString), content: array(appendString), }, { optional: ['summary', 'content'] }), - function_call: object({ call_id: absentString, name: string, namespace: string, arguments: appendString }, { optional: ['call_id', 'namespace'] }), - custom_tool_call: object({ call_id: absentString, name: string, namespace: string, input: appendString }, { optional: ['call_id', 'namespace'] }), + function_call: object({ call_id: absentString, name: string, namespace: nullable(string), arguments: appendString }, { optional: ['call_id', 'namespace'] }), + custom_tool_call: object({ call_id: absentString, name: string, namespace: nullable(string), input: appendString }, { optional: ['call_id', 'namespace'] }), }, 'item'); const logprobs = nullable(array(union('scope', { diff --git a/packages/translate/src/ir/output/types.ts b/packages/translate/src/ir/output/types.ts index 88a64797bb..8c52455cb4 100644 --- a/packages/translate/src/ir/output/types.ts +++ b/packages/translate/src/ir/output/types.ts @@ -63,7 +63,7 @@ type IROutputFunctionCallItem = { readonly type: 'function_call'; // All readonly call_id?: string | IRAbsent; // All readonly name: string; // All - readonly namespace?: string; // ChatCompletions, Responses, Messages + readonly namespace?: string | null; // ChatCompletions, Responses, Messages readonly arguments: string; // All }; @@ -72,7 +72,7 @@ type IROutputCustomToolCallItem = { readonly type: 'custom_tool_call'; // ChatCompletions, Responses, Messages readonly call_id?: string | IRAbsent; // ChatCompletions, Responses, Messages readonly name: string; // ChatCompletions, Responses, Messages - readonly namespace?: string; // ChatCompletions, Responses, Messages + readonly namespace?: string | null; // ChatCompletions, Responses, Messages readonly input: string; // ChatCompletions, Responses, Messages }; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/events.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/events.ts index 0b45358020..427cc283a3 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/events.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/events.ts @@ -442,18 +442,17 @@ export const readOpenAIChatCompletionsEvents = async function* ( tool.pendingTextFragmentIds = []; } } else if (tool.item !== undefined) { - if (tool.id !== undefined && !tool.callIdAssigned) { - tool.item.assign('call_id', tool.id); - tool.callIdAssigned = true; - } if (tool.name !== tool.emittedName) { - // A rename has to state the same pair the opening did, or a grouped call - // would end up with a flat name beside the group's namespace. + // A rename updates both parts of the identity; null clears an earlier namespace. const naming = namingOf(tool.name); tool.item.assign('name', naming.name); - if (naming.namespace !== undefined) tool.item.assign('namespace', naming.namespace); + tool.item.assign('namespace', naming.namespace ?? null); tool.emittedName = tool.name; } + if (tool.id !== undefined && !tool.callIdAssigned) { + tool.item.assign('call_id', tool.id); + tool.callIdAssigned = true; + } if (fragment !== undefined && fragment !== '' && !tool.wrapped) tool.item.append(tool.custom ? 'input' : 'arguments', fragment, textFragmentId === undefined ? undefined : [textFragmentId]); if (fragment !== undefined && tool.wrapped) tool.pendingInput += fragment; } diff --git a/packages/translate/src/protocols/openai-responses/as-source/events.ts b/packages/translate/src/protocols/openai-responses/as-source/events.ts index 9a8f0eeb80..b0f5ef16c2 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/events.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/events.ts @@ -333,14 +333,14 @@ export const writeOpenAIResponsesEvents = async function* ( if (item.type !== 'custom_tool_call') throw new TypeError('Responses tool unit has the wrong IR item'); return { type: 'custom_tool_call', id, call_id: unit.callId, name: item.name, - ...(item.namespace === undefined ? {} : { namespace: item.namespace }), + ...(item.namespace == null ? {} : { namespace: item.namespace }), input: item.input, status, }; } if (item.type !== 'function_call') throw new TypeError('Responses tool unit has the wrong IR item'); return { type: 'function_call', id, call_id: unit.callId, name: item.name, - ...(item.namespace === undefined ? {} : { namespace: item.namespace }), + ...(item.namespace == null ? {} : { namespace: item.namespace }), arguments: item.arguments, status, }; }; @@ -510,12 +510,12 @@ export const writeOpenAIResponsesEvents = async function* ( const added: OpenAIResponsesOutputItemEx = custom ? { type: 'custom_tool_call', id, call_id: resolvedCallId, name: item.name, - ...(item.namespace === undefined ? {} : { namespace: item.namespace }), + ...(item.namespace == null ? {} : { namespace: item.namespace }), input: '', status: 'in_progress', } : { type: 'function_call', id, call_id: resolvedCallId, name: item.name, - ...(item.namespace === undefined ? {} : { namespace: item.namespace }), + ...(item.namespace == null ? {} : { namespace: item.namespace }), arguments: '', status: 'in_progress', }; const content = createEmittedFieldState(options.roundTrip.builder, 'text'); From 42dc09a7fc17a5070103402afd55005eb7a5f1df Mon Sep 17 00:00:00 2001 From: Menci Date: Fri, 13 Nov 2026 15:27:24 +0800 Subject: [PATCH 66/66] test(translate): enforce stable Responses tool identities Cover function and custom calls when names or namespaces change across added, done, and terminal snapshots. --- .../openai-responses/as-target/events_test.ts | 69 ++++++++++++++++--- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts index 422ff9e666..c6473541d7 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts @@ -228,13 +228,64 @@ test.each(toolCases)('invalidates native $type provenance when its done snapshot expect(finalized).not.toHaveProperty('referencedContentHashes'); }); -test('rejects a snapshot that moves a tool call into another group', async () => { - const grouped = (namespace: string): ToolItem => ({ type: 'function_call', id: 'tool', call_id: 'call', name: 'lookup', namespace, arguments: '{}', status: 'completed' }); +for (const { type, field } of toolCases) { + test(`accepts an unchanged ${type} identity across snapshots`, async () => { + const added = { ...toolItem(type, '', 'in_progress'), namespace: 'files' }; + const done = { ...toolItem(type, '{}', 'completed'), namespace: 'files' }; + const captured = await collectProvenance([ + { type: 'response.created', response: response([], 'in_progress') }, + { type: 'response.output_item.added', output_index: 0, item: added }, + { type: 'response.output_item.done', output_index: 0, item: done }, + { type: 'response.completed', response: response([done], 'completed') }, + ], field); + expect(captured.turn[0]).toMatchObject({ type, name: 'lookup', namespace: 'files', call_id: 'call' }); + }); - await expect(collectProvenance([ - { type: 'response.created', response: response([], 'in_progress') }, - { type: 'response.output_item.added', output_index: 0, item: grouped('files') }, - { type: 'response.output_item.done', output_index: 0, item: grouped('web') }, - { type: 'response.completed', response: response([], 'completed') }, - ], 'arguments')).rejects.toThrow('namespace changed from files to web'); -}); + test.each([ + ['files', 'web'], + [undefined, 'files'], + ['files', undefined], + ])(`rejects ${type} namespace changing from %s to %s`, async (initial, final) => { + const added = { ...toolItem(type, '', 'in_progress'), ...(initial === undefined ? {} : { namespace: initial }) }; + const done = { ...toolItem(type, '{}', 'completed'), ...(final === undefined ? {} : { namespace: final }) }; + await expect(collectProvenance([ + { type: 'response.created', response: response([], 'in_progress') }, + { type: 'response.output_item.added', output_index: 0, item: added }, + { type: 'response.output_item.done', output_index: 0, item: done }, + { type: 'response.completed', response: response([done], 'completed') }, + ], field)).rejects.toThrow(`namespace changed from ${String(initial)} to ${String(final)}`); + }); + + test(`rejects a ${type} name change in the done snapshot`, async () => { + const added = toolItem(type, '', 'in_progress'); + const done = { ...toolItem(type, '{}', 'completed'), name: 'other' }; + await expect(collectProvenance([ + { type: 'response.created', response: response([], 'in_progress') }, + { type: 'response.output_item.added', output_index: 0, item: added }, + { type: 'response.output_item.done', output_index: 0, item: done }, + { type: 'response.completed', response: response([done], 'completed') }, + ], field)).rejects.toThrow('Responses snapshot conflicts with streamed value at ["choices",0,"items",0,"name"]'); + }); + + test(`rejects a ${type} namespace change after the done snapshot`, async () => { + const added = { ...toolItem(type, '', 'in_progress'), namespace: 'files' }; + const done = { ...toolItem(type, '{}', 'completed'), namespace: 'files' }; + await expect(collectProvenance([ + { type: 'response.created', response: response([], 'in_progress') }, + { type: 'response.output_item.added', output_index: 0, item: added }, + { type: 'response.output_item.done', output_index: 0, item: done }, + { type: 'response.completed', response: response([{ ...done, namespace: 'web' }], 'completed') }, + ], field)).rejects.toThrow('namespace changed from files to web'); + }); + + test(`rejects a ${type} name change after the done snapshot`, async () => { + const added = toolItem(type, '', 'in_progress'); + const done = toolItem(type, '{}', 'completed'); + await expect(collectProvenance([ + { type: 'response.created', response: response([], 'in_progress') }, + { type: 'response.output_item.added', output_index: 0, item: added }, + { type: 'response.output_item.done', output_index: 0, item: done }, + { type: 'response.completed', response: response([{ ...done, name: 'other' }], 'completed') }, + ], field)).rejects.toThrow('Responses terminal snapshot conflicts with closed output item 0'); + }); +}