From 6238b81b05a699631ab67d52a8c863a855f05ec4 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 04:52:54 +0800 Subject: [PATCH 1/6] 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 287ef1623aba1284c037ec4effd6f963f9d26fa9 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 06:13:42 +0800 Subject: [PATCH 2/6] 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 3807659ee0ec4db81d32d6a14adb2d942047cc86 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 07:41:10 +0800 Subject: [PATCH 3/6] 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 80e8a5c807ff9395ecfe9f908772b9fd54e51933 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 13:42:37 +0800 Subject: [PATCH 4/6] 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 21:50:30 +0800 Subject: [PATCH 5/6] style(translate): order integrated replay imports Align the current-main replay integration with repository import ordering and coalesce the Responses test import. Preserve the resolved replay behavior. --- .../protocols/anthropic-messages/as-source/events_test.ts | 2 +- .../__tests__/protocols/chat-reasoning-round-trip_test.ts | 3 +-- .../src/protocols/anthropic-messages/as-target/events.ts | 2 +- .../anthropic-messages/via-openai-responses/events.ts | 2 +- .../gemini-generate-content/via-anthropic-messages/events.ts | 2 +- .../gemini-generate-content/via-openai-responses/events.ts | 2 +- .../src/protocols/openai-chat-completions/as-target/events.ts | 2 +- .../openai-chat-completions/via-anthropic-messages/events.ts | 2 +- 8 files changed, 8 insertions(+), 9 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/chat-reasoning-round-trip_test.ts b/packages/translate/__tests__/protocols/chat-reasoning-round-trip_test.ts index 799ab06dc3..bc12299fe5 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 { reassembleOpenAIResponsesEvents, toOpenAIResponsesInputItems } 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/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-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/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-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-target/events.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/events.ts index 0649cc47c2..3a5580819d 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'; From 77618b8e3f48710b4e838de23d684d1e91b70b57 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 21:55:22 +0800 Subject: [PATCH 6/6] style(translate): finish replay import ordering Order the remaining replay codec imports after integrating current main. The complete 92-file TypeScript diff now passes scoped ESLint. --- .../openai-chat-completions/via-openai-responses/events.ts | 2 +- .../protocols/openai-responses/via-anthropic-messages/events.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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';