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 e9f9284a2b..0dc5daa5b2 100644 --- a/packages/protocols/src/openai-chat-completions/index.ts +++ b/packages/protocols/src/openai-chat-completions/index.ts @@ -180,3 +180,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 6736d19f7c..6cc0c353f8 100644 --- a/packages/protocols/src/openai-responses/index.ts +++ b/packages/protocols/src/openai-responses/index.ts @@ -1450,3 +1450,4 @@ export { reassembleOpenAIResponsesEvents } from './reassemble.ts'; export { openaiResponsesProtocolFrameToSSEFrame } from './to-sse.ts'; export { canonicalizeOpenAIResponsesPayload } from './canonicalize-payload.ts'; +export { toOpenAIResponsesInputItem, toOpenAIResponsesInputItems } from './history.ts'; diff --git a/packages/translate/AGENTS.md b/packages/translate/AGENTS.md index af0088cf7e..c90a2ddc43 100644 --- a/packages/translate/AGENTS.md +++ b/packages/translate/AGENTS.md @@ -29,7 +29,8 @@ Protocol-native request normalization and Part data classification belong to 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 `TranslateOptions.sidecarCodec`. 7. **Protocol-independent utility** — keep it in `shared/`, with tests under `__tests__/shared/`. Protocol behavior belongs in its owning protocol directory. diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-source/events_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/events_test.ts index 2f7ea48519..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,15 +2,15 @@ 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 { 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 writeAnthropicMessagesEvents((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/events_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-target/events_test.ts index eab9b2ffb8..ef5a9cd7a6 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-target/events_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-target/events_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 { readAnthropicMessagesEvents } from '../../../../src/protocols/anthropic-messages/as-target/events.ts'; -import { appendContent, collectContentViews, createContentState, createReferenceBuilder, type FieldStates } from '../../../../src/round-trip/reference/reference-builder.ts'; -import { finalizeAssistantTurnReferences } from '../../../../src/round-trip/reference/reference-selection.ts'; +import { 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 '../../fixtures.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 readAnthropicMessagesEvents((async function* () { yield* frames; })(), { roundTrip: { builder, onAssistantTurn: (_choice, turn, state) => { captured = { turn, state }; } }, })) { - if (record.type === 'write' && record.kind === 'append' && record.path.at(-1) === 'text') appendContent(builder, content, record.value, record.referencePieceIds); + if (record.type === 'write' && record.kind === 'append' && record.path.at(-1) === 'text') appendEmittedField(builder, content, record.value, record.referencePieceIds); } 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/chat-legacy-round-trip_test.ts b/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts index 9a1f76834b..267fbef566 100644 --- a/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts +++ b/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts @@ -1,13 +1,13 @@ import { expect, test } from 'vitest'; import { requireTrip } from './require-trip.ts'; -import { toOpenAIResponsesInputAssistantTurn } from '../../src/protocols/openai-responses/as-target/assistant-turn.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { iterate } from '../shared/iterables.ts'; 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 { routeAnthropicMessagesViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIChatCompletions, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; @@ -80,7 +80,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 requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(payload, context)); assertNativeHistory(replay.target, nativeName, ['result-0', 'result-1', 'result-2']); @@ -141,7 +141,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(requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'public-model', input: [...history, output] }, context))).rejects.toThrow('Cannot translate input_file tool output to OpenAI Chat Completions.'); } diff --git a/packages/translate/__tests__/protocols/chat-reasoning-round-trip_test.ts b/packages/translate/__tests__/protocols/chat-reasoning-round-trip_test.ts index 16f1a9b44a..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 { expect, test } from 'vitest'; import { requireTrip } from './require-trip.ts'; -import { toOpenAIResponsesInputAssistantTurn } from '../../src/protocols/openai-responses/as-target/assistant-turn.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsAssistantDeltaEx, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; -import { reassembleOpenAIResponsesEvents } from '@floway-dev/protocols/openai-responses'; +import { reassembleOpenAIResponsesEvents, toOpenAIResponsesInputItems } from '@floway-dev/protocols/openai-responses'; import { routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; const chunk = (delta: OpenAIChatCompletionsAssistantDeltaEx, finish_reason: 'stop' | null = null) => eventFrame({ @@ -26,7 +25,7 @@ test('round-trips native Chat reasoning fragments through Responses without corr for await (const frame of trip.events(upstream())) if (frame.type === 'event') yield frame.event; }; const result = await reassembleOpenAIResponsesEvents(responseEvents()); - const restored = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'source', input: toOpenAIResponsesInputAssistantTurn(result.output) }, context)); + const restored = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'source', input: toOpenAIResponsesInputItems(result.output) }, context)); expect(restored.target.messages).toEqual([{ role: 'assistant', content: 'answer', refusal: null, provider_specific_fields: { trace: 'native' }, reasoning_details: [ diff --git a/packages/translate/__tests__/protocols/fixtures.ts b/packages/translate/__tests__/protocols/fixtures.ts index 6146d453d9..ffa020514f 100644 --- a/packages/translate/__tests__/protocols/fixtures.ts +++ b/packages/translate/__tests__/protocols/fixtures.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; +import type { AssistantTurnProtocol } from '../../src/round-trip/input-assistant-turn.ts'; import { decodeRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; -import type { RoundTripProtocol } from '../../src/round-trip/sidecar.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { collect, iterate } from '../shared/iterables.ts'; import { collectAnthropicMessagesProtocolEventsToResult } from '@floway-dev/protocols/anthropic-messages'; @@ -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/gemini-generate-content/as-source/events_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/events_test.ts index 5025f19e9b..95ba186551 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/events_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/events_test.ts @@ -2,13 +2,13 @@ import { expect, test } from 'vitest'; import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; import { writeGeminiGenerateContentEvents } from '../../../../src/protocols/gemini-generate-content/as-source/events.ts'; -import { 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/helpers.ts b/packages/translate/__tests__/protocols/helpers.ts index dbb08188b1..8d27268b6d 100644 --- a/packages/translate/__tests__/protocols/helpers.ts +++ b/packages/translate/__tests__/protocols/helpers.ts @@ -7,8 +7,9 @@ import { readOpenAIChatCompletionsEvents as readOpenAIChatCompletionsEventsProdu import { writeOpenAIResponsesEvents as writeOpenAIResponsesEventsProduction } from '../../src/protocols/openai-responses/as-source/events.ts'; import { flattenNamespaceTools } from '../../src/protocols/openai-responses/as-source/namespace-tools.ts'; import { readOpenAIResponsesEvents as readOpenAIResponsesEventsProduction } from '../../src/protocols/openai-responses/as-target/events.ts'; +import 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'; const reader = (): RoundTripReader => ({ builder: createReferenceBuilder(), @@ -16,7 +17,7 @@ const reader = (): RoundTripWriter => ({ builder: createReferenceBuilder(), - prepareSidecar: async () => 'test-sidecar', + encodeSidecar: async () => 'test-sidecar', }); const namespaceToolNames = flattenNamespaceTools({ model: 'test', input: [], tools: [] }).names; diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/events_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/events_test.ts index e57603e70f..b29529b45a 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/events_test.ts @@ -2,8 +2,8 @@ import { expect, test } from 'vitest'; import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; import { writeOpenAIChatCompletionsEvents } from '../../../../src/protocols/openai-chat-completions/as-source/events.ts'; -import { createReferenceBuilder, 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 writeOpenAIChatCompletionsEvents((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 writeOpenAIChatCompletionsEvents((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/events_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts index decf5625ac..f5ab08bc33 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts @@ -3,10 +3,10 @@ import { expect, test } from 'vitest'; import type { IRRecord } from '../../../../src/ir/output/records.ts'; import type { OpenAIChatCompletionsInputAssistantTurn } from '../../../../src/protocols/openai-chat-completions/as-target/assistant-turn.ts'; import { readOpenAIChatCompletionsEvents } from '../../../../src/protocols/openai-chat-completions/as-target/events.ts'; -import { 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,14 +88,14 @@ 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.referencePieceIds); 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 }); + expect(hydrated).toEqual({ ok: true, value: turn }); return { turn, fieldState, appends, assigns, finalized }; }; diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts index 453b5b2b6e..05f9282e53 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts @@ -3,8 +3,8 @@ import { expect, test } from 'vitest'; import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; import { writeOpenAIResponsesEvents } from '../../../../src/protocols/openai-responses/as-source/events.ts'; import { flattenNamespaceTools } from '../../../../src/protocols/openai-responses/as-source/namespace-tools.ts'; -import { 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..3bae3671e5 --- /dev/null +++ b/packages/translate/__tests__/protocols/openai-responses/as-target/assistant-turn_test.ts @@ -0,0 +1,28 @@ +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('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/events_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts index c9d61a3a24..acb5790ce9 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts @@ -1,8 +1,8 @@ import { expect, test } from 'vitest'; import { readOpenAIResponsesEvents } from '../../../../src/protocols/openai-responses/as-target/events.ts'; -import { appendContent, collectContentViews, createContentState } from '../../../../src/round-trip/reference/reference-builder.ts'; -import { finalizeAssistantTurnReferences } from '../../../../src/round-trip/reference/reference-selection.ts'; +import { 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,7 +40,7 @@ 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 content = createEmittedFieldState(capture.builder, 'text'); const appends: { text: string; referencePieceIds?: readonly number[] }[] = []; const frames = (async function* () { for (const event of events) yield eventFrame(event); @@ -49,7 +49,7 @@ const collectProvenance = async (events: readonly OpenAIResponsesStreamEventEx[] for await (const record of readOpenAIResponsesEvents(frames, { roundTrip: capture.roundTrip })) { if (record.type !== 'write' || record.kind !== 'append' || record.path.at(-1) !== field) continue; appends.push({ text: record.value, ...(record.referencePieceIds === undefined ? {} : { referencePieceIds: record.referencePieceIds }) }); - appendContent(capture.builder, content, record.value, record.referencePieceIds); + appendEmittedField(capture.builder, content, record.value, record.referencePieceIds); } const captured = capture.turns.get(0); @@ -70,17 +70,17 @@ test('keeps the whole field literal when a done snapshot supplies a suffix witho ]); expect(captured.appends).toEqual([{ text: 'hel', referencePieceIds: [0] }, { text: 'lo' }]); - expect([...collectContentViews('hello', captured.content).values()]).toEqual([{ text: 'hello', offset: 0 }]); + 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); @@ -97,11 +97,11 @@ test('keeps a tiny complete snapshot-only atom literal and hydrates the native R ]); expect(captured.appends).toEqual([{ text: 'hello', referencePieceIds: [0] }]); - const finalized = await finalizeAssistantTurnReferences<'openaiResponses'>( + 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'); @@ -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 () => { @@ -136,9 +136,9 @@ test('invalidates streamed refusal provenance when its done snapshot supplies a 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 }); + 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 () => { @@ -152,9 +152,9 @@ test('collects a snapshot-only refusal through its refusal field state', async ( ], 'refusal'); expect(captured.appends).toEqual([{ text: 'refused', referencePieceIds: [0] }]); - 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 }); + expect(await restoreAssistantTurn('openaiResponses', ['refused'], finalized.targetTurn, finalized.referencedContentHashes)).toEqual({ ok: true, value: captured.turn }); }); type ToolItem = Extract; @@ -188,10 +188,10 @@ test.each(toolCases)('collects streamed native $type text through its existing s ], field); expect(captured.appends).toEqual([{ text: prefix, referencePieceIds: [0] }, { text: suffix, referencePieceIds: [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 }); + 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 }) => { @@ -205,10 +205,10 @@ test.each(toolCases)('collects a snapshot-only native $type atom and inlines the { 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)); + 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 }) => { @@ -223,7 +223,7 @@ test.each(toolCases)('invalidates native $type provenance when its done snapshot ], field); expect(captured.appends).toEqual([{ text: prefix, referencePieceIds: [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 f0e32770bd..e9f67a5a5d 100644 --- a/packages/translate/__tests__/protocols/round-trip-receive_test.ts +++ b/packages/translate/__tests__/protocols/round-trip-receive_test.ts @@ -6,12 +6,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'; @@ -28,7 +28,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' }] }]) @@ -37,7 +37,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: [] }] } @@ -64,7 +64,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: [] }] } @@ -80,7 +80,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, @@ -113,7 +113,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'; @@ -169,7 +169,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 0fff1fbd05..3ee86d9314 100644 --- a/packages/translate/__tests__/protocols/routes_test.ts +++ b/packages/translate/__tests__/protocols/routes_test.ts @@ -3,21 +3,21 @@ import { test } from 'vitest'; import { fixtureFrames, type nativeResult } from './fixtures.ts'; import { requireTrip } from './require-trip.ts'; import { partitionAnthropicMessagesTurns } from '../../src/protocols/anthropic-messages/as-source/conversation-turns.ts'; -import { findAnthropicMessagesSidecarCarriers } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; -import { findGeminiGenerateContentSidecarCarriers } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; +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 { toOpenAIChatCompletionsInputAssistantTurn } from '../../src/protocols/openai-chat-completions/as-target/assistant-turn.ts'; +import { findOpenAIChatCompletionsSidecarOccurrences } from '../../src/protocols/openai-chat-completions/as-source/round-trip.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 { toOpenAIResponsesInputAssistantTurn } from '../../src/protocols/openai-responses/as-target/assistant-turn.ts'; +import { findOpenAIResponsesSidecarOccurrences } from '../../src/protocols/openai-responses/as-source/round-trip.ts'; import { decodeRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; import { requireJSONObject } from '../../src/shared/json/object.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { collect, iterate } from '../shared/iterables.ts'; import { collectAnthropicMessagesProtocolEventsToResult, type AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; import { collectGeminiGenerateContentProtocolEventsToResult, type GeminiGenerateContentContent, type GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; +import { 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 { routeAnthropicMessagesViaOpenAIChatCompletions, routeAnthropicMessagesViaOpenAIResponses, routeGeminiGenerateContentViaAnthropicMessages, routeGeminiGenerateContentViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIResponses, routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses, routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; @@ -135,12 +135,12 @@ const collectAssistantTurn = async (protocol: Protocol, frames: Parameters { }; 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 e7eec86f9d..a6ede77697 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/common/json.ts'; -import { collectAnthropicMessagesTextViews } from '../../../src/protocols/anthropic-messages/as-source/round-trip.ts'; -import type { AnthropicMessagesReferenceableAssistantTurn } from '../../../src/protocols/anthropic-messages/as-target/assistant-turn.ts'; -import { collectGeminiGenerateContentTextViews } from '../../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; -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,75 +385,75 @@ 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 }], }; 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 }] }, }); }); 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'); + expect(JSON.stringify(hydrated.value.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 3dd3f55936..c40e504b41 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/common/json.ts'; import type { AnthropicMessagesInputAssistantTurn } from '../../../src/protocols/anthropic-messages/as-target/assistant-turn.ts'; -import { ContentReference } from '../../../src/round-trip/reference/assistant-turn.ts'; -import { appendContent, appendField, collectContentViews, createContentState, createFieldState, createReferenceBuilder } from '../../../src/round-trip/reference/reference-builder.ts'; -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'); @@ -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 16ec8e82bd..0fe7137e9d 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(); @@ -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,28 +80,28 @@ 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 () => { 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 }); + 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 }) => { @@ -109,24 +109,24 @@ 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 }, ]); 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 d58f8b1695..fff73b3943 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); @@ -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); @@ -38,13 +38,13 @@ 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); - 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 () => { @@ -52,15 +52,15 @@ 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)), ]); 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); @@ -72,14 +72,14 @@ 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')]); 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); @@ -89,10 +89,10 @@ 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 }); + expect(await restoreAssistantTurn('openaiChatCompletions', ['unused candidate'], turn)).toEqual({ ok: true, value: turn }); expect(digest).not.toHaveBeenCalled(); } finally { digest.mockRestore(); @@ -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..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 () => { @@ -16,4 +16,40 @@ 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); + }); + + 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/index.ts b/packages/translate/src/index.ts index 805c45f2a9..6ee8eb37df 100644 --- a/packages/translate/src/index.ts +++ b/packages/translate/src/index.ts @@ -1,5 +1,5 @@ -export type { RemoteImageData, RemoteImageLoader, SidecarCodec, TranslateTrip, TranslateInputError, TranslateOptions, Translator } from './types.ts'; -export { SidecarTaggedValue } from './round-trip/sidecar-tagged-value.ts'; +export type { RemoteImageData, RemoteImageLoader, TranslateTrip, TranslateInputError, TranslateOptions, Translator } from './types.ts'; +export { SidecarTaggedValue, type SidecarCodec } from './round-trip/codec.ts'; export { routeAnthropicMessagesViaOpenAIChatCompletions, routeAnthropicMessagesViaOpenAIResponses, diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/events.ts b/packages/translate/src/protocols/anthropic-messages/as-source/events.ts index 26c7630bfd..9c694e9b0a 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/events.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/events.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 { IRJSONObject } from '../../../ir/common/json.ts'; import { IRAbsent } from '../../../ir/output/absent.ts'; @@ -11,13 +11,13 @@ import type { IROutput } from '../../../ir/output/types.ts'; import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; import { createIRUnitQueue } from '../../../ir/output/unit-queue.ts'; import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; -import { appendContent, createContentState, type ContentState, type ContentStates } from '../../../round-trip/reference/reference-builder.ts'; -import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; +import { 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 { parseJSONObjectUnlessTruncated } from '../../../shared/json/completion.ts'; import { parseJSONObject, 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, @@ -50,7 +50,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; } @@ -101,7 +101,7 @@ export const writeAnthropicMessagesEvents = async function* ( const pending = createIRUnitQueue(); const ordinals = createIRUnitIndexAllocator(); 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. @@ -149,10 +149,10 @@ export const writeAnthropicMessagesEvents = 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); }; @@ -192,7 +192,7 @@ export const writeAnthropicMessagesEvents = 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); @@ -275,7 +275,7 @@ export const writeAnthropicMessagesEvents = async function* ( 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.referencePieceIds); } } for (const event of openPendingUnits(state, collector)) { @@ -327,12 +327,12 @@ export const writeAnthropicMessagesEvents = 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; @@ -354,10 +354,10 @@ export const writeAnthropicMessagesEvents = 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-source/round-trip.ts b/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts index 93eb4db66f..0dc665976b 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,12 @@ import type { IRJSONObject, IRJSONValue } from '../../../ir/common/json.ts'; -import type { ReplayContent } from '../../../round-trip/reference/assistant-turn.ts'; -import type { EncodedRoundTripSidecar } from '../../../round-trip/resolve.ts'; +import 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'; -type AnthropicMessagesReplayPart = NonNullable>[number]; +type AnthropicMessagesReplayPart = NonNullable>[number]; type AnthropicMessagesTextGroup = 'text' | 'thinking'; const anthropicMessagesTextGroup = (part: AnthropicMessagesReplayPart): TextContribution | undefined => { @@ -16,11 +15,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 +36,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 +49,9 @@ export const findAnthropicMessagesSidecarCarriers = (turn: AnthropicMessagesAssi }]); }); -export const computeAnthropicMessagesTurnDigest = (turn: AnthropicMessagesAssistantTurn): Promise => - computeRoundTripTurnDigest(messagesTurnDigestMaterialV1(turn)); +export const computeAnthropicMessagesTurnDigest = createTurnDigestComputer(messagesTurnDigestMaterialV1); -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); @@ -68,20 +66,11 @@ export const collectAnthropicMessagesReplayContents = (turn: AnthropicMessagesAs return candidates; }; -export const verifyAnthropicMessagesTurnDigest = ( - turn: AnthropicMessagesAssistantTurn, - 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 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-target/assistant-turn.ts b/packages/translate/src/protocols/anthropic-messages/as-target/assistant-turn.ts index 4604e380af..89c32edbe8 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/common/json.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/events.ts b/packages/translate/src/protocols/anthropic-messages/as-target/events.ts index 00e981efe4..1dc62b6aa4 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/events.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/events.ts @@ -1,10 +1,10 @@ -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 } from '../../../ir/output/builder.ts'; import type { IRRecord } from '../../../ir/output/records.ts'; import type { IROutputSourceCitation } from '../../../ir/output/types.ts'; -import { appendField, createFieldState, type FieldState, type FieldStates } from '../../../round-trip/reference/reference-builder.ts'; -import type { RoundTripReader } from '../../../round-trip/stream.ts'; +import 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 { @@ -48,15 +48,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; } @@ -86,7 +86,7 @@ export const readAnthropicMessagesEvents = 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') { @@ -98,14 +98,14 @@ export const readAnthropicMessagesEvents = 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; @@ -151,10 +151,10 @@ export const readAnthropicMessagesEvents = 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]); } @@ -162,20 +162,20 @@ export const readAnthropicMessagesEvents = 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]); } @@ -231,7 +231,7 @@ export const readAnthropicMessagesEvents = 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 7d1a62d87a..2a1e68bac8 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 { readOpenAIChatCompletionsEvents } from '../../openai-chat-completions/as-target/events.ts'; import { writeAnthropicMessagesEvents } from '../as-source/events.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; @@ -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 writeAnthropicMessagesEvents(readOpenAIChatCompletionsEvents(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 fd53391c21..1d82a504e3 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 { TranslateInputException } from '../../../shared/input-error.ts'; -import type { SidecarCodec } from '../../../types.ts'; import { filterAnthropicMessagesClientTools } from '../as-source/client-tools.ts'; import { partitionAnthropicMessagesTurns } from '../as-source/conversation-turns.ts'; -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 bc6d985145..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 { 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 { readOpenAIResponsesEvents } from '../../openai-responses/as-target/events.ts'; import { writeAnthropicMessagesEvents } from '../as-source/events.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 writeAnthropicMessagesEvents(readOpenAIResponsesEvents(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 cd9c0b0e7d..9bad07ebc0 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 { TranslateInputException } from '../../../shared/input-error.ts'; -import type { SidecarCodec } from '../../../types.ts'; import { filterAnthropicMessagesClientTools } from '../as-source/client-tools.ts'; import { partitionAnthropicMessagesTurns } from '../as-source/conversation-turns.ts'; -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/events.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts index ef1c3a912a..97d6616732 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts @@ -1,4 +1,4 @@ -import { collectGeminiGenerateContentTextViews, computeGeminiGenerateContentTurnDigest, createGeminiGenerateContentSidecarCarrier } from './round-trip.ts'; +import { collectGeminiGenerateContentTextPlacements, computeGeminiGenerateContentTurnDigest, createGeminiGenerateContentSidecarPart } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; import type { IRJSONObject } from '../../../ir/common/json.ts'; import { IRAbsent } from '../../../ir/output/absent.ts'; @@ -10,14 +10,14 @@ import { consumeIRRecords } from '../../../ir/output/stream.ts'; import type { IROutput, IROutputSourceCitation, IROutputTokenLogprob } from '../../../ir/output/types.ts'; import { createIRUnitQueue, type IRUnitQueue } from '../../../ir/output/unit-queue.ts'; import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; -import { appendContent, createContentState, type ContentStates, type ContentState as ReferenceContentState } from '../../../round-trip/reference/reference-builder.ts'; -import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; +import { 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 { parseJSONObjectUnlessTruncated } from '../../../shared/json/completion.ts'; import { isCompleteJSONObject } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; -import type { GeminiGenerateContentAssistantState, GeminiGenerateContentInputAssistantTurn } from '../as-target/assistant-turn.ts'; +import type { GeminiGenerateContentInputAssistantTurnState, GeminiGenerateContentInputAssistantTurn } from '../as-target/assistant-turn.ts'; import { eventFrame, FAST_SERVICE_TIER, type EventFrame } from '@floway-dev/protocols/common'; import { type GeminiGenerateContentCandidate, @@ -77,8 +77,8 @@ export const writeGeminiGenerateContentEvents = 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 => { @@ -87,8 +87,8 @@ export const writeGeminiGenerateContentEvents = 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 }); @@ -112,7 +112,7 @@ export const writeGeminiGenerateContentEvents = 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); @@ -140,9 +140,9 @@ export const writeGeminiGenerateContentEvents = 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 } : {}) }; }; @@ -168,7 +168,7 @@ export const writeGeminiGenerateContentEvents = 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); @@ -241,11 +241,11 @@ export const writeGeminiGenerateContentEvents = 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] = { @@ -282,10 +282,10 @@ export const writeGeminiGenerateContentEvents = 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 } }; } } @@ -421,10 +421,10 @@ export const writeGeminiGenerateContentEvents = 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-source/round-trip.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts index f2f24c54c2..b45b63b855 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,22 +1,21 @@ import type { IRJSONObject } from '../../../ir/common/json.ts'; -import type { ReplayContent } from '../../../round-trip/reference/assistant-turn.ts'; -import type { EncodedRoundTripSidecar } from '../../../round-trip/resolve.ts'; +import 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'; 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 +38,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 +51,9 @@ export const findGeminiGenerateContentSidecarCarriers = (turn: GeminiGenerateCon }]) ?? []; }); -export const computeGeminiGenerateContentTurnDigest = (turn: GeminiGenerateContentAssistantTurn): Promise => - computeRoundTripTurnDigest(geminiTurnDigestMaterialV1(turn)); +export const computeGeminiGenerateContentTurnDigest = createTurnDigestComputer(geminiTurnDigestMaterialV1); -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'); @@ -70,22 +68,13 @@ export const collectGeminiGenerateContentReplayContents = (turn: GeminiGenerateC return candidates; }; -export const verifyGeminiGenerateContentTurnDigest = ( - turn: GeminiGenerateContentAssistantTurn, - 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 -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-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 0f574ddb00..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 { 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 { readAnthropicMessagesEvents } from '../../anthropic-messages/as-target/events.ts'; import { writeGeminiGenerateContentEvents } from '../as-source/events.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 writeGeminiGenerateContentEvents(readAnthropicMessagesEvents(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/request.ts b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/request.ts index 492c9b946b..b99a9a6751 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 { TranslateInputException } from '../../../shared/input-error.ts'; -import type { SidecarCodec } from '../../../types.ts'; import { applyLastMessageCacheBreakpoint, applyLastSystemCacheBreakpoint, applyLastToolCacheBreakpoint } from '../../anthropic-messages/as-target/cache-breakpoints.ts'; import { anthropicMessagesToolInputSchema } from '../../anthropic-messages/as-target/tool-input-schema.ts'; import { geminiGenerateContentInlineData, 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 { @@ -216,7 +216,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 eeae06afe8..df36990221 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 { readOpenAIChatCompletionsEvents } from '../../openai-chat-completions/as-target/events.ts'; import { writeGeminiGenerateContentEvents } from '../as-source/events.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 writeGeminiGenerateContentEvents(readOpenAIChatCompletionsEvents(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 71c50594b2..86d06ab4a9 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 { TranslateInputException } from '../../../shared/input-error.ts'; -import type { SidecarCodec } from '../../../types.ts'; import { geminiGenerateContentInlineData, geminiGenerateContentPartText, geminiGenerateContentText, geminiGenerateContentThoughtText, geminiGenerateContentVisibleText } from '../as-source/content.ts'; import { partitionGeminiGenerateContentTurns } from '../as-source/conversation-turns.ts'; import { geminiGenerateContentReasoningEffort } from '../as-source/reasoning.ts'; -import { collectGeminiGenerateContentReplayContents, hasGeminiGenerateContentThoughtSignature, findGeminiGenerateContentSidecarCarriers, verifyGeminiGenerateContentTurnDigest } from '../as-source/round-trip.ts'; +import { 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 { geminiGenerateContentPartKind } from '@floway-dev/protocols/gemini-generate-content'; @@ -240,7 +240,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 5fd713d617..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 { 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 { readOpenAIResponsesEvents } from '../../openai-responses/as-target/events.ts'; import { writeGeminiGenerateContentEvents } from '../as-source/events.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 writeGeminiGenerateContentEvents(readOpenAIResponsesEvents(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/request.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/request.ts index 411f77d985..72daa36fd5 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 { TranslateInputException } from '../../../shared/input-error.ts'; -import type { SidecarCodec } from '../../../types.ts'; import { geminiGenerateContentInlineData, geminiGenerateContentPartText, geminiGenerateContentText, geminiGenerateContentThoughtText, geminiGenerateContentVisibleText } from '../as-source/content.ts'; import { partitionGeminiGenerateContentTurns } from '../as-source/conversation-turns.ts'; import { geminiGenerateContentReasoningEffort } from '../as-source/reasoning.ts'; -import { collectGeminiGenerateContentReplayContents, hasGeminiGenerateContentThoughtSignature, findGeminiGenerateContentSidecarCarriers, verifyGeminiGenerateContentTurnDigest } from '../as-source/round-trip.ts'; +import { 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 { geminiGenerateContentPartKind } from '@floway-dev/protocols/gemini-generate-content'; @@ -194,7 +194,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/events.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts index dbebad7b07..255b34cfb8 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts @@ -1,4 +1,4 @@ -import { collectOpenAIChatCompletionsTextViews, computeOpenAIChatCompletionsTurnDigest, createOpenAIChatCompletionsSidecarCarrier } from './round-trip.ts'; +import { collectOpenAIChatCompletionsTextPlacements, computeOpenAIChatCompletionsTurnDigest, createOpenAIChatCompletionsSidecarDetails } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; import { IRAbsent } from '../../../ir/output/absent.ts'; import type { IRErrorPayload } from '../../../ir/output/error.ts'; @@ -7,11 +7,11 @@ import type { IRRecord } from '../../../ir/output/records.ts'; import { consumeIRRecords } from '../../../ir/output/stream.ts'; import type { IROutputItem, IROutputMessageItem, IROutputTokenLogprob } from '../../../ir/output/types.ts'; import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; -import { appendContent, createContentState, type ContentStates, type ContentState } from '../../../round-trip/reference/reference-builder.ts'; -import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; +import { 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 { isJsonObject } from '@floway-dev/protocols/common'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { @@ -101,11 +101,11 @@ export const writeOpenAIChatCompletionsEvents = 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 = {}; @@ -113,11 +113,11 @@ export const writeOpenAIChatCompletionsEvents = 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]; @@ -126,7 +126,7 @@ export const writeOpenAIChatCompletionsEvents = 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; }; @@ -256,7 +256,7 @@ export const writeOpenAIChatCompletionsEvents = 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.referencePieceIds); yield deltaFrame(choice, path[6] === 'text' ? { content: record.value } : { refusal: record.value }, tier); continue; } @@ -287,7 +287,7 @@ export const writeOpenAIChatCompletionsEvents = 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.referencePieceIds); yield deltaFrame(choice, { reasoning_text: record.value }, tier); continue; } @@ -296,7 +296,7 @@ export const writeOpenAIChatCompletionsEvents = 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.referencePieceIds); const call = custom ? { index: ordinal, custom: { input: record.value } } : { index: ordinal, function: { arguments: record.value } }; @@ -351,8 +351,8 @@ export const writeOpenAIChatCompletionsEvents = 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); @@ -388,7 +388,7 @@ export const writeOpenAIChatCompletionsEvents = async function* ( }); if (annotations.length > 0) yield deltaFrame(record.choice, { annotations }, tier); if (choice.refusal_details?.explanation != null) { - appendContent(options.roundTrip.builder, textState(record.choice, 'refusal'), choice.refusal_details.explanation); + appendEmittedField(options.roundTrip.builder, textState(record.choice, 'refusal'), choice.refusal_details.explanation); yield deltaFrame(record.choice, { refusal: choice.refusal_details.explanation }, tier); } const summary = nativeAccumulator(record.choice).result().choices.find(choice => choice.index === record.choice)?.message.reasoning_text; @@ -398,9 +398,9 @@ export const writeOpenAIChatCompletionsEvents = 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-source/round-trip.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts index 0ef3b5905a..801eb84a42 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,11 @@ import type { IRJSONObject, IRJSONValue } from '../../../ir/common/json.ts'; -import type { ReplayContent } from '../../../round-trip/reference/assistant-turn.ts'; -import type { EncodedRoundTripSidecar } from '../../../round-trip/resolve.ts'; +import 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]; +type OpenAIChatCompletionsReplayPart = NonNullable>[number]; type OpenAIChatCompletionsTextGroup = 'text' | 'refusal'; const chatCompletionsTextGroup = (part: OpenAIChatCompletionsReplayPart): TextContribution => @@ -14,18 +13,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 +35,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 +50,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 +60,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 +74,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 +82,9 @@ export const findOpenAIChatCompletionsSidecarCarriers = (turn: OpenAIChatComplet }]); }; -export const computeOpenAIChatCompletionsTurnDigest = (turn: OpenAIChatCompletionsAssistantTurn): Promise => - computeRoundTripTurnDigest(chatTurnDigestMaterialV1(turn)); +export const computeOpenAIChatCompletionsTurnDigest = createTurnDigestComputer(chatTurnDigestMaterialV1); -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); @@ -105,23 +103,14 @@ export const collectOpenAIChatCompletionsReplayContents = (turn: OpenAIChatCompl return candidates; }; -export const verifyOpenAIChatCompletionsTurnDigest = ( - turn: OpenAIChatCompletionsAssistantTurn, - 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 -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-target/assistant-turn.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/assistant-turn.ts index f790e05ca6..9ef2a32d12 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,5 +1,5 @@ -import type { ReferenceState, ReferenceStateLeaves, ReferenceableValue, TextRule } from '../../../round-trip/reference/assistant-turn.ts'; -import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsAssistantOutputMessageEx } from '@floway-dev/protocols/openai-chat-completions'; +import type { ReferenceState, ReferenceStateLeaves, ReferenceableValue, TextRule } from '../../../round-trip/reference/types.ts'; +import { toOpenAIChatCompletionsInputAssistantMessage, type OpenAIChatCompletionsAssistantOutputMessageEx, type OpenAIChatCompletionsAssistantMessageEx } from '@floway-dev/protocols/openai-chat-completions'; type OpenAIChatCompletionsAssistantRule = { content: TextRule | [{ text: TextRule; refusal: TextRule }]; @@ -11,39 +11,16 @@ 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( +export const toOpenAIChatCompletionsInputAssistantTurn = ( outputTurn: OpenAIChatCompletionsOutputAssistantTurn, -): OpenAIChatCompletionsInputAssistantTurn; -export function toOpenAIChatCompletionsInputAssistantTurn( - outputTurn: OpenAIChatCompletionsOutputAssistantTurn, - outputState: OpenAIChatCompletionsOutputAssistantState, -): { turn: OpenAIChatCompletionsInputAssistantTurn; state: OpenAIChatCompletionsAssistantState }; -export function toOpenAIChatCompletionsInputAssistantTurn( - outputTurn: OpenAIChatCompletionsOutputAssistantTurn, - outputState?: OpenAIChatCompletionsOutputAssistantState, -): OpenAIChatCompletionsInputAssistantTurn | { - turn: OpenAIChatCompletionsInputAssistantTurn; - state: OpenAIChatCompletionsAssistantState; -} { - // 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) }; -} + outputState: OpenAIChatCompletionsOutputAssistantTurnState, +): { turn: OpenAIChatCompletionsInputAssistantTurn; state: OpenAIChatCompletionsInputAssistantTurnState } => ({ + turn: toOpenAIChatCompletionsInputAssistantMessage(outputTurn), + state: Object.assign({}, outputState), +}); 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 ee815569a3..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 @@ -1,12 +1,12 @@ -import { toOpenAIChatCompletionsInputAssistantTurn, type OpenAIChatCompletionsOutputAssistantState } from './assistant-turn.ts'; +import { toOpenAIChatCompletionsInputAssistantTurn, type OpenAIChatCompletionsOutputAssistantTurnState } from './assistant-turn.ts'; import { usageToIR } from './usage.ts'; import { IRAbsent } from '../../../ir/output/absent.ts'; import { createIRBuilder, type IRItemHandle, type IRPartHandle, type IRWriteHandle, type IRRelativePath } from '../../../ir/output/builder.ts'; import type { IRErrorPayload } from '../../../ir/output/error.ts'; import type { IRRecord } from '../../../ir/output/records.ts'; import type { IROutputSourceCitation } from '../../../ir/output/types.ts'; -import { 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'; @@ -20,14 +20,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; @@ -112,7 +112,7 @@ interface ChatChoiceState { currentTool?: ChatToolState; readonly logprobParts: Map; readonly logprobGroups: Map; - readonly fieldState: OpenAIChatCompletionsOutputAssistantState; + readonly fieldState: OpenAIChatCompletionsOutputAssistantTurnState; ended: boolean; incomplete: boolean; } @@ -159,9 +159,9 @@ export const readOpenAIChatCompletionsEvents = 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 => { @@ -194,7 +194,7 @@ export const readOpenAIChatCompletionsEvents = 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); @@ -273,7 +273,7 @@ export const readOpenAIChatCompletionsEvents = 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); }; @@ -303,18 +303,18 @@ export const readOpenAIChatCompletionsEvents = 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) { @@ -371,7 +371,7 @@ export const readOpenAIChatCompletionsEvents = async function* ( tool = { name: '', pendingInput: '', - pendingReferencePieceIds: [], + pendingTextFragmentIds: [], custom: call.type === 'custom' || call.custom !== undefined, wrapped: false, callIdAssigned: false, @@ -387,31 +387,31 @@ export const readOpenAIChatCompletionsEvents = async function* ( // https://github.com/openai/openai-node/blob/7423ac3e9351c46300cd094479cded5551a72eb4/src/lib/ChatCompletionStream.ts#L2104-L2106 if (fn?.name !== undefined && fn.name !== '') tool.name = fn.name; const fragment = fn?.arguments ?? fn?.input; - let 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 !== '') { @@ -431,10 +431,10 @@ export const readOpenAIChatCompletionsEvents = 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) { @@ -445,7 +445,7 @@ export const readOpenAIChatCompletionsEvents = 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; } }; @@ -461,7 +461,7 @@ export const readOpenAIChatCompletionsEvents = 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 f391712fc0..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 { 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 { readAnthropicMessagesEvents } from '../../anthropic-messages/as-target/events.ts'; import { writeOpenAIChatCompletionsEvents } from '../as-source/events.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 writeOpenAIChatCompletionsEvents(readAnthropicMessagesEvents(frames, { roundTrip: stream.reader }), { ...outputOptions, roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/request.ts b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/request.ts index 6c7cc5dc44..f39d0f3f41 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 { TranslateInputException } from '../../../shared/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 7b2b5171aa..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 { 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 { readOpenAIResponsesEvents } from '../../openai-responses/as-target/events.ts'; import { writeOpenAIChatCompletionsEvents } from '../as-source/events.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 writeOpenAIChatCompletionsEvents(readOpenAIResponsesEvents(frames, { roundTrip: stream.reader }), { ...outputOptions, roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/request.ts b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/request.ts index 9bc4f1a914..b55fef0a4c 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 { TranslateInputException } from '../../../shared/input-error.ts'; -import type { SidecarCodec } from '../../../types.ts'; import { partitionOpenAIChatCompletionsTurns } from '../as-source/conversation-turns.ts'; -import { collectOpenAIChatCompletionsReplayContents, hasOpenAIChatCompletionsReasoning, findOpenAIChatCompletionsSidecarCarriers, verifyOpenAIChatCompletionsTurnDigest } from '../as-source/round-trip.ts'; +import { collectOpenAIChatCompletionsReplayContents, hasOpenAIChatCompletionsReasoning, findOpenAIChatCompletionsSidecarOccurrences, verifyOpenAIChatCompletionsTurnDigest } from '../as-source/round-trip.ts'; import type { OpenAIChatCompletionsAssistantMessage, OpenAIChatCompletionsMessage, OpenAIChatCompletionsPayload, OpenAIChatCompletionsTool } from '@floway-dev/protocols/openai-chat-completions'; import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesAssistantInputContent, CanonicalOpenAIResponsesInputItem, OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; @@ -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/events.ts b/packages/translate/src/protocols/openai-responses/as-source/events.ts index 85cc651b33..aea752d90a 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/events.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/events.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 { IRAbsent } from '../../../ir/output/absent.ts'; import type { IRCollector } from '../../../ir/output/collector.ts'; @@ -11,13 +11,13 @@ import type { IROutput, IROutputItem, IROutputMessageItem, IROutputSourceCitatio import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; import { createIRUnitQueue } from '../../../ir/output/unit-queue.ts'; import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; -import { 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 { 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 { parseJSONObjectUnlessTruncated } from '../../../shared/json/completion.ts'; import { isCompleteJSONObject } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; -import { toOpenAIResponsesInputAssistantTurn, type OpenAIResponsesItemState, type OpenAIResponsesOutputAssistantState } from '../as-target/assistant-turn.ts'; +import { toOpenAIResponsesInputAssistantTurn, type OpenAIResponsesItemState, type OpenAIResponsesOutputAssistantTurnState } from '../as-target/assistant-turn.ts'; import { isJsonObject } from '@floway-dev/protocols/common'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { @@ -49,7 +49,7 @@ 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 OpenAIResponsesEventWriterOptions = SourceEventWriterOptions<'openaiResponses'> & { namespaceToolNames: NamespaceToolNames }; @@ -67,8 +67,8 @@ interface OpenedUnit { type ReasoningField = 'summary' | 'content'; type MessagePart = IROutputMessageItem['content'][number]; -type ResponsesMessageState = OpenAIResponsesItemState, ContentStates>; -type ResponsesReasoningState = OpenAIResponsesItemState, ContentStates>; +type ResponsesMessageState = OpenAIResponsesItemState, EmittedFieldStates>; +type ResponsesReasoningState = OpenAIResponsesItemState, EmittedFieldStates>; const initializedType = (value: unknown): IROutputItem['type'] | MessagePart['type'] | undefined => { if (!isJsonObject(value)) return undefined; @@ -186,7 +186,7 @@ export const writeOpenAIResponsesEvents = 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(); @@ -202,15 +202,15 @@ export const writeOpenAIResponsesEvents = 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'); @@ -225,17 +225,17 @@ export const writeOpenAIResponsesEvents = 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); }; @@ -489,7 +489,7 @@ export const writeOpenAIResponsesEvents = 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[] = []; @@ -812,9 +812,9 @@ export const writeOpenAIResponsesEvents = 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-source/round-trip.ts b/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts index 623e1fa547..eec8acceca 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,9 +1,8 @@ import type { IRJSONObject, IRJSONValue } from '../../../ir/common/json.ts'; -import type { ReplayContent } from '../../../round-trip/reference/assistant-turn.ts'; -import type { EncodedRoundTripSidecar } from '../../../round-trip/resolve.ts'; +import 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; @@ -18,14 +17,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 +41,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 +78,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 +90,9 @@ export const findOpenAIResponsesSidecarCarriers = (turn: OpenAIResponsesAssistan }, }]); -export const computeOpenAIResponsesTurnDigest = (turn: OpenAIResponsesAssistantTurn): Promise => - computeRoundTripTurnDigest(responsesTurnDigestMaterialV1(turn)); +export const computeOpenAIResponsesTurnDigest = createTurnDigestComputer(responsesTurnDigestMaterialV1); -export const collectOpenAIResponsesReplayContents = (turn: OpenAIResponsesAssistantTurn): ReplayContent[] => { +export const collectOpenAIResponsesReplayContents = (turn: OpenAIResponsesSourceAssistantTurn): ReplayContent[] => { const candidates: ReplayContent[] = []; for (const item of turn) { if (item.type === 'message') { @@ -112,25 +110,16 @@ export const collectOpenAIResponsesReplayContents = (turn: OpenAIResponsesAssist return candidates; }; -export const verifyOpenAIResponsesTurnDigest = ( - turn: OpenAIResponsesAssistantTurn, - 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 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-target/assistant-turn.ts b/packages/translate/src/protocols/openai-responses/as-target/assistant-turn.ts index e3e6f4cde1..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/assistant-turn.ts'; -import type { CanonicalOpenAIResponsesInputItem, OpenAIResponsesOutputItemEx } from '@floway-dev/protocols/openai-responses'; +import type { ReferenceState, ReferenceStateLeaves, ReferenceableValue, TextRule } from '../../../round-trip/reference/types.ts'; +import { toOpenAIResponsesInputItem, type OpenAIResponsesOutputItemEx, type CanonicalOpenAIResponsesInputItem } from '@floway-dev/protocols/openai-responses'; type OpenAIResponsesItemRule = T extends { type: 'message' } ? { content: TextRule | [{ text: TextRule; refusal: TextRule }] } @@ -11,82 +11,29 @@ 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]; - -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, -): CanonicalOpenAIResponsesInputItem[]; -export function toOpenAIResponsesInputAssistantTurn( +export const toOpenAIResponsesInputAssistantTurn = ( turn: OpenAIResponsesOutputAssistantTurn, - state: OpenAIResponsesOutputAssistantState, -): { turn: CanonicalOpenAIResponsesInputItem[]; state: OpenAIResponsesAssistantState }; -export function toOpenAIResponsesInputAssistantTurn( - turn: OpenAIResponsesOutputAssistantTurn, - state?: OpenAIResponsesOutputAssistantState, -) { - // 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 + state: OpenAIResponsesOutputAssistantTurnState, +): { 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: OpenAIResponsesAssistantState = []; + const inputState: OpenAIResponsesInputAssistantTurnState = []; inputState.length = items.length; items.forEach(({ turn: inputItem, sourceIndex }, index) => { if (!Object.hasOwn(state, sourceIndex)) return; @@ -94,4 +41,4 @@ export function toOpenAIResponsesInputAssistantTurn, 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 { @@ -29,7 +29,7 @@ interface WriteTarget { } interface ResponsesTextTarget extends WriteTarget { - fieldState?: FieldStates['text']; + fieldState?: ReadFieldStates['text']; } interface ResponsesOutputTextPartState extends ResponsesTextTarget { @@ -143,7 +143,7 @@ export const readOpenAIResponsesEvents = 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; @@ -220,7 +220,7 @@ export const readOpenAIResponsesEvents = 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; @@ -236,7 +236,7 @@ export const readOpenAIResponsesEvents = 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; @@ -265,7 +265,7 @@ export const readOpenAIResponsesEvents = 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); @@ -292,7 +292,7 @@ export const readOpenAIResponsesEvents = 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] ??= []; @@ -321,7 +321,7 @@ export const readOpenAIResponsesEvents = 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; @@ -597,7 +597,7 @@ export const readOpenAIResponsesEvents = 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_details', { 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 b21b9da9ad..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 { 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 { readAnthropicMessagesEvents } from '../../anthropic-messages/as-target/events.ts'; import { writeOpenAIResponsesEvents } from '../as-source/events.ts'; import type { NamespaceToolNames } from '../as-source/namespace-tools.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 writeOpenAIResponsesEvents(readAnthropicMessagesEvents(frames, { customToolNames, roundTrip: stream.reader }), { id: responseId, namespaceToolNames, roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts index 8c6519c06c..b2ca3da6ad 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 { TranslateInputException } from '../../../shared/input-error.ts'; import { parseJSONObject } from '../../../shared/json/object.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 { buildCustomToolInputSchema } from '../common/custom-tool-wrap.ts'; import { ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS, @@ -433,7 +434,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 44ac3b6ec0..4d9bdf0c6c 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 { readOpenAIChatCompletionsEvents } from '../../openai-chat-completions/as-target/events.ts'; import { writeOpenAIResponsesEvents } from '../as-source/events.ts'; import type { NamespaceToolNames } from '../as-source/namespace-tools.ts'; @@ -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 writeOpenAIResponsesEvents(readOpenAIChatCompletionsEvents(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 1fc0d54da9..d6fcd2dfed 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 { TranslateInputException } from '../../../shared/input-error.ts'; -import type { SidecarCodec } from '../../../types.ts'; import { restrictAllowedTools } from '../as-source/allowed-tools.ts'; import { partitionOpenAIResponsesTurns } from '../as-source/conversation-turns.ts'; import { flattenNamespaceTools, type NamespaceToolNames } from '../as-source/namespace-tools.ts'; import { rejectProgramCaller, rejectProgrammaticOpenAIResponsesPayload } from '../as-source/programmatic-tooling.ts'; -import { collectOpenAIResponsesReplayContents, findOpenAIResponsesSidecarCarriers, isOpenAIResponsesEncryptedReasoning, verifyOpenAIResponsesTurnDigest } from '../as-source/round-trip.ts'; +import { collectOpenAIResponsesReplayContents, findOpenAIResponsesSidecarOccurrences, isOpenAIResponsesEncryptedReasoning, verifyOpenAIResponsesTurnDigest } from '../as-source/round-trip.ts'; import { buildCustomToolInputSchema } from '../common/custom-tool-wrap.ts'; import type { OpenAIChatCompletionsAssistantMessage, OpenAIChatCompletionsResponseFormat, OpenAIChatCompletionsUserContentPart, OpenAIChatCompletionsPayload, OpenAIChatCompletionsMessage, OpenAIChatCompletionsTool, OpenAIChatCompletionsToolCall } from '@floway-dev/protocols/openai-chat-completions'; import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; @@ -269,7 +269,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..904ca37b03 --- /dev/null +++ b/packages/translate/src/round-trip/reader.ts @@ -0,0 +1,13 @@ +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'; + +/** The coordinator's inbound half: a target reader registers the turns it stated. */ +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 4bebe685a7..1cf37f84db 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/common/json.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 3d8f10818e..9198b0a0d9 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/common/json.ts'; import { requireJSONObject } from '../../shared/json/object.ts'; import { rawJSON } from '../../shared/json/raw-json.ts'; -import type { AssistantTurnByProtocol, AssistantTurnFor } from '../assistant-turn.ts'; +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..e62e3180a9 100644 --- a/packages/translate/src/round-trip/reference/restore.ts +++ b/packages/translate/src/round-trip/reference/restore.ts @@ -1,16 +1,15 @@ -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 = - | { 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[] = []; @@ -25,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); @@ -43,12 +42,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 +58,6 @@ export const restoreAssistantTurn = async } + ? { ok: true, value: 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 fe11524e10..2b9d5aead5 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/common/json.ts'; -import type { AnthropicMessagesAssistantState, AnthropicMessagesReferenceableAssistantTurn } from '../../protocols/anthropic-messages/as-target/assistant-turn.ts'; -import type { GeminiGenerateContentAssistantState, GeminiGenerateContentReferenceableAssistantTurn } from '../../protocols/gemini-generate-content/as-target/assistant-turn.ts'; -import type { OpenAIChatCompletionsAssistantState, OpenAIChatCompletionsReferenceableAssistantTurn } from '../../protocols/openai-chat-completions/as-target/assistant-turn.ts'; -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..8853c3bf33 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; @@ -40,5 +40,5 @@ export const roundTripAssistantTurn = async = { +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 d216fe5db1..0000000000 --- a/packages/translate/src/round-trip/stream.ts +++ /dev/null @@ -1,59 +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 }; -}; diff --git a/packages/translate/src/round-trip/text-groups.ts b/packages/translate/src/round-trip/text-groups.ts index fa58e726b6..ec7d292e0a 100644 --- a/packages/translate/src/round-trip/text-groups.ts +++ b/packages/translate/src/round-trip/text-groups.ts @@ -6,11 +6,12 @@ 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; + /** Concatenated text channel in which offset locates this field. */ + readonly enclosingText: string; readonly offset: number; } @@ -24,8 +25,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 +38,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 a6688cbfb6..5720e35a14 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/common/json.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)); @@ -35,3 +26,17 @@ export const verifyRoundTripTurnDigest = async (material: IRJSONValue, sourceTur const digest = await hashRoundTripContent(stringifyReplayMaterial(material)); 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)); + +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); + } +}; diff --git a/packages/translate/src/round-trip/writer.ts b/packages/translate/src/round-trip/writer.ts new file mode 100644 index 0000000000..4a370f8534 --- /dev/null +++ b/packages/translate/src/round-trip/writer.ts @@ -0,0 +1,17 @@ +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'; + +/** The coordinator's outbound half: a source writer states its turn's sidecar. */ +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 index 5db95628da..c79805c2b0 100644 --- a/packages/translate/src/shared/event-writer-options.ts +++ b/packages/translate/src/shared/event-writer-options.ts @@ -1,7 +1,7 @@ -import type { AssistantTurnByProtocol } from '../round-trip/assistant-turn.ts'; -import type { RoundTripWriter } from '../round-trip/stream.ts'; +import type { AssistantTurnProtocol } from '../round-trip/input-assistant-turn.ts'; +import type { RoundTripWriter } from '../round-trip/writer.ts'; -export interface SourceEventWriterOptions { +export interface SourceEventWriterOptions { id?: string; created?: number; roundTrip: RoundTripWriter; diff --git a/packages/translate/src/types.ts b/packages/translate/src/types.ts index d78efd00fa..1f565db7c6 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;