diff --git a/packages/translate/AGENTS.md b/packages/translate/AGENTS.md index 8a7031c696..78a20dd6ce 100644 --- a/packages/translate/AGENTS.md +++ b/packages/translate/AGENTS.md @@ -18,11 +18,11 @@ import the source protocol's subpath and call protocol's wire mapping in the matching `via-*` route. Retained files use functional names without another protocol name. 5. **Output IR** — keep private types, its path schema, stream operations, - validation, accumulation, operation paths, and native ordinal mapping under - `ir/`. Native SSE adapters - belong to their protocol: `as-target/sse-from.ts` consumes upstream SSE and - `as-source/sse-to.ts` emits client SSE with its sidecar. The return path is - upstream SSE → IR SSE → client SSE with sidecar. IR stays internal to translate. + validation, accumulation, operation paths, and native unit index allocation under + `ir/output/`. Native SSE adapters + belong to their protocol: `as-target/events.ts` reads upstream protocol events and + `as-source/events.ts` writes client protocol events with its sidecar. The return path is + upstream events → IR records → client events with sidecar. IR stays internal to translate. 6. **Assistant-turn round trips** — keep owner-specific sidecar validation, replay validation, and native stream coordination under `round-trip/`. Source replay inspection and carriers belong in `as-source/round-trip.ts`; native assistant-turn @@ -33,7 +33,7 @@ import the source protocol's subpath and call ## Current Production Subdirectories -- `ir/` — output model, its path schema, and protocol-independent streaming state. +- `ir/output/` — output model, its path schema, and protocol-independent streaming state. - `round-trip/` — authenticated assistant-turn sidecars, replay validation, and native stream coordination. - `protocols/anthropic-messages/as-source/` — interprets Messages requests and emits Messages SSE. - `protocols/anthropic-messages/as-target/` — maps requests to Messages and consumes Messages SSE. diff --git a/packages/translate/__tests__/ir/collector_test.ts b/packages/translate/__tests__/ir/collector_test.ts deleted file mode 100644 index 21c29b5b0b..0000000000 --- a/packages/translate/__tests__/ir/collector_test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, test } from 'vitest'; - -import { createIRCollector } from '../../src/ir/collector.ts'; -import { IRAbsent } from '../../src/ir/types.ts'; - -describe('IR accumulator', () => { - test('stores plain accumulated state without publication metadata', () => { - const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['usage'], value: {} }); - collector.apply({ type: 'write', kind: 'assign', path: ['usage', 'output_tokens_inclusive'], value: 0 }); - expect(collector.value(['usage'])).toEqual({ output_tokens_inclusive: 0 }); - }); - - test('enforces static append targets', () => { - const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: { type: 'text', text: '' } }); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'x' })).toThrow('append target cannot be assigned'); - expect(() => collector.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 1 } as any)).toThrow('append requires a string value'); - collector.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'x' }); - expect(collector.value(['choices', 0, 'items', 0, 'content', 0, 'text'])).toBe('x'); - - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1], value: { type: 'reasoning', content: [''] } }); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 1, 'content', 0], value: 1 })).toThrow('append target cannot be assigned'); - }); - - test('rejects undefined and only assigns IRAbsent to absent fields', () => { - const collector = createIRCollector(); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['model'], value: IRAbsent })).toThrow('IRAbsent is not allowed'); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call', name: 'f', arguments: '' } }); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'call_id'], value: undefined as any })).toThrow('undefined'); - collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'call_id'], value: IRAbsent }); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'call_id'], value: IRAbsent })).toThrow('absent target'); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'refusal'], value: { category: IRAbsent, explanation: null } as any })).toThrow('assigned directly'); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'refusal'], value: { category: undefined, explanation: null } as any })).toThrow('cannot contain undefined'); - expect(() => collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1], value: { type: 'reasoning', summary: [IRAbsent] } })).toThrow('append target must initialize to an empty string'); - }); - - test('enforces append ownership by item type', () => { - const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call', name: 'f', arguments: '' } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1], value: { type: 'custom_tool_call', name: 'c', input: '' } }); - expect(() => collector.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'input'], value: 'x' })).toThrow('append-target path'); - expect(() => collector.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 1, 'arguments'], value: 'x' })).toThrow('append-target path'); - }); - - test('rejects an unallocated choice end', () => { - const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - expect(() => collector.applyEvent({ type: 'choice_end', choice: 99, finish_reason: 'stop' })).toThrow('does not exist'); - }); - - test('rejects operations after part and item end', () => { - const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: { type: 'text', text: '' } }); - collector.applyEvent({ type: 'part_end', choice: 0, item: 0, part: 0 }); - expect(() => collector.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'late' })).toThrow('closed part'); - collector.applyEvent({ type: 'item_end', choice: 0, item: 0 }); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'type'], value: 'message' })).toThrow('closed item'); - }); - - test('rejects ended units that omit required schema fields', () => { - const incompleteItem = createIRCollector(); - incompleteItem.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - incompleteItem.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call' } }); - expect(() => incompleteItem.applyEvent({ type: 'item_end', choice: 0, item: 0 })).toThrow('requires field name'); - - const incompleteMessage = createIRCollector(); - incompleteMessage.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - incompleteMessage.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message' } }); - expect(() => incompleteMessage.applyEvent({ type: 'item_end', choice: 0, item: 0 })).toThrow('requires field content'); - - const incompleteChoice = createIRCollector(); - incompleteChoice.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: {} }); - expect(() => incompleteChoice.applyEvent({ type: 'choice_end', choice: 0, finish_reason: 'stop' })).toThrow('requires field items'); - }); -}); diff --git a/packages/translate/__tests__/ir/helpers.ts b/packages/translate/__tests__/ir/helpers.ts deleted file mode 100644 index 396b11e24d..0000000000 --- a/packages/translate/__tests__/ir/helpers.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { consumeIRRecords, createIRBuilder, type IRRecord } from '../../src/ir/stream.ts'; -import type { IR, IRItem } from '../../src/ir/types.ts'; -import { anthropicMessagesFromIR as anthropicMessagesFromIRProduction } from '../../src/protocols/anthropic-messages/as-source/sse-to.ts'; -import { irFromAnthropicMessages as irFromAnthropicMessagesProduction } from '../../src/protocols/anthropic-messages/as-target/sse-from.ts'; -import { geminiGenerateContentFromIR as geminiGenerateContentFromIRProduction } from '../../src/protocols/gemini-generate-content/as-source/sse-to.ts'; -import { openaiChatCompletionsFromIR as openaiChatCompletionsFromIRProduction } from '../../src/protocols/openai-chat-completions/as-source/sse-to.ts'; -import { irFromOpenAIChatCompletions as irFromOpenAIChatCompletionsProduction } from '../../src/protocols/openai-chat-completions/as-target/sse-from.ts'; -import { flattenNamespaceTools } from '../../src/protocols/openai-responses/as-source/namespace-tools.ts'; -import { openaiResponsesFromIR as openaiResponsesFromIRProduction } from '../../src/protocols/openai-responses/as-source/sse-to.ts'; -import { irFromOpenAIResponses as irFromOpenAIResponsesProduction } from '../../src/protocols/openai-responses/as-target/sse-from.ts'; -import { createReferenceBuilder } from '../../src/round-trip/reference/reference-builder.ts'; -import type { RoundTripReader, RoundTripWriter } from '../../src/round-trip/stream.ts'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; - -export const iterate = async function* (values: readonly T[]): AsyncGenerator { yield* values; }; -export const collect = async (values: AsyncIterable): Promise => { const result: T[] = []; for await (const value of values) result.push(value); return result; }; -export const events = async function* (frames: AsyncIterable>): AsyncGenerator { for await (const frame of frames) if (frame.type === 'event') yield frame.event; }; - -const reader = (): RoundTripReader => ({ - builder: createReferenceBuilder(), - onAssistantTurn: () => {}, -}); -const writer = (): RoundTripWriter => ({ - builder: createReferenceBuilder(), - prepareSidecar: async () => 'test-sidecar', -}); -const namespaceToolNames = flattenNamespaceTools({ model: 'test', input: [], tools: [] }).names; - -// These ordinary IR fixtures use independent mock participants. Provenance is -// exercised by the native-reader and public-route tests sharing one coordinator. -const withoutProvenance = async function* (records: AsyncIterable): AsyncGenerator { - for await (const record of records) { - if (record.type === 'write' && record.kind === 'append') { - const { referencePieceIds: _referencePieceIds, ...operation } = record; - yield operation; - } else yield record; - } -}; - -export const irFromAnthropicMessages = (frames: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => - withoutProvenance(irFromAnthropicMessagesProduction(frames, { ...options, roundTrip: reader<'anthropicMessages'>() })); -export const irFromOpenAIChatCompletions = (frames: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => - withoutProvenance(irFromOpenAIChatCompletionsProduction(frames, { ...options, roundTrip: reader<'openaiChatCompletions'>() })); -export const irFromOpenAIResponses = ( - frames: Parameters[0], - roundTrip: RoundTripReader<'openaiResponses'> = reader<'openaiResponses'>(), -) => withoutProvenance(irFromOpenAIResponsesProduction(frames, { roundTrip })); -export const anthropicMessagesFromIR = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => - anthropicMessagesFromIRProduction(records, { ...options, roundTrip: writer<'anthropicMessages'>() }); -export const openaiChatCompletionsFromIR = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => - openaiChatCompletionsFromIRProduction(records, { ...options, roundTrip: writer<'openaiChatCompletions'>() }); -export const openaiResponsesFromIR = (records: Parameters[0], options: Omit[1], 'roundTrip' | 'namespaceToolNames'> = {}) => - openaiResponsesFromIRProduction(records, { ...options, roundTrip: writer<'openaiResponses'>(), namespaceToolNames }); -export const geminiGenerateContentFromIR = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => - geminiGenerateContentFromIRProduction(records, { ...options, roundTrip: writer<'geminiGenerateContent'>() }); -export const completeIR = (value: readonly IRItem[]): IRRecord[] => { - const b = createIRBuilder(); - b.choice(0); - b.assign(['model'], 'model'); - b.event({ type: 'start', id: 'source' }); - for (const item of value) { - const index = b.state.choices[0].items.length; - const path = ['choices', 0, 'items', index] as const; - if (item.type === 'message') { - b.initialize(path, { type: 'message', content: [] }); - item.content.forEach((part, partIndex) => { - const partPath = [...path, 'content', partIndex] as const; - if (part.type === 'text') { - b.initialize(partPath, { type: 'text', text: '', ...(part.annotations === undefined ? {} : { annotations: part.annotations }) }); - b.append([...partPath, 'text'], part.text); - } else if (part.type === 'refusal') { - b.initialize(partPath, { type: 'refusal', refusal: '' }); - b.append([...partPath, 'refusal'], part.refusal); - } else if (part.type === 'image') b.initialize(partPath, part); - else { - b.initialize(partPath, { type: 'audio', audio: { ...(part.audio.mime_type === undefined ? {} : { mime_type: part.audio.mime_type }) } }); - for (const field of ['data', 'transcript'] as const) if (part.audio[field] !== undefined) { - const value = part.audio[field]; - if (value === undefined) continue; - b.initialize([...partPath, 'audio', field], ''); - b.append([...partPath, 'audio', field], value); - } - } - b.event({ type: 'part_end', choice: 0, item: index, part: partIndex }); - }); - } else if (item.type === 'reasoning') { - b.initialize(path, { type: 'reasoning', ...(item.summary === undefined ? {} : { summary: item.summary.map(() => '') }), ...(item.content === undefined ? {} : { content: item.content.map(() => '') }) }); - item.summary?.forEach((text, part) => b.append([...path, 'summary', part], text)); - item.content?.forEach((text, part) => b.append([...path, 'content', part], text)); - } else if (item.type === 'function_call') { - b.initialize(path, { type: 'function_call', ...(item.call_id === undefined ? {} : { call_id: item.call_id }), name: item.name, arguments: '' }); - b.append([...path, 'arguments'], item.arguments); - } else { - b.initialize(path, { type: 'custom_tool_call', ...(item.call_id === undefined ? {} : { call_id: item.call_id }), name: item.name, input: '' }); - b.append([...path, 'input'], item.input); - } - b.event({ type: 'item_end', choice: 0, item: index }); - } - b.event({ type: 'choice_end', choice: 0, finish_reason: 'stop' }); - b.event({ type: 'finish', status: 'completed' }); - return [...b.drain()]; -}; - -export const collectIR = async (records: AsyncIterable): Promise => { - let state: IR = { choices: [] }; - for await (const value of consumeIRRecords(records)) { - state = value.collector.state; - if (value.record.type === 'error') throw new Error('IR generation failed', { cause: value.record.error }); - } - return state; -}; diff --git a/packages/translate/__tests__/ir/initialize_test.ts b/packages/translate/__tests__/ir/initialize_test.ts deleted file mode 100644 index eff27b313b..0000000000 --- a/packages/translate/__tests__/ir/initialize_test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { expect, test } from 'vitest'; - -import { collect, iterate, openaiResponsesFromIR } from './helpers.ts'; -import { createIRCollector } from '../../src/ir/collector.ts'; -import { createIRBuilder, type IRRecord } from '../../src/ir/stream.ts'; - -test('initialize carries real required fields while append targets start empty', async () => { - const records: IRRecord[] = [ - { type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }, - { type: 'write', kind: 'assign', path: ['model'], value: 'model' }, - { type: 'start', id: 'source' }, - { type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call', call_id: 'call', name: 'real-name', arguments: '' } }, - { type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'arguments'], value: '{}' }, - { type: 'item_end', choice: 0, item: 0 }, - { type: 'choice_end', choice: 0, finish_reason: 'tool_calls' }, - { type: 'finish', status: 'completed' }, - ]; - const output = await collect(openaiResponsesFromIR(iterate(records))); - const serialized = JSON.stringify(output); - expect(serialized).toContain('real-name'); - expect(serialized).toContain('{}'); -}); - -test('initialize arrays require the exact next index and never replace entries', () => { - const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - expect(() => collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } })).toThrow('absent target'); - for (const index of [-1, 0.5, 2]) expect(() => collector.apply({ type: 'write', kind: 'initialize', path: ['choices', index], value: { items: [] } })).toThrow(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 1], value: { items: [] } }); - expect(collector.state.choices).toHaveLength(2); -}); - -test('append accepts only statically declared string fields', () => { - const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: { type: 'text', text: '' } }); - expect(() => collector.apply({ type: 'write', kind: 'append', path: ['model'], value: 'x' })).toThrow('append-target path'); - collector.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'x' }); - expect(collector.value(['choices', 0, 'items', 0, 'content', 0, 'text'])).toBe('x'); -}); - -test('builder initializes items without separate start events', () => { - const builder = createIRBuilder(); - builder.item(0, { type: 'message', content: [] }); - expect(builder.drain()).toEqual([ - { type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }, - { type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }, - ]); -}); diff --git a/packages/translate/__tests__/ir/output/collector_test.ts b/packages/translate/__tests__/ir/output/collector_test.ts new file mode 100644 index 0000000000..82b0a10cea --- /dev/null +++ b/packages/translate/__tests__/ir/output/collector_test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'vitest'; + +import { IRAbsent } from '../../../src/ir/output/absent.ts'; +import { createIRCollector } from '../../../src/ir/output/collector.ts'; + +describe('output IR accumulator', () => { + test('stores plain accumulated state without publication metadata', () => { + const collector = createIRCollector(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['usage'], value: {} }); + collector.applyWrite({ type: 'write', kind: 'assign', path: ['usage', 'output_tokens_inclusive'], value: 0 }); + expect(collector.value(['usage'])).toEqual({ output_tokens_inclusive: 0 }); + }); + + test('enforces static append targets', () => { + const collector = createIRCollector(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: { type: 'text', text: '' } }); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'x' })).toThrow('append target cannot be assigned'); + expect(() => collector.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 1 } as any)).toThrow('append requires a string value'); + collector.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'x' }); + expect(collector.value(['choices', 0, 'items', 0, 'content', 0, 'text'])).toBe('x'); + + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1], value: { type: 'reasoning', content: [''] } }); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 1, 'content', 0], value: 1 })).toThrow('append target cannot be assigned'); + }); + + test('rejects undefined and only assigns IRAbsent to absent fields', () => { + const collector = createIRCollector(); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['model'], value: IRAbsent })).toThrow('IRAbsent is not allowed'); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call', name: 'f', arguments: '' } }); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'call_id'], value: undefined as any })).toThrow('undefined'); + collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'call_id'], value: IRAbsent }); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'call_id'], value: IRAbsent })).toThrow('absent target'); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'refusal_details'], value: { category: IRAbsent, explanation: null } as any })).toThrow('assigned directly'); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'refusal_details'], value: { category: undefined, explanation: null } as any })).toThrow('cannot contain undefined'); + expect(() => collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1], value: { type: 'reasoning', summary: [IRAbsent] } })).toThrow('append target must initialize to an empty string'); + }); + + test('enforces append ownership by item type', () => { + const collector = createIRCollector(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call', name: 'f', arguments: '' } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1], value: { type: 'custom_tool_call', name: 'c', input: '' } }); + expect(() => collector.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'input'], value: 'x' })).toThrow('append-target path'); + expect(() => collector.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 1, 'arguments'], value: 'x' })).toThrow('append-target path'); + }); + + test('rejects an unallocated choice end', () => { + const collector = createIRCollector(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + expect(() => collector.applyEvent({ type: 'choice_end', choice: 99, finish_reason: 'stop' })).toThrow('does not exist'); + }); + + test('rejects operations after part and item end', () => { + const collector = createIRCollector(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: { type: 'text', text: '' } }); + collector.applyEvent({ type: 'part_end', choice: 0, item: 0, part: 0 }); + expect(() => collector.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'late' })).toThrow('closed part'); + collector.applyEvent({ type: 'item_end', choice: 0, item: 0 }); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'type'], value: 'message' })).toThrow('closed item'); + }); + + test('rejects ended units that omit required schema fields', () => { + const incompleteItem = createIRCollector(); + incompleteItem.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + incompleteItem.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call' } }); + expect(() => incompleteItem.applyEvent({ type: 'item_end', choice: 0, item: 0 })).toThrow('requires field name'); + + const incompleteMessage = createIRCollector(); + incompleteMessage.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + incompleteMessage.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message' } }); + expect(() => incompleteMessage.applyEvent({ type: 'item_end', choice: 0, item: 0 })).toThrow('requires field content'); + + const incompleteChoice = createIRCollector(); + incompleteChoice.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: {} }); + expect(() => incompleteChoice.applyEvent({ type: 'choice_end', choice: 0, finish_reason: 'stop' })).toThrow('requires field items'); + }); +}); diff --git a/packages/translate/__tests__/ir/copy-ownership_test.ts b/packages/translate/__tests__/ir/output/copy-ownership_test.ts similarity index 62% rename from packages/translate/__tests__/ir/copy-ownership_test.ts rename to packages/translate/__tests__/ir/output/copy-ownership_test.ts index e5d88f05e6..741ef8a64b 100644 --- a/packages/translate/__tests__/ir/copy-ownership_test.ts +++ b/packages/translate/__tests__/ir/output/copy-ownership_test.ts @@ -1,14 +1,14 @@ import { expect, test } from 'vitest'; -import { collect, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, iterate } from './helpers.ts'; -import { createIRCollector } from '../../src/ir/collector.ts'; -import { consumeIRRecords, createIRBuilder } from '../../src/ir/stream.ts'; -import { eventFrame } from '@floway-dev/protocols/common'; +import { createIRBuilder } from '../../../src/ir/output/builder.ts'; +import { createIRCollector } from '../../../src/ir/output/collector.ts'; +import { consumeIRRecords } from '../../../src/ir/output/stream.ts'; +import { collect, iterate } from '../../shared/iterables.ts'; const initializeTextPart = (collector: ReturnType, part: object): void => { - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: part as any }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: part as any }); }; test('collectors borrow record trees and copy only containers first written by that collector', () => { @@ -21,13 +21,13 @@ test('collectors borrow record trees and copy only containers first written by t expect(first.state.choices[0].items[0].type === 'message' && first.state.choices[0].items[0].content[0]).toBe(part); expect(second.state.choices[0].items[0].type === 'message' && second.state.choices[0].items[0].content[0]).toBe(part); - first.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'one' }); + first.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'one' }); const firstPart = first.state.choices[0].items[0].type === 'message' ? first.state.choices[0].items[0].content[0] : undefined; expect(firstPart).not.toBe(part); expect(firstPart?.type === 'text' && firstPart.annotations).toBe(annotations); expect(second.state.choices[0].items[0].type === 'message' && second.state.choices[0].items[0].content[0]).toBe(part); - second.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'other' }); + second.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'other' }); expect(first.value(['choices', 0, 'items', 0, 'content', 0, 'text'])).toBe('one'); expect(second.value(['choices', 0, 'items', 0, 'content', 0, 'text'])).toBe('other'); expect(part.text).toBe(''); @@ -62,32 +62,21 @@ test('builder transfers payloads without copying and later operations never modi test('array writes copy the array while retaining immutable sibling values', () => { const sibling = Object.freeze({ items: Object.freeze([]) }); const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: sibling }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: sibling }); const preceding = collector.state.choices; - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 1], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 1], value: { items: [] } }); expect(collector.state.choices).toBe(preceding); expect(collector.state.choices[0]).toBe(sibling); const owned = collector.state.choices; - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 2], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 2], value: { items: [] } }); expect(collector.state.choices).toBe(owned); }); test('value exposes the accumulator live readonly view', () => { const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['usage'], value: {} }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['usage'], value: {} }); const value = collector.value>(['usage'])!; - collector.apply({ type: 'write', kind: 'assign', path: ['usage', 'output_tokens_inclusive'], value: 1 }); + collector.applyWrite({ type: 'write', kind: 'assign', path: ['usage', 'output_tokens_inclusive'], value: 1 }); expect(value).toEqual({}); expect(collector.value(['usage'])).toEqual({ output_tokens_inclusive: 1 }); }); - -test.each([ - ['ChatCompletions', irFromOpenAIChatCompletions, (error: object) => ({ id: 'chat', model: 'm', choices: [], error })], - ['Responses', irFromOpenAIResponses, (error: object) => ({ type: 'error', error })], - ['Messages', irFromAnthropicMessages, (error: object) => ({ type: 'error', error })], -] as const)('%s reader transfers immutable error objects without copying', async (_name, reader, event) => { - const error = Object.freeze({ type: 'api_error', message: 'original', details: Object.freeze([Object.freeze({ text: 'original' })]) }); - const frames = await collect(reader(iterate([eventFrame(event(error) as any)]))); - const record = frames.find(record => record.type === 'error'); - expect(record?.error).toBe(error); -}); diff --git a/packages/translate/__tests__/ir/handles_test.ts b/packages/translate/__tests__/ir/output/handles_test.ts similarity index 83% rename from packages/translate/__tests__/ir/handles_test.ts rename to packages/translate/__tests__/ir/output/handles_test.ts index a7d9055259..a474d2db90 100644 --- a/packages/translate/__tests__/ir/handles_test.ts +++ b/packages/translate/__tests__/ir/output/handles_test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; -import { createIRBuilder } from '../../src/ir/stream.ts'; +import { createIRBuilder } from '../../../src/ir/output/builder.ts'; test('write handles build unit paths and ensure fields once', () => { const builder = createIRBuilder(); @@ -15,7 +15,7 @@ test('write handles build unit paths and ensure fields once', () => { expect(choice.path).toEqual(['choices', 0]); expect(item.path).toEqual(['choices', 0, 'items', 0]); - expect(item.resolve(['summary', 0])).toEqual(['choices', 0, 'items', 0, 'summary', 0]); + expect(item.absolutePath(['summary', 0])).toEqual(['choices', 0, 'items', 0, 'summary', 0]); expect(builder.drain()).toEqual([ { type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }, { type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'reasoning' } }, @@ -47,9 +47,9 @@ test('ending an item closes only its open parts and is idempotent', () => { ]); }); -test('handles expose accumulated values needed for snapshot reconciliation and annotation coordinates', () => { +test('a handle reads back the value accumulated from earlier upstream events', () => { const builder = createIRBuilder(); - const choice = builder.choice(0); + builder.choice(0); const first = builder.item(0, { type: 'message', content: [] }); const firstText = first.part({ type: 'text', text: '' }); firstText.append('text', 'first'); @@ -58,8 +58,5 @@ test('handles expose accumulated values needed for snapshot reconciliation and a secondText.append('text', 'second'); expect(firstText.current('text')).toBe('first'); - expect(choice.textParts()).toEqual([ - { item: first, part: firstText, text: 'first' }, - { item: second, part: secondText, text: 'second' }, - ]); + expect(secondText.current('text')).toBe('second'); }); diff --git a/packages/translate/__tests__/ir/output/helpers.ts b/packages/translate/__tests__/ir/output/helpers.ts new file mode 100644 index 0000000000..1b598ae405 --- /dev/null +++ b/packages/translate/__tests__/ir/output/helpers.ts @@ -0,0 +1,61 @@ +import { createIRBuilder } from '../../../src/ir/output/builder.ts'; +import type { IRRecord } from '../../../src/ir/output/records.ts'; +import { consumeIRRecords } from '../../../src/ir/output/stream.ts'; +import type { IROutput, IROutputItem } from '../../../src/ir/output/types.ts'; + +export const completeIR = (value: readonly IROutputItem[]): IRRecord[] => { + const b = createIRBuilder(); + b.choice(0); + b.assign(['model'], 'model'); + b.event({ type: 'start', id: 'source' }); + for (const item of value) { + const index = b.state.choices[0].items.length; + const path = ['choices', 0, 'items', index] as const; + if (item.type === 'message') { + b.initialize(path, { type: 'message', content: [] }); + item.content.forEach((part, partIndex) => { + const partPath = [...path, 'content', partIndex] as const; + if (part.type === 'text') { + b.initialize(partPath, { type: 'text', text: '', ...(part.annotations === undefined ? {} : { annotations: part.annotations }) }); + b.append([...partPath, 'text'], part.text); + } else if (part.type === 'refusal') { + b.initialize(partPath, { type: 'refusal', refusal: '' }); + b.append([...partPath, 'refusal'], part.refusal); + } else if (part.type === 'image') b.initialize(partPath, part); + else { + b.initialize(partPath, { type: 'audio', audio: { ...(part.audio.media_type === undefined ? {} : { media_type: part.audio.media_type }) } }); + for (const field of ['data', 'transcript'] as const) if (part.audio[field] !== undefined) { + const value = part.audio[field]; + if (value === undefined) continue; + b.initialize([...partPath, 'audio', field], ''); + b.append([...partPath, 'audio', field], value); + } + } + b.event({ type: 'part_end', choice: 0, item: index, part: partIndex }); + }); + } else if (item.type === 'reasoning') { + b.initialize(path, { type: 'reasoning', ...(item.summary === undefined ? {} : { summary: item.summary.map(() => '') }), ...(item.content === undefined ? {} : { content: item.content.map(() => '') }) }); + item.summary?.forEach((text, part) => b.append([...path, 'summary', part], text)); + item.content?.forEach((text, part) => b.append([...path, 'content', part], text)); + } else if (item.type === 'function_call') { + b.initialize(path, { type: 'function_call', ...(item.call_id === undefined ? {} : { call_id: item.call_id }), name: item.name, arguments: '' }); + b.append([...path, 'arguments'], item.arguments); + } else { + b.initialize(path, { type: 'custom_tool_call', ...(item.call_id === undefined ? {} : { call_id: item.call_id }), name: item.name, input: '' }); + b.append([...path, 'input'], item.input); + } + b.event({ type: 'item_end', choice: 0, item: index }); + } + b.event({ type: 'choice_end', choice: 0, finish_reason: 'stop' }); + b.event({ type: 'finish', status: 'completed' }); + return [...b.drain()]; +}; + +export const collectIR = async (records: AsyncIterable): Promise => { + let state: IROutput = { choices: [] }; + for await (const value of consumeIRRecords(records)) { + state = value.collector.state; + if (value.record.type === 'error') throw new Error('output IR generation failed', { cause: value.record.error }); + } + return state; +}; diff --git a/packages/translate/__tests__/ir/output/initialize_test.ts b/packages/translate/__tests__/ir/output/initialize_test.ts new file mode 100644 index 0000000000..94900f974a --- /dev/null +++ b/packages/translate/__tests__/ir/output/initialize_test.ts @@ -0,0 +1,32 @@ +import { expect, test } from 'vitest'; + +import { createIRBuilder } from '../../../src/ir/output/builder.ts'; +import { createIRCollector } from '../../../src/ir/output/collector.ts'; + +test('initialize arrays require the exact next index and never replace entries', () => { + const collector = createIRCollector(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + expect(() => collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } })).toThrow('absent target'); + for (const index of [-1, 0.5, 2]) expect(() => collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', index], value: { items: [] } })).toThrow(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 1], value: { items: [] } }); + expect(collector.state.choices).toHaveLength(2); +}); + +test('append accepts only statically declared string fields', () => { + const collector = createIRCollector(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: { type: 'text', text: '' } }); + expect(() => collector.applyWrite({ type: 'write', kind: 'append', path: ['model'], value: 'x' })).toThrow('append-target path'); + collector.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'x' }); + expect(collector.value(['choices', 0, 'items', 0, 'content', 0, 'text'])).toBe('x'); +}); + +test('builder initializes items without separate start events', () => { + const builder = createIRBuilder(); + builder.item(0, { type: 'message', content: [] }); + expect(builder.drain()).toEqual([ + { type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }, + { type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }, + ]); +}); diff --git a/packages/translate/__tests__/ir/part-id_test.ts b/packages/translate/__tests__/ir/output/part-id_test.ts similarity index 91% rename from packages/translate/__tests__/ir/part-id_test.ts rename to packages/translate/__tests__/ir/output/part-id_test.ts index f6450cc581..54db819ba4 100644 --- a/packages/translate/__tests__/ir/part-id_test.ts +++ b/packages/translate/__tests__/ir/output/part-id_test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; -import { createIRBuilder } from '../../src/ir/stream.ts'; +import { createIRBuilder } from '../../../src/ir/output/builder.ts'; test('carries optional source part IDs on append operations', () => { const builder = createIRBuilder(); @@ -17,7 +17,7 @@ test('carries optional source part IDs on append operations', () => { ]); }); -test('retains every original ID when a native buffer is merged into one IR append', () => { +test('retains every original ID when a native buffer is merged into one output IR append', () => { const builder = createIRBuilder(); const part = builder.item(0, { type: 'message', content: [] }).part({ type: 'text', text: '' }); builder.drain(); diff --git a/packages/translate/__tests__/ir/schema_test.ts b/packages/translate/__tests__/ir/output/schema_test.ts similarity index 77% rename from packages/translate/__tests__/ir/schema_test.ts rename to packages/translate/__tests__/ir/output/schema_test.ts index 143f6774c1..2b32283b04 100644 --- a/packages/translate/__tests__/ir/schema_test.ts +++ b/packages/translate/__tests__/ir/output/schema_test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; -import { irEndedUnit, irItemPath, irPartPath, irRecordOwnerPaths, irUnitChain, irUnitPath, isIRAppendTarget, isIRItemPath, isIRAbsentTarget, isIRPartPath } from '../../src/ir/operation-paths.ts'; +import { irEndedUnit, irItemPath, irPartPath, irRecordOwnerPaths, irUnitChain, irUnitPath, isIRItemPath, isIRPartPath, resolveIRPath } from '../../../src/ir/output/operation-paths.ts'; const root = { choices: [{ @@ -14,12 +14,12 @@ const root = { }; const item = (index: number, ...rest: (string | number)[]) => ['choices', 0, 'items', index, ...rest]; -describe('IR schema queries', () => { +describe('output IR schema queries', () => { test.each([ [item(0, 'content', 0, 'text'), true], [item(0, 'content', 1, 'audio', 'data'), true], [item(0, 'content', 1, 'audio', 'transcript'), true], - [item(0, 'content', 1, 'audio', 'mime_type'), false], + [item(0, 'content', 1, 'audio', 'media_type'), false], [item(1, 'summary', 0), true], [item(1, 'content', 0), true], [item(2, 'arguments'), true], @@ -28,14 +28,15 @@ describe('IR schema queries', () => { [item(2, 'input'), false], [['model'], false], ])('append target at %j is %s', (path, expected) => { - expect(isIRAppendTarget(path, root)).toBe(expected); + const node = resolveIRPath(path, root)?.node; + expect(node?.kind === 'string' && node.append === true).toBe(expected); }); test('only tool call IDs may be IRAbsent', () => { - expect(isIRAbsentTarget(item(2, 'call_id'), root)).toBe(true); - expect(isIRAbsentTarget(item(3, 'call_id'), root)).toBe(true); - expect(isIRAbsentTarget(item(2, 'name'), root)).toBe(false); - expect(isIRAbsentTarget(item(0, 'call_id'), root)).toBe(false); + expect(resolveIRPath(item(2, 'call_id'), root)?.node).toMatchObject({ kind: 'string', absent: true }); + expect(resolveIRPath(item(3, 'call_id'), root)?.node).toMatchObject({ kind: 'string', absent: true }); + expect(resolveIRPath(item(2, 'name'), root)?.node).not.toMatchObject({ kind: 'string', absent: true }); + expect(resolveIRPath(item(0, 'call_id'), root)?.node).not.toMatchObject({ kind: 'string', absent: true }); }); test('items and parts are the lifecycle units of a choice', () => { diff --git a/packages/translate/__tests__/ir/output/unit-queue_test.ts b/packages/translate/__tests__/ir/output/unit-queue_test.ts new file mode 100644 index 0000000000..a81542bcb3 --- /dev/null +++ b/packages/translate/__tests__/ir/output/unit-queue_test.ts @@ -0,0 +1,36 @@ +import { expect, test } from 'vitest'; + +import { createIRUnitQueue } from '../../../src/ir/output/unit-queue.ts'; + +interface Unit { readonly name: string } + +test('unit queue dequeues units in the order they were enqueued', () => { + const queue = createIRUnitQueue(); + const [a, b] = [{ name: 'a' }, { name: 'b' }]; + queue.enqueue(a); + queue.enqueue(b); + expect(queue.head).toBe(a); + queue.dropHead(); + expect(queue.head).toBe(b); + queue.dropHead(); + expect(queue.head).toBeUndefined(); + const c = { name: 'c' }; + queue.enqueue(c); + expect(queue.head).toBe(c); +}); + +test('unit queue owns its links without changing payloads or coupling independent queues', () => { + const unit = Object.freeze({ name: 'shared' }); + const first = createIRUnitQueue(); + const second = createIRUnitQueue(); + first.enqueue(unit); + first.enqueue(unit); + second.enqueue(unit); + first.dropHead(); + expect(first.head).toBe(unit); + expect(second.head).toBe(unit); + first.dropHead(); + expect(first.head).toBeUndefined(); + expect(second.head).toBe(unit); + expect(Object.keys(unit)).toEqual(['name']); +}); diff --git a/packages/translate/__tests__/ir/unit-queue_test.ts b/packages/translate/__tests__/ir/output/unit-registry_test.ts similarity index 61% rename from packages/translate/__tests__/ir/unit-queue_test.ts rename to packages/translate/__tests__/ir/output/unit-registry_test.ts index 500bb6ef1d..e68845953a 100644 --- a/packages/translate/__tests__/ir/unit-queue_test.ts +++ b/packages/translate/__tests__/ir/output/unit-registry_test.ts @@ -1,24 +1,7 @@ import { expect, test } from 'vitest'; -import type { IRRecord } from '../../src/ir/stream.ts'; -import { createIRUnitQueue, createIRUnitRegistry, type IRQueuedUnit } from '../../src/ir/unit-queue.ts'; - -interface Unit extends IRQueuedUnit { readonly name: string } - -test('unit queue dequeues units in the order they were enqueued', () => { - const queue = createIRUnitQueue(); - const [a, b] = [{ name: 'a' }, { name: 'b' }]; - queue.enqueue(a); - queue.enqueue(b); - expect(queue.head).toBe(a); - queue.dequeue(); - expect(queue.head).toBe(b); - queue.dequeue(); - expect(queue.head).toBeUndefined(); - const c = { name: 'c' }; - queue.enqueue(c); - expect(queue.head).toBe(c); -}); +import type { IRRecord } from '../../../src/ir/output/records.ts'; +import { createIRUnitRegistry } from '../../../src/ir/output/unit-registry.ts'; test('unit registry finds the innermost registered owner of a record', () => { const registry = createIRUnitRegistry(); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/events_test.ts similarity index 87% rename from packages/translate/__tests__/protocols/anthropic-messages/as-source/sse-to_test.ts rename to packages/translate/__tests__/protocols/anthropic-messages/as-source/events_test.ts index 20ab1c350c..2f7ea48519 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-source/events_test.ts @@ -1,11 +1,11 @@ import { expect, test } from 'vitest'; -import { createIRBuilder } from '../../../../src/ir/stream.ts'; -import { anthropicMessagesFromIR } from '../../../../src/protocols/anthropic-messages/as-source/sse-to.ts'; +import { 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 type { RoundTripWriter } from '../../../../src/round-trip/stream.ts'; import { hashRoundTripContent } from '../../../../src/round-trip/turn-digest.ts'; -import { type AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; +import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; test.each(['text', 'refusal', 'audio'] as const)('resolves blocked %s against the emitted Messages text field', async kind => { const references = createReferenceBuilder(); @@ -42,7 +42,7 @@ test.each(['text', 'refusal', 'audio'] as const)('resolves blocked %s against th }, }; const events: AnthropicMessagesStreamEventEx[] = []; - for await (const frame of anthropicMessagesFromIR((async function* () { yield* source.drain(); })(), { roundTrip })) events.push(frame.event); + for await (const frame of writeAnthropicMessagesEvents((async function* () { yield* source.drain(); })(), { roundTrip })) events.push(frame.event); expect(events.flatMap(event => event.type === 'content_block_delta' && event.delta.type === 'text_delta' ? [event.delta.text] : [])).toEqual(['Hi']); expect(prepared).toBe(true); }); @@ -78,7 +78,7 @@ test('resolves blocked reasoning in emitted summary/content order before live ap prepareSidecar: async (...args) => { prepared = args; return 'sidecar'; }, }; const events: AnthropicMessagesStreamEventEx[] = []; - for await (const frame of anthropicMessagesFromIR((async function* () { yield* source.drain(); })(), { roundTrip })) events.push(frame.event); + for await (const frame of writeAnthropicMessagesEvents((async function* () { yield* source.drain(); })(), { roundTrip })) events.push(frame.event); expect(events.flatMap(event => event.type === 'content_block_delta' && event.delta.type === 'thinking_delta' ? [event.delta.thinking] : [])).toEqual(['ABX', 'C']); if (prepared === undefined) throw new Error('Messages sidecar was not prepared'); const [, , turn, state] = prepared; @@ -114,6 +114,6 @@ test.each(['{"x":1,}', '{"x": 1}'])('tracks the emitted canonical JSON view with return 'sidecar'; }, }; - for await (const _frame of anthropicMessagesFromIR((async function* () { yield* source.drain(); })(), { roundTrip, parseToolArguments: original.endsWith(',}') ? () => ({ x: 1 }) : undefined })) {} + for await (const _frame of writeAnthropicMessagesEvents((async function* () { yield* source.drain(); })(), { roundTrip, parseToolArguments: original.endsWith(',}') ? () => ({ x: 1 }) : undefined })) {} expect(prepared).toBe(true); }); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-target/sse-from_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-target/events_test.ts similarity index 91% rename from packages/translate/__tests__/protocols/anthropic-messages/as-target/sse-from_test.ts rename to packages/translate/__tests__/protocols/anthropic-messages/as-target/events_test.ts index db364566b8..eab9b2ffb8 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-target/sse-from_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-target/events_test.ts @@ -1,10 +1,10 @@ import { expect, test } from 'vitest'; import type { AnthropicMessagesAssistantState, AnthropicMessagesInputAssistantTurn } from '../../../../src/protocols/anthropic-messages/as-target/assistant-turn.ts'; -import { irFromAnthropicMessages } from '../../../../src/protocols/anthropic-messages/as-target/sse-from.ts'; +import { readAnthropicMessagesEvents } from '../../../../src/protocols/anthropic-messages/as-target/events.ts'; import { appendContent, collectContentViews, createContentState, createReferenceBuilder, type FieldStates } from '../../../../src/round-trip/reference/reference-builder.ts'; import { finalizeAssistantTurnReferences } from '../../../../src/round-trip/reference/reference-selection.ts'; -import { fixtureFrames } from '../../../ir/translation-cases.ts'; +import { fixtureFrames } from '../../fixtures.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; @@ -26,7 +26,7 @@ test('keeps native-shaped field state aligned through sparse block ordering and const builder = createReferenceBuilder(); const content = createContentState(builder, 'text'); let captured: { turn: AnthropicMessagesInputAssistantTurn; state: AnthropicMessagesAssistantState } | undefined; - for await (const record of irFromAnthropicMessages((async function* () { yield* frames; })(), { + for await (const record of readAnthropicMessagesEvents((async function* () { yield* frames; })(), { roundTrip: { builder, onAssistantTurn: (_choice, turn, state) => { captured = { turn, state }; } }, })) { if (record.type === 'write' && record.kind === 'append' && record.path.at(-1) === 'text') appendContent(builder, content, record.value, record.referencePieceIds); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events_test.ts index e65ea405a7..a03fca1f03 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events_test.ts @@ -1,4 +1,4 @@ import { translateToSourceEvents } from '../../../../src/protocols/anthropic-messages/via-openai-chat-completions/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; testOutputTranslation('anthropic-messages', 'openai-chat-completions', (frames, codec) => translateToSourceEvents(frames, codec)); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts index 079e83e658..f3d783b4e0 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts @@ -1,8 +1,8 @@ import { test } from 'vitest'; import { translateToSourceEvents } from '../../../../src/protocols/anthropic-messages/via-openai-responses/events.ts'; -import { structuredResponsesFixture } from '../../../ir/responses-fixture.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { structuredResponsesFixture } from '../../openai-responses/fixtures.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { openaiResponsesResultToEvents, type OpenAIResponsesResultEx, type OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events_test.ts index 0f05aec1df..3234ee801f 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events_test.ts @@ -1,8 +1,8 @@ import { expect, test } from 'vitest'; import { translateToSourceEvents } from '../../../../src/protocols/anthropic-messages/via-openai-responses/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; import { parseAnthropicMessagesStream, collectAnthropicMessagesProtocolEventsToResult, anthropicMessagesProtocolFrameToSSEFrame } from '@floway-dev/protocols/anthropic-messages'; import { parseOpenAIResponsesStream } from '@floway-dev/protocols/openai-responses'; diff --git a/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts b/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts index 0e54d49260..7e786f5648 100644 --- a/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts +++ b/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts @@ -1,8 +1,8 @@ import { expect, test } from 'vitest'; import { toOpenAIResponsesInputAssistantTurn } from '../../src/protocols/openai-responses/as-target/assistant-turn.ts'; -import { iterate } from '../ir/helpers.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { iterate } from '../shared/iterables.ts'; import { collectAnthropicMessagesProtocolEventsToResult, type AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { collectGeminiGenerateContentProtocolEventsToResult, type GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; diff --git a/packages/translate/__tests__/protocols/copy-ownership_test.ts b/packages/translate/__tests__/protocols/copy-ownership_test.ts new file mode 100644 index 0000000000..c9f0ae3b40 --- /dev/null +++ b/packages/translate/__tests__/protocols/copy-ownership_test.ts @@ -0,0 +1,16 @@ +import { expect, test } from 'vitest'; + +import { readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents } from './helpers.ts'; +import { collect, iterate } from '../shared/iterables.ts'; +import { eventFrame } from '@floway-dev/protocols/common'; + +test.each([ + ['ChatCompletions', readOpenAIChatCompletionsEvents, (error: object) => ({ id: 'chat', model: 'm', choices: [], error })], + ['Responses', readOpenAIResponsesEvents, (error: object) => ({ type: 'error', error })], + ['Messages', readAnthropicMessagesEvents, (error: object) => ({ type: 'error', error })], +] as const)('%s reader transfers immutable error objects without copying', async (_name, reader, event) => { + const error = Object.freeze({ type: 'api_error', message: 'original', details: Object.freeze([Object.freeze({ text: 'original' })]) }); + const frames = await collect(reader(iterate([eventFrame(event(error) as any)]))); + const record = frames.find(record => record.type === 'error'); + expect(record?.error).toBe(error); +}); diff --git a/packages/translate/__tests__/ir/edge-cases_test.ts b/packages/translate/__tests__/protocols/edge-cases_test.ts similarity index 83% rename from packages/translate/__tests__/ir/edge-cases_test.ts rename to packages/translate/__tests__/protocols/edge-cases_test.ts index 652013558f..41e83be185 100644 --- a/packages/translate/__tests__/ir/edge-cases_test.ts +++ b/packages/translate/__tests__/protocols/edge-cases_test.ts @@ -1,18 +1,20 @@ import { expect, test, vi } from 'vitest'; -import { anthropicMessagesFromIR, collectIR, collect, completeIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, iterate, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; -import { fixtureFrames, nativeResult } from './translation-cases.ts'; -import { createIRBuilder } from '../../src/ir/stream.ts'; +import { fixtureFrames, nativeResult } from './fixtures.ts'; +import { writeAnthropicMessagesEvents, writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; +import { createIRBuilder } from '../../src/ir/output/builder.ts'; import { translateToSourceEvents as messagesViaChat } from '../../src/protocols/anthropic-messages/via-openai-chat-completions/events.ts'; import { buildTargetRequest as messagesRequestViaChat } from '../../src/protocols/anthropic-messages/via-openai-chat-completions/request.ts'; import { translateToSourceEvents as messagesViaResponses } from '../../src/protocols/anthropic-messages/via-openai-responses/events.ts'; import { translateToSourceEvents as generateContentViaChat } from '../../src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts'; import { translateToSourceEvents as generateContentViaResponses } from '../../src/protocols/gemini-generate-content/via-openai-responses/events.ts'; -import { irFromOpenAIChatCompletions as irFromOpenAIChatCompletionsProduction } from '../../src/protocols/openai-chat-completions/as-target/sse-from.ts'; +import { readOpenAIChatCompletionsEvents as readOpenAIChatCompletionsEventsProduction } from '../../src/protocols/openai-chat-completions/as-target/events.ts'; import { translateToSourceEvents as responsesViaMessages } from '../../src/protocols/openai-responses/via-anthropic-messages/events.ts'; import { translateToSourceEvents as responsesViaChat } from '../../src/protocols/openai-responses/via-openai-chat-completions/events.ts'; +import { collectIR, completeIR } from '../ir/output/helpers.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { createRoundTripReaderCapture, emptyNamespaceToolNames } from '../round-trip/test-helpers.ts'; +import { collect, iterate } from '../shared/iterables.ts'; import { eventFrame } from '@floway-dev/protocols/common'; for (const [code, category] of [['bio_policy', 'bio'], ['cyber_policy', 'cyber']] as const) { @@ -46,7 +48,7 @@ test('rate limiting becomes a native GenerateContent error', async () => { }); test('ChatCompletions ignores empty identity frames and starts when a model appears', async () => { - const frames = await collect(irFromOpenAIChatCompletions(iterate([ + const frames = await collect(readOpenAIChatCompletionsEvents(iterate([ eventFrame({ id: '', object: '', created: 0, model: '', choices: [], prompt_filter_results: [{}], usage: null } as any), eventFrame({ id: 'chat-real', object: 'chat.completion.chunk', created: 10, model: 'served', choices: [] } as any), eventFrame({ id: 'chat-real', object: 'chat.completion.chunk', created: 10, model: 'served', choices: [{ index: 0, delta: { content: 'answer' }, finish_reason: null }] } as any), @@ -57,7 +59,7 @@ test('ChatCompletions ignores empty identity frames and starts when a model appe }); test('ChatCompletions rejects generated content before any non-empty model', async () => { - await expect(collectIR(irFromOpenAIChatCompletions(iterate([ + await expect(collectIR(readOpenAIChatCompletionsEvents(iterate([ eventFrame({ id: '', object: '', created: 0, model: '', choices: [] } as any), eventFrame({ id: '', object: '', created: 0, model: '', choices: [{ index: 0, delta: { content: 'answer' }, finish_reason: null }] } as any), ])))).rejects.toThrow('before a non-empty model'); @@ -65,7 +67,7 @@ test('ChatCompletions rejects generated content before any non-empty model', asy test('ChatCompletions role-only choices still require finish_reason without creating IR records', async () => { const role = eventFrame({ id: 'chat_role', model: 'served', choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] } as any); - await expect(collectIR(irFromOpenAIChatCompletions(iterate([role])))).rejects.toThrow('ended without finish_reason'); + await expect(collectIR(readOpenAIChatCompletionsEvents(iterate([role])))).rejects.toThrow('ended without finish_reason'); let reached!: () => void; let release!: () => void; @@ -78,7 +80,7 @@ test('ChatCompletions role-only choices still require finish_reason without crea yield eventFrame({ id: 'chat_role', model: 'served', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] } as any); }; const output: any[] = []; - const run = (async () => { for await (const record of irFromOpenAIChatCompletions(source())) output.push(record); })(); + const run = (async () => { for await (const record of readOpenAIChatCompletionsEvents(source())) output.push(record); })(); await atGate; expect(output.some(record => record.type === 'write' && record.path[0] === 'choices')).toBe(false); release(); @@ -87,13 +89,13 @@ test('ChatCompletions role-only choices still require finish_reason without crea test.each([['id', 'an id'], ['model', 'a model']] as const)('Messages message_start requires a string %s', async (field, expected) => { const message = { id: 'msg', model: 'claude-test', content: [], usage: { input_tokens: 0, output_tokens: 0 }, [field]: 42 }; - await expect(collectIR(irFromAnthropicMessages(iterate([ + await expect(collectIR(readAnthropicMessagesEvents(iterate([ eventFrame({ type: 'message_start', message } as any), ])))).rejects.toThrow(`requires ${expected}`); }); test('Messages takes tool input only from input_json_delta', async () => { - const ir = await collectIR(irFromAnthropicMessages(iterate([ + const ir = await collectIR(readAnthropicMessagesEvents(iterate([ eventFrame({ type: 'message_start', message: { id: 'msg', model: 'claude-test', content: [], usage: { input_tokens: 0, output_tokens: 0 } } } as any), eventFrame({ type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'call', name: 'lookup', input: { ignored: true } } } as any), eventFrame({ type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '{"q":1}' } } as any), @@ -110,46 +112,46 @@ test('ChatCompletions ignores late identity changes and accepts later model upda eventFrame({ id: 'chat-late', object: 'other', created: 20, model: 'fallback', choices: [{ index: 0, delta: { content: 'B' }, finish_reason: null }] } as any), eventFrame({ id: '', object: '', created: 0, model: '', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] } as any), ]; - const frames = await collect(irFromOpenAIChatCompletions(iterate(input))); + const frames = await collect(readOpenAIChatCompletionsEvents(iterate(input))); const start = frames.find(record => record.type === 'start'); expect(start).toEqual({ type: 'start', id: 'chat-first', created: 10 }); - expect((await collectIR(irFromOpenAIChatCompletions(iterate(input)))).model).toBe('fallback'); + expect((await collectIR(readOpenAIChatCompletionsEvents(iterate(input)))).model).toBe('fallback'); }); test('ChatCompletions choice indexes must fit the request choice count', async () => { - await expect(collectIR(irFromOpenAIChatCompletions(iterate([ + await expect(collectIR(readOpenAIChatCompletionsEvents(iterate([ eventFrame({ id: 'chat_choices', model: 'served', choices: [{ index: 1, delta: {}, finish_reason: 'stop' }] } as any), ]), { maxChoices: 1 }))).rejects.toThrow('configured choice count'); }); test('Responses rejects output events before the initial envelope', async () => { - await expect(collectIR(irFromOpenAIResponses(iterate([ + await expect(collectIR(readOpenAIResponsesEvents(iterate([ eventFrame({ type: 'response.output_item.added', output_index: 0, item: { type: 'message', id: 'msg', role: 'assistant', content: [] } } as any), ])))).rejects.toThrow('before the initial response envelope'); }); test('Messages and GenerateContent reject custom tool calls they cannot represent', async () => { const frames = completeIR([{ type: 'custom_tool_call', call_id: 'call_custom', name: 'run_code', input: 'input' }]); - await expect(collect(anthropicMessagesFromIR(iterate(frames)))).rejects.toThrow('cannot represent custom tool calls'); - await expect(collect(geminiGenerateContentFromIR(iterate(frames)))).rejects.toThrow('cannot represent custom tool calls'); + await expect(collect(writeAnthropicMessagesEvents(iterate(frames)))).rejects.toThrow('cannot represent custom tool calls'); + await expect(collect(writeGeminiGenerateContentEvents(iterate(frames)))).rejects.toThrow('cannot represent custom tool calls'); }); test('writers omit output they cannot represent', async () => { - const image = completeIR([{ type: 'message', content: [{ type: 'text', text: 'before' }, { type: 'image', image: { data: 'aW1n', mime_type: 'image/png' } }] }]); - expect(JSON.stringify(await collect(anthropicMessagesFromIR(iterate(image))))).toContain('before'); - expect(JSON.stringify(await collect(openaiChatCompletionsFromIR(iterate(image))))).toContain('before'); + const image = completeIR([{ type: 'message', content: [{ type: 'text', text: 'before' }, { type: 'image', image: { data: 'aW1n', media_type: 'image/png' } }] }]); + expect(JSON.stringify(await collect(writeAnthropicMessagesEvents(iterate(image))))).toContain('before'); + expect(JSON.stringify(await collect(writeOpenAIChatCompletionsEvents(iterate(image))))).toContain('before'); const documentCitation = completeIR([{ type: 'message', content: [{ type: 'text', text: 'source', annotations: [{ type: 'source_citation', source_kind: 'document', source: 'file_1', source_label: 'Document', output_text_range: { start: 0, end_exclusive: 6 } }] }] }]); - expect(JSON.stringify(await collect(openaiChatCompletionsFromIR(iterate(documentCitation))))).not.toContain('url_citation'); + expect(JSON.stringify(await collect(writeOpenAIChatCompletionsEvents(iterate(documentCitation))))).not.toContain('url_citation'); const ranged = completeIR([{ type: 'message', content: [{ type: 'text', text: 'source', annotations: [{ type: 'source_citation', source_kind: 'url', source: 'https://example.com', output_text_range: { start: 0, end_exclusive: 6 } }] }] }]); - expect(JSON.stringify(await collect(anthropicMessagesFromIR(iterate(ranged))))).toContain('source'); + expect(JSON.stringify(await collect(writeAnthropicMessagesEvents(iterate(ranged))))).toContain('source'); const documentForResponses = completeIR([{ type: 'message', content: [{ type: 'text', text: 'source', annotations: [{ type: 'source_citation', source_kind: 'document', source: 'file_1', source_label: 'Document', output_text_range: { start: 0, end_exclusive: 6 } }] }] }]); - expect(JSON.stringify(await collect(openaiResponsesFromIR(iterate(documentForResponses))))).not.toContain('citation'); + expect(JSON.stringify(await collect(writeOpenAIResponsesEvents(iterate(documentForResponses))))).not.toContain('citation'); const sourceLess = completeIR([{ type: 'message', content: [{ type: 'text', text: 'source', annotations: [{ type: 'source_citation', source_kind: 'document', source: null, source_text: 'quote' }] }] }]); - expect(JSON.stringify(await collect(openaiResponsesFromIR(iterate(sourceLess))))).not.toContain('citation'); + expect(JSON.stringify(await collect(writeOpenAIResponsesEvents(iterate(sourceLess))))).not.toContain('citation'); }); test('single-choice writers reject a second choice when it is initialized', async () => { @@ -158,12 +160,12 @@ test('single-choice writers reject a second choice when it is initialized', asyn b.event({ type: 'start', id: 'source' }); b.choice(1); const frames = [...b.drain()]; - await expect(collect(anthropicMessagesFromIR(iterate(frames)))).rejects.toThrow('cannot represent multiple choices'); - await expect(collect(openaiResponsesFromIR(iterate(frames)))).rejects.toThrow('cannot represent multiple choices'); + await expect(collect(writeAnthropicMessagesEvents(iterate(frames)))).rejects.toThrow('cannot represent multiple choices'); + await expect(collect(writeOpenAIResponsesEvents(iterate(frames)))).rejects.toThrow('cannot represent multiple choices'); }); test('Responses initial envelope requires a model', async () => { - await expect(collectIR(irFromOpenAIResponses(iterate([ + await expect(collectIR(readOpenAIResponsesEvents(iterate([ eventFrame({ type: 'response.created', response: { id: 'resp_missing_model', output: [], usage: null } } as any), ])))).rejects.toThrow('requires a model'); }); @@ -190,8 +192,8 @@ for (const source of ['openai-chat-completions', 'anthropic-messages'] as const) test('ChatCompletions EOF succeeds after explicit choice completion and fails before it', async () => { const frames = fixtureFrames('openai-chat-completions', { text: ['answer'] }); - expect((await collectIR(irFromOpenAIChatCompletionsProduction(iterate(frames.slice(0, -1)), { roundTrip: createRoundTripReaderCapture().roundTrip }))).choices[0].items).toMatchObject([{ content: [{ text: 'answer' }] }]); - await expect(collectIR(irFromOpenAIChatCompletionsProduction(iterate(frames.slice(0, -2)), { roundTrip: createRoundTripReaderCapture().roundTrip }))).rejects.toThrow('finish_reason'); + expect((await collectIR(readOpenAIChatCompletionsEventsProduction(iterate(frames.slice(0, -1)), { roundTrip: createRoundTripReaderCapture().roundTrip }))).choices[0].items).toMatchObject([{ content: [{ text: 'answer' }] }]); + await expect(collectIR(readOpenAIChatCompletionsEventsProduction(iterate(frames.slice(0, -2)), { roundTrip: createRoundTripReaderCapture().roundTrip }))).rejects.toThrow('finish_reason'); }); test.each([messagesViaChat, generateContentViaChat])('completed empty function JSON is rejected by object-requiring destinations', async translate => { @@ -224,7 +226,7 @@ test('ignores vendor object arrays and retains scalar reasoning items', async () const frames = fixtureFrames('openai-chat-completions', { thinking: ['ab'], text: ['answer'] }); const records = [{ type: 'vendor_record', id: 'record_a', segments: ['a'] }, { type: 'vendor_record', id: 'record_b', segments: ['b'] }]; frames.splice(-1, 0, eventFrame({ id: 'chatcmpl_test', model: 'served-model', created: 100, choices: [{ index: 0, delta: { vendor_records: records }, finish_reason: null }] })); - const ir = await collectIR(irFromOpenAIChatCompletionsProduction(iterate(frames), { roundTrip: createRoundTripReaderCapture().roundTrip })); + const ir = await collectIR(readOpenAIChatCompletionsEventsProduction(iterate(frames), { roundTrip: createRoundTripReaderCapture().roundTrip })); expect(ir.choices[0].items.filter(item => item.type === 'reasoning')).toEqual([{ type: 'reasoning', summary: ['ab'] }]); const result = await nativeResult('openai-responses', await collect(responsesViaChat(iterate(frames), new Set(), createTestSidecarCodec(), emptyNamespaceToolNames()))); expect(result.output.filter((item: any) => item.type === 'reasoning' && item.encrypted_content === undefined)).toMatchObject([{ summary: [{ text: 'ab' }] }]); @@ -235,7 +237,7 @@ test('ignores vendor object arrays and retains scalar reasoning items', async () test('late vendor extensions preserve messages on either side of reasoning', async () => { const chunk = (delta: any, finish_reason: string | null = null) => eventFrame({ id: 'chatcmpl_order', model: 'served-model', created: 100, choices: [{ index: 0, delta, finish_reason }] }); const frames = [chunk({ content: 'before' }), chunk({ reasoning_text: 'think' }), chunk({ content: 'after' }), chunk({}, 'stop'), chunk({ vendor_records: [{ type: 'vendor_record', id: 'record_order', segments: ['think'] }] }), { type: 'done' as const }]; - const ir = await collectIR(irFromOpenAIChatCompletionsProduction(iterate(frames as any), { roundTrip: createRoundTripReaderCapture().roundTrip })); + const ir = await collectIR(readOpenAIChatCompletionsEventsProduction(iterate(frames as any), { roundTrip: createRoundTripReaderCapture().roundTrip })); expect(ir.choices[0].items).toEqual([{ type: 'message', content: [{ type: 'text', text: 'before' }] }, { type: 'reasoning', summary: ['think'] }, { type: 'message', content: [{ type: 'text', text: 'after' }] }]); }); diff --git a/packages/translate/__tests__/ir/translation-cases.ts b/packages/translate/__tests__/protocols/fixtures.ts similarity index 99% rename from packages/translate/__tests__/ir/translation-cases.ts rename to packages/translate/__tests__/protocols/fixtures.ts index eacb60b174..6146d453d9 100644 --- a/packages/translate/__tests__/ir/translation-cases.ts +++ b/packages/translate/__tests__/protocols/fixtures.ts @@ -1,9 +1,9 @@ import { expect, test } from 'vitest'; -import { collect, iterate } from './helpers.ts'; import { decodeRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; import type { RoundTripProtocol } from '../../src/round-trip/sidecar.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { collect, iterate } from '../shared/iterables.ts'; import { collectAnthropicMessagesProtocolEventsToResult } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, doneFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { collectGeminiGenerateContentProtocolEventsToResult } from '@floway-dev/protocols/gemini-generate-content'; diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/events_test.ts similarity index 81% rename from packages/translate/__tests__/protocols/gemini-generate-content/as-source/sse-to_test.ts rename to packages/translate/__tests__/protocols/gemini-generate-content/as-source/events_test.ts index a354be3ec3..5025f19e9b 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/events_test.ts @@ -1,9 +1,9 @@ import { expect, test } from 'vitest'; -import { createIRBuilder } from '../../../../src/ir/stream.ts'; -import { geminiGenerateContentFromIR } from '../../../../src/protocols/gemini-generate-content/as-source/sse-to.ts'; +import { 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 type { RoundTripWriter } from '../../../../src/round-trip/stream.ts'; test('resolves delayed Gemini audio atoms within the complete emitted data field', async () => { const references = createReferenceBuilder(); @@ -14,7 +14,7 @@ test('resolves delayed Gemini audio atoms within the complete emitted data field source.assign(['model'], 'model'); source.event({ type: 'start', id: 'response' }); const message = source.item(0, { type: 'message', content: [] }); - const audio = message.part({ type: 'audio', audio: { data: '', mime_type: 'audio/wav' } }); + const audio = message.part({ type: 'audio', audio: { data: '', media_type: 'audio/wav' } }); audio.append(['audio', 'data'], 'Y'); audio.append(['audio', 'data'], 'Q==', [partId]); audio.end(); @@ -33,7 +33,7 @@ test('resolves delayed Gemini audio atoms within the complete emitted data field return 'sidecar'; }, }; - for await (const _frame of geminiGenerateContentFromIR((async function* () { yield* source.drain(); })(), { roundTrip })) {} + for await (const _frame of writeGeminiGenerateContentEvents((async function* () { yield* source.drain(); })(), { roundTrip })) {} expect(prepared).toBe(true); }); @@ -62,6 +62,6 @@ test.each(['{"x":1,}', '{"x": 1}'])('tracks the emitted canonical JSON view with return 'sidecar'; }, }; - for await (const _frame of geminiGenerateContentFromIR((async function* () { yield* source.drain(); })(), { roundTrip, parseToolArguments: original.endsWith(',}') ? () => ({ x: 1 }) : undefined })) {} + for await (const _frame of writeGeminiGenerateContentEvents((async function* () { yield* source.drain(); })(), { roundTrip, parseToolArguments: original.endsWith(',}') ? () => ({ x: 1 }) : undefined })) {} expect(prepared).toBe(true); }); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/events_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/events_test.ts index 4c350535f3..9576b13585 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/events_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/events_test.ts @@ -1,4 +1,4 @@ import { translateToSourceEvents } from '../../../../src/protocols/gemini-generate-content/via-anthropic-messages/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; testOutputTranslation('gemini-generate-content', 'anthropic-messages', (frames, codec) => translateToSourceEvents(frames, codec)); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/events_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/events_test.ts index 4fd9041eff..803a949a56 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/events_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/events_test.ts @@ -1,4 +1,4 @@ import { translateToSourceEvents } from '../../../../src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; testOutputTranslation('gemini-generate-content', 'openai-chat-completions', (frames, codec) => translateToSourceEvents(frames, codec)); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/events_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/events_test.ts index b9f72afc5d..da065b0d58 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/events_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/events_test.ts @@ -1,4 +1,4 @@ import { translateToSourceEvents } from '../../../../src/protocols/gemini-generate-content/via-openai-responses/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; testOutputTranslation('gemini-generate-content', 'openai-responses', (frames, codec) => translateToSourceEvents(frames, codec)); diff --git a/packages/translate/__tests__/protocols/helpers.ts b/packages/translate/__tests__/protocols/helpers.ts new file mode 100644 index 0000000000..dbb08188b1 --- /dev/null +++ b/packages/translate/__tests__/protocols/helpers.ts @@ -0,0 +1,49 @@ +import type { IRRecord } from '../../src/ir/output/records.ts'; +import { writeAnthropicMessagesEvents as writeAnthropicMessagesEventsProduction } from '../../src/protocols/anthropic-messages/as-source/events.ts'; +import { readAnthropicMessagesEvents as readAnthropicMessagesEventsProduction } from '../../src/protocols/anthropic-messages/as-target/events.ts'; +import { writeGeminiGenerateContentEvents as writeGeminiGenerateContentEventsProduction } from '../../src/protocols/gemini-generate-content/as-source/events.ts'; +import { writeOpenAIChatCompletionsEvents as writeOpenAIChatCompletionsEventsProduction } from '../../src/protocols/openai-chat-completions/as-source/events.ts'; +import { readOpenAIChatCompletionsEvents as readOpenAIChatCompletionsEventsProduction } from '../../src/protocols/openai-chat-completions/as-target/events.ts'; +import { writeOpenAIResponsesEvents as writeOpenAIResponsesEventsProduction } from '../../src/protocols/openai-responses/as-source/events.ts'; +import { flattenNamespaceTools } from '../../src/protocols/openai-responses/as-source/namespace-tools.ts'; +import { readOpenAIResponsesEvents as readOpenAIResponsesEventsProduction } from '../../src/protocols/openai-responses/as-target/events.ts'; +import { createReferenceBuilder } from '../../src/round-trip/reference/reference-builder.ts'; +import type { RoundTripReader, RoundTripWriter } from '../../src/round-trip/stream.ts'; + +const reader = (): RoundTripReader => ({ + builder: createReferenceBuilder(), + onAssistantTurn: () => {}, +}); +const writer = (): RoundTripWriter => ({ + builder: createReferenceBuilder(), + prepareSidecar: async () => 'test-sidecar', +}); +const namespaceToolNames = flattenNamespaceTools({ model: 'test', input: [], tools: [] }).names; + +// These ordinary IR fixtures use independent mock participants. Provenance is +// exercised by the native-reader and public-route tests sharing one coordinator. +const withoutProvenance = async function* (records: AsyncIterable): AsyncGenerator { + for await (const record of records) { + if (record.type === 'write' && record.kind === 'append') { + const { referencePieceIds: _referencePieceIds, ...operation } = record; + yield operation; + } else yield record; + } +}; + +export const readAnthropicMessagesEvents = (frames: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => + withoutProvenance(readAnthropicMessagesEventsProduction(frames, { ...options, roundTrip: reader<'anthropicMessages'>() })); +export const readOpenAIChatCompletionsEvents = (frames: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => + withoutProvenance(readOpenAIChatCompletionsEventsProduction(frames, { ...options, roundTrip: reader<'openaiChatCompletions'>() })); +export const readOpenAIResponsesEvents = ( + frames: Parameters[0], + roundTrip: RoundTripReader<'openaiResponses'> = reader<'openaiResponses'>(), +) => withoutProvenance(readOpenAIResponsesEventsProduction(frames, { roundTrip })); +export const writeAnthropicMessagesEvents = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => + writeAnthropicMessagesEventsProduction(records, { ...options, roundTrip: writer<'anthropicMessages'>() }); +export const writeOpenAIChatCompletionsEvents = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => + writeOpenAIChatCompletionsEventsProduction(records, { ...options, roundTrip: writer<'openaiChatCompletions'>() }); +export const writeOpenAIResponsesEvents = (records: Parameters[0], options: Omit[1], 'roundTrip' | 'namespaceToolNames'> = {}) => + writeOpenAIResponsesEventsProduction(records, { ...options, roundTrip: writer<'openaiResponses'>(), namespaceToolNames }); +export const writeGeminiGenerateContentEvents = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => + writeGeminiGenerateContentEventsProduction(records, { ...options, roundTrip: writer<'geminiGenerateContent'>() }); diff --git a/packages/translate/__tests__/ir/initial-envelope_test.ts b/packages/translate/__tests__/protocols/initial-envelope_test.ts similarity index 100% rename from packages/translate/__tests__/ir/initial-envelope_test.ts rename to packages/translate/__tests__/protocols/initial-envelope_test.ts diff --git a/packages/translate/__tests__/ir/lifecycle_test.ts b/packages/translate/__tests__/protocols/lifecycle_test.ts similarity index 83% rename from packages/translate/__tests__/ir/lifecycle_test.ts rename to packages/translate/__tests__/protocols/lifecycle_test.ts index 18e5df52ea..d4d0bf46b9 100644 --- a/packages/translate/__tests__/ir/lifecycle_test.ts +++ b/packages/translate/__tests__/protocols/lifecycle_test.ts @@ -1,9 +1,11 @@ import { expect, test, vi } from 'vitest'; -import { anthropicMessagesFromIR, collect, collectIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, iterate, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; -import { nativeResult } from './translation-cases.ts'; -import type { IRRecord } from '../../src/ir/stream.ts'; -import { IRAbsent } from '../../src/ir/types.ts'; +import { nativeResult } from './fixtures.ts'; +import { writeAnthropicMessagesEvents, writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; +import { IRAbsent } from '../../src/ir/output/absent.ts'; +import type { IRRecord } from '../../src/ir/output/records.ts'; +import { collectIR } from '../ir/output/helpers.ts'; +import { collect, iterate } from '../shared/iterables.ts'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; const chat = (delta: any, finish_reason: string | null = null) => eventFrame({ id: 'chatcmpl_lifecycle', model: 'served', created: 1, choices: [{ index: 0, delta, finish_reason }] } as any); @@ -26,7 +28,7 @@ const gated = async (prefix: T[], suffix: T[], convert: (source: AsyncIterabl }; test('ChatCompletions emits scalar reasoning before the next upstream frame', async () => { - const frames = await gated([chat({ reasoning_text: 'live' })], [chat({ content: 'body' }), chat({}, 'stop'), doneFrame()], irFromOpenAIChatCompletions, output => { + const frames = await gated([chat({ reasoning_text: 'live' })], [chat({ content: 'body' }), chat({}, 'stop'), doneFrame()], readOpenAIChatCompletionsEvents, output => { expect(output).toContainEqual(expect.objectContaining({ type: 'write', kind: 'append', value: 'live' })); }); const ir = await collectIR(iterate(frames)); @@ -35,7 +37,7 @@ test('ChatCompletions emits scalar reasoning before the next upstream frame', as test('ChatCompletions locks the first reasoning field independently for each choice', async () => { const chunk = (choices: any[]) => eventFrame({ id: 'chat', model: 'm', created: 1, choices } as any); - const ir = await collectIR(irFromOpenAIChatCompletions(iterate([ + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate([ chunk([{ index: 0, delta: { reasoning: 'A' } }, { index: 1, delta: { reasoning_content: 'X' } }]), chunk([{ index: 0, delta: { reasoning_text: 'B', reasoning: 'C' } }, { index: 1, delta: { reasoning: 'Y', reasoning_content: 'Z' } }]), chunk([{ index: 0, delta: {}, finish_reason: 'stop' }, { index: 1, delta: {}, finish_reason: 'stop' }]), doneFrame(), @@ -47,7 +49,7 @@ test('ChatCompletions locks the first reasoning field independently for each cho }); test('an empty first reasoning string locks the field without interrupting text', async () => { - const ir = await collectIR(irFromOpenAIChatCompletions(iterate([ + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate([ chat({ content: 'before' }), chat({ reasoning: '' }), chat({ reasoning_text: 'ignored', content: 'after' }), chat({ reasoning: 'selected' }), chat({}, 'stop'), doneFrame(), ]))); @@ -58,17 +60,17 @@ test('an empty first reasoning string locks the field without interrupting text' }); test('ChatCompletions creates separate reasoning runs at every text interruption', async () => { - const ir = await collectIR(irFromOpenAIChatCompletions(iterate([chat({ reasoning_text: 'r1' }), chat({ content: 'body' }), chat({ reasoning_text: 'r2' }), chat({ content: 'tail' }), chat({}, 'stop'), doneFrame()]))); + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate([chat({ reasoning_text: 'r1' }), chat({ content: 'body' }), chat({ reasoning_text: 'r2' }), chat({ content: 'tail' }), chat({}, 'stop'), doneFrame()]))); expect(ir.choices[0].items).toEqual([{ type: 'reasoning', summary: ['r1'] }, { type: 'message', content: [{ type: 'text', text: 'body' }] }, { type: 'reasoning', summary: ['r2'] }, { type: 'message', content: [{ type: 'text', text: 'tail' }] }]); }); test('an old open tool update leaves the current ChatCompletions text run open', async () => { - const ir = await collectIR(irFromOpenAIChatCompletions(iterate([chat({ content: 'before' }), tool(0, '{"x":', 'call', 'lookup'), chat({ content: 'middle' }), tool(0, '1}'), chat({ content: 'after' }), chat({}, 'tool_calls'), doneFrame()]))); + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate([chat({ content: 'before' }), tool(0, '{"x":', 'call', 'lookup'), chat({ content: 'middle' }), tool(0, '1}'), chat({ content: 'after' }), chat({}, 'tool_calls'), doneFrame()]))); expect(ir.choices[0].items).toEqual([{ type: 'message', content: [{ type: 'text', text: 'before' }] }, { type: 'function_call', name: 'lookup', call_id: 'call', arguments: '{"x":1}' }, { type: 'message', content: [{ type: 'text', text: 'middleafter' }] }]); }); test('a complete JSON prefix stays open and preserves following argument text', async () => { - const frames = await gated([tool(0, '{"x":1}', 'call', 'lookup')], [tool(0, ',{"x":2}'), chat({}, 'tool_calls'), doneFrame()], irFromOpenAIChatCompletions, output => { + const frames = await gated([tool(0, '{"x":1}', 'call', 'lookup')], [tool(0, ',{"x":2}'), chat({}, 'tool_calls'), doneFrame()], readOpenAIChatCompletionsEvents, output => { expect(output.filter((record: any) => record.type === 'item_end')).toEqual([]); }); const ir = await collectIR(iterate(frames)); @@ -78,7 +80,7 @@ test('a complete JSON prefix stays open and preserves following argument text', test('a new tool closes the preceding function and later updates warn without reopening it', async () => { const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { - const frames = await gated([tool(0, '{"x":1}', 'call0', 'lookup'), tool(1, '{"y":', 'call1', 'lookup')], [tool(0, 'BAD'), tool(1, '2}'), chat({}, 'tool_calls'), doneFrame()], irFromOpenAIChatCompletions, output => { + const frames = await gated([tool(0, '{"x":1}', 'call0', 'lookup'), tool(1, '{"y":', 'call1', 'lookup')], [tool(0, 'BAD'), tool(1, '2}'), chat({}, 'tool_calls'), doneFrame()], readOpenAIChatCompletionsEvents, output => { expect(output.filter((record: any) => record.type === 'item_end')).toEqual([{ type: 'item_end', choice: 0, item: 0, status: 'completed' }]); }); expect(warning).toHaveBeenCalledTimes(1); @@ -89,7 +91,7 @@ test('a new tool closes the preceding function and later updates warn without re test('a native custom call stays open across subsequent tool starts', async () => { const custom = (input: string, first = false) => chat({ tool_calls: [{ index: 0, ...(first ? { id: 'custom0', type: 'custom' } : {}), custom: { ...(first ? { name: 'script' } : {}), input } }] }); - const frames = await gated([custom('first', true), tool(1, '{}', 'call1', 'lookup')], [custom('second'), chat({}, 'tool_calls'), doneFrame()], irFromOpenAIChatCompletions, output => { + const frames = await gated([custom('first', true), tool(1, '{}', 'call1', 'lookup')], [custom('second'), chat({}, 'tool_calls'), doneFrame()], readOpenAIChatCompletionsEvents, output => { expect(output.filter((record: any) => record.type === 'item_end')).toEqual([]); }); const ir = await collectIR(iterate(frames)); @@ -97,7 +99,7 @@ test('a native custom call stays open across subsequent tool starts', async () = }); test('a wrapped custom input is published when its native function boundary closes', async () => { - const frames = await gated([tool(0, '{"input":"text"}', 'call0', 'script'), tool(1, '{}', 'call1', 'lookup')], [chat({}, 'tool_calls'), doneFrame()], source => irFromOpenAIChatCompletions(source, { customToolNames: new Set(['script']) }), output => { + const frames = await gated([tool(0, '{"input":"text"}', 'call0', 'script'), tool(1, '{}', 'call1', 'lookup')], [chat({}, 'tool_calls'), doneFrame()], source => readOpenAIChatCompletionsEvents(source, { customToolNames: new Set(['script']) }), output => { expect(output).toContainEqual({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'input'], value: 'text' }); expect(output).toContainEqual({ type: 'item_end', choice: 0, item: 0, status: 'completed' }); }); @@ -105,14 +107,14 @@ test('a wrapped custom input is published when its native function boundary clos }); test('a custom call start completes the preceding function', async () => { - const frames = await gated([tool(0, '{}', 'call0', 'lookup'), chat({ tool_calls: [{ index: 1, id: 'custom1', type: 'custom', custom: { name: 'script', input: 'text' } }] })], [chat({}, 'tool_calls'), doneFrame()], irFromOpenAIChatCompletions, output => { + const frames = await gated([tool(0, '{}', 'call0', 'lookup'), chat({ tool_calls: [{ index: 1, id: 'custom1', type: 'custom', custom: { name: 'script', input: 'text' } }] })], [chat({}, 'tool_calls'), doneFrame()], readOpenAIChatCompletionsEvents, output => { expect(output.filter((record: any) => record.type === 'item_end')).toEqual([{ type: 'item_end', choice: 0, item: 0, status: 'completed' }]); }); expect((await collectIR(iterate(frames))).choices[0].items).toHaveLength(2); }); -test.each([['Responses', openaiResponsesFromIR], ['Messages', anthropicMessagesFromIR]] as const)('%s waits for the actual tool identity before publishing its start', async (name, writer) => { - const frames = await gated([tool(0, '{"x":', undefined, 'lookup')], [tool(0, '1}', 'call_real'), chat({}, 'tool_calls'), doneFrame()], source => writer(irFromOpenAIChatCompletions(source)), output => { +test.each([['Responses', writeOpenAIResponsesEvents], ['Messages', writeAnthropicMessagesEvents]] as const)('%s waits for the actual tool identity before publishing its start', async (name, writer) => { + const frames = await gated([tool(0, '{"x":', undefined, 'lookup')], [tool(0, '1}', 'call_real'), chat({}, 'tool_calls'), doneFrame()], source => writer(readOpenAIChatCompletionsEvents(source)), output => { expect(output.some((frame: any) => frame.event?.type === 'response.output_item.added' || frame.event?.type === 'content_block_start')).toBe(false); }); const result: any = await nativeResult(name === 'Responses' ? 'openai-responses' : 'anthropic-messages', frames); @@ -133,7 +135,7 @@ const irSuffix = (): IRRecord[] => [ { type: 'finish', status: 'completed' }, ]; -test.each([['Responses', openaiResponsesFromIR], ['Messages', anthropicMessagesFromIR]] as const)('%s opens a pending call on the late call_id record', async (name, writer) => { +test.each([['Responses', writeOpenAIResponsesEvents], ['Messages', writeAnthropicMessagesEvents]] as const)('%s opens a pending call on the late call_id record', async (name, writer) => { const prefix: IRRecord[] = [...irPrefix(), { type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1], value: { type: 'message', content: [] } }, { type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1, 'content', 0], value: { type: 'text', text: '' } }, @@ -154,13 +156,13 @@ test.each([['Responses', openaiResponsesFromIR], ['Messages', anthropicMessagesF expect(name === 'Responses' ? result.output[0].call_id : result.content[0].id).toBe('call_real'); }); -test.each([['Responses', openaiResponsesFromIR], ['Messages', anthropicMessagesFromIR], ['ChatCompletions', openaiChatCompletionsFromIR]] as const)('%s opens call_id IRAbsent without waiting', async (_name, writer) => { +test.each([['Responses', writeOpenAIResponsesEvents], ['Messages', writeAnthropicMessagesEvents], ['ChatCompletions', writeOpenAIChatCompletionsEvents]] as const)('%s opens call_id IRAbsent without waiting', async (_name, writer) => { await gated(irPrefix(IRAbsent), irSuffix(), writer, prefix => { expect(prefix.some((frame: any) => frame.event?.type === 'response.output_item.added' || frame.event?.type === 'content_block_start' || frame.event?.choices?.[0]?.delta.tool_calls !== undefined)).toBe(true); }); }); -test.each([['Responses', openaiResponsesFromIR], ['Messages', anthropicMessagesFromIR]] as const)('%s synthesizes a call ID when the item closes with it absent', async (_name, writer) => { +test.each([['Responses', writeOpenAIResponsesEvents], ['Messages', writeAnthropicMessagesEvents]] as const)('%s synthesizes a call ID when the item closes with it absent', async (_name, writer) => { const frames = await gated(irPrefix(), irSuffix(), writer, prefix => { expect(prefix.some((frame: any) => frame.event?.type === 'response.output_item.added' || frame.event?.type === 'content_block_start')).toBe(false); }); @@ -168,7 +170,7 @@ test.each([['Responses', openaiResponsesFromIR], ['Messages', anthropicMessagesF }); test('GenerateContent emits a completed call without waiting for a call ID', async () => { - const result: any = await nativeResult('gemini-generate-content', await collect(geminiGenerateContentFromIR(iterate([...irPrefix(), ...irSuffix()])))); + const result: any = await nativeResult('gemini-generate-content', await collect(writeGeminiGenerateContentEvents(iterate([...irPrefix(), ...irSuffix()])))); expect(result.candidates[0].content.parts[0].functionCall).toEqual({ name: 'tool', args: { x: 1 } }); }); @@ -179,8 +181,8 @@ const call = (index: number, arguments_ = '', status = 'in_progress') => ({ type const args = (index: number, delta: string) => eventFrame({ type: 'response.function_call_arguments.delta', output_index: index, delta } as any); const endCall = (index: number, arguments_: string, status = 'completed') => eventFrame({ type: 'response.output_item.done', output_index: index, item: call(index, arguments_, status) } as any); -test.each([['ChatCompletions', openaiChatCompletionsFromIR], ['Messages', anthropicMessagesFromIR]] as const)('%s streams the second tool while the first remains open', async (name, writer) => { - await gated([responseStart, add(0, call(0)), args(0, '{"x":'), add(1, call(1)), args(1, '{"y":2}'), endCall(1, '{"y":2}')], [args(0, '1}'), endCall(0, '{"x":1}'), eventFrame({ type: 'response.completed', response: response([call(0, '{"x":1}', 'completed'), call(1, '{"y":2}', 'completed')]) } as any)], source => writer(irFromOpenAIResponses(source)), output => { +test.each([['ChatCompletions', writeOpenAIChatCompletionsEvents], ['Messages', writeAnthropicMessagesEvents]] as const)('%s streams the second tool while the first remains open', async (name, writer) => { + await gated([responseStart, add(0, call(0)), args(0, '{"x":'), add(1, call(1)), args(1, '{"y":2}'), endCall(1, '{"y":2}')], [args(0, '1}'), endCall(0, '{"x":1}'), eventFrame({ type: 'response.completed', response: response([call(0, '{"x":1}', 'completed'), call(1, '{"y":2}', 'completed')]) } as any)], source => writer(readOpenAIResponsesEvents(source)), output => { expect(output.some((frame: any) => name === 'Messages' ? frame.event?.type === 'content_block_delta' && frame.event.index === 1 && frame.event.delta?.partial_json === '{"y":2}' : frame.event?.choices?.some((choice: any) => choice.delta.tool_calls?.some((tool: any) => tool.index === 1 && tool.function.arguments === '{"y":2}')))).toBe(true); @@ -189,7 +191,7 @@ test.each([['ChatCompletions', openaiChatCompletionsFromIR], ['Messages', anthro test('Messages closes an earlier concurrent block immediately and the frontier block after its sidecar', async () => { const source = [responseStart, add(0, call(0)), args(0, '{"x":'), add(1, call(1)), args(1, '{"y":2}'), endCall(1, '{"y":2}'), args(0, '1}'), endCall(0, '{"x":1}'), eventFrame({ type: 'response.completed', response: response([call(0, '{"x":1}', 'completed'), call(1, '{"y":2}', 'completed')]) } as any)]; - const frames: any[] = await collect(anthropicMessagesFromIR(irFromOpenAIResponses(iterate(source)))); + const frames: any[] = await collect(writeAnthropicMessagesEvents(readOpenAIResponsesEvents(iterate(source)))); const lifecycle = frames.flatMap(frame => frame.event?.type === 'content_block_start' ? [`start:${frame.event.index}:${frame.event.content_block.type}`] : frame.event?.type === 'content_block_stop' ? [`stop:${frame.event.index}`] : []); @@ -203,24 +205,24 @@ test('Messages closes an earlier concurrent block immediately and the frontier b ]); }); -test.each([['ChatCompletions', openaiChatCompletionsFromIR], ['Messages', anthropicMessagesFromIR], ['GenerateContent', geminiGenerateContentFromIR]] as const)('%s flattens reasoning content and summary in arrival order', async (name, writer) => { +test.each([['ChatCompletions', writeOpenAIChatCompletionsEvents], ['Messages', writeAnthropicMessagesEvents], ['GenerateContent', writeGeminiGenerateContentEvents]] as const)('%s flattens reasoning content and summary in arrival order', async (name, writer) => { const item = { type: 'reasoning', id: 'r', summary: [] }; const source = [responseStart, add(0, item), eventFrame({ type: 'response.content_part.added', output_index: 0, content_index: 0, part: { type: 'reasoning_text', text: '' } } as any), eventFrame({ type: 'response.reasoning_text.delta', output_index: 0, content_index: 0, delta: 'C' } as any), eventFrame({ type: 'response.reasoning_summary_part.added', output_index: 0, summary_index: 0, part: { type: 'summary_text', text: '' } } as any), eventFrame({ type: 'response.reasoning_summary_text.delta', output_index: 0, summary_index: 0, delta: 'S' } as any), eventFrame({ type: 'response.completed', response: response([{ ...item, content: [{ type: 'reasoning_text', text: 'C' }], summary: [{ type: 'summary_text', text: 'S' }] }]) } as any)]; - const frames: any[] = await collect(writer(irFromOpenAIResponses(iterate(source)))); + const frames: any[] = await collect(writer(readOpenAIResponsesEvents(iterate(source)))); const text = frames.map(frame => name === 'ChatCompletions' ? frame.event?.choices?.[0]?.delta.reasoning_text ?? '' : name === 'Messages' ? frame.event.delta?.thinking ?? '' : frame.event.candidates?.[0]?.content?.parts?.filter((part: any) => part.thought).map((part: any) => part.text).join('') ?? '').join(''); expect(text).toBe('CS'); }); test('GenerateContent preserves completed tool order when the second finishes first', async () => { - const frames = await gated([responseStart, add(0, call(0)), args(0, '{"x":'), add(1, call(1)), args(1, '{}'), endCall(1, '{}')], [args(0, '1}'), endCall(0, '{"x":1}'), eventFrame({ type: 'response.completed', response: response([call(0, '{"x":1}', 'completed'), call(1, '{}', 'completed')]) } as any)], source => geminiGenerateContentFromIR(irFromOpenAIResponses(source)), output => { + const frames = await gated([responseStart, add(0, call(0)), args(0, '{"x":'), add(1, call(1)), args(1, '{}'), endCall(1, '{}')], [args(0, '1}'), endCall(0, '{"x":1}'), eventFrame({ type: 'response.completed', response: response([call(0, '{"x":1}', 'completed'), call(1, '{}', 'completed')]) } as any)], source => writeGeminiGenerateContentEvents(readOpenAIResponsesEvents(source)), output => { expect(output).toEqual([]); }); expect(frames.flatMap((frame: any) => frame.event.candidates?.flatMap((candidate: any) => candidate.content?.parts ?? []) ?? []).filter((part: any) => part.functionCall).map((part: any) => part.functionCall.id)).toEqual(['call_0', 'call_1']); }); -test.each([['ChatCompletions', openaiChatCompletionsFromIR], ['Responses', openaiResponsesFromIR], ['GenerateContent', geminiGenerateContentFromIR]] as const)('%s preserves Messages token cutoff without parsing incomplete input', async (name, writer) => { +test.each([['ChatCompletions', writeOpenAIChatCompletionsEvents], ['Responses', writeOpenAIResponsesEvents], ['GenerateContent', writeGeminiGenerateContentEvents]] as const)('%s preserves Messages token cutoff without parsing incomplete input', async (name, writer) => { const source = [messageStart, eventFrame({ type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'call', name: 'lookup', input: {} } } as any), eventFrame({ type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '{"x":' } } as any), eventFrame({ type: 'content_block_stop', index: 0 } as any), ...messageEnd('max_tokens')]; - const frames: any[] = await collect(writer(irFromAnthropicMessages(iterate(source)))); + const frames: any[] = await collect(writer(readAnthropicMessagesEvents(iterate(source)))); if (name === 'ChatCompletions') { expect(frames.some(frame => frame.event?.choices?.[0]?.finish_reason === 'length')).toBe(true); expect(frames.flatMap(frame => frame.event?.choices?.[0]?.delta.tool_calls ?? []).map(tool => tool.function.arguments ?? '').join('')).toBe('{"x":'); @@ -234,7 +236,7 @@ test.each([['ChatCompletions', openaiChatCompletionsFromIR], ['Responses', opena }); test('empty ChatCompletions scalar deltas do not interrupt reasoning', async () => { - const ir = await collectIR(irFromOpenAIChatCompletions(iterate([chat({ role: 'assistant', content: '' }), chat({ reasoning_text: 'r1' }), chat({ content: '' }), chat({ reasoning_text: 'r2' }), chat({}, 'stop'), doneFrame()]))); + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate([chat({ role: 'assistant', content: '' }), chat({ reasoning_text: 'r1' }), chat({ content: '' }), chat({ reasoning_text: 'r2' }), chat({}, 'stop'), doneFrame()]))); expect(ir.choices[0].items).toEqual([{ type: 'reasoning', summary: ['r1r2'] }]); }); @@ -251,7 +253,7 @@ test('Responses closes only the addressed text part while a refusal remains open { type: 'part_end', choice: 0, item: 0, part: 0 }, ]; const suffix: IRRecord[] = [{ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 1, 'refusal'], value: 'C' }, { type: 'part_end', choice: 0, item: 0, part: 1 }, { type: 'item_end', choice: 0, item: 0 }, { type: 'choice_end', choice: 0, finish_reason: 'stop' }, { type: 'finish', status: 'completed' }]; - const frames = await gated(prefix, suffix, openaiResponsesFromIR, output => { + const frames = await gated(prefix, suffix, writeOpenAIResponsesEvents, output => { expect(output.filter(frame => frame.event.type === 'response.content_part.done').map(frame => frame.event.content_index)).toEqual([0]); }); const result: any = await nativeResult('openai-responses', frames); @@ -261,7 +263,7 @@ test('Responses closes only the addressed text part while a refusal remains open test('GenerateContent retains reasoning arrival order while it waits for a prior tool', async () => { const reasoning = { type: 'reasoning', id: 'r', content: [{ type: 'reasoning_text', text: 'C' }], summary: [] }; const source = [responseStart, add(0, call(0)), args(0, '{"x":'), add(1, reasoning), eventFrame({ type: 'response.reasoning_summary_part.added', output_index: 1, summary_index: 0, part: { type: 'summary_text', text: 'S' } } as any), eventFrame({ type: 'response.output_item.done', output_index: 1, item: { ...reasoning, summary: [{ type: 'summary_text', text: 'S' }] } } as any), args(0, '1}'), endCall(0, '{"x":1}'), eventFrame({ type: 'response.completed', response: response([call(0, '{"x":1}', 'completed'), { ...reasoning, summary: [{ type: 'summary_text', text: 'S' }] }]) } as any)]; - const frames = await collect(geminiGenerateContentFromIR(irFromOpenAIResponses(iterate(source)))); + const frames = await collect(writeGeminiGenerateContentEvents(readOpenAIResponsesEvents(iterate(source)))); const parts = frames.flatMap(frame => (frame.event as any).candidates?.flatMap((candidate: any) => candidate.content?.parts ?? []) ?? []); expect(parts[0].functionCall.id).toBe('call_0'); expect(parts.filter((part: any) => part.thought).map((part: any) => part.text).join('')).toBe('CS'); @@ -274,7 +276,7 @@ test.each([false, true])('GenerateContent keeps parallel text arrival order with const offset = buffered ? 1 : 0; const prefix = [messageStart, ...(buffered ? [eventFrame({ type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'call', name: 'lookup', input: {} } } as any), eventFrame({ type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '{"x":' } } as any)] : []), block(offset), text(offset, 'a'), block(offset + 1), text(offset + 1, 'b'), text(offset, 'c')]; const suffix = [...(buffered ? [eventFrame({ type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '1}' } } as any), stop(0)] : []), stop(offset + 1), stop(offset), ...messageEnd(buffered ? 'tool_use' : 'end_turn')]; - const frames = await gated(prefix, suffix, source => geminiGenerateContentFromIR(irFromAnthropicMessages(source)), output => { + const frames = await gated(prefix, suffix, source => writeGeminiGenerateContentEvents(readAnthropicMessagesEvents(source)), output => { const text = output.flatMap(frame => frame.event.candidates?.flatMap((candidate: any) => candidate.content?.parts ?? []) ?? []).map((part: any) => part.text ?? '').join(''); expect(text).toBe(buffered ? '' : 'abc'); }); @@ -290,7 +292,7 @@ test('Messages publishes cumulative usage progress before pulling the rest of th first.event.usage = { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 }; const last = chat({ content: 'B' }, 'stop'); last.event.usage = { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 }; - const output = await gated([initial, first], [last, doneFrame()], frames => anthropicMessagesFromIR(irFromOpenAIChatCompletions(frames)), prefix => { + const output = await gated([initial, first], [last, doneFrame()], frames => writeAnthropicMessagesEvents(readOpenAIChatCompletionsEvents(frames)), prefix => { expect(prefix).toContainEqual(expect.objectContaining({ event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'A' } } })); expect(prefix.some(frame => frame.event?.type === 'message_delta' && frame.event.delta.stop_reason === null && frame.event.usage.output_tokens === 1)).toBe(true); expect(prefix.some(frame => frame.event?.delta?.stop_reason === 'end_turn')).toBe(false); diff --git a/packages/translate/__tests__/ir/continuous-usage_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/continuous-usage_test.ts similarity index 93% rename from packages/translate/__tests__/ir/continuous-usage_test.ts rename to packages/translate/__tests__/protocols/openai-chat-completions/as-source/continuous-usage_test.ts index a64739675a..20a2995bf6 100644 --- a/packages/translate/__tests__/ir/continuous-usage_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/continuous-usage_test.ts @@ -1,9 +1,9 @@ import { expect, test } from 'vitest'; -import { collect, iterate } from './helpers.ts'; -import { fixtureFrames } from './translation-cases.ts'; -import { routeOpenAIChatCompletions } from '../../src/protocols/openai-chat-completions/index.ts'; -import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { routeOpenAIChatCompletions } from '../../../../src/protocols/openai-chat-completions/index.ts'; +import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { collect, iterate } from '../../../shared/iterables.ts'; +import { fixtureFrames } from '../../fixtures.ts'; import type { OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; const protocols = ['anthropic-messages', 'openai-responses'] as const; diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/events_test.ts similarity index 88% rename from packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts rename to packages/translate/__tests__/protocols/openai-chat-completions/as-source/events_test.ts index 7fe2a02421..e57603e70f 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/events_test.ts @@ -1,9 +1,9 @@ import { expect, test } from 'vitest'; -import { createIRBuilder } from '../../../../src/ir/stream.ts'; -import { openaiChatCompletionsFromIR } from '../../../../src/protocols/openai-chat-completions/as-source/sse-to.ts'; +import { 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 type { RoundTripWriter } from '../../../../src/round-trip/stream.ts'; test('prepares each Chat choice independently while another choice has partial audio', async () => { const source = createIRBuilder(2); @@ -13,7 +13,7 @@ test('prepares each Chat choice independently while another choice has partial a source.event({ type: 'start', id: 'response' }); source.initialize(['choices', 1, 'items', 0], { type: 'message', content: [] }); - source.initialize(['choices', 1, 'items', 0, 'content', 0], { type: 'audio', audio: { mime_type: 'audio/wav' } }); + source.initialize(['choices', 1, 'items', 0, 'content', 0], { type: 'audio', audio: { media_type: 'audio/wav' } }); source.initialize(['choices', 1, 'items', 0, 'content', 0, 'audio', 'data'], ''); source.append(['choices', 1, 'items', 0, 'content', 0, 'audio', 'data'], 'YQ=='); @@ -37,7 +37,7 @@ test('prepares each Chat choice independently while another choice has partial a prepareSidecar: async choice => { prepared.push(choice); return `sidecar-${choice}`; }, }; const frames = []; - for await (const frame of openaiChatCompletionsFromIR((async function* () { yield* source.drain(); })(), { + for await (const frame of writeOpenAIChatCompletionsEvents((async function* () { yield* source.drain(); })(), { roundTrip, audioMetadata: choice => ({ id: `audio-${choice}`, expires_at: 10 }), })) frames.push(frame); @@ -79,7 +79,7 @@ test.each([false, true])('resolves delayed transcript provenance at actual proje builder: references, prepareSidecar: async (...args) => { prepared = args; return 'sidecar'; }, }; - for await (const _frame of openaiChatCompletionsFromIR((async function* () { yield* source.drain(); })(), { + for await (const _frame of writeOpenAIChatCompletionsEvents((async function* () { yield* source.drain(); })(), { roundTrip, audioMetadata: () => ({ id: 'audio', expires_at: 10 }), })) {} if (prepared === undefined) throw new Error('ChatCompletions sidecar was not prepared'); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts similarity index 94% rename from packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts rename to packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts index 134b73403f..decf5625ac 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts @@ -1,8 +1,8 @@ import { expect, test } from 'vitest'; -import type { IRRecord } from '../../../../src/ir/stream.ts'; +import type { IRRecord } from '../../../../src/ir/output/records.ts'; import type { OpenAIChatCompletionsInputAssistantTurn } from '../../../../src/protocols/openai-chat-completions/as-target/assistant-turn.ts'; -import { irFromOpenAIChatCompletions } from '../../../../src/protocols/openai-chat-completions/as-target/sse-from.ts'; +import { readOpenAIChatCompletionsEvents } from '../../../../src/protocols/openai-chat-completions/as-target/events.ts'; import { appendContent, collectContentViews, createContentState, createReferenceBuilder } from '../../../../src/round-trip/reference/reference-builder.ts'; import { finalizeAssistantTurnReferences } from '../../../../src/round-trip/reference/reference-selection.ts'; import { restoreAssistantTurn } from '../../../../src/round-trip/reference/restore.ts'; @@ -20,7 +20,7 @@ const drain = async (frames: ReturnType[]): Promise { turns.set(choice, turn); }, }; - for await (const _record of irFromOpenAIChatCompletions((async function* () { yield* frames; yield doneFrame(); })(), { maxChoices: 2, roundTrip })) {} + for await (const _record of readOpenAIChatCompletionsEvents((async function* () { yield* frames; yield doneFrame(); })(), { maxChoices: 2, roundTrip })) {} return turns; }; @@ -82,7 +82,7 @@ const traceTools = async (frames: ReturnType[], options: { prefix? builder, onAssistantTurn: (...args) => { captured = args; }, }; - for await (const record of irFromOpenAIChatCompletions((async function* () { yield* frames; yield doneFrame(); })(), { roundTrip, customToolNames: options.customToolNames })) { + for await (const record of readOpenAIChatCompletionsEvents((async function* () { yield* frames; yield doneFrame(); })(), { roundTrip, customToolNames: options.customToolNames })) { if (record.type === 'write' && record.kind === 'assign') assigns.push(record); if (record.type !== 'write' || record.kind !== 'append') continue; const field = record.path.at(-1); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events_test.ts index 7a6cdd62a2..3fdcfbc082 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events_test.ts @@ -1,4 +1,4 @@ import { translateToSourceEvents } from '../../../../src/protocols/openai-chat-completions/via-anthropic-messages/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; testOutputTranslation('openai-chat-completions', 'anthropic-messages', (frames, codec) => translateToSourceEvents(frames, {}, codec)); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts index beafe30ffa..a7d7f70afe 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts @@ -2,9 +2,9 @@ import { expect, test } from 'vitest'; import { translateToSourceEvents } from '../../../../src/protocols/openai-chat-completions/via-openai-responses/events.ts'; import { buildTargetRequest } from '../../../../src/protocols/openai-chat-completions/via-openai-responses/request.ts'; -import { structuredResponsesFixture } from '../../../ir/responses-fixture.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; +import { structuredResponsesFixture } from '../../openai-responses/fixtures.ts'; import { eventFrame, type ProtocolFrame, type SseFrame, sseFrame } from '@floway-dev/protocols/common'; import { collectOpenAIChatCompletionsProtocolEventsToResult, parseOpenAIChatCompletionsStream, openaiChatCompletionsProtocolFrameToSSEFrame as writeChatFrame, type OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; import { openaiResponsesResultToEvents, parseOpenAIResponsesStream, collectOpenAIResponsesProtocolEventsToResult, type OpenAIResponsesResultEx, type OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts similarity index 92% rename from packages/translate/__tests__/protocols/openai-responses/as-source/sse-to_test.ts rename to packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts index 70cc9ccb60..453b5b2b6e 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts @@ -1,10 +1,10 @@ import { expect, test } from 'vitest'; -import { createIRBuilder } from '../../../../src/ir/stream.ts'; +import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; +import { writeOpenAIResponsesEvents } from '../../../../src/protocols/openai-responses/as-source/events.ts'; import { flattenNamespaceTools } from '../../../../src/protocols/openai-responses/as-source/namespace-tools.ts'; -import { openaiResponsesFromIR } from '../../../../src/protocols/openai-responses/as-source/sse-to.ts'; import { appendField, collectContentViews, createReferenceBuilder, createFieldState, createReferenceFinalizer } from '../../../../src/round-trip/reference/reference-builder.ts'; -import { type RoundTripWriter } from '../../../../src/round-trip/stream.ts'; +import type { RoundTripWriter } from '../../../../src/round-trip/stream.ts'; test('collects sparse namespace-restored Responses state from emitted IR atoms', async () => { const namespace = flattenNamespaceTools({ @@ -72,14 +72,14 @@ test('collects sparse namespace-restored Responses state from emitted IR atoms', builder.event({ type: 'item_end', choice: 0, item: 3 }); builder.initialize(['choices', 0, 'items', 4], { type: 'message', content: [] }); - builder.initialize(['choices', 0, 'items', 4, 'content', 0], { type: 'image', image: { data: 'literal', mime_type: 'image/png' } }); + builder.initialize(['choices', 0, 'items', 4, 'content', 0], { type: 'image', image: { data: 'literal', media_type: 'image/png' } }); builder.event({ type: 'part_end', choice: 0, item: 4, part: 0 }); builder.event({ type: 'item_end', choice: 0, item: 4 }); builder.event({ type: 'choice_end', choice: 0, finish_reason: 'tool_calls' }); builder.event({ type: 'finish', status: 'completed' }); - for await (const _frame of openaiResponsesFromIR((async function* () { yield* builder.drain(); })(), { roundTrip, namespaceToolNames: namespace.names })) {} + for await (const _frame of writeOpenAIResponsesEvents((async function* () { yield* builder.drain(); })(), { roundTrip, namespaceToolNames: namespace.names })) {} if (prepared === undefined) throw new Error('Responses sidecar was not prepared'); const [, , turn, state] = prepared; diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts new file mode 100644 index 0000000000..42fa12acb2 --- /dev/null +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts @@ -0,0 +1,22 @@ +import { expect, test } from 'vitest'; + +import type { IRRecord } from '../../../../src/ir/output/records.ts'; +import { collect, iterate } from '../../../shared/iterables.ts'; +import { writeOpenAIResponsesEvents } from '../../helpers.ts'; + +test('initialize carries real required fields while append targets start empty', async () => { + const records: IRRecord[] = [ + { type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }, + { type: 'write', kind: 'assign', path: ['model'], value: 'model' }, + { type: 'start', id: 'source' }, + { type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call', call_id: 'call', name: 'real-name', arguments: '' } }, + { type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'arguments'], value: '{}' }, + { type: 'item_end', choice: 0, item: 0 }, + { type: 'choice_end', choice: 0, finish_reason: 'tool_calls' }, + { type: 'finish', status: 'completed' }, + ]; + const output = await collect(writeOpenAIResponsesEvents(iterate(records))); + const serialized = JSON.stringify(output); + expect(serialized).toContain('real-name'); + expect(serialized).toContain('{}'); +}); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-target/sse-from_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts similarity index 98% rename from packages/translate/__tests__/protocols/openai-responses/as-target/sse-from_test.ts rename to packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts index b37e944ccb..c9d61a3a24 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-target/sse-from_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; -import { irFromOpenAIResponses } from '../../../../src/protocols/openai-responses/as-target/sse-from.ts'; +import { readOpenAIResponsesEvents } from '../../../../src/protocols/openai-responses/as-target/events.ts'; import { appendContent, collectContentViews, createContentState } from '../../../../src/round-trip/reference/reference-builder.ts'; import { finalizeAssistantTurnReferences } from '../../../../src/round-trip/reference/reference-selection.ts'; import { restoreAssistantTurn } from '../../../../src/round-trip/reference/restore.ts'; @@ -46,7 +46,7 @@ const collectProvenance = async (events: readonly OpenAIResponsesStreamEventEx[] for (const event of events) yield eventFrame(event); })(); - for await (const record of irFromOpenAIResponses(frames, { roundTrip: capture.roundTrip })) { + for await (const record of readOpenAIResponsesEvents(frames, { roundTrip: capture.roundTrip })) { if (record.type !== 'write' || record.kind !== 'append' || record.path.at(-1) !== field) continue; appends.push({ text: record.value, ...(record.referencePieceIds === undefined ? {} : { referencePieceIds: record.referencePieceIds }) }); appendContent(capture.builder, content, record.value, record.referencePieceIds); diff --git a/packages/translate/__tests__/ir/snapshot-policy_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts similarity index 97% rename from packages/translate/__tests__/ir/snapshot-policy_test.ts rename to packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts index c93e4ec4ec..8a57aa52e4 100644 --- a/packages/translate/__tests__/ir/snapshot-policy_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts @@ -1,11 +1,13 @@ import { expect, test } from 'vitest'; -import { collect, collectIR, irFromOpenAIResponses, iterate } from './helpers.ts'; -import { createRoundTripReaderCapture } from '../round-trip/test-helpers.ts'; +import { collectIR } from '../../../ir/output/helpers.ts'; +import { createRoundTripReaderCapture } from '../../../round-trip/test-helpers.ts'; +import { collect, iterate } from '../../../shared/iterables.ts'; +import { readOpenAIResponsesEvents } from '../../helpers.ts'; import { eventFrame } from '@floway-dev/protocols/common'; const response = (output: any[] = []) => ({ id: 'resp', model: 'served', created_at: 1, status: 'completed', output, usage: null, error: null, incomplete_details: null }); -const read = async (events: any[]) => await collectIR(irFromOpenAIResponses(iterate(events.map(event => eventFrame(event as any))))); +const read = async (events: any[]) => await collectIR(readOpenAIResponsesEvents(iterate(events.map(event => eventFrame(event as any))))); test.each([-1, 1.5, Number.MAX_SAFE_INTEGER + 1])('output indexes must be nonnegative safe integers: %s', async outputIndex => { await expect(read([ @@ -236,7 +238,7 @@ test('output item snapshots require text in every declared part', async () => { }); test('Responses retains the initial response id when later envelopes rotate it', async () => { - const records = await collect(irFromOpenAIResponses(iterate([ + const records = await collect(readOpenAIResponsesEvents(iterate([ eventFrame({ type: 'response.created', response: response([]) } as any), eventFrame({ type: 'response.in_progress', response: { ...response([]), id: 'resp_rotated' } } as any), eventFrame({ type: 'response.completed', response: { ...response([]), id: 'resp_terminal' } } as any), @@ -284,7 +286,7 @@ test('an empty terminal Responses output captures closed snapshots for round tri const added = { type: 'message', id: 'msg', role: 'assistant', status: 'in_progress', content: [] }; const done = { ...added, status: 'completed', content: [{ type: 'output_text', text: 'answer', annotations: [], logprobs: [] }] }; const capture = createRoundTripReaderCapture<'openaiResponses'>(); - await collectIR(irFromOpenAIResponses(iterate([ + await collectIR(readOpenAIResponsesEvents(iterate([ eventFrame({ type: 'response.created', response: response([]) } as any), eventFrame({ type: 'response.output_item.added', output_index: 0, item: added } as any), eventFrame({ type: 'response.output_item.done', output_index: 0, item: done } as any), diff --git a/packages/translate/__tests__/ir/responses-fixture.ts b/packages/translate/__tests__/protocols/openai-responses/fixtures.ts similarity index 100% rename from packages/translate/__tests__/ir/responses-fixture.ts rename to packages/translate/__tests__/protocols/openai-responses/fixtures.ts diff --git a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts b/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts index d956a51402..c47c4cd04e 100644 --- a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts @@ -1,9 +1,9 @@ import { test } from 'vitest'; import { translateToSourceEvents } from '../../../../src/protocols/openai-responses/via-anthropic-messages/events.ts'; -import { fixtureFrames } from '../../../ir/translation-cases.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; import { emptyNamespaceToolNames } from '../../../round-trip/test-helpers.ts'; +import { fixtureFrames } from '../../fixtures.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; diff --git a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events_test.ts b/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events_test.ts index 9793ccb088..d8873d7dfa 100644 --- a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events_test.ts @@ -1,5 +1,5 @@ import { translateToSourceEvents } from '../../../../src/protocols/openai-responses/via-anthropic-messages/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; import { emptyNamespaceToolNames } from '../../../round-trip/test-helpers.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; testOutputTranslation('openai-responses', 'anthropic-messages', (frames, codec) => translateToSourceEvents(frames, 'resp_test', new Set(), codec, emptyNamespaceToolNames())); diff --git a/packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/events_test.ts b/packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/events_test.ts index 71e8cf633c..c1ffc06400 100644 --- a/packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/events_test.ts @@ -1,5 +1,5 @@ import { translateToSourceEvents } from '../../../../src/protocols/openai-responses/via-openai-chat-completions/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; import { emptyNamespaceToolNames } from '../../../round-trip/test-helpers.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; testOutputTranslation('openai-responses', 'openai-chat-completions', (frames, codec) => translateToSourceEvents(frames, new Set(), codec, emptyNamespaceToolNames())); diff --git a/packages/translate/__tests__/ir/ownership_test.ts b/packages/translate/__tests__/protocols/ownership_test.ts similarity index 80% rename from packages/translate/__tests__/ir/ownership_test.ts rename to packages/translate/__tests__/protocols/ownership_test.ts index 3e98ca9cc0..705ccd72e1 100644 --- a/packages/translate/__tests__/ir/ownership_test.ts +++ b/packages/translate/__tests__/protocols/ownership_test.ts @@ -1,7 +1,9 @@ import { expect, test, vi } from 'vitest'; -import { collect, collectIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, iterate, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; -import { nativeResult } from './translation-cases.ts'; +import { nativeResult } from './fixtures.ts'; +import { writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; +import { collectIR } from '../ir/output/helpers.ts'; +import { collect, iterate } from '../shared/iterables.ts'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; const chunk = (delta: any, extra: any = {}) => eventFrame({ id: 'chat_owner', model: 'served', created: 1, choices: [{ index: 0, delta, finish_reason: null, ...extra }] } as any); @@ -20,10 +22,10 @@ const messagesCitationFrames = (citedText?: string) => [ test('ChatCompletions logprobs retain their text owner when a tool appears in the same chunk', async () => { const token = { token: 'hello', logprob: -0.1, bytes: [104, 101, 108, 108, 111], top_logprobs: [] }; const source = [chunk({ content: 'hello', annotations: [citation(0, 5)], tool_calls: [{ index: 0, id: 'call', type: 'function', function: { name: 'lookup', arguments: '{}' } }] }, { logprobs: { content: [token], refusal: null }, finish_reason: 'tool_calls' }), doneFrame()]; - const ir = await collectIR(irFromOpenAIChatCompletions(iterate(source))); + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate(source))); expect(ir.choices[0].items).toHaveLength(2); expect(ir.choices[0].logprobs).toEqual([{ scope: 'text_part', item_index: 0, content_index: 0, tokens: [token] }]); - const frames = await collect(openaiResponsesFromIR(irFromOpenAIChatCompletions(iterate(source)))); + const frames = await collect(writeOpenAIResponsesEvents(readOpenAIChatCompletionsEvents(iterate(source)))); expect(frames.find(frame => frame.type === 'event' && frame.event.type === 'response.output_text.done')).toMatchObject({ event: { logprobs: [token] } }); const annotation = frames.findIndex(frame => frame.type === 'event' && frame.event.type === 'response.output_text.annotation.added'); const end = frames.findIndex(frame => frame.type === 'event' && frame.event.type === 'response.output_item.done'); @@ -37,10 +39,10 @@ test('ChatCompletions logprobs retain their text owner when a tool appears in th test('ChatCompletions citations rebase cumulative Unicode coordinates across interrupted items', async () => { const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const source = [chunk({ content: 'A😀' }), chunk({ reasoning_text: 'R' }), chunk({ content: '中B' }), chunk({ annotations: [citation(1, 3)] }, { finish_reason: 'stop' }), doneFrame()]; - const ir = await collectIR(irFromOpenAIChatCompletions(iterate(source))); + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate(source))); expect(ir.choices[0].items[0]).toMatchObject({ content: [{ text: 'A😀' }] }); expect(ir.choices[0].items[2]).toMatchObject({ content: [{ text: '中B', annotations: [{ output_text_range: { start: 0, end_exclusive: 1 } }] }] }); - const result = await nativeResult('openai-responses', await collect(openaiResponsesFromIR(irFromOpenAIChatCompletions(iterate(source))))); + const result = await nativeResult('openai-responses', await collect(writeOpenAIResponsesEvents(readOpenAIChatCompletionsEvents(iterate(source))))); expect(result.output[2].content[0].annotations).toEqual([{ type: 'url_citation', url: 'https://example.com', title: 'Source', start_index: 0, end_index: 1 }]); expect(warning).toHaveBeenCalledWith('Clamping ChatCompletions citation to open text', expect.objectContaining({ choice: 0 })); warning.mockRestore(); @@ -56,7 +58,7 @@ test('Responses converts only new citations after indexed text keeps streaming', chunk({ annotations: [first, second] }, { finish_reason: 'stop' }), doneFrame(), ]; - const frames = await collect(openaiResponsesFromIR(irFromOpenAIChatCompletions(iterate(source)))); + const frames = await collect(writeOpenAIResponsesEvents(readOpenAIChatCompletionsEvents(iterate(source)))); const annotations = frames.flatMap(frame => frame.type === 'event' && frame.event.type === 'response.output_text.annotation.added' ? [frame.event] : []); expect(annotations.map(event => event.annotation_index)).toEqual([0, 1]); expect(annotations.map(event => event.annotation)).toEqual([ @@ -68,7 +70,7 @@ test('Responses converts only new citations after indexed text keeps streaming', test('late ChatCompletions citations on closed text are dropped', async () => { const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const source = [chunk({ content: 'A😀' }), chunk({ reasoning_text: 'R' }), chunk({ content: '中B' }), chunk({ reasoning_text: 'tail' }), chunk({ annotations: [citation(1, 3)] }, { finish_reason: 'stop' }), doneFrame()]; - const ir = await collectIR(irFromOpenAIChatCompletions(iterate(source))); + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate(source))); expect(ir.choices[0].items.filter(item => item.type === 'message').every(item => item.content.every(part => part.type !== 'text' || part.annotations === undefined))).toBe(true); expect(warning).toHaveBeenCalledWith('Dropping ChatCompletions citation outside open text', expect.objectContaining({ choice: 0 })); warning.mockRestore(); @@ -76,34 +78,34 @@ test('late ChatCompletions citations on closed text are dropped', async () => { test('ChatCompletions rejects a reversed citation before clamping it to available text', async () => { const source = [chunk({ content: 'abc' }), chunk({ annotations: [citation(5, 3)] }, { finish_reason: 'stop' }), doneFrame()]; - await expect(collectIR(irFromOpenAIChatCompletions(iterate(source)))).rejects.toThrow('Invalid code-point range'); + await expect(collectIR(readOpenAIChatCompletionsEvents(iterate(source)))).rejects.toThrow('Invalid code-point range'); }); test('Messages cited text remains source text without inventing output coordinates', async () => { - const ir = await collectIR(irFromAnthropicMessages(iterate(messagesCitationFrames('source quote')))); + const ir = await collectIR(readAnthropicMessagesEvents(iterate(messagesCitationFrames('source quote')))); const item = ir.choices[0].items[0]; if (item.type !== 'message' || item.content[0].type !== 'text') throw new Error('expected cited text part'); expect(item.content[0].annotations?.[0]).toMatchObject({ source_text: 'source quote' }); expect(item.content[0].annotations?.[0]).not.toHaveProperty('output_text_range'); - const result = await nativeResult('openai-responses', await collect(openaiResponsesFromIR(irFromAnthropicMessages(iterate(messagesCitationFrames('source quote')))))); + const result = await nativeResult('openai-responses', await collect(writeOpenAIResponsesEvents(readAnthropicMessagesEvents(iterate(messagesCitationFrames('source quote')))))); expect(result.output[0].content[0].annotations).toEqual([]); }); test('Messages URL citations without cited text do not expand to the entire Responses text', async () => { - const ir = await collectIR(irFromAnthropicMessages(iterate(messagesCitationFrames()))); + const ir = await collectIR(readAnthropicMessagesEvents(iterate(messagesCitationFrames()))); const item = ir.choices[0].items[0]; expect(item).toMatchObject({ content: [{ annotations: [{ source: 'https://docs.example.com/page-1' }] }] }); if (item.type !== 'message' || item.content[0].type !== 'text') throw new Error('expected cited text part'); expect(item.content[0].annotations?.[0]).not.toHaveProperty('output_text_range'); - const result = await nativeResult('openai-responses', await collect(openaiResponsesFromIR(irFromAnthropicMessages(iterate(messagesCitationFrames()))))); + const result = await nativeResult('openai-responses', await collect(writeOpenAIResponsesEvents(readAnthropicMessagesEvents(iterate(messagesCitationFrames()))))); expect(result.output[0].content[0].annotations).toEqual([]); }); test('range-less Messages citations do not expand to whole-part ChatCompletions or GenerateContent citations', async () => { - const chatFrames = await collect(openaiChatCompletionsFromIR(irFromAnthropicMessages(iterate(messagesCitationFrames('source quote'))), { id: 'chat_target', created: 1 })); + const chatFrames = await collect(writeOpenAIChatCompletionsEvents(readAnthropicMessagesEvents(iterate(messagesCitationFrames('source quote'))), { id: 'chat_target', created: 1 })); expect(chatFrames.some(frame => frame.type === 'event' && !('error' in frame.event) && frame.event.choices.some(choice => 'annotations' in choice.delta))).toBe(false); - const generateContentFrames = await collect(geminiGenerateContentFromIR(irFromAnthropicMessages(iterate(messagesCitationFrames('source quote'))))); + const generateContentFrames = await collect(writeGeminiGenerateContentEvents(readAnthropicMessagesEvents(iterate(messagesCitationFrames('source quote'))))); expect(generateContentFrames.some(frame => frame.type === 'event' && 'candidates' in frame.event && frame.event.candidates?.some(candidate => candidate.groundingMetadata !== undefined))).toBe(false); }); diff --git a/packages/translate/__tests__/protocols/round-trip-receive_test.ts b/packages/translate/__tests__/protocols/round-trip-receive_test.ts index ef74461ed8..deacc16be5 100644 --- a/packages/translate/__tests__/protocols/round-trip-receive_test.ts +++ b/packages/translate/__tests__/protocols/round-trip-receive_test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; -import type { IRJSONObject } from '../../src/ir/types.ts'; +import type { IRJSONObject } from '../../src/ir/common/json.ts'; import { computeAnthropicMessagesTurnDigest } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; import { computeGeminiGenerateContentTurnDigest } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; import { computeOpenAIChatCompletionsTurnDigest } from '../../src/protocols/openai-chat-completions/as-source/round-trip.ts'; diff --git a/packages/translate/__tests__/protocols/routes_test.ts b/packages/translate/__tests__/protocols/routes_test.ts index 129b7a61c5..9c462a803d 100644 --- a/packages/translate/__tests__/protocols/routes_test.ts +++ b/packages/translate/__tests__/protocols/routes_test.ts @@ -1,5 +1,6 @@ import { test } from 'vitest'; +import { fixtureFrames, type nativeResult } from './fixtures.ts'; import { partitionAnthropicMessagesTurns } from '../../src/protocols/anthropic-messages/as-source/conversation-turns.ts'; import { findAnthropicMessagesSidecarCarriers } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; import { findGeminiGenerateContentSidecarCarriers } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; @@ -11,9 +12,8 @@ import { findOpenAIResponsesSidecarCarriers } from '../../src/protocols/openai-r import { toOpenAIResponsesInputAssistantTurn } from '../../src/protocols/openai-responses/as-target/assistant-turn.ts'; import { decodeRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; import { requireJSONObject } from '../../src/shared/json/object.ts'; -import { collect, iterate } from '../ir/helpers.ts'; -import { fixtureFrames, type nativeResult } from '../ir/translation-cases.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { collect, iterate } from '../shared/iterables.ts'; import { collectAnthropicMessagesProtocolEventsToResult, type AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; import { collectGeminiGenerateContentProtocolEventsToResult, type GeminiGenerateContentContent, type GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; import { collectOpenAIChatCompletionsProtocolEventsToResult, type OpenAIChatCompletionsAssistantMessageEx, type OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; diff --git a/packages/translate/__tests__/ir/streams_test.ts b/packages/translate/__tests__/protocols/streams_test.ts similarity index 85% rename from packages/translate/__tests__/ir/streams_test.ts rename to packages/translate/__tests__/protocols/streams_test.ts index 7040a5fdb2..28128aae4b 100644 --- a/packages/translate/__tests__/ir/streams_test.ts +++ b/packages/translate/__tests__/protocols/streams_test.ts @@ -1,13 +1,16 @@ import { describe, expect, it } from 'vitest'; -import { anthropicMessagesFromIR, collectIR, collect, completeIR, events, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, iterate, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; -import { createIRBuilder } from '../../src/ir/stream.ts'; -import type { IRItem, IRJSONObject } from '../../src/ir/types.ts'; +import { writeAnthropicMessagesEvents, writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; +import type { IRJSONObject } from '../../src/ir/common/json.ts'; +import { createIRBuilder } from '../../src/ir/output/builder.ts'; +import type { IROutputItem } from '../../src/ir/output/types.ts'; import { usageFromIR as messagesUsageFromIR } from '../../src/protocols/anthropic-messages/as-source/usage.ts'; import { usageToIR as messagesUsageToIR } from '../../src/protocols/anthropic-messages/as-target/usage.ts'; import { usageFromIR as responsesUsageFromIR } from '../../src/protocols/openai-responses/as-source/usage.ts'; import { usageToIR as responsesUsageToIR } from '../../src/protocols/openai-responses/as-target/usage.ts'; import { TextCoordinateIndex } from '../../src/shared/text-coordinate-index.ts'; +import { collectIR, completeIR } from '../ir/output/helpers.ts'; +import { collect, events, iterate } from '../shared/iterables.ts'; import { reassembleAnthropicMessagesEvents, type AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; import { reassembleGeminiGenerateContentEvents } from '@floway-dev/protocols/gemini-generate-content'; @@ -16,7 +19,7 @@ import { openaiResponsesResultToEvents, reassembleOpenAIResponsesEvents, type Op const options = { id: 'target', created: 1 }; const chatFrame = (choices: any[], usage?: any) => eventFrame({ id: 'source', object: 'chat.completion.chunk', model: 'model', created: 1, choices, ...(usage === undefined ? {} : { usage }) } as OpenAIChatCompletionsStreamEvent); -const chat = () => irFromOpenAIChatCompletions(iterate([ +const chat = () => readOpenAIChatCompletionsEvents(iterate([ chatFrame([{ index: 0, delta: { reasoning_text: 'think' } }]), chatFrame([{ index: 0, delta: { content: 'A😀' } }]), chatFrame([{ index: 0, delta: { content: '中B', annotations: [{ type: 'url_citation', url_citation: { start_index: 1, end_index: 3, url: 'https://example.com', title: 'Source' } }] } }]), @@ -32,8 +35,8 @@ const responseFrames = () => iterate([...openaiResponsesResultToEvents({ { type: 'function_call', id: 'f', call_id: 'call', name: 'tool', arguments: '{"a":1}', status: 'completed' }, ], usage: { input_tokens: 11, output_tokens: 7, total_tokens: 18, input_tokens_details: { cached_tokens: 3 }, output_tokens_details: { reasoning_tokens: 2 } }, } as OpenAIResponsesResultEx), doneFrame()]); -const responses = () => irFromOpenAIResponses(responseFrames()); -const messages = () => irFromAnthropicMessages(iterate([ +const responses = () => readOpenAIResponsesEvents(responseFrames()); +const messages = () => readAnthropicMessagesEvents(iterate([ { type: 'message_start', message: { id: 'source', type: 'message', role: 'assistant', model: 'model', content: [], usage: { input_tokens: 6, output_tokens: 0, cache_read_input_tokens: 3, cache_creation_input_tokens: 2 } } }, { type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '', signature: '' } }, { type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'think' } }, @@ -52,11 +55,11 @@ const messages = () => irFromAnthropicMessages(iterate([ ].map(value => eventFrame(value as AnthropicMessagesStreamEventEx)))); const translateChatFrames = async (nativeFrames: readonly any[]) => { - const irFrames = await collect(irFromOpenAIChatCompletions(iterate(nativeFrames as any[]))); - const chatFrames = await collect(openaiChatCompletionsFromIR(iterate(irFrames))); - const responsesFrames = await collect(openaiResponsesFromIR(iterate(irFrames))); - const messagesFrames = await collect(anthropicMessagesFromIR(iterate(irFrames))); - const generateContentFrames = await collect(geminiGenerateContentFromIR(iterate(irFrames))); + const irFrames = await collect(readOpenAIChatCompletionsEvents(iterate(nativeFrames as any[]))); + const chatFrames = await collect(writeOpenAIChatCompletionsEvents(iterate(irFrames))); + const responsesFrames = await collect(writeOpenAIResponsesEvents(iterate(irFrames))); + const messagesFrames = await collect(writeAnthropicMessagesEvents(iterate(irFrames))); + const generateContentFrames = await collect(writeGeminiGenerateContentEvents(iterate(irFrames))); return { irFrames, chatFrames, @@ -87,13 +90,13 @@ describe('IR streaming', () => { for (const [name, source] of [['ChatCompletions', chat], ['Responses', responses], ['Messages', messages]] as const) { it(`ChatCompletions via ${name} emits text and streamed function arguments`, async () => { - const result = await reassembleOpenAIChatCompletionsEvents(events(openaiChatCompletionsFromIR(source(), options))); + const result = await reassembleOpenAIChatCompletionsEvents(events(writeOpenAIChatCompletionsEvents(source(), options))); expect(result.choices[0].message.content).toBe('A😀中B'); expect(result.choices[0].message.tool_calls?.[0]).toMatchObject({ type: 'function', function: { name: 'tool', arguments: '{"a":1}' } }); expect(result.usage?.prompt_tokens).toBe(11); }); it(`Responses via ${name} completes item lifecycles`, async () => { - const frames = await collect(openaiResponsesFromIR(source(), options)); + const frames = await collect(writeOpenAIResponsesEvents(source(), options)); const result = await reassembleOpenAIResponsesEvents(events(iterate(frames))); expect(result.output.filter(item => item.type === 'message').flatMap(item => item.type === 'message' ? item.content.map(p => p.type === 'output_text' ? p.text : '') : []).join('')).toBe('A😀中B'); if (name === 'Responses') { @@ -109,13 +112,13 @@ describe('IR streaming', () => { expect(frames.at(-1)?.type).toBe('event'); }); it(`Messages via ${name} serializes blocks and parses tool input`, async () => { - const result = await reassembleAnthropicMessagesEvents(events(anthropicMessagesFromIR(source(), options))); + const result = await reassembleAnthropicMessagesEvents(events(writeAnthropicMessagesEvents(source(), options))); expect(result.content.filter(block => block.type === 'text').map(block => block.type === 'text' ? block.text : '').join('')).toBe('A😀中B'); expect(result.content.find(block => block.type === 'tool_use')).toMatchObject({ name: 'tool', input: { a: 1 } }); expect(result.usage.output_tokens).toBe(7); }); it(`GenerateContent via ${name} emits complete object args and final usage`, async () => { - const frames = await collect(geminiGenerateContentFromIR(source(), options)); + const frames = await collect(writeGeminiGenerateContentEvents(source(), options)); const result = await reassembleGeminiGenerateContentEvents(events(iterate(frames))); expect(result.candidates?.[0].content?.parts?.filter(p => p.text !== undefined && !p.thought).map(p => p.text).join('')).toBe('A😀中B'); expect(result.candidates?.[0].content?.parts?.find(p => p.functionCall !== undefined)?.functionCall).toMatchObject({ name: 'tool', args: { a: 1 } }); @@ -190,10 +193,10 @@ describe('IR streaming', () => { const value = await collectIR(chat()); const item = value.choices[0].items.find(i => i.type === 'message'); expect(item?.type === 'message' && item.content[0].type === 'text' && item.content[0].annotations?.[0].output_text_range).toEqual({ start: 1, end_exclusive: 4 }); - expect(TextCoordinateIndex.codePointsToUTF16('A😀中B').codePointRangeToIR(1, 3)).toEqual({ start: 1, end_exclusive: 4 }); + expect(TextCoordinateIndex.codePointsToUTF16('A😀中B').codePointRangeToUTF16(1, 3)).toEqual({ start: 1, end_exclusive: 4 }); const index = TextCoordinateIndex.utf16ToCodePoints('A😀中B'); - expect(index.irRangeToCodePoints({ start: 1, end_exclusive: 4 })).toEqual({ start: 1, end: 3 }); - expect(() => index.irRangeToCodePoints({ start: 2, end_exclusive: 4 })).toThrow(); + expect(index.utf16RangeToCodePoints({ start: 1, end_exclusive: 4 })).toEqual({ start: 1, end: 3 }); + expect(() => index.utf16RangeToCodePoints({ start: 2, end_exclusive: 4 })).toThrow(); }); it('delivers text before pulling the upstream completion', async () => { @@ -203,7 +206,7 @@ describe('IR streaming', () => { exhausted = true; yield chatFrame([{ index: 0, delta: {}, finish_reason: 'stop' }]); yield doneFrame(); }; - const stream = openaiChatCompletionsFromIR(irFromOpenAIChatCompletions(upstream()), options); + const stream = writeOpenAIChatCompletionsEvents(readOpenAIChatCompletionsEvents(upstream()), options); let found = false; for await (const frame of stream) if (frame.type === 'event' && !('error' in frame.event) && frame.event.choices[0]?.delta.content === 'first') { expect(exhausted).toBe(false); found = true; break; @@ -212,16 +215,16 @@ describe('IR streaming', () => { }); it('rejects missing terminals and unsafe replacement on an append-only downstream', async () => { - await expect(collectIR(irFromOpenAIChatCompletions(iterate([chatFrame([{ index: 0, delta: { content: 'x' } }])])))).rejects.toThrow('without finish_reason'); + await expect(collectIR(readOpenAIChatCompletionsEvents(iterate([chatFrame([{ index: 0, delta: { content: 'x' } }])])))).rejects.toThrow('without finish_reason'); const frames = completeIR([{ type: 'message', content: [{ type: 'text', text: 'a' }] }]); const part = frames.findIndex(record => record.type === 'write' && record.kind === 'initialize' && record.path.length === 6); frames.splice(part + 1, 0, { type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'b' }); - await expect(collect(openaiChatCompletionsFromIR(iterate(frames), options))).rejects.toThrow('append target cannot be assigned'); + await expect(collect(writeOpenAIChatCompletionsEvents(iterate(frames), options))).rejects.toThrow('append target cannot be assigned'); }); it('handles Responses terminal-only output and implicit audio events', async () => { const result: OpenAIResponsesResultEx = { id: 's', object: 'response', model: 'm', status: 'completed', output: [], error: null, incomplete_details: null }; - const source = irFromOpenAIResponses(iterate([ + const source = readOpenAIResponsesEvents(iterate([ eventFrame({ type: 'response.created', response: result } as OpenAIResponsesStreamEventEx), eventFrame({ type: 'response.audio.delta', delta: 'YWJj' } as OpenAIResponsesStreamEventEx), eventFrame({ type: 'response.audio.transcript.delta', delta: 'abc' } as OpenAIResponsesStreamEventEx), @@ -231,7 +234,7 @@ describe('IR streaming', () => { ])); const ir = await collectIR(source); expect(ir.choices[0].items[0]).toMatchObject({ type: 'message', content: [{ type: 'audio', audio: { data: 'YWJj', transcript: 'abc' } }] }); - const out = await collect(events(openaiResponsesFromIR(iterate(completeIR(ir.choices[0].items)), options))); + const out = await collect(events(writeOpenAIResponsesEvents(iterate(completeIR(ir.choices[0].items)), options))); expect(out).toContainEqual(expect.objectContaining({ type: 'response.audio.delta', delta: 'YWJj' })); }); @@ -257,7 +260,7 @@ describe('IR streaming', () => { b.event({ type: 'finish', status: 'completed' }); yield* b.drain(); }; - const output = events(geminiGenerateContentFromIR(source(), options)); + const output = events(writeGeminiGenerateContentEvents(source(), options)); const result = []; for (const [choice, text] of ['a', 'b'].entries()) { const body = await output.next(); @@ -289,7 +292,7 @@ describe('IR streaming', () => { }); it('ends at candidate termination without emitting an empty final frame when usage is absent', async () => { - const frames = await collect(events(geminiGenerateContentFromIR(iterate(completeIR([{ type: 'message', content: [{ type: 'text', text: 'answer' }] }])), options))); + const frames = await collect(events(writeGeminiGenerateContentEvents(iterate(completeIR([{ type: 'message', content: [{ type: 'text', text: 'answer' }] }])), options))); expect(frames).toEqual([ { responseId: 'target', modelVersion: 'model', candidates: [{ index: 0, content: { role: 'model', parts: [{ text: 'answer' }] } }] }, { responseId: 'target', modelVersion: 'model', candidates: [{ index: 0, content: { role: 'model', parts: [{ thoughtSignature: 'test-sidecar' }] } }] }, @@ -308,7 +311,7 @@ describe('IR streaming', () => { b.event({ type: 'choice_end', choice: 0, finish_reason: 'length' }); b.event({ type: 'choice_end', choice: 1, finish_reason: 'stop' }); b.event({ type: 'finish', status: 'incomplete' }); - await expect(collect(geminiGenerateContentFromIR(iterate(b.drain()), options))).rejects.toThrow(); + await expect(collect(writeGeminiGenerateContentEvents(iterate(b.drain()), options))).rejects.toThrow(); }); it('preserves GenerateContent item order when a tool delays later text and audio', async () => { @@ -324,7 +327,7 @@ describe('IR streaming', () => { b.event({ type: 'part_end', choice: 0, item: 1, part: 0 }); b.event({ type: 'item_end', choice: 0, item: 1 }); b.initialize(['choices', 0, 'items', 2], { type: 'message', content: [] }); - b.initialize(['choices', 0, 'items', 2, 'content', 0], { type: 'audio', audio: { mime_type: 'audio/wav' } }); + b.initialize(['choices', 0, 'items', 2, 'content', 0], { type: 'audio', audio: { media_type: 'audio/wav' } }); b.initialize(['choices', 0, 'items', 2, 'content', 0, 'audio', 'data'], ''); b.append(['choices', 0, 'items', 2, 'content', 0, 'audio', 'data'], 'YQ=='); b.event({ type: 'part_end', choice: 0, item: 2, part: 0 }); @@ -333,7 +336,7 @@ describe('IR streaming', () => { b.event({ type: 'choice_end', choice: 0, finish_reason: 'tool_calls' }); b.event({ type: 'finish', status: 'completed' }); - const result = await reassembleGeminiGenerateContentEvents(events(geminiGenerateContentFromIR(iterate(b.drain()), options))); + const result = await reassembleGeminiGenerateContentEvents(events(writeGeminiGenerateContentEvents(iterate(b.drain()), options))); expect(result.candidates?.[0].content?.parts).toEqual([ { functionCall: { id: 'call', name: 'tool', args: {} } }, { text: 'after tool' }, @@ -349,18 +352,18 @@ describe('IR streaming', () => { it('uses target ChatCompletions audio replay metadata across deltas', async () => { const frames = [chatFrame([{ index: 0, delta: { audio: { id: 'audio', expires_at: 123, data: 'YQ==' } } }]), chatFrame([{ index: 0, delta: { audio: { transcript: 'word' } } }]), chatFrame([{ index: 0, delta: {}, finish_reason: 'stop' }]), doneFrame()]; - const result = await reassembleOpenAIChatCompletionsEvents(events(openaiChatCompletionsFromIR(irFromOpenAIChatCompletions(iterate(frames)), { ...options, audioMetadata: () => ({ id: 'target-audio', expires_at: 456 }) }))); + const result = await reassembleOpenAIChatCompletionsEvents(events(writeOpenAIChatCompletionsEvents(readOpenAIChatCompletionsEvents(iterate(frames)), { ...options, audioMetadata: () => ({ id: 'target-audio', expires_at: 456 }) }))); expect(result.choices[0].message.audio).toEqual({ id: 'target-audio', expires_at: 456, data: 'YQ==', transcript: 'word' }); }); it('retains text-image-text order in Responses output', async () => { - const frames = openaiResponsesFromIR(iterate(completeIR([{ type: 'message', content: [{ type: 'text', text: 'before' }, { type: 'image', image: { data: 'YQ==', mime_type: 'image/png' } }, { type: 'text', text: 'after' }] }])), options); + const frames = writeOpenAIResponsesEvents(iterate(completeIR([{ type: 'message', content: [{ type: 'text', text: 'before' }, { type: 'image', image: { data: 'YQ==', media_type: 'image/png' } }, { type: 'text', text: 'after' }] }])), options); const result = await reassembleOpenAIResponsesEvents(events(frames)); expect(result.output.map(item => item.type)).toEqual(['message', 'image_generation_call', 'message', 'reasoning']); }); it('marks ChatCompletions length termination incomplete and exposes IR errors', async () => { - const frames = await collect(irFromOpenAIChatCompletions(iterate([chatFrame([{ index: 0, delta: { content: 'text' }, finish_reason: 'length' }]), doneFrame()]))); + const frames = await collect(readOpenAIChatCompletionsEvents(iterate([chatFrame([{ index: 0, delta: { content: 'text' }, finish_reason: 'length' }]), doneFrame()]))); expect(frames.find(record => record.type === 'finish')).toMatchObject({ status: 'incomplete' }); await expect(collectIR(iterate([{ type: 'error', error: { message: 'failure' } }]))).rejects.toThrow(); }); @@ -371,7 +374,7 @@ describe('IR streaming', () => { const resultImage = { ...image, status: success ? 'completed' : 'failed', result: success ? 'YQ==' : null }; const response: any = { id: 's', model: 'm', output: [resultImage, message], usage: null }; const input: any[] = [{ type: 'response.created', response: { ...response, output: [] } }, { type: 'response.output_item.added', output_index: 0, item: image }, { type: 'response.output_item.added', output_index: 1, item: message }, { type: 'response.output_item.done', output_index: 1, item: message }, { type: 'response.output_item.done', output_index: 0, item: resultImage }, { type: 'response.completed', response }]; - const frames = await collect(irFromOpenAIResponses(iterate(input.map(eventFrame)))); + const frames = await collect(readOpenAIResponsesEvents(iterate(input.map(eventFrame)))); const ir = await collectIR(iterate(frames)); const parts = ir.choices[0].items.flatMap(item => item.type === 'message' ? item.content : []); expect(parts.map(part => part.type)).toEqual(success ? ['image', 'text'] : ['text']); @@ -382,12 +385,12 @@ describe('IR streaming', () => { const item: any = { type: 'message', id: 'msg', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: 'text', annotations: [] }] }; const response: any = { id: 's', model: 'm', status: 'completed', output: [item], usage: null }; const input: any[] = [{ type: 'response.created', response: { ...response, output: [] } }, { type: 'response.output_item.added', output_index: 0, item: { ...item, status: 'in_progress', content: [] } }, { type: 'response.content_part.added', output_index: 0, content_index: 0, part: { type: 'output_text', text: '', annotations: [] } }, { type: 'response.output_text.delta', output_index: 0, content_index: 0, delta: 'text' }, { type: 'response.content_part.done', output_index: 0, content_index: 0, part: item.content[0] }, { type: 'response.output_item.done', output_index: 0, item }, { type: 'response.completed', response }]; - const records = await collect(irFromOpenAIResponses(iterate(input.map(eventFrame)))); + const records = await collect(readOpenAIResponsesEvents(iterate(input.map(eventFrame)))); expect(records.filter(record => record.type === 'part_end')).toEqual([{ type: 'part_end', choice: 0, item: 0, part: 0 }]); expect(records.findIndex(record => record.type === 'part_end')).toBeLessThan(records.findIndex(record => record.type === 'item_end')); }); - it.each([['Messages', anthropicMessagesFromIR, reassembleAnthropicMessagesEvents], ['GenerateContent', geminiGenerateContentFromIR, reassembleGeminiGenerateContentEvents]] as const)('allows configured JSON repair in %s', async (_name, adapter, reassemble) => { + it.each([['Messages', writeAnthropicMessagesEvents, reassembleAnthropicMessagesEvents], ['GenerateContent', writeGeminiGenerateContentEvents, reassembleGeminiGenerateContentEvents]] as const)('allows configured JSON repair in %s', async (_name, adapter, reassemble) => { const frames = completeIR([{ type: 'function_call', name: 'tool', call_id: 'c', arguments: '{"a":1' }]); const output = adapter(iterate(frames), { ...options, parseToolArguments: text => JSON.parse(`${text}}`) }); const result: any = await (reassemble as any)(events(output)); @@ -396,7 +399,7 @@ describe('IR streaming', () => { }); it('waits for function names before Responses item creation', async () => { - const frames = await collect(openaiResponsesFromIR(chat(), options)); + const frames = await collect(writeOpenAIResponsesEvents(chat(), options)); const added = frames.flatMap(frame => frame.type === 'event' && frame.event.type === 'response.output_item.added' && frame.event.item.type === 'function_call' ? [frame.event.item] : []); expect(added).toHaveLength(1); expect(added[0]).toMatchObject({ name: 'tool', call_id: 'call' }); @@ -412,14 +415,14 @@ describe('IR streaming', () => { { type: 'write', kind: 'initialize', path: ['choices', 0, 'logprobs'], value: [] }, { type: 'write', kind: 'initialize', path: ['choices', 0, 'logprobs', 0], value: { scope: 'text_part', item_index: 0, content_index: 0, tokens: [] } }, ...tokens.map((value, index) => ({ type: 'write' as const, kind: 'assign' as const, path: ['choices', 0, 'logprobs', 0, 'tokens', index], value }))); - const frames = await collect(openaiResponsesFromIR(iterate(records), options)); + const frames = await collect(writeOpenAIResponsesEvents(iterate(records), options)); const emitted = frames.flatMap(frame => frame.type === 'event' && frame.event.type === 'response.output_text.delta' ? frame.event.logprobs ?? [] : []); expect(emitted).toEqual(tokens); const result = await reassembleOpenAIResponsesEvents(events(iterate(frames))); expect(result.output[0]).toMatchObject({ content: [{ logprobs: tokens }] }); }); - it.each<{ items: readonly IRItem[] }>([{ items: [] }, { items: [{ type: 'reasoning', content: ['thought'] }] }])('exposes invalid logprob item ownership %j', async ({ items }) => { + it.each<{ items: readonly IROutputItem[] }>([{ items: [] }, { items: [{ type: 'reasoning', content: ['thought'] }] }])('exposes invalid logprob item ownership %j', async ({ items }) => { const frames = completeIR(items); const end = frames.findIndex(record => record.type === 'choice_end'); const logprobs = [ @@ -428,7 +431,7 @@ describe('IR streaming', () => { { type: 'write', kind: 'assign', path: ['choices', 0, 'logprobs', 0, 'tokens', 0], value: { token: 'x', logprob: -1 } }, ] as const; frames.splice(end, 0, ...logprobs); - await expect(collect(openaiChatCompletionsFromIR(iterate(frames), options))).rejects.toThrow(); + await expect(collect(writeOpenAIChatCompletionsEvents(iterate(frames), options))).rejects.toThrow(); }); it('uses the same lifecycle validation when collecting IR', async () => { @@ -438,7 +441,7 @@ describe('IR streaming', () => { }); it('preserves Responses generated image format', async () => { - const result = await reassembleOpenAIResponsesEvents(events(openaiResponsesFromIR(iterate(completeIR([{ type: 'message', content: [{ type: 'image', image: { data: 'YQ==', mime_type: 'image/jpeg' } }] }])), options))); + const result = await reassembleOpenAIResponsesEvents(events(writeOpenAIResponsesEvents(iterate(completeIR([{ type: 'message', content: [{ type: 'image', image: { data: 'YQ==', media_type: 'image/jpeg' } }] }])), options))); expect(result.output[0]).toMatchObject({ type: 'image_generation_call', output_format: 'jpeg' }); }); @@ -447,36 +450,36 @@ describe('IR streaming', () => { const transcript = { type: 'response.audio.transcript.delta', delta: 'hello' }; const response = { id: 's', model: 'm', output: [] }; const input: any[] = [{ type: 'response.created', response }, ...(transcriptFirst ? [transcript, data] : [data, transcript]), { type: 'response.audio.done' }, { type: 'response.audio.transcript.done' }, { type: 'response.completed', response }]; - const source = () => irFromOpenAIResponses(iterate(input.map(eventFrame))); - const messages = await reassembleAnthropicMessagesEvents(events(anthropicMessagesFromIR(source(), options))); + const source = () => readOpenAIResponsesEvents(iterate(input.map(eventFrame))); + const messages = await reassembleAnthropicMessagesEvents(events(writeAnthropicMessagesEvents(source(), options))); expect(messages.content.filter(block => block.type !== 'redacted_thinking')).toMatchObject([{ type: 'text', text: 'hello' }]); - const chat = await reassembleOpenAIChatCompletionsEvents(events(openaiChatCompletionsFromIR(source(), { ...options, audioMetadata: () => ({ id: 'a', expires_at: 123 }) }))); + const chat = await reassembleOpenAIChatCompletionsEvents(events(writeOpenAIChatCompletionsEvents(source(), { ...options, audioMetadata: () => ({ id: 'a', expires_at: 123 }) }))); expect(chat.choices[0].message.audio).toEqual({ id: 'a', expires_at: 123, data: 'YQ==', transcript: 'hello' }); expect(chat.choices[0].message.content).toBeNull(); - await expect(reassembleGeminiGenerateContentEvents(events(geminiGenerateContentFromIR(source(), options)))) + await expect(reassembleGeminiGenerateContentEvents(events(writeGeminiGenerateContentEvents(source(), options)))) .rejects.toThrow('GenerateContent inline data requires a MIME type'); }); it('emits a transcript-only audio part once in GenerateContent', async () => { - const result = await reassembleGeminiGenerateContentEvents(events(geminiGenerateContentFromIR(iterate(completeIR([{ type: 'message', content: [{ type: 'audio', audio: { transcript: 'hello' } }] }])), options))); + const result = await reassembleGeminiGenerateContentEvents(events(writeGeminiGenerateContentEvents(iterate(completeIR([{ type: 'message', content: [{ type: 'audio', audio: { transcript: 'hello' } }] }])), options))); expect(result.candidates?.[0].content?.parts).toEqual([{ text: 'hello' }, { thoughtSignature: 'test-sidecar' }]); }); it('emits transcript-only audio before ChatCompletions finish_reason', async () => { - const frames = await collect(openaiChatCompletionsFromIR(iterate(completeIR([{ type: 'message', content: [{ type: 'audio', audio: { transcript: 'hello' } }] }])), options)); + const frames = await collect(writeOpenAIChatCompletionsEvents(iterate(completeIR([{ type: 'message', content: [{ type: 'audio', audio: { transcript: 'hello' } }] }])), options)); const chunks = frames.flatMap(frame => frame.type === 'event' && !('error' in frame.event) ? frame.event.choices : []); expect(chunks.findIndex(choice => choice.delta.content === 'hello')).toBeLessThan(chunks.findIndex(choice => choice.finish_reason === 'stop')); }); - it.each([['Responses', openaiResponsesFromIR], ['Messages', anthropicMessagesFromIR]] as const)('rejects completed unnamed tools in %s', async (_name, adapter) => { + it.each([['Responses', writeOpenAIResponsesEvents], ['Messages', writeAnthropicMessagesEvents]] as const)('rejects completed unnamed tools in %s', async (_name, adapter) => { await expect(collect(adapter(iterate(completeIR([{ type: 'function_call', name: '', arguments: '{}' }])), options))).rejects.toThrow('require a name'); }); it.each([ - ['ChatCompletions', openaiChatCompletionsFromIR], - ['Responses', openaiResponsesFromIR], - ['Messages', anthropicMessagesFromIR], - ['GenerateContent', geminiGenerateContentFromIR], + ['ChatCompletions', writeOpenAIChatCompletionsEvents], + ['Responses', writeOpenAIResponsesEvents], + ['Messages', writeAnthropicMessagesEvents], + ['GenerateContent', writeGeminiGenerateContentEvents], ] as const)('%s rejects IR start without a model', async (_name, adapter) => { await expect(collect(adapter(iterate([{ type: 'start', id: 'source' }] as any), options))).rejects.toThrow('requires an IR model'); }); diff --git a/packages/translate/__tests__/ir/usage_test.ts b/packages/translate/__tests__/protocols/usage_test.ts similarity index 81% rename from packages/translate/__tests__/ir/usage_test.ts rename to packages/translate/__tests__/protocols/usage_test.ts index 3067b168c4..8ddcc21aaa 100644 --- a/packages/translate/__tests__/ir/usage_test.ts +++ b/packages/translate/__tests__/protocols/usage_test.ts @@ -1,20 +1,23 @@ import { expect, test } from 'vitest'; -import { collectIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, iterate, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; -import { fixtureFrames } from './translation-cases.ts'; +import { fixtureFrames } from './fixtures.ts'; +import { writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; import { usageFromIR as messagesUsageFromIR } from '../../src/protocols/anthropic-messages/as-source/usage.ts'; import { usageToIR as messagesUsageToIR } from '../../src/protocols/anthropic-messages/as-target/usage.ts'; import { usageFromIR } from '../../src/protocols/gemini-generate-content/as-source/usage.ts'; import { usageFromIR as chatUsageFromIR } from '../../src/protocols/openai-chat-completions/as-source/usage.ts'; import { usageToIR as chatUsageToIR } from '../../src/protocols/openai-chat-completions/as-target/usage.ts'; import { usageFromIR as responsesUsageFromIR } from '../../src/protocols/openai-responses/as-source/usage.ts'; -import { usageToIR as responsesUsageToIR } from '../../src/protocols/openai-responses/as-target/usage.ts';import { eventFrame } from '@floway-dev/protocols/common'; +import { usageToIR as responsesUsageToIR } from '../../src/protocols/openai-responses/as-target/usage.ts'; +import { collectIR } from '../ir/output/helpers.ts'; +import { iterate } from '../shared/iterables.ts'; +import { eventFrame } from '@floway-dev/protocols/common'; const initialUsage = { input_tokens: 17, output_tokens: 2, total_tokens: 19, input_tokens_details: { cached_tokens: 3, cache_write_tokens: 4 } }; const sources = [ - ['ChatCompletions', irFromOpenAIChatCompletions, { id: 'resp_usage', model: 'served', created: 1, choices: [], service_tier: 'priority', usage: { prompt_tokens: 17, completion_tokens: 2, total_tokens: 19, prompt_tokens_details: { cached_tokens: 3, cache_write_tokens: 4 } } }, 'priority'], - ['Responses', irFromOpenAIResponses, { type: 'response.created', response: { id: 'resp_usage', model: 'served', created_at: 1, status: 'in_progress', output: [], service_tier: 'fast', usage: initialUsage } }, 'fast'], - ['Messages', irFromAnthropicMessages, { type: 'message_start', message: { id: 'msg_usage', model: 'served', usage: { input_tokens: 10, output_tokens: 2, cache_read_input_tokens: 3, cache_creation_input_tokens: 4, speed: 'fast' } } }, 'fast'], + ['ChatCompletions', readOpenAIChatCompletionsEvents, { id: 'resp_usage', model: 'served', created: 1, choices: [], service_tier: 'priority', usage: { prompt_tokens: 17, completion_tokens: 2, total_tokens: 19, prompt_tokens_details: { cached_tokens: 3, cache_write_tokens: 4 } } }, 'priority'], + ['Responses', readOpenAIResponsesEvents, { type: 'response.created', response: { id: 'resp_usage', model: 'served', created_at: 1, status: 'in_progress', output: [], service_tier: 'fast', usage: initialUsage } }, 'fast'], + ['Messages', readAnthropicMessagesEvents, { type: 'message_start', message: { id: 'msg_usage', model: 'served', usage: { input_tokens: 10, output_tokens: 2, cache_read_input_tokens: 3, cache_creation_input_tokens: 4, speed: 'fast' } } }, 'fast'], ] as const; test.each(sources)('Responses via %s exposes known initial accounting before pulling another upstream frame', async (_name, reader, event, tier) => { @@ -24,7 +27,7 @@ test.each(sources)('Responses via %s exposes known initial accounting before pul pulledNext = true; throw new Error('The converter pulled past the initial upstream frame'); }; - const stream = openaiResponsesFromIR(reader(upstream())); + const stream = writeOpenAIResponsesEvents(reader(upstream())); try { for (const type of ['response.created', 'response.in_progress']) { const frame = (await stream.next()).value; @@ -42,7 +45,7 @@ test('Responses preserves absent initial accounting and later measured usage', a { type: 'response.completed', response: { id: 'resp_usage', model: 'served', created_at: 1, status: 'completed', output: [], service_tier: 'fast', usage: initialUsage } }, ].map(event => eventFrame(event as any)); const output = []; - for await (const frame of openaiResponsesFromIR(irFromOpenAIResponses(iterate(upstream)))) output.push(frame); + for await (const frame of writeOpenAIResponsesEvents(readOpenAIResponsesEvents(iterate(upstream)))) output.push(frame); expect(output[0]).toMatchObject({ event: { response: { usage: null } } }); expect(output[0].type === 'event' && output[0].event.type === 'response.created' && output[0].event.response).not.toHaveProperty('service_tier'); expect(output.at(-1)).toMatchObject({ event: { type: 'response.completed', response: { usage: initialUsage, service_tier: 'fast' } } }); @@ -55,7 +58,7 @@ test('ChatCompletions via Messages publishes its initial role with already-known pulledNext = true; throw new Error('The converter pulled past message_start'); }; - const stream = openaiChatCompletionsFromIR(irFromAnthropicMessages(upstream())); + const stream = writeOpenAIChatCompletionsEvents(readAnthropicMessagesEvents(upstream())); try { expect((await stream.next()).value).toMatchObject({ event: { model: 'served', service_tier: 'fast', choices: [{ delta: { role: 'assistant' } }] } }); expect(pulledNext).toBe(false); @@ -67,7 +70,7 @@ test('ChatCompletions via Messages publishes its initial role with already-known test.each([['fast', 'priority'], ['default', 'standard']])('GenerateContent maps the %s alias to its %s tier', async (tier, serviceTier) => { const source = fixtureFrames('openai-responses', { text: ['answer'], usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 }, tier }); const output = []; - for await (const frame of geminiGenerateContentFromIR(irFromOpenAIResponses(iterate(source)))) output.push(frame); + for await (const frame of writeGeminiGenerateContentEvents(readOpenAIResponsesEvents(iterate(source)))) output.push(frame); expect(output.at(-1)).toMatchObject({ event: { usageMetadata: { serviceTier } } }); }); @@ -102,7 +105,7 @@ test('Messages reader accepts a message_delta without usage', async () => { { type: 'message_delta', delta: { stop_reason: 'end_turn' } }, { type: 'message_stop' }, ].map(event => eventFrame(event as any)); - const result = await collectIR(irFromAnthropicMessages(iterate(upstream))); + const result = await collectIR(readAnthropicMessagesEvents(iterate(upstream))); expect(result.usage).toMatchObject({ input_tokens_inclusive: 1, output_tokens_inclusive: 0 }); }); @@ -134,6 +137,6 @@ test.each([ { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 2, ...late } }, { type: 'message_stop' }, ].map(event => eventFrame(event as any)); - const result = await collectIR(irFromAnthropicMessages(iterate(upstream))); + const result = await collectIR(readAnthropicMessagesEvents(iterate(upstream))); expect(result.usage).toMatchObject({ input_tokens_inclusive: 10 + total, output_tokens_inclusive: 2, cache_creation_input_tokens: total }); }); diff --git a/packages/translate/__tests__/round-trip/reference/reference-builder_test.ts b/packages/translate/__tests__/round-trip/reference/reference-builder_test.ts index d57e84ffb2..e7eec86f9d 100644 --- a/packages/translate/__tests__/round-trip/reference/reference-builder_test.ts +++ b/packages/translate/__tests__/round-trip/reference/reference-builder_test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import type { IRJSONObject } from '../../../src/ir/types.ts'; +import type { IRJSONObject } from '../../../src/ir/common/json.ts'; import { collectAnthropicMessagesTextViews } from '../../../src/protocols/anthropic-messages/as-source/round-trip.ts'; import type { AnthropicMessagesReferenceableAssistantTurn } from '../../../src/protocols/anthropic-messages/as-target/assistant-turn.ts'; import { collectGeminiGenerateContentTextViews } from '../../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; diff --git a/packages/translate/__tests__/round-trip/reference/reference-cost-json_test.ts b/packages/translate/__tests__/round-trip/reference/reference-cost-json_test.ts index d6f73aa6de..3dd3f55936 100644 --- a/packages/translate/__tests__/round-trip/reference/reference-cost-json_test.ts +++ b/packages/translate/__tests__/round-trip/reference/reference-cost-json_test.ts @@ -1,6 +1,6 @@ import { expect, it } from 'vitest'; -import type { IRJSONObject } from '../../../src/ir/types.ts'; +import type { IRJSONObject } from '../../../src/ir/common/json.ts'; import type { AnthropicMessagesInputAssistantTurn } from '../../../src/protocols/anthropic-messages/as-target/assistant-turn.ts'; import { ContentReference } from '../../../src/round-trip/reference/assistant-turn.ts'; import { appendContent, appendField, collectContentViews, createContentState, createFieldState, createReferenceBuilder } from '../../../src/round-trip/reference/reference-builder.ts'; diff --git a/packages/translate/__tests__/shared/iterables.ts b/packages/translate/__tests__/shared/iterables.ts new file mode 100644 index 0000000000..b56dd306ad --- /dev/null +++ b/packages/translate/__tests__/shared/iterables.ts @@ -0,0 +1,5 @@ +import type { ProtocolFrame } from '@floway-dev/protocols/common'; + +export const iterate = async function* (values: readonly T[]): AsyncGenerator { yield* values; }; +export const collect = async (values: AsyncIterable): Promise => { const result: T[] = []; for await (const value of values) result.push(value); return result; }; +export const events = async function* (frames: AsyncIterable>): AsyncGenerator { for await (const frame of frames) if (frame.type === 'event') yield frame.event; }; diff --git a/packages/translate/__tests__/shared/json/completion_test.ts b/packages/translate/__tests__/shared/json/completion_test.ts new file mode 100644 index 0000000000..572ee41fb1 --- /dev/null +++ b/packages/translate/__tests__/shared/json/completion_test.ts @@ -0,0 +1,17 @@ +import { expect, test, vi } from 'vitest'; + +import { parseJSONObjectUnlessTruncated } from '../../../src/shared/json/completion.ts'; + +test('truncated JSON completion does not invoke the parser', () => { + const parser = vi.fn(() => ({ repaired: true })); + expect(parseJSONObjectUnlessTruncated('{"value":', 'truncated', parser)).toEqual({ completion: 'truncated' }); + expect(parser).not.toHaveBeenCalled(); +}); + +test('complete JSON completion uses repair and still requires an object', () => { + expect(parseJSONObjectUnlessTruncated('{"value":', 'complete', text => JSON.parse(`${text}1}`))).toEqual({ + completion: 'complete', + value: { value: 1 }, + }); + expect(() => parseJSONObjectUnlessTruncated('[]', 'complete')).toThrow('Tool arguments require a JSON object'); +}); diff --git a/packages/translate/__tests__/shared/json/object_test.ts b/packages/translate/__tests__/shared/json/object_test.ts index 1da5567f41..8d9ece2940 100644 --- a/packages/translate/__tests__/shared/json/object_test.ts +++ b/packages/translate/__tests__/shared/json/object_test.ts @@ -1,23 +1,9 @@ -import { expect, test, vi } from 'vitest'; +import { expect, test } from 'vitest'; -import { parseJSONObject, parseJSONObjectAtCompletion } from '../../../src/shared/json/object.ts'; +import { parseJSONObject } from '../../../src/shared/json/object.ts'; test('default object parsing preserves number tokens that would serialize differently', () => { const source = '{"rounded":0.10000000000000001,"negativeZero":-0,"canonical":1.25}'; expect(JSON.stringify(parseJSONObject(source))).toBe(source); }); - -test('truncated JSON completion does not invoke the parser', () => { - const parser = vi.fn(() => ({ repaired: true })); - expect(parseJSONObjectAtCompletion('{"value":', 'truncated', parser)).toEqual({ completion: 'truncated' }); - expect(parser).not.toHaveBeenCalled(); -}); - -test('complete JSON completion uses repair and still requires an object', () => { - expect(parseJSONObjectAtCompletion('{"value":', 'complete', text => JSON.parse(`${text}1}`))).toEqual({ - completion: 'complete', - value: { value: 1 }, - }); - expect(() => parseJSONObjectAtCompletion('[]', 'complete')).toThrow('Tool arguments require a JSON object'); -}); diff --git a/packages/translate/__tests__/shared/text-coordinate-index_test.ts b/packages/translate/__tests__/shared/text-coordinate-index_test.ts index 01aa76a666..c978a18a73 100644 --- a/packages/translate/__tests__/shared/text-coordinate-index_test.ts +++ b/packages/translate/__tests__/shared/text-coordinate-index_test.ts @@ -6,22 +6,22 @@ test('maps code-point ranges to UTF-16 and follows later appends', () => { const index = TextCoordinateIndex.codePointsToUTF16('A\ud83d'); index.append('\ude00中B'); index.assertUTF16Length('A😀中B'.length); - expect(index.codePointRangeToIR(1, 3)).toEqual({ start: 1, end_exclusive: 4 }); - expect(() => index.codePointRangeToIR(0, 5)).toThrow('exceeds text'); + expect(index.codePointRangeToUTF16(1, 3)).toEqual({ start: 1, end_exclusive: 4 }); + expect(() => index.codePointRangeToUTF16(0, 5)).toThrow('exceeds text'); }); test('maps UTF-16 ranges to code points and rejects surrogate interiors', () => { const index = TextCoordinateIndex.utf16ToCodePoints('A\ud83d'); index.append('\ude00中B'); - expect(index.irRangeToCodePoints({ start: 1, end_exclusive: 4 })).toEqual({ start: 1, end: 3 }); - expect(() => index.irRangeToCodePoints({ start: 2, end_exclusive: 4 })).toThrow('splits a surrogate pair'); + expect(index.utf16RangeToCodePoints({ start: 1, end_exclusive: 4 })).toEqual({ start: 1, end: 3 }); + expect(() => index.utf16RangeToCodePoints({ start: 2, end_exclusive: 4 })).toThrow('splits a surrogate pair'); }); test('maps UTF-16 ranges to UTF-8 bytes and requires well-formed text', () => { const index = TextCoordinateIndex.utf16ToUTF8('A\ud83d'); - expect(() => index.irRangeToUTF8({ start: 0, end_exclusive: 2 })).toThrow('well-formed Unicode'); + expect(() => index.utf16RangeToUTF8({ start: 0, end_exclusive: 2 })).toThrow('well-formed Unicode'); index.append('\ude00中B'); - expect(index.irRangeToUTF8({ start: 1, end_exclusive: 4 })).toEqual({ start: 1, end: 8 }); + expect(index.utf16RangeToUTF8({ start: 1, end_exclusive: 4 })).toEqual({ start: 1, end: 8 }); }); test('detects a missed append at the protocol owner', () => { diff --git a/packages/translate/src/ir/collector.ts b/packages/translate/src/ir/collector.ts deleted file mode 100644 index 5d7bd28b85..0000000000 --- a/packages/translate/src/ir/collector.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { irEndedUnit, irPathKey, resolveIRPath, unionVariantNode, valueAt, type IRUnit } from './operation-paths.ts'; -import type { IRSchemaNode } from './schema.ts'; -import type { IREvent, IRWrite, IRPath } from './stream.ts'; -import { IRAbsent, type IR, type IRValue } from './types.ts'; - -type IRValueVisitor = (path: IRPath, node: IRSchemaNode, value: IRValue) => void; - -const isPlainObject = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); - -// Opaque values are replaced as a whole, so only what no IR value may contain is rejected. -const assertPlainValue = (path: IRPath, value: unknown): void => { - if (value === undefined) throw new TypeError(`IR values cannot contain undefined: ${irPathKey(path)}`); - if (value === IRAbsent) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); - if (typeof value !== 'object' || value === null) return; - for (const [key, child] of Object.entries(value)) assertPlainValue([...path, Array.isArray(value) ? Number(key) : key], child); -}; - -// Walks a value that is about to be written together with the schema node describing it. -// `IRAbsent` is accepted where the schema allows it unless `allowNone` is off. -const walkValue = (path: IRPath, node: IRSchemaNode, value: IRValue, visitor: IRValueVisitor, allowNone: boolean): void => { - if (value === undefined) throw new TypeError(`IR values cannot contain undefined: ${irPathKey(path)}`); - if (node.kind === 'nullable') { - if (value === null) return visitor(path, node, value); - node = node.of; - } - visitor(path, node, value); - switch (node.kind) { - case 'string': - if (value === IRAbsent) { - if (!allowNone) throw new TypeError('IRAbsent must be assigned directly to call_id'); - if (node.absent !== true) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); - } else if (typeof value !== 'string') throw new TypeError(`IR value must be a string: ${irPathKey(path)}`); - return; - case 'number': - if (typeof value !== 'number') throw new TypeError(`IR value must be a number: ${irPathKey(path)}`); - return; - case 'array': - if (!Array.isArray(value)) throw new TypeError(`IR value must be an array: ${irPathKey(path)}`); - value.forEach((child, index) => walkValue([...path, index], node.of, child, visitor, allowNone)); - return; - case 'object': - case 'union': { - if (!isPlainObject(value)) throw new TypeError(`IR value must be an object: ${irPathKey(path)}`); - const variant = unionVariantNode(node, value); - if (variant?.kind !== 'object') throw new TypeError(`IR value has an unknown variant: ${irPathKey(path)}`); - for (const [key, child] of Object.entries(value)) { - const field = node.kind === 'union' && key === node.discriminant ? { kind: 'string' } as const : Object.hasOwn(variant.fields, key) ? variant.fields[key] : undefined; - if (field === undefined) throw new TypeError(`IR value has no field ${key}: ${irPathKey(path)}`); - walkValue([...path, key], field, child, visitor, allowNone); - } - return; - } - case 'opaque': - assertPlainValue(path, value); - return; - default: - } -}; - -// Fails if a unit that has ended lacks any field required by its schema. -const assertComplete = (path: IRPath, node: IRSchemaNode, value: unknown): void => { - const actual = unionVariantNode(node, value); - if (actual === undefined || typeof value !== 'object' || value === null) return; - if (actual.kind === 'array') (value as unknown[]).forEach((child, index) => assertComplete([...path, index], actual.of, child)); - else if (actual.kind === 'object') { - const present = value as Record; - if (actual.requiresAnyOf !== undefined && !actual.requiresAnyOf.some(key => present[key] !== undefined)) { - throw new TypeError(`IR value requires one of ${actual.requiresAnyOf.join(', ')}: ${irPathKey(path)}`); - } - for (const [key, field] of Object.entries(actual.fields)) { - if (!Object.hasOwn(present, key)) { - if (!actual.optional.includes(key)) throw new TypeError(`IR value requires field ${key}: ${irPathKey(path)}`); - continue; - } - assertComplete([...path, key], field, present[key]); - } - } -}; - -const createCounter = () => { - const counts = new Map(); - return { - get: (key: K): number => counts.get(key) ?? 0, - increment: (key: K): void => { counts.set(key, (counts.get(key) ?? 0) + 1); }, - decrement: (key: K): void => { - const count = counts.get(key); - if (count === undefined) throw new TypeError(`IR lifecycle counter underflow: ${String(key)}`); - if (count === 1) counts.delete(key); - else counts.set(key, count - 1); - }, - }; -}; - -// Tracks which units (choices, items and parts) are open or closed. -const createLifecycle = () => { - const open = new Set(); - const closed = new Set(); - const openChildren = createCounter(); - - const assertWritable = (units: readonly IRUnit[], path: IRPath): void => { - for (const unit of units) if (closed.has(irPathKey(unit.path))) throw new TypeError(`IR write targets a closed ${unit.kind}: ${irPathKey(path)}`); - }; - - // `units` ends with the unit that was initialized. - const begin = (units: readonly IRUnit[]): void => { - open.add(irPathKey(units[units.length - 1].path)); - if (units.length > 1) openChildren.increment(irPathKey(units[units.length - 2].path)); - }; - - const end = (units: readonly IRUnit[]): void => { - const unit = units[units.length - 1]; - const key = irPathKey(unit.path); - if (!open.delete(key)) throw new TypeError(`IR ${unit.kind} is not open: ${key}`); - if (openChildren.get(key) !== 0) throw new TypeError(`IR ${unit.kind} ended with an open unit inside it: ${key}`); - closed.add(key); - if (units.length > 1) openChildren.decrement(irPathKey(units[units.length - 2].path)); - }; - - const assertFinishable = (): void => { - if (open.size !== 0) throw new TypeError('IR finish arrived with open units'); - }; - - return { assertWritable, begin, end, assertFinishable, isOpen: (path: IRPath): boolean => open.has(irPathKey(path)), isClosed: (path: IRPath): boolean => closed.has(irPathKey(path)) }; -}; - -export const createIRCollector = () => { - const state: IR = { choices: [] }; - // Containers created by this collector. Everything else is borrowed from a record and - // is copied before its first write. - const owned = new WeakSet([state, state.choices]); - const lifecycle = createLifecycle(); - - const writableContainer = (path: IRPath): Record => { - let target = state as unknown as Record; - for (const segment of path) { - const child = target[segment] as object; - if (!owned.has(child)) { - const copy = Array.isArray(child) ? [...child] : { ...child }; - owned.add(copy); - Object.defineProperty(target, segment, { value: copy, writable: true, enumerable: true, configurable: true }); - } - target = target[segment] as Record; - } - return target; - }; - - // Returns whether the write changed the state. - const apply = (write: IRWrite): boolean => { - if (write.value === undefined) throw new TypeError('IR writes cannot carry undefined'); - const { path } = write; - const resolution = resolveIRPath(path, state); - if (resolution !== undefined) lifecycle.assertWritable(resolution.units, path); - const key = path.at(-1); - if (key === undefined) throw new TypeError('IR writes require a property path'); - const parentPath = path.slice(0, -1); - const parent = valueAt(state, parentPath); - if (parent === undefined) throw new TypeError(`Missing IR path: ${irPathKey(parentPath)}`); - if (typeof parent !== 'object' || parent === null) throw new TypeError('IR write parent must be an object or array'); - const present = Object.hasOwn(parent, key); - if (write.kind === 'initialize' && present) throw new TypeError('IR initialize requires an absent target'); - if (Array.isArray(parent)) { - if (typeof key !== 'number' || !Number.isInteger(key) || key < 0 || key > parent.length) throw new TypeError('IR array write requires a contiguous nonnegative index'); - if (!present && key !== parent.length) throw new TypeError('IR array insertion must use the next index'); - if (write.kind === 'initialize' && key !== parent.length) throw new TypeError('IR array initialization must use the next index'); - } - const current = (parent as Record)[key]; - const node = resolution?.node; - - if (write.kind === 'append') { - if (node?.kind !== 'string' || node.append !== true) throw new TypeError(`IR append requires an append-target path: ${irPathKey(path)}`); - if (typeof current !== 'string') throw new TypeError('IR append requires a string target'); - if (typeof write.value !== 'string') throw new TypeError('IR append requires a string value'); - if (write.value === '') return false; - } else { - if (node === undefined) throw new TypeError(`IR path is not part of the IR schema: ${irPathKey(path)}`); - if (write.kind === 'initialize') { - walkValue(path, node, write.value, (childPath, childNode, child) => { - if (childNode.kind === 'string' && childNode.append === true && child !== '') throw new TypeError(`IR append target must initialize to an empty string: ${irPathKey(childPath)}`); - }, true); - } else if (write.value === IRAbsent) { - if (node.kind !== 'string' || node.absent !== true) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); - if (present) throw new TypeError('IRAbsent may only be assigned to an absent target'); - } else { - walkValue(path, node, write.value, (childPath, childNode) => { - if (childNode.kind === 'string' && childNode.append === true) throw new TypeError(`IR append target cannot be assigned: ${irPathKey(childPath)}`); - }, false); - if (present && Object.is(current, write.value)) return false; - } - } - - const target = writableContainer(parentPath); - if (write.kind === 'append') target[key] = (target[key] as string) + write.value; - else Object.defineProperty(target, key, { value: write.value, writable: true, enumerable: true, configurable: true }); - if (write.kind === 'initialize' && resolution !== undefined) { - const unit = resolution.units.at(-1); - if (unit !== undefined && unit.path.length === path.length) lifecycle.begin(resolution.units); - } - return true; - }; - - const applyEvent = (event: IREvent): void => { - const ended = irEndedUnit(event); - if (ended !== undefined) { - const resolution = resolveIRPath(ended.path, state); - if (resolution === undefined || valueAt(state, ended.path) === undefined) throw new TypeError(`IR ${ended.kind} does not exist: ${irPathKey(ended.path)}`); - lifecycle.end(resolution.units); - assertComplete(ended.path, resolution.node, valueAt(state, ended.path)); - } else if (event.type === 'finish') lifecycle.assertFinishable(); - }; - - const value = (path: IRPath): T | undefined => valueAt(state, path) as T | undefined; - // A tool call that closed without a call ID has none to wait for. - const optional = (path: IRPath): T | typeof IRAbsent | undefined => { - const actual = value(path); - if (actual !== undefined) return actual; - const resolution = resolveIRPath(path, state); - const owner = resolution?.units.at(-1); - return resolution?.node.kind === 'string' && resolution.node.absent === true && owner !== undefined && lifecycle.isClosed(owner.path) ? IRAbsent : undefined; - }; - return { state, apply, applyEvent, value, isOpen: lifecycle.isOpen, optional }; -}; - -export type IRCollector = ReturnType; diff --git a/packages/translate/src/ir/common/json.ts b/packages/translate/src/ir/common/json.ts new file mode 100644 index 0000000000..5a8c1cd4d7 --- /dev/null +++ b/packages/translate/src/ir/common/json.ts @@ -0,0 +1,6 @@ +// The JSON value shape, shared by complete JSON and output values that admit IRAbsent. +export type JSONValueOf = Extra | null | boolean | number | string | readonly JSONValueOf[] | { readonly [key: string]: JSONValueOf }; + +export type IRJSONValue = JSONValueOf; + +export type IRJSONObject = { readonly [key: string]: IRJSONValue }; diff --git a/packages/translate/src/ir/ordinal-mapper.ts b/packages/translate/src/ir/ordinal-mapper.ts deleted file mode 100644 index afd2cf8424..0000000000 --- a/packages/translate/src/ir/ordinal-mapper.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { irPathKey } from './operation-paths.ts'; -import type { IRPath } from './stream.ts'; - -// Numbers the native units (blocks, output items, tool calls) of a stream in the order -// they open, and forgets a unit when it closes. -export const createIROrdinalMapper = () => { - const ordinals = new Map(); - let next = 0; - const open = (path: IRPath): number => { - const key = irPathKey(path); - if (ordinals.has(key)) throw new TypeError(`IR unit is already open: ${key}`); - const ordinal = next++; - ordinals.set(key, ordinal); - return ordinal; - }; - const get = (path: IRPath): number => { - const key = irPathKey(path); - const ordinal = ordinals.get(key); - if (ordinal === undefined) throw new TypeError(`Unknown open IR unit: ${key}`); - return ordinal; - }; - const close = (path: IRPath): void => { - get(path); - ordinals.delete(irPathKey(path)); - }; - return { open, get, close }; -}; diff --git a/packages/translate/src/ir/output/absent.ts b/packages/translate/src/ir/output/absent.ts new file mode 100644 index 0000000000..77c37e4609 --- /dev/null +++ b/packages/translate/src/ir/output/absent.ts @@ -0,0 +1,6 @@ +// A field whose absence is itself a fact: an optional value is now known never +// to arrive, as opposed to not having arrived yet. Only the output stream needs +// this, because a request arrives complete. +export const IRAbsent: unique symbol = Symbol('IRAbsent'); + +export type IRAbsent = typeof IRAbsent; diff --git a/packages/translate/src/ir/stream.ts b/packages/translate/src/ir/output/builder.ts similarity index 64% rename from packages/translate/src/ir/stream.ts rename to packages/translate/src/ir/output/builder.ts index fec713ba24..3264ff985d 100644 --- a/packages/translate/src/ir/stream.ts +++ b/packages/translate/src/ir/output/builder.ts @@ -1,23 +1,9 @@ -import { createIRCollector, type IRCollector } from './collector.ts'; +import { createIRCollector } from './collector.ts'; import { irItemPath, irPartPath, irPathKey, irUnitPath } from './operation-paths.ts'; -import type { IRErrorPayload, IRUsage, IRValue } from './types.ts'; +import type { IREvent, IRPath, IRRecord, IRWrite } from './records.ts'; +import type { IROutputUsage, IROutputValue } from './types.ts'; -export type IRPath = readonly (string | number)[]; -export type IRWrite = Readonly< - | { type: 'write'; kind: 'initialize'; path: IRPath; value: IRValue } - | { type: 'write'; kind: 'assign'; path: IRPath; value: IRValue } - | { type: 'write'; kind: 'append'; path: IRPath; value: string; referencePieceIds?: readonly number[] }>; -export type IREvent = Readonly< - | { type: 'start'; id?: string; created?: number } - | { type: 'item_end'; choice: number; item: number; status?: 'completed' | 'incomplete' } - | { type: 'part_end'; choice: number; item: number; part: number } - | { type: 'choice_end'; choice: number; finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter' } - | { type: 'finish'; status: 'completed' | 'incomplete' } - | { type: 'error'; error: IRErrorPayload } - | { type: 'ping' }>; -export type IRRecord = IRWrite | IREvent; - -export type IRRelativePath = string | number | IRPath; +export type IRRelativePath = string | IRPath; export interface IRWriteHandle { readonly path: IRPath; @@ -26,7 +12,7 @@ export interface IRWriteHandle { assign: (path: IRRelativePath, value: unknown) => void; initialize: (path: IRRelativePath, value: unknown) => void; ensure: (path: IRRelativePath, initialValue: unknown) => void; - resolve: (path: IRRelativePath) => IRPath; + absolutePath: (path: IRRelativePath) => IRPath; // Snapshot reconciliation needs the value accumulated from earlier upstream events. current: (path: IRRelativePath) => T | undefined; } @@ -45,8 +31,6 @@ export interface IRItemHandle extends IRWriteHandle { export interface IRChoiceHandle extends IRWriteHandle { readonly isOpen: boolean; end: (finishReason: 'stop' | 'length' | 'tool_calls' | 'content_filter') => void; - // ChatCompletions annotations use coordinates across all text emitted for a choice. - textParts: () => readonly { item: IRItemHandle; part: IRPartHandle; text: string }[]; } export const createIRBuilder = (maxChoices = 1) => { @@ -56,8 +40,6 @@ export const createIRBuilder = (maxChoices = 1) => { let records: IRRecord[] = []; const initialized = new Set(); const choices = new Map(); - const items = new Map(); - const parts = new Map(); const itemCounts = new Map(); const partCounts = new Map(); @@ -66,13 +48,16 @@ export const createIRBuilder = (maxChoices = 1) => { if (typeof value !== 'object' || value === null) return; for (const [key, child] of Object.entries(value)) remember([...path, Array.isArray(value) ? Number(key) : key], child); }; - const write = (value: IRWrite): void => { if (collector.apply(value)) records.push(value); }; + const write = (value: IRWrite): void => { if (collector.applyWrite(value)) records.push(value); }; + // A handle accepts any caller value; the collector is what decides whether it + // is a legal output IR value, so the write record states what was handed over + // and validation runs on the way in. const assign = (path: IRPath, value: unknown): void => { - write({ type: 'write', kind: 'assign', path, value: value as IRValue }); + write({ type: 'write', kind: 'assign', path, value: value as IROutputValue }); remember(path, value); }; const initialize = (path: IRPath, value: unknown): void => { - write({ type: 'write', kind: 'initialize', path, value: value as IRValue }); + write({ type: 'write', kind: 'initialize', path, value: value as IROutputValue }); remember(path, value); }; const append = (path: IRPath, value: string, referencePieceIds?: readonly number[]): void => write({ @@ -84,7 +69,7 @@ export const createIRBuilder = (maxChoices = 1) => { }); const event = (value: IREvent): void => { collector.applyEvent(value); records.push(value); }; const drain = (): readonly IRRecord[] => { const drained = records; records = []; return drained; }; - const relativePath = (base: IRPath, path: IRRelativePath): IRPath => [...base, ...Array.isArray(path) ? path : [path]]; + const relativePath = (base: IRPath, path: IRRelativePath): IRPath => [...base, ...typeof path === 'string' ? [path] : path]; const writeHandle = (path: IRPath, index: number): IRWriteHandle => ({ path, index, @@ -96,11 +81,11 @@ export const createIRBuilder = (maxChoices = 1) => { if (initialized.has(irPathKey(target))) return; initialize(target, initialValue); }, - resolve: subpath => relativePath(path, subpath), + absolutePath: subpath => relativePath(path, subpath), current: subpath => collector.value(relativePath(path, subpath)), }); // Usage arrives as cumulative snapshots, so each reported field replaces the previous one. - const usage = (fields: IRUsage): void => { + const usage = (fields: IROutputUsage): void => { if (state.usage === undefined) initialize(['usage'], {}); for (const [key, value] of Object.entries(fields)) assign(['usage', key], value); }; @@ -119,12 +104,6 @@ export const createIRBuilder = (maxChoices = 1) => { event({ type: 'choice_end', choice: choiceIndex, finish_reason: finishReason }); open = false; }, - textParts: () => [...parts.values()].flatMap(part => { - const itemHandle = items.get(irPathKey(part.path.slice(0, -2))); - if (itemHandle?.path[1] !== choiceIndex) return []; - const text = part.current('text'); - return text === undefined ? [] : [{ item: itemHandle, part, text }]; - }), }; choices.set(choiceIndex, handle); itemCounts.set(choiceIndex, 0); @@ -159,7 +138,6 @@ export const createIRBuilder = (maxChoices = 1) => { }, }; openParts.push(partHandle); - parts.set(irPathKey(partPath), partHandle); return partHandle; }, end: status => { @@ -169,36 +147,7 @@ export const createIRBuilder = (maxChoices = 1) => { open = false; }, }; - items.set(irPathKey(path), handle); return handle; }; return { collector, state, initialize, assign, append, event, drain, usage, choice, item }; }; - -// Applies each record to a fresh collector and yields it together with the collector, so a -// writer sees the state as of that record. A well-formed stream is exactly one `start`, then -// records, then `finish` or `error`. -export const consumeIRRecords = async function* (records: AsyncIterable): AsyncGenerator<{ record: IRRecord; collector: IRCollector }> { - const collector = createIRCollector(); - let started = false; - let finished = false; - for await (const record of records) { - if (finished) throw new Error('IR record arrived after finish'); - if (record.type === 'write') { - collector.apply(record); - } else { - if (record.type === 'start') { - if (started) throw new Error('Duplicate IR start'); - started = true; - } else if (record.type === 'finish') { - if (!started) throw new Error('IR finish arrived before start'); - finished = true; - } else if (record.type === 'error') { - finished = true; - } - collector.applyEvent(record); - } - yield { record, collector }; - } - if (!finished) throw new Error('IR stream ended without finish'); -}; diff --git a/packages/translate/src/ir/output/collector.ts b/packages/translate/src/ir/output/collector.ts new file mode 100644 index 0000000000..7f7c594879 --- /dev/null +++ b/packages/translate/src/ir/output/collector.ts @@ -0,0 +1,105 @@ +import { IRAbsent } from './absent.ts'; +import { createLifecycle } from './lifecycle.ts'; +import { irEndedUnit, irPathKey, resolveIRPath, valueAt } from './operation-paths.ts'; +import type { IREvent, IRWrite, IRPath } from './records.ts'; +import { type IROutput } from './types.ts'; +import { assertComplete, walkValue } from './value-validation.ts'; + +export const createIRCollector = () => { + const state: IROutput = { choices: [] }; + // Containers created by this collector. Everything else is borrowed from a record and + // is copied before its first write. + const owned = new WeakSet([state, state.choices]); + const lifecycle = createLifecycle(); + + const writableContainer = (path: IRPath): Record => { + let target: Record = state; + for (const segment of path) { + const child = target[segment] as object; + if (!owned.has(child)) { + const copy = Array.isArray(child) ? [...child] : { ...child }; + owned.add(copy); + Object.defineProperty(target, segment, { value: copy, writable: true, enumerable: true, configurable: true }); + } + target = target[segment] as Record; + } + return target; + }; + + // Returns whether the write changed the state. + const applyWrite = (write: IRWrite): boolean => { + if (write.value === undefined) throw new TypeError('output IR writes cannot carry undefined'); + const { path } = write; + const resolution = resolveIRPath(path, state); + if (resolution !== undefined) lifecycle.assertWritable(resolution.units, path); + const key = path.at(-1); + if (key === undefined) throw new TypeError('output IR writes require a property path'); + const parentPath = path.slice(0, -1); + const parent = valueAt(state, parentPath); + if (parent === undefined) throw new TypeError(`Missing output IR path: ${irPathKey(parentPath)}`); + if (typeof parent !== 'object' || parent === null) throw new TypeError('output IR write parent must be an object or array'); + const present = Object.hasOwn(parent, key); + if (write.kind === 'initialize' && present) throw new TypeError('output IR initialize requires an absent target'); + if (Array.isArray(parent)) { + if (typeof key !== 'number' || !Number.isInteger(key) || key < 0 || key > parent.length) throw new TypeError('output IR array write requires a contiguous nonnegative index'); + if (!present && key !== parent.length) throw new TypeError('output IR array insertion must use the next index'); + if (write.kind === 'initialize' && key !== parent.length) throw new TypeError('output IR array initialization must use the next index'); + } + const current = (parent as Record)[key]; + const node = resolution?.node; + + if (write.kind === 'append') { + if (node?.kind !== 'string' || node.append !== true) throw new TypeError(`output IR append requires an append-target path: ${irPathKey(path)}`); + if (typeof current !== 'string') throw new TypeError('output IR append requires a string target'); + if (typeof write.value !== 'string') throw new TypeError('output IR append requires a string value'); + if (write.value === '') return false; + } else { + if (node === undefined) throw new TypeError(`output IR path is not part of the output IR schema: ${irPathKey(path)}`); + if (write.kind === 'initialize') { + walkValue(path, node, write.value, (childPath, childNode, child) => { + if (childNode.kind === 'string' && childNode.append === true && child !== '') throw new TypeError(`output IR append target must initialize to an empty string: ${irPathKey(childPath)}`); + }, { allowAbsent: true }); + } else if (write.value === IRAbsent) { + if (node.kind !== 'string' || node.absent !== true) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); + if (present) throw new TypeError('IRAbsent may only be assigned to an absent target'); + } else { + walkValue(path, node, write.value, (childPath, childNode) => { + if (childNode.kind === 'string' && childNode.append === true) throw new TypeError(`output IR append target cannot be assigned: ${irPathKey(childPath)}`); + }, { allowAbsent: false }); + if (present && Object.is(current, write.value)) return false; + } + } + + const target = writableContainer(parentPath); + if (write.kind === 'append') target[key] = (target[key] as string) + write.value; + else Object.defineProperty(target, key, { value: write.value, writable: true, enumerable: true, configurable: true }); + if (write.kind === 'initialize' && resolution !== undefined) { + const unit = resolution.units.at(-1); + if (unit !== undefined && unit.path.length === path.length) lifecycle.openUnit(resolution.units); + } + return true; + }; + + const applyEvent = (event: IREvent): void => { + const ended = irEndedUnit(event); + if (ended !== undefined) { + const resolution = resolveIRPath(ended.path, state); + if (resolution === undefined || valueAt(state, ended.path) === undefined) throw new TypeError(`output IR ${ended.kind} does not exist: ${irPathKey(ended.path)}`); + lifecycle.closeUnit(resolution.units); + assertComplete(ended.path, resolution.node, valueAt(state, ended.path)); + } else if (event.type === 'finish') lifecycle.assertFinishable(); + }; + + const value = (path: IRPath): T | undefined => valueAt(state, path) as T | undefined; + // A tool call that closed without a call ID has none to wait for. + const valueOrAbsent = (path: IRPath): T | typeof IRAbsent | undefined => { + const actual = value(path); + if (actual !== undefined) return actual; + const resolution = resolveIRPath(path, state); + const owner = resolution?.units.at(-1); + return resolution?.node.kind === 'string' && resolution.node.absent === true && owner !== undefined && lifecycle.isClosed(owner.path) ? IRAbsent : undefined; + }; + return { state, applyWrite, applyEvent, value, isOpen: lifecycle.isOpen, valueOrAbsent }; +}; + +export type IRCollector = ReturnType; diff --git a/packages/translate/src/ir/output/error.ts b/packages/translate/src/ir/output/error.ts new file mode 100644 index 0000000000..edd52c87e6 --- /dev/null +++ b/packages/translate/src/ir/output/error.ts @@ -0,0 +1,8 @@ +// The error fields upstream protocols agree on; each writer picks the ones its protocol defines. +export interface IRErrorPayload { + readonly message: string; + readonly type?: string; + readonly code?: string | null; + readonly param?: string | null; + readonly provider_specific_fields?: Readonly>; +} diff --git a/packages/translate/src/ir/output/lifecycle.ts b/packages/translate/src/ir/output/lifecycle.ts new file mode 100644 index 0000000000..194fecbf72 --- /dev/null +++ b/packages/translate/src/ir/output/lifecycle.ts @@ -0,0 +1,49 @@ +import { irPathKey } from './operation-paths.ts'; +import type { IRUnit } from './operation-paths.ts'; +import type { IRPath } from './records.ts'; + +const createCounter = () => { + const counts = new Map(); + return { + get: (key: K): number => counts.get(key) ?? 0, + increment: (key: K): void => { counts.set(key, (counts.get(key) ?? 0) + 1); }, + decrement: (key: K): void => { + const count = counts.get(key); + if (count === undefined) throw new TypeError(`output IR lifecycle counter underflow: ${String(key)}`); + if (count === 1) counts.delete(key); + else counts.set(key, count - 1); + }, + }; +}; + +// Tracks which units (choices, items and parts) are open or closed. +export const createLifecycle = () => { + const open = new Set(); + const closed = new Set(); + const openChildren = createCounter(); + + const assertWritable = (units: readonly IRUnit[], path: IRPath): void => { + for (const unit of units) if (closed.has(irPathKey(unit.path))) throw new TypeError(`output IR write targets a closed ${unit.kind}: ${irPathKey(path)}`); + }; + + // `units` ends with the unit that was initialized. + const openUnit = (units: readonly IRUnit[]): void => { + open.add(irPathKey(units[units.length - 1].path)); + if (units.length > 1) openChildren.increment(irPathKey(units[units.length - 2].path)); + }; + + const closeUnit = (units: readonly IRUnit[]): void => { + const unit = units[units.length - 1]; + const key = irPathKey(unit.path); + if (!open.delete(key)) throw new TypeError(`output IR ${unit.kind} is not open: ${key}`); + if (openChildren.get(key) !== 0) throw new TypeError(`output IR ${unit.kind} ended with an open unit inside it: ${key}`); + closed.add(key); + if (units.length > 1) openChildren.decrement(irPathKey(units[units.length - 2].path)); + }; + + const assertFinishable = (): void => { + if (open.size !== 0) throw new TypeError('output IR finish arrived with open units'); + }; + + return { assertWritable, openUnit, closeUnit, assertFinishable, isOpen: (path: IRPath): boolean => open.has(irPathKey(path)), isClosed: (path: IRPath): boolean => closed.has(irPathKey(path)) }; +}; diff --git a/packages/translate/src/ir/operation-paths.ts b/packages/translate/src/ir/output/operation-paths.ts similarity index 77% rename from packages/translate/src/ir/operation-paths.ts rename to packages/translate/src/ir/output/operation-paths.ts index 23a652b93f..c7460b8f3d 100644 --- a/packages/translate/src/ir/operation-paths.ts +++ b/packages/translate/src/ir/output/operation-paths.ts @@ -1,9 +1,9 @@ +import type { IREvent, IRPath, IRRecord } from './records.ts'; import { irSchema, type IRObjectNode, type IRSchemaNode, type IRUnionNode, type IRUnitKind } from './schema.ts'; -import type { IREvent, IRPath, IRRecord } from './stream.ts'; // Everything here is derived from the schema in schema.ts. A path is resolved against the // schema, and the answers come from the traits of the node it lands on. Where the node -// depends on which variant of a union the IR holds there, the variant is read from `root` +// depends on which variant of a union the output IR holds there, the variant is read from `root` // (the accumulated IR), so `root` must already contain the structure the path goes through. export const irPathKey = (path: IRPath): string => JSON.stringify(path); @@ -89,16 +89,6 @@ export const isIRItemPath = (path: IRPath, root: unknown): boolean => unitAt(pat export const isIRPartPath = (path: IRPath, root: unknown): boolean => unitAt(path, root) === 'part'; -export const isIRAppendTarget = (path: IRPath, root: unknown): boolean => { - const node = resolveIRPath(path, root)?.node; - return node?.kind === 'string' && node.append === true; -}; - -export const isIRAbsentTarget = (path: IRPath, root: unknown): boolean => { - const node = resolveIRPath(path, root)?.node; - return node?.kind === 'string' && node.absent === true; -}; - const WILDCARD = Symbol('index'); type TemplateSegment = string | typeof WILDCARD; @@ -125,39 +115,17 @@ const findUnitTemplate = (node: IRSchemaNode, kind: IRUnitKind, prefix: readonly const unitTemplates = new Map(); -// The units enclosing a unit, outermost first, followed by the unit itself. -const unitKindsAlong = (kind: IRUnitKind): readonly IRUnitKind[] => { - const kinds: IRUnitKind[] = []; - const visit = (node: IRSchemaNode): boolean => { - const own = unitKindOf(node); - if (own !== undefined) kinds.push(own); - if (own === kind) return true; - let found = false; - switch (node.kind) { - case 'nullable': found = visit(node.of); break; - case 'array': found = visit(node.of); break; - case 'object': found = Object.values(node.fields).some(visit); break; - case 'union': found = Object.values(node.cases).some(visit); break; - default: - } - if (!found && own !== undefined) kinds.pop(); - return found; - }; - visit(irSchema); - return kinds; -}; - // The path of a unit from the indices of it and its enclosing units, outermost first. export const irUnitPath = (kind: IRUnitKind, ...indices: readonly number[]): IRPath => { let template = unitTemplates.get(kind); if (template === undefined) { template = findUnitTemplate(irSchema, kind, []); - if (template === undefined) throw new TypeError(`The IR schema has no ${kind} unit`); + if (template === undefined) throw new TypeError(`The output IR schema has no ${kind} unit`); unitTemplates.set(kind, template); } let next = 0; const path = template.map(segment => segment === WILDCARD ? indices[next++] : segment); - if (next !== indices.length) throw new TypeError(`IR ${kind} paths take ${next} indices, got ${indices.length}`); + if (next !== indices.length) throw new TypeError(`output IR ${kind} paths take ${next} indices, got ${indices.length}`); return path; }; @@ -167,11 +135,10 @@ export const irPartPath = (choice: number, item: number, part: number): IRPath = // An `_end` event names the unit it ends by the indices of that unit and the units around it. export const irEndedUnit = (event: IREvent): IRUnit | undefined => { - if (!event.type.endsWith('_end')) return undefined; - const kind = event.type.slice(0, -'_end'.length) as IRUnitKind; - const kinds = unitKindsAlong(kind); - if (kinds.at(-1) !== kind) return undefined; - return { kind, path: irUnitPath(kind, ...kinds.map(unit => (event as unknown as Record)[unit])) }; + if (event.type === 'choice_end') return { kind: 'choice', path: irUnitPath('choice', event.choice) }; + if (event.type === 'item_end') return { kind: 'item', path: irUnitPath('item', event.choice, event.item) }; + if (event.type === 'part_end') return { kind: 'part', path: irUnitPath('part', event.choice, event.item, event.part) }; + return undefined; }; // The units a record belongs to, innermost first. diff --git a/packages/translate/src/ir/output/records.ts b/packages/translate/src/ir/output/records.ts new file mode 100644 index 0000000000..67a7617e08 --- /dev/null +++ b/packages/translate/src/ir/output/records.ts @@ -0,0 +1,17 @@ +import type { IRErrorPayload } from './error.ts'; +import type { IROutputValue } from './types.ts'; + +export type IRPath = readonly (string | number)[]; +export type IRWrite = Readonly< + | { type: 'write'; kind: 'initialize'; path: IRPath; value: IROutputValue } + | { type: 'write'; kind: 'assign'; path: IRPath; value: IROutputValue } + | { type: 'write'; kind: 'append'; path: IRPath; value: string; referencePieceIds?: readonly number[] }>; +export type IREvent = Readonly< + | { type: 'start'; id?: string; created?: number } + | { type: 'item_end'; choice: number; item: number; status?: 'completed' | 'incomplete' } + | { type: 'part_end'; choice: number; item: number; part: number } + | { type: 'choice_end'; choice: number; finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter' } + | { type: 'finish'; status: 'completed' | 'incomplete' } + | { type: 'error'; error: IRErrorPayload } + | { type: 'ping' }>; +export type IRRecord = IRWrite | IREvent; diff --git a/packages/translate/src/ir/schema.ts b/packages/translate/src/ir/output/schema.ts similarity index 77% rename from packages/translate/src/ir/schema.ts rename to packages/translate/src/ir/output/schema.ts index dfafbd0bbb..b5377c5294 100644 --- a/packages/translate/src/ir/schema.ts +++ b/packages/translate/src/ir/output/schema.ts @@ -1,9 +1,10 @@ -import type { IR, IRAbsent, IRSourceCitation, IRTokenLogprob } from './types.ts'; +import type { IRAbsent } from './absent.ts'; +import type { IROutput, IROutputSourceCitation, IROutputTokenLogprob } from './types.ts'; -// The structure of the IR, declared once. Every question about a path (is it an append +// The structure of the IROutput, declared once. Every question about a path (is it an append // target, may it hold `IRAbsent`, which lifecycle unit does it belong to) is answered by // walking this schema, never by matching path shapes. The value type inferred from the -// schema is checked against the IR types at the bottom of this file. +// schema is checked against the output IR types at the bottom of this file. // Items, content parts and choices have their own start and end in a stream. export type IRUnitKind = 'choice' | 'item' | 'part'; @@ -33,14 +34,14 @@ export interface IRUnionNode { readonly kind: 'opaque'; readonly value?: V } +// A value that is only ever replaced as a whole. `phantom` exists only to carry its generic type. +export interface IROpaqueNode { readonly kind: 'opaque'; readonly phantom?: V } export type IRSchemaNode = IRStringNode | IRNumberNode | IRNullableNode | IRArrayNode | IRObjectNode | IRUnionNode | IROpaqueNode; export interface IRSchemaFields { readonly [key: string]: IRSchemaNode } const string = { kind: 'string' } as const; const appendString = { kind: 'string', append: true } as const; -const callId = { kind: 'string', absent: true } as const; +const absentString = { kind: 'string', absent: true } as const; const number = { kind: 'number' } as const; const nullable = (of: N): IRNullableNode => ({ kind: 'nullable', of }); const array = (of: N): IRArrayNode => ({ kind: 'array', of }); @@ -54,11 +55,11 @@ const union = ({ kind: 'union', discriminant, cases, ...unit === undefined ? {} : { unit } }); const contentPart = union('type', { - text: object({ text: appendString, annotations: nullable(array(opaque())) }, { optional: ['annotations'] }), + text: object({ text: appendString, annotations: nullable(array(opaque())) }, { optional: ['annotations'] }), refusal: object({ refusal: appendString }), - image: object({ image: object({ data: string, mime_type: string }, { optional: ['mime_type'] }) }), + image: object({ image: object({ data: string, media_type: string }, { optional: ['media_type'] }) }), audio: object({ - audio: object({ data: appendString, transcript: appendString, mime_type: string }, { optional: ['data', 'transcript', 'mime_type'], requiresAnyOf: ['data', 'transcript'] }), + audio: object({ data: appendString, transcript: appendString, media_type: string }, { optional: ['data', 'transcript', 'media_type'], requiresAnyOf: ['data', 'transcript'] }), }), }, 'part'); @@ -68,21 +69,21 @@ const item = union('type', { summary: array(appendString), content: array(appendString), }, { optional: ['summary', 'content'] }), - function_call: object({ call_id: callId, name: string, arguments: appendString }, { optional: ['call_id'] }), - custom_tool_call: object({ call_id: callId, name: string, input: appendString }, { optional: ['call_id'] }), + function_call: object({ call_id: absentString, name: string, arguments: appendString }, { optional: ['call_id'] }), + custom_tool_call: object({ call_id: absentString, name: string, input: appendString }, { optional: ['call_id'] }), }, 'item'); const logprobs = nullable(array(union('scope', { - choice: object({ tokens: array(opaque()) }), - text_part: object({ item_index: number, content_index: number, tokens: array(opaque()) }), + choice: object({ tokens: array(opaque()) }), + text_part: object({ item_index: number, content_index: number, tokens: array(opaque()) }), }))); export const irSchema = object({ choices: array(object({ items: array(item), - refusal: object({ category: nullable(string), explanation: nullable(string) }), + refusal_details: object({ category: nullable(string), explanation: nullable(string) }), logprobs, - }, { optional: ['refusal', 'logprobs'], unit: 'choice' })), + }, { optional: ['refusal_details', 'logprobs'], unit: 'choice' })), model: string, service_tier: string, usage: allOptional({ @@ -117,7 +118,7 @@ type ValueOf = type Mutual = [A] extends [B] ? ([B] extends [A] ? true : false) : false; -// Compile-time proof that the schema and the IR types describe the same values: a field the -// schema declares optional, nullable, appendable or `IRAbsent`-capable differs from the IR type +// Compile-time proof that the schema and the output IR types describe the same values: a field the +// schema declares optional, nullable, appendable or `IRAbsent`-capable differs from the output IR type // exactly when this stops being `true`. -export const irSchemaMatchesIRTypes: Mutual, IR> = true; +export const irSchemaTypeEquality: Mutual, IROutput> = true; diff --git a/packages/translate/src/ir/output/stream.ts b/packages/translate/src/ir/output/stream.ts new file mode 100644 index 0000000000..5110a47a42 --- /dev/null +++ b/packages/translate/src/ir/output/stream.ts @@ -0,0 +1,30 @@ +import { createIRCollector, type IRCollector } from './collector.ts'; +import type { IRRecord } from './records.ts'; + +// Applies each record to a fresh collector and yields it together with the collector, so a +// writer sees the state as of that record. A well-formed stream is exactly one `start`, then +// records, then `finish` or `error`. +export const consumeIRRecords = async function* (records: AsyncIterable): AsyncGenerator<{ record: IRRecord; collector: IRCollector }> { + const collector = createIRCollector(); + let started = false; + let finished = false; + for await (const record of records) { + if (finished) throw new Error('output IR record arrived after finish'); + if (record.type === 'write') { + collector.applyWrite(record); + } else { + if (record.type === 'start') { + if (started) throw new Error('Duplicate output IR start'); + started = true; + } else if (record.type === 'finish') { + if (!started) throw new Error('output IR finish arrived before start'); + finished = true; + } else if (record.type === 'error') { + finished = true; + } + collector.applyEvent(record); + } + yield { record, collector }; + } + if (!finished) throw new Error('output IR stream ended without finish'); +}; diff --git a/packages/translate/src/ir/types.ts b/packages/translate/src/ir/output/types.ts similarity index 54% rename from packages/translate/src/ir/types.ts rename to packages/translate/src/ir/output/types.ts index 9e55e6e097..e1991f9c86 100644 --- a/packages/translate/src/ir/types.ts +++ b/packages/translate/src/ir/output/types.ts @@ -1,132 +1,112 @@ -// The JSON value shape, declared once; IRValue adds the IRAbsent marker wherever a leaf may hold it. -type IRValueOf = Extra | null | boolean | number | string | readonly IRValueOf[] | { readonly [key: string]: IRValueOf }; +import type { IRAbsent } from './absent.ts'; +import type { TextRange } from '../../shared/text-range.ts'; +import type { JSONValueOf } from '../common/json.ts'; // All -export type IRJSONValue = IRValueOf; +export type IROutputValue = JSONValueOf; // All -export type IRJSONObject = { readonly [key: string]: IRJSONValue }; - -// The error fields upstream protocols agree on; each writer picks the ones its protocol defines. -export interface IRErrorPayload { - readonly message: string; - readonly type?: string; - readonly code?: string | null; - readonly param?: string | null; - readonly provider_specific_fields?: Readonly>; -} - -// All -export const IRAbsent: unique symbol = Symbol('IRAbsent'); - -// All -export type IRAbsent = typeof IRAbsent; - -// All -export type IRValue = IRValueOf; - -// All -export interface IR { - readonly choices: readonly IRChoice[]; // All +export type IROutput = { + readonly choices: readonly IROutputChoice[]; // All readonly model?: string; // All readonly service_tier?: string; // All - readonly usage?: IRUsage; // All -} + readonly usage?: IROutputUsage; // All +}; // All -interface IRChoice { - readonly items: readonly IRItem[]; // All - readonly refusal?: IRClassifierRefusal; // All - readonly logprobs?: readonly IRLogprobGroup[] | null; // ChatCompletions, Responses, GenerateContent -} +type IROutputChoice = { + readonly items: readonly IROutputItem[]; // All + readonly refusal_details?: IROutputRefusalDetails; // All + readonly logprobs?: readonly IROutputLogprobGroup[] | null; // ChatCompletions, Responses, GenerateContent +}; // All -interface IRClassifierRefusal { +type IROutputRefusalDetails = { readonly category: string | null; // Messages, Responses readonly explanation: string | null; // All -} +}; // All -export type IRItem = IRMessageItem | IRReasoningItem | IRFunctionCallItem | IRCustomToolCallItem; +export type IROutputItem = IROutputMessageItem | IROutputReasoningItem | IROutputFunctionCallItem | IROutputCustomToolCallItem; // All -export interface IRMessageItem { +export type IROutputMessageItem = { readonly type: 'message'; // All - readonly content: readonly IRContentPart[]; // All -} + readonly content: readonly IROutputContentPart[]; // All +}; // All -type IRContentPart = IRTextPart | IRRefusalPart | IRImagePart | IRAudioPart; +type IROutputContentPart = IROutputTextPart | IROutputRefusalPart | IROutputImagePart | IROutputAudioPart; // All -interface IRTextPart { +type IROutputTextPart = { readonly type: 'text'; // All readonly text: string; // All - readonly annotations?: readonly IRSourceCitation[] | null; // All -} + readonly annotations?: readonly IROutputSourceCitation[] | null; // All +}; // All -interface IRRefusalPart { +type IROutputRefusalPart = { readonly type: 'refusal'; // All readonly refusal: string; // All -} +}; // All -export interface IRReasoningItem { +export type IROutputReasoningItem = { readonly type: 'reasoning'; // All readonly summary?: readonly string[]; // All readonly content?: readonly string[]; // All -} +}; // All -interface IRFunctionCallItem { +type IROutputFunctionCallItem = { readonly type: 'function_call'; // All readonly call_id?: string | IRAbsent; // All readonly name: string; // All readonly arguments: string; // All -} +}; // ChatCompletions, Responses, Messages -interface IRCustomToolCallItem { +type IROutputCustomToolCallItem = { readonly type: 'custom_tool_call'; // ChatCompletions, Responses, Messages readonly call_id?: string | IRAbsent; // ChatCompletions, Responses, Messages readonly name: string; // ChatCompletions, Responses, Messages readonly input: string; // ChatCompletions, Responses, Messages -} +}; // Responses, GenerateContent -interface IRImagePart { +type IROutputImagePart = { readonly type: 'image'; // Responses, GenerateContent - readonly image: IRImage; // Responses, GenerateContent -} + readonly image: IROutputImage; // Responses, GenerateContent +}; // Responses, GenerateContent -interface IRImage { +type IROutputImage = { readonly data: string; // Responses, GenerateContent - readonly mime_type?: string; // Responses, GenerateContent -} + readonly media_type?: string; // Responses, GenerateContent +}; // ChatCompletions, Responses, GenerateContent -export interface IRAudioPart { +export type IROutputAudioPart = { readonly type: 'audio'; // ChatCompletions, Responses, GenerateContent - readonly audio: IRAudio; // ChatCompletions, Responses, GenerateContent -} + readonly audio: IROutputAudio; // ChatCompletions, Responses, GenerateContent +}; // ChatCompletions, Responses, GenerateContent -interface IRAudio { +type IROutputAudio = { readonly data?: string; // ChatCompletions, Responses, GenerateContent readonly transcript?: string; // ChatCompletions, Responses, GenerateContent // No current upstream reader produces it; reserved for a GenerateContent upstream. - readonly mime_type?: string; // GenerateContent -} + readonly media_type?: string; // GenerateContent +}; // All -export type IRSourceCitation = { +export type IROutputSourceCitation = { readonly type: 'source_citation'; // All readonly source_label?: string | null; // All readonly source_text?: string; // Messages, GenerateContent - readonly source_page_range?: IRSourcePageRange; // Messages, GenerateContent - readonly output_text_range?: IRUTF16TextRange; // ChatCompletions, Responses, GenerateContent + readonly source_page_range?: IROutputSourcePageRange; // Messages, GenerateContent + readonly output_text_range?: TextRange; // ChatCompletions, Responses, GenerateContent } & ( | { readonly source_kind: 'url' | 'document' | 'search_result'; // All @@ -140,52 +120,43 @@ export type IRSourceCitation = { ); // Messages, GenerateContent -interface IRSourcePageRange { +type IROutputSourcePageRange = { readonly start_one_based: number; // Messages, GenerateContent readonly end_one_based_exclusive: number; // Messages -} - -// ChatCompletions, Responses, GenerateContent -export interface IRUTF16TextRange { - readonly start: number; // ChatCompletions, Responses, GenerateContent - readonly end_exclusive: number; // ChatCompletions, Responses, GenerateContent -} +}; // ChatCompletions, Responses, GenerateContent -type IRLogprobGroup = IRChoiceLogprobs | IRTextPartLogprobs; +type IROutputLogprobGroup = IROutputChoiceLogprobs | IROutputTextPartLogprobs; // Reserved for a GenerateContent upstream: the current upstream readers only produce text-part logprobs. // ChatCompletions, GenerateContent -interface IRChoiceLogprobs { +type IROutputChoiceLogprobs = { readonly scope: 'choice'; // ChatCompletions, GenerateContent - readonly tokens: readonly IRTokenLogprob[]; // ChatCompletions, GenerateContent -} + readonly tokens: readonly IROutputTokenLogprob[]; // ChatCompletions, GenerateContent +}; // ChatCompletions, Responses -interface IRTextPartLogprobs { +type IROutputTextPartLogprobs = { readonly scope: 'text_part'; // ChatCompletions, Responses readonly item_index: number; // ChatCompletions, Responses readonly content_index: number; // ChatCompletions, Responses - readonly tokens: readonly IRTokenLogprob[]; // ChatCompletions, Responses -} + readonly tokens: readonly IROutputTokenLogprob[]; // ChatCompletions, Responses +}; // ChatCompletions, Responses, GenerateContent -export interface IRTokenLogprob { - readonly token: string; // ChatCompletions, Responses, GenerateContent - readonly logprob: number; // ChatCompletions, Responses, GenerateContent - readonly bytes?: readonly number[] | null; // ChatCompletions, Responses - readonly top_logprobs?: readonly IRTokenLogprobAlternative[]; // ChatCompletions, Responses, GenerateContent -} +export type IROutputTokenLogprob = IROutputTokenLogprobAlternative & { + readonly top_logprobs?: readonly IROutputTokenLogprobAlternative[]; // ChatCompletions, Responses, GenerateContent +}; // ChatCompletions, Responses, GenerateContent -interface IRTokenLogprobAlternative { +type IROutputTokenLogprobAlternative = { readonly token: string; // ChatCompletions, Responses, GenerateContent readonly logprob: number; // ChatCompletions, Responses, GenerateContent readonly bytes?: readonly number[] | null; // ChatCompletions, Responses -} +}; // All -export interface IRUsage { +export type IROutputUsage = { readonly input_tokens_inclusive?: number; // All readonly output_tokens_inclusive?: number; // All readonly total_tokens?: number; // ChatCompletions, Responses, GenerateContent @@ -197,4 +168,4 @@ export interface IRUsage { readonly input_text_tokens?: number; // ChatCompletions, Responses, GenerateContent readonly output_audio_tokens?: number; // ChatCompletions, Responses, GenerateContent readonly output_text_tokens?: number; // ChatCompletions, Responses, GenerateContent -} +}; diff --git a/packages/translate/src/ir/output/unit-index.ts b/packages/translate/src/ir/output/unit-index.ts new file mode 100644 index 0000000000..7209f71086 --- /dev/null +++ b/packages/translate/src/ir/output/unit-index.ts @@ -0,0 +1,27 @@ +import { irPathKey } from './operation-paths.ts'; +import type { IRPath } from './records.ts'; + +// Numbers the native units (blocks, output items, tool calls) of a stream in the order +// they open, and forgets a unit when it closes. +export const createIRUnitIndexAllocator = () => { + const indices = new Map(); + let next = 0; + const open = (path: IRPath): number => { + const key = irPathKey(path); + if (indices.has(key)) throw new TypeError(`output IR unit is already open: ${key}`); + const index = next++; + indices.set(key, index); + return index; + }; + const get = (path: IRPath): number => { + const key = irPathKey(path); + const index = indices.get(key); + if (index === undefined) throw new TypeError(`Unknown open output IR unit: ${key}`); + return index; + }; + const close = (path: IRPath): void => { + get(path); + indices.delete(irPathKey(path)); + }; + return { open, get, close }; +}; diff --git a/packages/translate/src/ir/output/unit-queue.ts b/packages/translate/src/ir/output/unit-queue.ts new file mode 100644 index 0000000000..ae997416de --- /dev/null +++ b/packages/translate/src/ir/output/unit-queue.ts @@ -0,0 +1,27 @@ +// The queue's own link, so the units it carries stay plain values. +interface QueuedNode { + readonly value: T; + next?: QueuedNode; +} + +// Units wait here until the writer can open them, and are opened in output IR order. +export const createIRUnitQueue = () => { + let head: QueuedNode | undefined; + let tail: QueuedNode | undefined; + return { + get head(): T | undefined { return head?.value; }, + enqueue: (unit: T): void => { + const node: QueuedNode = { value: unit }; + if (tail === undefined) head = node; + else tail.next = node; + tail = node; + }, + // Removes the head unit, which the caller has just opened or discarded. + dropHead: (): void => { + head = head!.next; + if (head === undefined) tail = undefined; + }, + }; +}; + +export type IRUnitQueue = ReturnType>; diff --git a/packages/translate/src/ir/output/unit-registry.ts b/packages/translate/src/ir/output/unit-registry.ts new file mode 100644 index 0000000000..32629eaea4 --- /dev/null +++ b/packages/translate/src/ir/output/unit-registry.ts @@ -0,0 +1,21 @@ +import { irPathKey, irRecordOwnerPaths } from './operation-paths.ts'; +import type { IRPath, IRRecord } from './records.ts'; + +// Finds the unit that a record belongs to. A writer registers each unit at the path +// where its output IR structure was initialized. +export const createIRUnitRegistry = () => { + const units = new Map(); + return { + set: (path: IRPath, unit: T): void => { units.set(irPathKey(path), unit); }, + get: (path: IRPath): T | undefined => units.get(irPathKey(path)), + has: (path: IRPath): boolean => units.has(irPathKey(path)), + delete: (path: IRPath): void => { units.delete(irPathKey(path)); }, + forRecord: (record: IRRecord, root: unknown): T | undefined => { + for (const path of irRecordOwnerPaths(record, root)) { + const unit = units.get(irPathKey(path)); + if (unit !== undefined) return unit; + } + return undefined; + }, + }; +}; diff --git a/packages/translate/src/ir/output/value-validation.ts b/packages/translate/src/ir/output/value-validation.ts new file mode 100644 index 0000000000..75549562c4 --- /dev/null +++ b/packages/translate/src/ir/output/value-validation.ts @@ -0,0 +1,79 @@ +import { IRAbsent } from './absent.ts'; +import { irPathKey, unionVariantNode } from './operation-paths.ts'; +import type { IRPath } from './records.ts'; +import type { IRSchemaNode } from './schema.ts'; +import { type IROutputValue } from './types.ts'; + +type IRValueVisitor = (path: IRPath, node: IRSchemaNode, value: IROutputValue) => void; + +const isPlainObject = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); + +// Opaque values are replaced as a whole, so only what no output IR value may contain is rejected. +const assertPlainValue = (path: IRPath, value: unknown): void => { + if (value === undefined) throw new TypeError(`output IR values cannot contain undefined: ${irPathKey(path)}`); + if (value === IRAbsent) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); + if (typeof value !== 'object' || value === null) return; + for (const [key, child] of Object.entries(value)) assertPlainValue([...path, Array.isArray(value) ? Number(key) : key], child); +}; + +// Walks a value that is about to be written together with the schema node describing it. +// `IRAbsent` is accepted where the schema allows it unless `allowAbsent` is off. +export const walkValue = (path: IRPath, node: IRSchemaNode, value: IROutputValue, visitor: IRValueVisitor, { allowAbsent }: { readonly allowAbsent: boolean }): void => { + if (value === undefined) throw new TypeError(`output IR values cannot contain undefined: ${irPathKey(path)}`); + if (node.kind === 'nullable') { + if (value === null) return visitor(path, node, value); + node = node.of; + } + visitor(path, node, value); + switch (node.kind) { + case 'string': + if (value === IRAbsent) { + if (!allowAbsent) throw new TypeError('IRAbsent must be assigned directly to call_id'); + if (node.absent !== true) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); + } else if (typeof value !== 'string') throw new TypeError(`output IR value must be a string: ${irPathKey(path)}`); + return; + case 'number': + if (typeof value !== 'number') throw new TypeError(`output IR value must be a number: ${irPathKey(path)}`); + return; + case 'array': + if (!Array.isArray(value)) throw new TypeError(`output IR value must be an array: ${irPathKey(path)}`); + value.forEach((child, index) => walkValue([...path, index], node.of, child, visitor, { allowAbsent })); + return; + case 'object': + case 'union': { + if (!isPlainObject(value)) throw new TypeError(`output IR value must be an object: ${irPathKey(path)}`); + const variant = unionVariantNode(node, value); + if (variant?.kind !== 'object') throw new TypeError(`output IR value has an unknown variant: ${irPathKey(path)}`); + for (const [key, child] of Object.entries(value)) { + const field = node.kind === 'union' && key === node.discriminant ? { kind: 'string' } as const : Object.hasOwn(variant.fields, key) ? variant.fields[key] : undefined; + if (field === undefined) throw new TypeError(`output IR value has no field ${key}: ${irPathKey(path)}`); + walkValue([...path, key], field, child, visitor, { allowAbsent }); + } + return; + } + case 'opaque': + assertPlainValue(path, value); + return; + default: + } +}; + +// Fails if a unit that has ended lacks any field required by its schema. +export const assertComplete = (path: IRPath, node: IRSchemaNode, value: unknown): void => { + const actual = unionVariantNode(node, value); + if (actual === undefined || typeof value !== 'object' || value === null) return; + if (actual.kind === 'array') (value as unknown[]).forEach((child, index) => assertComplete([...path, index], actual.of, child)); + else if (actual.kind === 'object') { + const present = value as Record; + if (actual.requiresAnyOf !== undefined && !actual.requiresAnyOf.some(key => present[key] !== undefined)) { + throw new TypeError(`output IR value requires one of ${actual.requiresAnyOf.join(', ')}: ${irPathKey(path)}`); + } + for (const [key, field] of Object.entries(actual.fields)) { + if (!Object.hasOwn(present, key)) { + if (!actual.optional.includes(key)) throw new TypeError(`output IR value requires field ${key}: ${irPathKey(path)}`); + continue; + } + assertComplete([...path, key], field, present[key]); + } + } +}; diff --git a/packages/translate/src/ir/unit-queue.ts b/packages/translate/src/ir/unit-queue.ts deleted file mode 100644 index 4b73fad318..0000000000 --- a/packages/translate/src/ir/unit-queue.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { irPathKey, irRecordOwnerPaths } from './operation-paths.ts'; -import type { IRPath, IRRecord } from './stream.ts'; - -// Finds the unit that a record belongs to. A writer registers each unit at the path -// where its IR structure was initialized. -export const createIRUnitRegistry = () => { - const units = new Map(); - return { - set: (path: IRPath, unit: T): void => { units.set(irPathKey(path), unit); }, - get: (path: IRPath): T | undefined => units.get(irPathKey(path)), - has: (path: IRPath): boolean => units.has(irPathKey(path)), - delete: (path: IRPath): void => { units.delete(irPathKey(path)); }, - forRecord: (record: IRRecord, root: unknown): T | undefined => { - for (const path of irRecordOwnerPaths(record, root)) { - const unit = units.get(irPathKey(path)); - if (unit !== undefined) return unit; - } - return undefined; - }, - }; -}; - -// Units wait here until the writer can open them, and are opened in IR order. -export interface IRQueuedUnit { nextPending?: T } - -export const createIRUnitQueue = >() => { - let head: T | undefined; - let tail: T | undefined; - return { - get head(): T | undefined { return head; }, - enqueue: (unit: T): void => { - if (tail === undefined) head = unit; - else tail.nextPending = unit; - tail = unit; - }, - // Removes the head unit, which the caller has just opened or discarded. - dequeue: (): void => { - const unit = head!; - head = unit.nextPending; - unit.nextPending = undefined; - if (head === undefined) tail = undefined; - }, - }; -}; - -export type IRUnitQueue> = ReturnType>; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/events.ts similarity index 89% rename from packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts rename to packages/translate/src/protocols/anthropic-messages/as-source/events.ts index d31c3cf762..26c7630bfd 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/events.ts @@ -1,16 +1,22 @@ import { collectAnthropicMessagesTextViews, computeAnthropicMessagesTurnDigest, createAnthropicMessagesSidecarCarrier } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; -import type { IRCollector } from '../../../ir/collector.ts'; -import { isIRItemPath, isIRPartPath, irPathKey } from '../../../ir/operation-paths.ts'; -import { createIROrdinalMapper } from '../../../ir/ordinal-mapper.ts'; -import { consumeIRRecords, type IRPath, type IRRecord } from '../../../ir/stream.ts'; -import { IRAbsent, type IR, type IRErrorPayload, type IRJSONObject } from '../../../ir/types.ts'; -import { createIRUnitQueue, createIRUnitRegistry } from '../../../ir/unit-queue.ts'; +import type { IRJSONObject } from '../../../ir/common/json.ts'; +import { IRAbsent } from '../../../ir/output/absent.ts'; +import type { IRCollector } from '../../../ir/output/collector.ts'; +import type { IRErrorPayload } from '../../../ir/output/error.ts'; +import { isIRItemPath, isIRPartPath, irPathKey } from '../../../ir/output/operation-paths.ts'; +import type { IRPath, IRRecord } from '../../../ir/output/records.ts'; +import { consumeIRRecords } from '../../../ir/output/stream.ts'; +import type { IROutput } from '../../../ir/output/types.ts'; +import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; +import { createIRUnitQueue } from '../../../ir/output/unit-queue.ts'; +import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; import { appendContent, createContentState, type ContentState, type ContentStates } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; -import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; -import { parseJSONObject, parseJSONObjectAtCompletion, requireJSONObject } from '../../../shared/json/object.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 { generateAnthropicId, @@ -23,6 +29,7 @@ import { type AnthropicMessagesStreamEventEx, type AnthropicMessagesUsage, } from '@floway-dev/protocols/anthropic-messages'; +import { isJsonObject } from '@floway-dev/protocols/common'; import { eventFrame, type EventFrame } from '@floway-dev/protocols/common'; const MESSAGES_ERROR_TYPES = [ @@ -46,14 +53,10 @@ interface MessagesUnit { textContentState?: ContentState<'text'>; // Keep source intervals until their native field is emitted, including terminal JSON normalization. pendingText?: Map; - nextPending?: MessagesUnit; } -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - const unitKind = (value: unknown): MessagesUnit['kind'] | 'custom_tool_call' | undefined => { - if (!isRecord(value)) return undefined; + if (!isJsonObject(value)) return undefined; return value.type === 'text' || value.type === 'refusal' || value.type === 'audio' @@ -83,20 +86,20 @@ const outputTextKind = (unit: MessagesUnit, record: IRAppendRecord): 'text' | 't return undefined; }; -interface AnthropicMessagesSSEOutputOptions extends RoundTripOutputOptions<'anthropicMessages'> { +interface AnthropicMessagesEventWriterOptions extends SourceEventWriterOptions<'anthropicMessages'> { parseToolArguments?: (text: string) => IRJSONObject; } -export const anthropicMessagesFromIR = async function* ( +export const writeAnthropicMessagesEvents = async function* ( records: AsyncIterable, - options: AnthropicMessagesSSEOutputOptions, + options: AnthropicMessagesEventWriterOptions, ): AsyncGenerator> { let metadata = { id: '', model: '' }; let started = false; let finishReason: NonNullable = 'end_turn'; const units = createIRUnitRegistry(); const pending = createIRUnitQueue(); - const ordinals = createIROrdinalMapper(); + const ordinals = createIRUnitIndexAllocator(); const nativeAccumulator = createAnthropicMessagesEventAccumulator(); const contentState: NonNullable['content']> = []; let nextBlockIndex = 0; @@ -111,7 +114,7 @@ export const anthropicMessagesFromIR = async function* ( const blockDelta = (index: number, delta: AnthropicMessagesContentBlockDeltaEvent['delta']): EventFrame => emit({ type: 'content_block_delta', index, delta }); - const usage = (state: IR): AnthropicMessagesUsage => { + const usage = (state: IROutput): AnthropicMessagesUsage => { const value = usageFromIR(state.usage ?? {}); if (state.service_tier === 'fast') value.speed = 'fast'; else if (typeof state.service_tier === 'string') value.service_tier = state.service_tier; @@ -119,7 +122,7 @@ export const anthropicMessagesFromIR = async function* ( }; // Queues the content blocks that the IR structures initialized by a record will become. - const registerUnits = (record: IRRecord, state: IR): void => { + const registerUnits = (record: IRRecord, state: IROutput): void => { if (record.type === 'write' && record.kind === 'initialize') { let path: IRPath | undefined; let kind: MessagesUnit['kind'] | undefined; @@ -155,7 +158,7 @@ export const anthropicMessagesFromIR = async function* ( }; // Opens the waiting units in IR order, until one still lacks what its start event needs. - const openPendingUnits = (state: IR, collector: IRCollector): AnthropicMessagesStreamEventEx[] => { + const openPendingUnits = (state: IROutput, collector: IRCollector): AnthropicMessagesStreamEventEx[] => { const pendingEvents: AnthropicMessagesStreamEventEx[] = []; while (pending.head !== undefined) { const unit = pending.head; @@ -179,12 +182,12 @@ export const anthropicMessagesFromIR = async function* ( else wait = !closed; } else if (item.type === 'function_call') { if (item.name === '') throw new TypeError('Completed tool calls require a name'); - const callId = collector.optional([...unit.path, 'call_id']); + const callId = collector.valueOrAbsent([...unit.path, 'call_id']); if (callId === undefined) wait = true; else block = { type: 'tool_use', id: callId === IRAbsent ? `${metadata.id}_${itemIndex}` : callId, name: item.name, input: {} }; } if (wait) break; - pending.dequeue(); + pending.dropHead(); if (block === undefined) { units.delete(unit.path); continue; } unit.opened = true; const index = ordinals.open(unit.path); @@ -253,7 +256,7 @@ export const anthropicMessagesFromIR = async function* ( : record.finish_reason === 'tool_calls' ? 'tool_use' : record.finish_reason === 'content_filter' ? 'refusal' : 'end_turn'; for (const item of state.choices[0].items) if (item.type === 'function_call') { - parseJSONObjectAtCompletion(item.arguments, record.finish_reason === 'length' ? 'truncated' : 'complete', options.parseToolArguments); + parseJSONObjectUnlessTruncated(item.arguments, record.finish_reason === 'length' ? 'truncated' : 'complete', options.parseToolArguments); } continue; } @@ -312,7 +315,7 @@ export const anthropicMessagesFromIR = async function* ( serializedInput = serialized; if (serialized !== '') yield blockDelta(index, { type: 'input_json_delta', partial_json: serialized }); } else { - const parsed = parseJSONObjectAtCompletion(item.arguments, record.type === 'item_end' && record.status === 'incomplete' ? 'truncated' : 'complete'); + const parsed = parseJSONObjectUnlessTruncated(item.arguments, record.type === 'item_end' && record.status === 'incomplete' ? 'truncated' : 'complete'); if (parsed.completion === 'complete') serializedInput = JSON.stringify(parsed.value); } } @@ -361,7 +364,7 @@ export const anthropicMessagesFromIR = async function* ( yield pendingBlockStop; pendingBlockStop = undefined; } - const refusal = state.choices[0]?.refusal; + const refusal = state.choices[0]?.refusal_details; yield emit({ type: 'message_delta', delta: { diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts b/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts index 8eb74e8204..93eb4db66f 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts @@ -1,4 +1,4 @@ -import type { IRJSONObject, IRJSONValue } from '../../../ir/types.ts'; +import type { IRJSONObject, IRJSONValue } from '../../../ir/common/json.ts'; import type { ReplayContent } from '../../../round-trip/reference/assistant-turn.ts'; import type { EncodedRoundTripSidecar } from '../../../round-trip/resolve.ts'; import { collectTextGroups, collectTextPlacementIndex, type TextContribution, type TextPlacementIndex } from '../../../round-trip/text-groups.ts'; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/usage.ts b/packages/translate/src/protocols/anthropic-messages/as-source/usage.ts index 10b65072dd..20061d6d57 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/usage.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/usage.ts @@ -1,9 +1,9 @@ -import type { IRUsage } from '../../../ir/types.ts'; +import type { IROutputUsage } from '../../../ir/output/types.ts'; import { requireTokenCount } from '../../../shared/token-count.ts'; import { type AnthropicMessagesUsage, createAnthropicMessagesUsage } from '@floway-dev/protocols/anthropic-messages'; import { splitInclusiveInputTokens } from '@floway-dev/protocols/common'; -export const usageFromIR = (usage: IRUsage): AnthropicMessagesUsage => { - const count = (key: keyof IRUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); +export const usageFromIR = (usage: IROutputUsage): AnthropicMessagesUsage => { + const count = (key: keyof IROutputUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); const input = count('input_tokens_inclusive') ?? 0; const output = count('output_tokens_inclusive') ?? 0; const cached = count('cached_input_tokens') ?? 0; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/assistant-turn.ts b/packages/translate/src/protocols/anthropic-messages/as-target/assistant-turn.ts index 0358b85b89..4604e380af 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/assistant-turn.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/assistant-turn.ts @@ -1,4 +1,4 @@ -import type { IRJSONObject } from '../../../ir/types.ts'; +import type { IRJSONObject } from '../../../ir/common/json.ts'; import type { ReferenceState, ReferenceStateLeaves, ReferenceableValue, TextRule, JSONRule } from '../../../round-trip/reference/assistant-turn.ts'; import type { AnthropicMessagesAssistantContentBlock } from '@floway-dev/protocols/anthropic-messages'; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/sse-from.ts b/packages/translate/src/protocols/anthropic-messages/as-target/events.ts similarity index 96% rename from packages/translate/src/protocols/anthropic-messages/as-target/sse-from.ts rename to packages/translate/src/protocols/anthropic-messages/as-target/events.ts index bc83751297..00e981efe4 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/sse-from.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/events.ts @@ -1,7 +1,8 @@ import type { AnthropicMessagesAssistantState } from './assistant-turn.ts'; import { type AnthropicMessagesInputCounts, usageToIR } from './usage.ts'; -import { createIRBuilder, type IRItemHandle, type IRPartHandle, type IRRecord } from '../../../ir/stream.ts'; -import type { IRSourceCitation } from '../../../ir/types.ts'; +import { createIRBuilder, type IRItemHandle, type IRPartHandle } from '../../../ir/output/builder.ts'; +import type { IRRecord } from '../../../ir/output/records.ts'; +import type { IROutputSourceCitation } from '../../../ir/output/types.ts'; import { appendField, createFieldState, type FieldState, type FieldStates } from '../../../round-trip/reference/reference-builder.ts'; import type { RoundTripReader } from '../../../round-trip/stream.ts'; import { requireJSONObject } from '../../../shared/json/object.ts'; @@ -15,7 +16,7 @@ import { } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; -const messagesCitationToIR = (citation: AnthropicMessagesTextCitation): IRSourceCitation | undefined => { +const messagesCitationToIR = (citation: AnthropicMessagesTextCitation): IROutputSourceCitation | undefined => { const isSearchLocation = citation.type === 'web_search_result_location' || citation.type === 'search_result_location'; const source = citation.type === 'web_search_result_location' ? citation.url : citation.type === 'search_result_location' ? citation.source : citation.file_id ?? undefined; @@ -31,7 +32,7 @@ const messagesCitationToIR = (citation: AnthropicMessagesTextCitation): IRSource ...(citation.type === 'page_location' ? { source_page_range: { start_one_based: citation.start_page_number, end_one_based_exclusive: citation.end_page_number } } : {}), - } as IRSourceCitation; + } as IROutputSourceCitation; }; // Blocks that open no IR item; their content is not translated. @@ -60,7 +61,7 @@ interface MessagesBlockState { wrappedInput: string; } -export const irFromAnthropicMessages = async function* ( +export const readAnthropicMessagesEvents = async function* ( frames: AsyncIterable>, options: { customToolNames?: ReadonlySet; roundTrip: RoundTripReader<'anthropicMessages'> }, ): AsyncGenerator { @@ -214,7 +215,7 @@ export const irFromAnthropicMessages = async function* ( assignUsage(e.usage); if (e.delta.stop_reason === 'compaction') throw new TypeError('Cannot translate Messages compaction stop'); if (e.delta.stop_reason === 'refusal') { - ir.choice(0).assign('refusal', { + ir.choice(0).assign('refusal_details', { category: e.delta.stop_details?.category ?? null, explanation: e.delta.stop_details?.explanation ?? null, }); diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/usage.ts b/packages/translate/src/protocols/anthropic-messages/as-target/usage.ts index 68ecb081b4..cffe87e8cc 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/usage.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/usage.ts @@ -1,4 +1,4 @@ -import type { IRUsage } from '../../../ir/types.ts'; +import type { IROutputUsage } from '../../../ir/output/types.ts'; import { requireTokenCount } from '../../../shared/token-count.ts'; import { type AnthropicMessagesUsageDeltaEx, splitAnthropicMessagesCacheCreationTokens } from '@floway-dev/protocols/anthropic-messages'; import { splitInclusiveInputTokens } from '@floway-dev/protocols/common'; @@ -11,10 +11,10 @@ export interface AnthropicMessagesInputCounts { cacheWrite?: number; } -export const usageToIR = (usage: Partial | null | undefined, counts: AnthropicMessagesInputCounts): IRUsage => { - const result: Partial> = {}; +export const usageToIR = (usage: Partial | null | undefined, counts: AnthropicMessagesInputCounts): IROutputUsage => { + const result: Partial> = {}; if (usage == null) return result; - const put = (key: keyof IRUsage, value: number | undefined | null): void => { if (value != null) result[key] = requireTokenCount(key, value); }; + const put = (key: keyof IROutputUsage, value: number | undefined | null): void => { if (value != null) result[key] = requireTokenCount(key, value); }; if (usage.input_tokens != null) counts.input = requireTokenCount('input_tokens', usage.input_tokens); if (usage.cache_read_input_tokens != null) counts.cacheRead = requireTokenCount('cache_read_input_tokens', usage.cache_read_input_tokens); const hasCacheWrite = usage.cache_creation_input_tokens != null || usage.cache_creation != null; diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts index efbdc00b2d..7d1a62d87a 100644 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts +++ b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts @@ -1,12 +1,12 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromOpenAIChatCompletions } from '../../openai-chat-completions/as-target/sse-from.ts'; -import { anthropicMessagesFromIR } from '../as-source/sse-to.ts'; +import { readOpenAIChatCompletionsEvents } from '../../openai-chat-completions/as-target/events.ts'; +import { writeAnthropicMessagesEvents } from '../as-source/events.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec, maxChoices = 1): AsyncIterable> => { const stream = createRoundTripStream('anthropicMessages', 'openaiChatCompletions', codec); - return anthropicMessagesFromIR(irFromOpenAIChatCompletions(frames, { maxChoices, roundTrip: stream.reader }), { roundTrip: stream.writer }); + return writeAnthropicMessagesEvents(readOpenAIChatCompletionsEvents(frames, { maxChoices, roundTrip: stream.reader }), { roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts index 777ceb4971..bc6d985145 100644 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts +++ b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts @@ -1,12 +1,12 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromOpenAIResponses } from '../../openai-responses/as-target/sse-from.ts'; -import { anthropicMessagesFromIR } from '../as-source/sse-to.ts'; +import { readOpenAIResponsesEvents } from '../../openai-responses/as-target/events.ts'; +import { writeAnthropicMessagesEvents } from '../as-source/events.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { const stream = createRoundTripStream('anthropicMessages', 'openaiResponses', codec); - return anthropicMessagesFromIR(irFromOpenAIResponses(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); + return writeAnthropicMessagesEvents(readOpenAIResponsesEvents(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts similarity index 91% rename from packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts rename to packages/translate/src/protocols/gemini-generate-content/as-source/events.ts index 7e224bee09..ef1c3a912a 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts @@ -1,15 +1,21 @@ import { collectGeminiGenerateContentTextViews, computeGeminiGenerateContentTurnDigest, createGeminiGenerateContentSidecarCarrier } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; -import type { IRCollector } from '../../../ir/collector.ts'; -import { irItemPath, irPartPath, irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/operation-paths.ts'; -import { consumeIRRecords, type IRPath, type IRRecord } from '../../../ir/stream.ts'; -import { IRAbsent, type IR, type IRErrorPayload, type IRJSONObject, type IRSourceCitation, type IRTokenLogprob } from '../../../ir/types.ts'; -import { createIRUnitQueue, createIRUnitRegistry, type IRUnitQueue } from '../../../ir/unit-queue.ts'; +import type { IRJSONObject } from '../../../ir/common/json.ts'; +import { IRAbsent } from '../../../ir/output/absent.ts'; +import type { IRCollector } from '../../../ir/output/collector.ts'; +import type { IRErrorPayload } from '../../../ir/output/error.ts'; +import { irItemPath, irPartPath, irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/output/operation-paths.ts'; +import type { IRPath, IRRecord } from '../../../ir/output/records.ts'; +import { consumeIRRecords } from '../../../ir/output/stream.ts'; +import type { IROutput, IROutputSourceCitation, IROutputTokenLogprob } from '../../../ir/output/types.ts'; +import { createIRUnitQueue, type IRUnitQueue } from '../../../ir/output/unit-queue.ts'; +import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; import { appendContent, createContentState, type ContentStates, type ContentState as ReferenceContentState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; -import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; -import { isCompleteJSONObject, parseJSONObjectAtCompletion } from '../../../shared/json/object.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 { eventFrame, FAST_SERVICE_TIER, type EventFrame } from '@floway-dev/protocols/common'; @@ -37,7 +43,6 @@ interface GenerateContentUnit { opened: boolean; pendingData?: TextProvenanceSpan[]; pendingArguments?: TextProvenanceSpan[]; - nextPending?: GenerateContentUnit; } interface PendingTextQueue { @@ -53,13 +58,13 @@ interface TextSegment { targetStart: number; } -interface GeminiGenerateContentSSEOutputOptions extends RoundTripOutputOptions<'geminiGenerateContent'> { +interface GeminiGenerateContentEventWriterOptions extends SourceEventWriterOptions<'geminiGenerateContent'> { parseToolArguments?: (text: string) => IRJSONObject; } -export const geminiGenerateContentFromIR = async function* ( +export const writeGeminiGenerateContentEvents = async function* ( records: AsyncIterable, - options: GeminiGenerateContentSSEOutputOptions, + options: GeminiGenerateContentEventWriterOptions, ): AsyncGenerator> { let metadata = { id: '', model: '' }; let started = false; @@ -186,7 +191,7 @@ export const geminiGenerateContentFromIR = async function* ( }; // Queues the units that the IR structures initialized by a record will become. - const registerUnits = (record: IRRecord, state: IR): void => { + const registerUnits = (record: IRRecord, state: IROutput): void => { if (record.type === 'write' && record.kind === 'initialize') { const choice = record.path[1]; if (typeof choice !== 'number') return; @@ -206,7 +211,7 @@ export const geminiGenerateContentFromIR = async function* ( }; // Opens each choice's waiting units in IR order, until one still lacks what its part needs. - const openPendingUnits = function* (record: IRRecord, state: IR, collector: IRCollector): Generator> { + const openPendingUnits = function* (record: IRRecord, state: IROutput, collector: IRCollector): Generator> { const endingChoice = record.type === 'choice_end' ? record : undefined; for (const [choice, queue] of queues) { const choiceEnding = endingChoice?.choice === choice ? endingChoice : undefined; @@ -220,9 +225,9 @@ export const geminiGenerateContentFromIR = async function* ( if (item.type !== 'message') throw new TypeError('GenerateContent message unit requires a message item'); const content = item.content[unit.path[5] as number]; const inlineMedia = content.type === 'image' - ? { mimeType: content.image.mime_type, data: content.image.data } + ? { mimeType: content.image.media_type, data: content.image.data } : content.type === 'audio' && content.audio.data !== undefined - ? { mimeType: content.audio.mime_type, data: content.audio.data } + ? { mimeType: content.audio.media_type, data: content.audio.data } : undefined; if (content.type === 'text' || content.type === 'refusal') { part = undefined; @@ -267,7 +272,7 @@ export const geminiGenerateContentFromIR = async function* ( if (completion === 'truncated') part = undefined; else if (!isCompleteJSONObject(item.arguments) && choiceEnding === undefined && record.type !== 'finish') wait = true; else { - const parsed = parseJSONObjectAtCompletion(item.arguments, completion, options.parseToolArguments); + const parsed = parseJSONObjectUnlessTruncated(item.arguments, completion, options.parseToolArguments); if (parsed.completion === 'complete') { const partIndex = occupyPart(choice, 'other'); part = { @@ -287,7 +292,7 @@ export const geminiGenerateContentFromIR = async function* ( } } if (wait) break; - queue.dequeue(); + queue.dropHead(); unit.opened = true; if (part !== undefined) yield emitPart(choice, part); for (const pendingPart of drainPendingText(choice, collector)) yield emitPart(choice, pendingPart); @@ -298,7 +303,7 @@ export const geminiGenerateContentFromIR = async function* ( }; // The terminal candidate of a choice: finish reason, grounding built from its citations, and logprobs. - const terminalCandidate = (record: Extract, state: IR, collector: IRCollector): GeminiGenerateContentCandidate => { + const terminalCandidate = (record: Extract, state: IROutput, collector: IRCollector): GeminiGenerateContentCandidate => { const choice = state.choices[record.choice]; const partTexts = new Map(); for (const [partIndex, segments] of segmentsByChoice.get(record.choice) ?? []) { @@ -331,7 +336,7 @@ export const geminiGenerateContentFromIR = async function* ( const range = { start: segment.targetStart + start - segment.sourceStart, end_exclusive: segment.targetStart + end - segment.sourceStart }; partText.coordinateIndex ??= TextCoordinateIndex.utf16ToUTF8(partText.text); partText.coordinateIndex.assertUTF16Length(partText.text.length); - const bytes = partText.coordinateIndex.irRangeToUTF8(range); + const bytes = partText.coordinateIndex.utf16RangeToUTF8(range); supports.push({ groundingChunkIndices: [chunkIndex], segment: { partIndex: segment.partIndex, startIndex: bytes.start, endIndex: bytes.end, text: partText.text.slice(range.start, range.end_exclusive) }, @@ -342,7 +347,7 @@ export const geminiGenerateContentFromIR = async function* ( }); const tokens = choice.logprobs?.flatMap(group => group.tokens); return { - ...(choice.refusal?.explanation == null ? {} : { finishMessage: choice.refusal.explanation }), + ...(choice.refusal_details?.explanation == null ? {} : { finishMessage: choice.refusal_details.explanation }), index: record.choice, finishReason: record.finish_reason === 'length' ? 'MAX_TOKENS' : record.finish_reason === 'content_filter' ? 'SAFETY' : 'STOP', ...(chunks.length === 0 ? {} : { groundingMetadata: { groundingChunks: chunks, groundingSupports: supports } }), @@ -430,14 +435,14 @@ export const geminiGenerateContentFromIR = async function* ( } }; -const logprobsResult = (tokens: readonly IRTokenLogprob[]): NonNullable => ({ +const logprobsResult = (tokens: readonly IROutputTokenLogprob[]): NonNullable => ({ chosenCandidates: tokens.map(token => ({ token: token.token, logProbability: token.logprob })), topCandidates: tokens.map(token => ({ candidates: (token.top_logprobs ?? []).map(alternative => ({ token: alternative.token, logProbability: alternative.logprob })), })), }); -const groundingChunk = (citation: IRSourceCitation): GeminiGenerateContentGroundingChunk => { +const groundingChunk = (citation: IROutputSourceCitation): GeminiGenerateContentGroundingChunk => { if (citation.source_kind === 'url') return { web: { uri: citation.source, title: citation.source_label ?? undefined } }; return { retrievedContext: { diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts index c7a6839460..f2f24c54c2 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts @@ -1,4 +1,4 @@ -import type { IRJSONObject } from '../../../ir/types.ts'; +import type { IRJSONObject } from '../../../ir/common/json.ts'; import type { ReplayContent } from '../../../round-trip/reference/assistant-turn.ts'; import type { EncodedRoundTripSidecar } from '../../../round-trip/resolve.ts'; import { collectTextGroups, collectTextPlacementIndex, type TextContribution, type TextPlacementIndex } from '../../../round-trip/text-groups.ts'; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/usage.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/usage.ts index febb58e32f..bb5a802f2c 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/usage.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/usage.ts @@ -1,15 +1,15 @@ -import type { IRUsage } from '../../../ir/types.ts'; +import type { IROutputUsage } from '../../../ir/output/types.ts'; import { requireTokenCount } from '../../../shared/token-count.ts'; import { splitInclusiveOutputTokens } from '@floway-dev/protocols/common'; import type { GeminiGenerateContentModalityTokenCount, GeminiGenerateContentUsageMetadata } from '@floway-dev/protocols/gemini-generate-content'; -export const usageFromIR = (usage: IRUsage): GeminiGenerateContentUsageMetadata => { - const count = (key: keyof IRUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); +export const usageFromIR = (usage: IROutputUsage): GeminiGenerateContentUsageMetadata => { + const count = (key: keyof IROutputUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); const input = count('input_tokens_inclusive') ?? 0; const output = count('output_tokens_inclusive') ?? 0; const reasoning = count('reasoning_tokens'); const split = splitInclusiveOutputTokens(output, reasoning); - const modalityTokens = (keys: readonly (readonly [modality: string, key: keyof IRUsage])[]): GeminiGenerateContentModalityTokenCount[] => + const modalityTokens = (keys: readonly (readonly [modality: string, key: keyof IROutputUsage])[]): GeminiGenerateContentModalityTokenCount[] => keys.flatMap(([modality, key]) => usage[key] === undefined ? [] : [{ modality, tokenCount: requireTokenCount(key, usage[key]) }]); const promptDetails = modalityTokens([['AUDIO', 'input_audio_tokens'], ['IMAGE', 'input_image_tokens'], ['TEXT', 'input_text_tokens']]); const candidatesDetails = modalityTokens([['AUDIO', 'output_audio_tokens'], ['TEXT', 'output_text_tokens']]); diff --git a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts index b80a1d6eb8..0f574ddb00 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts @@ -1,12 +1,12 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromAnthropicMessages } from '../../anthropic-messages/as-target/sse-from.ts'; -import { geminiGenerateContentFromIR } from '../as-source/sse-to.ts'; +import { readAnthropicMessagesEvents } from '../../anthropic-messages/as-target/events.ts'; +import { writeGeminiGenerateContentEvents } from '../as-source/events.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { const stream = createRoundTripStream('geminiGenerateContent', 'anthropicMessages', codec); - return geminiGenerateContentFromIR(irFromAnthropicMessages(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); + return writeGeminiGenerateContentEvents(readAnthropicMessagesEvents(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts index 98467f304d..eeae06afe8 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts @@ -1,12 +1,12 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromOpenAIChatCompletions } from '../../openai-chat-completions/as-target/sse-from.ts'; -import { geminiGenerateContentFromIR } from '../as-source/sse-to.ts'; +import { readOpenAIChatCompletionsEvents } from '../../openai-chat-completions/as-target/events.ts'; +import { writeGeminiGenerateContentEvents } from '../as-source/events.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec, maxChoices = 1): AsyncIterable> => { const stream = createRoundTripStream('geminiGenerateContent', 'openaiChatCompletions', codec); - return geminiGenerateContentFromIR(irFromOpenAIChatCompletions(frames, { maxChoices, roundTrip: stream.reader }), { roundTrip: stream.writer }); + return writeGeminiGenerateContentEvents(readOpenAIChatCompletionsEvents(frames, { maxChoices, roundTrip: stream.reader }), { roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts index 7d472247a2..5fd713d617 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts @@ -1,12 +1,12 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromOpenAIResponses } from '../../openai-responses/as-target/sse-from.ts'; -import { geminiGenerateContentFromIR } from '../as-source/sse-to.ts'; +import { readOpenAIResponsesEvents } from '../../openai-responses/as-target/events.ts'; +import { writeGeminiGenerateContentEvents } from '../as-source/events.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { const stream = createRoundTripStream('geminiGenerateContent', 'openaiResponses', codec); - return geminiGenerateContentFromIR(irFromOpenAIResponses(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); + return writeGeminiGenerateContentEvents(readOpenAIResponsesEvents(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts similarity index 91% rename from packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts rename to packages/translate/src/protocols/openai-chat-completions/as-source/events.ts index 9cba106198..dbebad7b07 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts @@ -1,14 +1,18 @@ import { collectOpenAIChatCompletionsTextViews, computeOpenAIChatCompletionsTurnDigest, createOpenAIChatCompletionsSidecarCarrier } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; -import { irItemPath, irPartPath, irPathKey } from '../../../ir/operation-paths.ts'; -import { createIROrdinalMapper } from '../../../ir/ordinal-mapper.ts'; -import { consumeIRRecords, type IRRecord } from '../../../ir/stream.ts'; -import { IRAbsent, type IRErrorPayload, type IRItem, type IRMessageItem, type IRTokenLogprob } from '../../../ir/types.ts'; +import { IRAbsent } from '../../../ir/output/absent.ts'; +import type { IRErrorPayload } from '../../../ir/output/error.ts'; +import { irItemPath, irPartPath, irPathKey } from '../../../ir/output/operation-paths.ts'; +import type { IRRecord } from '../../../ir/output/records.ts'; +import { consumeIRRecords } from '../../../ir/output/stream.ts'; +import type { IROutputItem, IROutputMessageItem, IROutputTokenLogprob } from '../../../ir/output/types.ts'; +import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; import { appendContent, createContentState, type ContentStates, type ContentState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; -import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; +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 { isJsonObject } from '@floway-dev/protocols/common'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { type OpenAIChatCompletionsAnnotation, @@ -27,7 +31,7 @@ const irChatError = (error: IRErrorPayload): IRErrorPayload => ({ ...(error.provider_specific_fields === undefined ? {} : { provider_specific_fields: error.provider_specific_fields }), }); -interface OpenAIChatCompletionsSSEOutputOptions extends RoundTripOutputOptions<'openaiChatCompletions'> { +interface OpenAIChatCompletionsEventWriterOptions extends SourceEventWriterOptions<'openaiChatCompletions'> { // Replay identity belongs to the newly emitted target audio, not the source IR content. audioMetadata?: (choice: number) => { id: string; expires_at: number }; continuousUsageStats?: boolean; @@ -35,13 +39,10 @@ interface OpenAIChatCompletionsSSEOutputOptions extends RoundTripOutputOptions<' const tierFields = (tier?: string): { service_tier?: string } => tier === undefined ? {} : { service_tier: tier }; -type IRToolCallItem = Extract; +type IRToolCallItem = Extract; -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - -const isIRItem = (value: unknown): value is IRItem => { - if (!isRecord(value)) return false; +const isIRItem = (value: unknown): value is IROutputItem => { + if (!isJsonObject(value)) return false; switch (value.type) { case 'message': return Array.isArray(value.content); case 'reasoning': return true; @@ -51,8 +52,8 @@ const isIRItem = (value: unknown): value is IRItem => { } }; -const partType = (value: unknown): IRMessageItem['content'][number]['type'] | undefined => { - if (!isRecord(value)) return undefined; +const partType = (value: unknown): IROutputMessageItem['content'][number]['type'] | undefined => { + if (!isJsonObject(value)) return undefined; return value.type === 'text' || value.type === 'refusal' || value.type === 'image' || value.type === 'audio' ? value.type : undefined; }; @@ -63,7 +64,7 @@ type LogprobMetadata = { readonly scope: 'choice' } | { }; const logprobMetadata = (value: unknown): LogprobMetadata | undefined => { - if (!isRecord(value)) return undefined; + if (!isJsonObject(value)) return undefined; if (value.scope === 'choice') return { scope: 'choice' }; if (value.scope === 'text_part' && typeof value.item_index === 'number' && typeof value.content_index === 'number') { return { scope: 'text_part', item_index: value.item_index, content_index: value.content_index }; @@ -71,25 +72,25 @@ const logprobMetadata = (value: unknown): LogprobMetadata | undefined => { return undefined; }; -const isIRTokenLogprob = (value: unknown): value is IRTokenLogprob => - isRecord(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; +const isIRTokenLogprob = (value: unknown): value is IROutputTokenLogprob => + isJsonObject(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; // Emitted deltas carry the assistant extension fields (reasoning, annotations) beside the standard ones. type ChatChoiceChunk = Omit & { delta: OpenAIChatCompletionsAssistantDeltaEx }; type ChatChunkFields = Partial>; type ChatTextField = 'content' | 'refusal' | 'reasoning_text'; -export const openaiChatCompletionsFromIR = async function* ( +export const writeOpenAIChatCompletionsEvents = async function* ( records: AsyncIterable, - options: OpenAIChatCompletionsSSEOutputOptions, + options: OpenAIChatCompletionsEventWriterOptions, ): AsyncGenerator> { let metadata = { id: '', model: '', created: 0 }; let started = false; const startedChoices = new Set(); - const tools = new Map>(); + const tools = new Map>(); const startedAudio = new Set(); - const itemTypes = new Map(); - const partTypes = new Map(); + const itemTypes = new Map(); + const partTypes = new Map(); const logprobRefusal = new Map(); const nativeAccumulators = new Map>(); const nativeAccumulator = (choice: number): ReturnType => { @@ -153,7 +154,7 @@ export const openaiChatCompletionsFromIR = async function* ( const ensureChoice = (choice: number, tier?: string): ProtocolFrame | undefined => { if (startedChoices.has(choice)) return undefined; startedChoices.add(choice); - tools.set(choice, createIROrdinalMapper()); + tools.set(choice, createIRUnitIndexAllocator()); return deltaFrame(choice, { role: 'assistant', content: '' }, tier); }; @@ -377,7 +378,7 @@ export const openaiChatCompletionsFromIR = async function* ( const range = annotation.output_text_range; coordinateIndex ??= TextCoordinateIndex.utf16ToCodePoints(flattened); coordinateIndex.assertUTF16Length(flattened.length); - const offsets = coordinateIndex.irRangeToCodePoints({ start: base + range.start, end_exclusive: base + range.end_exclusive }); + const offsets = coordinateIndex.utf16RangeToCodePoints({ start: base + range.start, end_exclusive: base + range.end_exclusive }); annotations.push({ type: 'url_citation', url_citation: { start_index: offsets.start, end_index: offsets.end, title: annotation.source_label ?? annotation.source, url: annotation.source }, @@ -386,9 +387,9 @@ export const openaiChatCompletionsFromIR = async function* ( }); }); if (annotations.length > 0) yield deltaFrame(record.choice, { annotations }, tier); - if (choice.refusal?.explanation != null) { - appendContent(options.roundTrip.builder, textState(record.choice, 'refusal'), choice.refusal.explanation); - yield deltaFrame(record.choice, { refusal: choice.refusal.explanation }, tier); + if (choice.refusal_details?.explanation != null) { + appendContent(options.roundTrip.builder, textState(record.choice, 'refusal'), choice.refusal_details.explanation); + yield deltaFrame(record.choice, { refusal: choice.refusal_details.explanation }, tier); } const summary = nativeAccumulator(record.choice).result().choices.find(choice => choice.index === record.choice)?.message.reasoning_text; if (typeof summary === 'string' && summary !== '') { diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts index c9577cba28..0ef3b5905a 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts @@ -1,4 +1,4 @@ -import type { IRJSONObject, IRJSONValue } from '../../../ir/types.ts'; +import type { IRJSONObject, IRJSONValue } from '../../../ir/common/json.ts'; import type { ReplayContent } from '../../../round-trip/reference/assistant-turn.ts'; import type { EncodedRoundTripSidecar } from '../../../round-trip/resolve.ts'; import { collectTextGroups, collectTextPlacementIndex, type TextContribution, type TextPlacementIndex } from '../../../round-trip/text-groups.ts'; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/usage.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/usage.ts index c6ad0844aa..029893fee6 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/usage.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/usage.ts @@ -1,9 +1,9 @@ -import type { IRUsage } from '../../../ir/types.ts'; +import type { IROutputUsage } from '../../../ir/output/types.ts'; import { requireTokenCount } from '../../../shared/token-count.ts'; import type { OpenAIChatCompletionsUsageEx } from '@floway-dev/protocols/openai-chat-completions'; -export const usageFromIR = (usage: IRUsage): OpenAIChatCompletionsUsageEx => { - const count = (key: keyof IRUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); +export const usageFromIR = (usage: IROutputUsage): OpenAIChatCompletionsUsageEx => { + const count = (key: keyof IROutputUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); const input = count('input_tokens_inclusive') ?? 0; const output = count('output_tokens_inclusive') ?? 0; const inputDetails: NonNullable = {}; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/events.ts similarity index 97% rename from packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts rename to packages/translate/src/protocols/openai-chat-completions/as-target/events.ts index 26e65ac120..ee815569a3 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/events.ts @@ -1,14 +1,10 @@ import { toOpenAIChatCompletionsInputAssistantTurn, type OpenAIChatCompletionsOutputAssistantState } from './assistant-turn.ts'; import { usageToIR } from './usage.ts'; -import { - createIRBuilder, - type IRRecord, - type IRItemHandle, - type IRPartHandle, - type IRWriteHandle, - type IRRelativePath, -} from '../../../ir/stream.ts'; -import { IRAbsent, type IRErrorPayload, type IRSourceCitation } from '../../../ir/types.ts'; +import { IRAbsent } from '../../../ir/output/absent.ts'; +import { createIRBuilder, type IRItemHandle, type IRPartHandle, type IRWriteHandle, type IRRelativePath } from '../../../ir/output/builder.ts'; +import type { IRErrorPayload } from '../../../ir/output/error.ts'; +import type { IRRecord } from '../../../ir/output/records.ts'; +import type { IROutputSourceCitation } from '../../../ir/output/types.ts'; import { appendField, createFieldState, type FieldStates, type FieldState, type ReferencePieceId } from '../../../round-trip/reference/reference-builder.ts'; import type { RoundTripReader } from '../../../round-trip/stream.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; @@ -121,7 +117,7 @@ interface ChatChoiceState { incomplete: boolean; } -export const irFromOpenAIChatCompletions = async function* ( +export const readOpenAIChatCompletionsEvents = async function* ( frames: AsyncIterable>, options: { customToolNames?: ReadonlySet; maxChoices?: number; roundTrip: RoundTripReader<'openaiChatCompletions'> }, ): AsyncGenerator { @@ -226,12 +222,12 @@ export const irFromOpenAIChatCompletions = async function* ( } const start = Math.min(native.start_index, availableCodePoints); const end = Math.min(native.end_index, availableCodePoints); - return { annotation, range: state.coordinateIndex!.codePointRangeToIR(start, end), exceedsAvailableText: native.end_index > availableCodePoints }; + return { annotation, range: state.coordinateIndex!.codePointRangeToUTF16(start, end), exceedsAvailableText: native.end_index > availableCodePoints }; }); // Only the message that is still open can receive citations; earlier parts already ended. const openSegments = state.message?.isOpen === true ? state.textSegments.filter(segment => segment.item === state.message) : []; for (const segment of openSegments) { - const converted: IRSourceCitation[] = citations.flatMap(({ annotation, range }) => { + const converted: IROutputSourceCitation[] = citations.flatMap(({ annotation, range }) => { const start = Math.max(range.start, segment.start); const end = Math.min(range.end_exclusive, segment.end); if (start >= end) return []; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/usage.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/usage.ts index d140be125c..b0efbed8d8 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/usage.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/usage.ts @@ -1,11 +1,11 @@ -import type { IRUsage } from '../../../ir/types.ts'; +import type { IROutputUsage } from '../../../ir/output/types.ts'; import { requireTokenCount } from '../../../shared/token-count.ts'; import { splitInclusiveInputTokens } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsUsageEx } from '@floway-dev/protocols/openai-chat-completions'; -export const usageToIR = (usage: Partial): IRUsage => { - const result: Partial> = {}; - const put = (key: keyof IRUsage, value: number | undefined | null): void => { if (value != null) result[key] = requireTokenCount(key, value); }; +export const usageToIR = (usage: Partial): IROutputUsage => { + const result: Partial> = {}; + const put = (key: keyof IROutputUsage, value: number | undefined | null): void => { if (value != null) result[key] = requireTokenCount(key, value); }; put('input_tokens_inclusive', usage.prompt_tokens); put('output_tokens_inclusive', usage.completion_tokens); put('total_tokens', usage.total_tokens); diff --git a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts index 3a9743ea27..f391712fc0 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts @@ -1,7 +1,7 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromAnthropicMessages } from '../../anthropic-messages/as-target/sse-from.ts'; -import { openaiChatCompletionsFromIR } from '../as-source/sse-to.ts'; +import { readAnthropicMessagesEvents } from '../../anthropic-messages/as-target/events.ts'; +import { writeOpenAIChatCompletionsEvents } from '../as-source/events.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsStreamOptionsEx } from '@floway-dev/protocols/openai-chat-completions'; @@ -13,5 +13,5 @@ export const translateToSourceEvents = ( ): AsyncIterable> => { const outputOptions = { continuousUsageStats: streamOptions.include_usage === true && streamOptions.continuous_usage_stats === true }; const stream = createRoundTripStream('openaiChatCompletions', 'anthropicMessages', codec); - return openaiChatCompletionsFromIR(irFromAnthropicMessages(frames, { roundTrip: stream.reader }), { ...outputOptions, roundTrip: stream.writer }); + return writeOpenAIChatCompletionsEvents(readAnthropicMessagesEvents(frames, { roundTrip: stream.reader }), { ...outputOptions, roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts index b09f7e7e74..7b2b5171aa 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts @@ -1,7 +1,7 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromOpenAIResponses } from '../../openai-responses/as-target/sse-from.ts'; -import { openaiChatCompletionsFromIR } from '../as-source/sse-to.ts'; +import { readOpenAIResponsesEvents } from '../../openai-responses/as-target/events.ts'; +import { writeOpenAIChatCompletionsEvents } from '../as-source/events.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsStreamOptionsEx } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; @@ -13,5 +13,5 @@ export const translateToSourceEvents = ( ): AsyncIterable> => { const outputOptions = { continuousUsageStats: streamOptions.include_usage === true && streamOptions.continuous_usage_stats === true }; const stream = createRoundTripStream('openaiChatCompletions', 'openaiResponses', codec); - return openaiChatCompletionsFromIR(irFromOpenAIResponses(frames, { roundTrip: stream.reader }), { ...outputOptions, roundTrip: stream.writer }); + return writeOpenAIChatCompletionsEvents(readOpenAIResponsesEvents(frames, { roundTrip: stream.reader }), { ...outputOptions, roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts b/packages/translate/src/protocols/openai-responses/as-source/events.ts similarity index 91% rename from packages/translate/src/protocols/openai-responses/as-source/sse-to.ts rename to packages/translate/src/protocols/openai-responses/as-source/events.ts index b08ce80dfe..85cc651b33 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/events.ts @@ -2,18 +2,23 @@ import { restoreNamespaceOutputItem, type NamespaceToolNames } from './namespace import { createRandomOpenAIResponsesResponseId } from './response-id.ts'; import { collectOpenAIResponsesTextViews, computeOpenAIResponsesTurnDigest, createOpenAIResponsesSidecarCarrier } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; -import type { IRCollector } from '../../../ir/collector.ts'; -import { irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/operation-paths.ts'; -import { createIROrdinalMapper } from '../../../ir/ordinal-mapper.ts'; -import { consumeIRRecords, type IRPath, type IRRecord } from '../../../ir/stream.ts'; -import { IRAbsent, type IR, type IRItem, type IRMessageItem, type IRSourceCitation, type IRTokenLogprob } from '../../../ir/types.ts'; -import { createIRUnitQueue, createIRUnitRegistry } from '../../../ir/unit-queue.ts'; +import { IRAbsent } from '../../../ir/output/absent.ts'; +import type { IRCollector } from '../../../ir/output/collector.ts'; +import { irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/output/operation-paths.ts'; +import type { IRPath, IRRecord } from '../../../ir/output/records.ts'; +import { consumeIRRecords } from '../../../ir/output/stream.ts'; +import type { IROutput, IROutputItem, IROutputMessageItem, IROutputSourceCitation, IROutputTokenLogprob } from '../../../ir/output/types.ts'; +import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; +import { createIRUnitQueue } from '../../../ir/output/unit-queue.ts'; +import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; import { appendContent, createContentState, type ContentState, type ContentStates } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; -import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; -import { isCompleteJSONObject, parseJSONObjectAtCompletion } from '../../../shared/json/object.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 { isJsonObject } from '@floway-dev/protocols/common'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { createRandomOpenAIResponsesItemId, @@ -29,7 +34,7 @@ import { interface ResponsesOutputUnitBase { readonly path: IRPath; readonly parts: IRPath[]; - readonly contentOrdinals: ReturnType; + readonly contentOrdinals: ReturnType; readonly annotationProgress: Map; readonly coordinateIndexes: Map; pendingText?: Map; @@ -38,7 +43,6 @@ interface ResponsesOutputUnitBase { id?: string; callId?: string; status?: 'completed' | 'incomplete'; - nextPending?: ResponsesOutputUnit; } type ResponsesOutputUnit = ResponsesOutputUnitBase & ( @@ -48,12 +52,12 @@ type ResponsesOutputUnit = ResponsesOutputUnitBase & ( | { readonly kind: 'function_call' | 'custom_tool_call'; toolContentState?: ContentState<'text'> } ); -type OpenAIResponsesSSEOutputOptions = RoundTripOutputOptions<'openaiResponses'> & { namespaceToolNames: NamespaceToolNames }; +type OpenAIResponsesEventWriterOptions = SourceEventWriterOptions<'openaiResponses'> & { namespaceToolNames: NamespaceToolNames }; const IMAGE_OUTPUT_FORMATS = { 'image/png': 'png', 'image/jpeg': 'jpeg', 'image/webp': 'webp' } as const; type IRWriteRecord = Extract; -type IRToolCallItem = Extract; +type IRToolCallItem = Extract; // The item an opened unit announces, and the events that bring it up to the content IR already holds. interface OpenedUnit { readonly added: OpenAIResponsesOutputItemEx; @@ -61,16 +65,13 @@ interface OpenedUnit { } type ReasoningField = 'summary' | 'content'; -type MessagePart = IRMessageItem['content'][number]; +type MessagePart = IROutputMessageItem['content'][number]; type ResponsesMessageState = OpenAIResponsesItemState, ContentStates>; type ResponsesReasoningState = OpenAIResponsesItemState, ContentStates>; -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - -const initializedType = (value: unknown): IRItem['type'] | MessagePart['type'] | undefined => { - if (!isRecord(value)) return undefined; +const initializedType = (value: unknown): IROutputItem['type'] | MessagePart['type'] | undefined => { + if (!isJsonObject(value)) return undefined; return value.type === 'message' || value.type === 'reasoning' || value.type === 'function_call' @@ -86,14 +87,14 @@ const initializedType = (value: unknown): IRItem['type'] | MessagePart['type'] | type TextPartLogprobMetadata = { readonly item_index: number; readonly content_index: number }; const textPartLogprobMetadata = (value: unknown): TextPartLogprobMetadata | undefined => { - if (!isRecord(value) || value.scope !== 'text_part') return undefined; + if (!isJsonObject(value) || value.scope !== 'text_part') return undefined; return typeof value.item_index === 'number' && typeof value.content_index === 'number' ? { item_index: value.item_index, content_index: value.content_index } : undefined; }; -const isIRTokenLogprob = (value: unknown): value is IRTokenLogprob => - isRecord(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; +const isIRTokenLogprob = (value: unknown): value is IROutputTokenLogprob => + isJsonObject(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; const reasoningPartAdded = (field: ReasoningField, itemId: string, outputIndex: number, index: number): OpenAIResponsesStreamEventEx => field === 'summary' @@ -168,7 +169,7 @@ const emptyContentPart = (type: 'text' | 'refusal'): OpenAIResponsesOutputConten : { type: 'refusal', refusal: '' }; // The text or refusal part a message unit covers; other part kinds never join a message unit. -const textOrRefusalPart = (state: IR, path: IRPath): Extract => { +const textOrRefusalPart = (state: IROutput, path: IRPath): Extract => { const item = state.choices[0].items[path[3] as number]; if (item.type !== 'message') throw new TypeError('Responses content unit requires a message item'); const part = item.content[path[5] as number]; @@ -176,9 +177,9 @@ const textOrRefusalPart = (state: IR, path: IRPath): Extract, - options: OpenAIResponsesSSEOutputOptions, + options: OpenAIResponsesEventWriterOptions, ): AsyncGenerator> { let metadata = { id: '', model: '', created: 0 }; let started = false; @@ -249,10 +250,10 @@ export const openaiResponsesFromIR = async function* ( return index; }; - const convertAnnotation = (coordinateIndex: () => TextCoordinateIndex, annotation: IRSourceCitation): OpenAIResponsesAnnotation | undefined => { + const convertAnnotation = (coordinateIndex: () => TextCoordinateIndex, annotation: IROutputSourceCitation): OpenAIResponsesAnnotation | undefined => { if ((annotation.source_kind === 'url' || annotation.source_kind === 'search_result') && annotation.source != null) { if (annotation.output_text_range === undefined) return undefined; - const range = coordinateIndex().irRangeToCodePoints(annotation.output_text_range); + const range = coordinateIndex().utf16RangeToCodePoints(annotation.output_text_range); return { type: 'url_citation', url: annotation.source, @@ -284,11 +285,11 @@ export const openaiResponsesFromIR = async function* ( return unit.outputIndex; }; - const contentPart = (state: IR, unit: ResponsesOutputUnit, path: IRPath): OpenAIResponsesOutputRefusal | Required => { + const contentPart = (state: IROutput, unit: ResponsesOutputUnit, path: IRPath): OpenAIResponsesOutputRefusal | Required => { const part = textOrRefusalPart(state, path); if (part.type === 'refusal') return { type: 'refusal', refusal: part.refusal }; const logprobGroup = logprobGroups.get(irPathKey(path)); - let logprobs: readonly IRTokenLogprob[] = []; + let logprobs: readonly IROutputTokenLogprob[] = []; if (logprobGroup !== undefined) { const group = state.choices[0].logprobs?.[logprobGroup]; if (group === undefined) throw new TypeError(`Responses logprob group ${logprobGroup} is missing`); @@ -297,7 +298,7 @@ export const openaiResponsesFromIR = async function* ( return { type: 'output_text', text: part.text, annotations: convertAnnotations(unit, path, part), logprobs: [...logprobs] }; }; - const outputItem = (state: IR, unit: ResponsesOutputUnit): OpenAIResponsesOutputItemEx => { + const outputItem = (state: IROutput, unit: ResponsesOutputUnit): OpenAIResponsesOutputItemEx => { const itemIndex = unit.path[3]; if (typeof itemIndex !== 'number') throw new TypeError('Responses output unit has no item index'); const item = state.choices[0].items[itemIndex]; @@ -310,7 +311,7 @@ export const openaiResponsesFromIR = async function* ( const part = item.content[unit.path[5] as number]; if (part.type !== 'image') throw new TypeError('Responses image unit requires an image part'); if (status !== 'completed') throw new TypeError('Responses image unit can only complete'); - const outputFormat = part.image.mime_type === undefined ? undefined : IMAGE_OUTPUT_FORMATS[part.image.mime_type as keyof typeof IMAGE_OUTPUT_FORMATS]; + const outputFormat = part.image.media_type === undefined ? undefined : IMAGE_OUTPUT_FORMATS[part.image.media_type as keyof typeof IMAGE_OUTPUT_FORMATS]; return { type: 'image_generation_call', id, status, result: part.image.data, ...(outputFormat === undefined ? {} : { output_format: outputFormat }) }; } if (unit.kind === 'reasoning') { @@ -330,7 +331,7 @@ export const openaiResponsesFromIR = async function* ( return { type: 'function_call', id, call_id: unit.callId, name: item.name, arguments: item.arguments, status }; }; - const response = (state: IR, status: OpenAIResponsesResultEx['status'], usage: OpenAIResponsesResultEx['usage']): OpenAIResponsesResultEx => ({ + const response = (state: IROutput, status: OpenAIResponsesResultEx['status'], usage: OpenAIResponsesResultEx['usage']): OpenAIResponsesResultEx => ({ id: metadata.id, object: 'response', created_at: metadata.created, @@ -344,7 +345,7 @@ export const openaiResponsesFromIR = async function* ( }); // Creates the output units that the IR structures initialized by a record will become. - const registerUnits = (record: IRRecord, state: IR): void => { + const registerUnits = (record: IRRecord, state: IROutput): void => { if (record.type === 'write' && record.kind === 'initialize') { if (record.path[0] === 'choices' && record.path[1] !== 0) throw new Error('Responses cannot represent multiple choices'); if (record.path.length === 4 && record.path[0] === 'choices' && record.path[2] === 'logprobs') { @@ -361,7 +362,7 @@ export const openaiResponsesFromIR = async function* ( const unit: ResponsesOutputUnitBase = { path: record.path, parts: [], - contentOrdinals: createIROrdinalMapper(), + contentOrdinals: createIRUnitIndexAllocator(), annotationProgress: new Map(), coordinateIndexes: new Map(), opened: false, @@ -387,7 +388,7 @@ export const openaiResponsesFromIR = async function* ( path: record.path, kind: 'message', parts: [], - contentOrdinals: createIROrdinalMapper(), + contentOrdinals: createIRUnitIndexAllocator(), annotationProgress: new Map(), coordinateIndexes: new Map(), contentState: {}, @@ -403,7 +404,7 @@ export const openaiResponsesFromIR = async function* ( path: record.path, kind: 'image', parts: [record.path], - contentOrdinals: createIROrdinalMapper(), + contentOrdinals: createIRUnitIndexAllocator(), annotationProgress: new Map(), coordinateIndexes: new Map(), opened: false, @@ -413,7 +414,7 @@ export const openaiResponsesFromIR = async function* ( } }; - const openMessageUnit = (unit: ResponsesOutputUnit, state: IR, outputIndex: number): OpenedUnit => { + const openMessageUnit = (unit: ResponsesOutputUnit, state: IROutput, outputIndex: number): OpenedUnit => { const id = createRandomOpenAIResponsesItemId('message'); unit.id = id; const followUps: OpenAIResponsesStreamEventEx[] = []; @@ -464,7 +465,7 @@ export const openaiResponsesFromIR = async function* ( return { added: { type: 'image_generation_call', id, status: 'in_progress', result: null }, followUps: [] }; }; - const openReasoningUnit = (unit: ResponsesOutputUnit, item: IRItem, outputIndex: number): OpenedUnit => { + const openReasoningUnit = (unit: ResponsesOutputUnit, item: IROutputItem, outputIndex: number): OpenedUnit => { const reasoning = item.type === 'reasoning' ? item : undefined; const id = createRandomOpenAIResponsesItemId('reasoning'); unit.id = id; @@ -502,7 +503,7 @@ export const openaiResponsesFromIR = async function* ( }; // Opens the waiting units in IR order, until one still lacks what its opening event needs. - const openPendingUnits = (state: IR, collector: IRCollector): { events: OpenAIResponsesStreamEventEx[]; justOpened: Set } => { + const openPendingUnits = (state: IROutput, collector: IRCollector): { events: OpenAIResponsesStreamEventEx[]; justOpened: Set } => { const justOpened = new Set(); const pendingEvents: OpenAIResponsesStreamEventEx[] = []; while (pending.head !== undefined) { @@ -511,11 +512,11 @@ export const openaiResponsesFromIR = async function* ( let tool: { item: IRToolCallItem; callId: string | typeof IRAbsent } | undefined; if (unit.kind === 'function_call' || unit.kind === 'custom_tool_call') { if ((item.type !== 'function_call' && item.type !== 'custom_tool_call') || item.name === '') throw new TypeError('Completed tool calls require a name'); - const callId = collector.optional([...unit.path, 'call_id']); + const callId = collector.valueOrAbsent([...unit.path, 'call_id']); if (callId === undefined) break; tool = { item, callId }; } - pending.dequeue(); + pending.dropHead(); const outputIndex = outputUnits.length; unit.outputIndex = outputIndex; if (unit.kind === 'message' || unit.kind === 'reasoning') nativeState[outputIndex] = unit.contentState; @@ -531,7 +532,7 @@ export const openaiResponsesFromIR = async function* ( return { events: pendingEvents, justOpened }; }; - const messageUnitEvents = (unit: ResponsesOutputUnit, outputIndex: number, record: IRWriteRecord, state: IR): OpenAIResponsesStreamEventEx[] => { + const messageUnitEvents = (unit: ResponsesOutputUnit, outputIndex: number, record: IRWriteRecord, state: IROutput): OpenAIResponsesStreamEventEx[] => { const itemId = requireId(unit); if (record.kind === 'initialize' && record.path.length === 6) { const type = initializedType(record.value); @@ -600,7 +601,7 @@ export const openaiResponsesFromIR = async function* ( }; // The events for an operation on a unit that is already open. - const openedUnitEvents = (unit: ResponsesOutputUnit, record: IRWriteRecord, state: IR): OpenAIResponsesStreamEventEx[] => { + const openedUnitEvents = (unit: ResponsesOutputUnit, record: IRWriteRecord, state: IROutput): OpenAIResponsesStreamEventEx[] => { const outputIndex = requireOutputIndex(unit); switch (unit.kind) { case 'message': return messageUnitEvents(unit, outputIndex, record, state); @@ -645,7 +646,7 @@ export const openaiResponsesFromIR = async function* ( const item = state.choices[0].items[current.path[3] as number]; if (item.type !== 'function_call') throw new TypeError('Responses pending function unit requires a function item'); const outputIndex = requireOutputIndex(current); - parseJSONObjectAtCompletion(item.arguments, finishReason === 'length' ? 'truncated' : 'complete'); + parseJSONObjectUnlessTruncated(item.arguments, finishReason === 'length' ? 'truncated' : 'complete'); yield emit({ type: 'response.function_call_arguments.done', item_id: requireId(current), @@ -799,7 +800,7 @@ export const openaiResponsesFromIR = async function* ( } if (record.type === 'finish') { - const refusal = state.choices[0].refusal; + const refusal = state.choices[0].refusal_details; const finalUsage = state.usage === undefined ? null : usageFromIR(state.usage); if (refusal?.category === 'cyber' || refusal?.category === 'bio') { const declined = refusal.explanation == null || refusal.explanation.trim() === ''; diff --git a/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts b/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts index d58668c45b..623e1fa547 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts @@ -1,4 +1,4 @@ -import type { IRJSONObject, IRJSONValue } from '../../../ir/types.ts'; +import type { IRJSONObject, IRJSONValue } from '../../../ir/common/json.ts'; import type { ReplayContent } from '../../../round-trip/reference/assistant-turn.ts'; import type { EncodedRoundTripSidecar } from '../../../round-trip/resolve.ts'; import { collectTextGroups, collectTextPlacementIndex, type TextContribution, type TextPlacementIndex } from '../../../round-trip/text-groups.ts'; diff --git a/packages/translate/src/protocols/openai-responses/as-source/usage.ts b/packages/translate/src/protocols/openai-responses/as-source/usage.ts index b985f13c5d..d8a3f7c195 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/usage.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/usage.ts @@ -1,12 +1,12 @@ -import type { IRUsage } from '../../../ir/types.ts'; +import type { IROutputUsage } from '../../../ir/output/types.ts'; import { requireTokenCount } from '../../../shared/token-count.ts'; import type { OpenAIResponsesResultEx } from '@floway-dev/protocols/openai-responses'; type OpenAIResponsesUsage = NonNullable; type OpenAIResponsesInputTokensDetails = NonNullable; -export const usageFromIR = (usage: IRUsage): OpenAIResponsesUsage => { - const count = (key: keyof IRUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); +export const usageFromIR = (usage: IROutputUsage): OpenAIResponsesUsage => { + const count = (key: keyof IROutputUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); const input = count('input_tokens_inclusive') ?? 0; const output = count('output_tokens_inclusive') ?? 0; const inputDetails: Partial = {}; diff --git a/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts b/packages/translate/src/protocols/openai-responses/as-target/events.ts similarity index 97% rename from packages/translate/src/protocols/openai-responses/as-target/sse-from.ts rename to packages/translate/src/protocols/openai-responses/as-target/events.ts index 8521bdc743..68b382a78f 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/events.ts @@ -1,14 +1,8 @@ import { toOpenAIResponsesInputAssistantTurn, type OpenAIResponsesItemState, type OpenAIResponsesOutputAssistantState } from './assistant-turn.ts'; import { usageToIR } from './usage.ts'; -import { - createIRBuilder, - type IRRecord, - type IRItemHandle, - type IRPartHandle, - type IRRelativePath, - type IRWriteHandle, -} from '../../../ir/stream.ts'; -import type { IRSourceCitation } from '../../../ir/types.ts'; +import { createIRBuilder, type IRItemHandle, type IRPartHandle, type IRRelativePath, type IRWriteHandle } from '../../../ir/output/builder.ts'; +import type { IRRecord } from '../../../ir/output/records.ts'; +import type { IROutputSourceCitation } from '../../../ir/output/types.ts'; import { appendField, createFieldState, invalidateField, type FieldStates } from '../../../round-trip/reference/reference-builder.ts'; import type { RoundTripReader } from '../../../round-trip/stream.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; @@ -98,14 +92,14 @@ const closedSnapshotEqual = (left: OpenAIResponsesOutputItemEx, right: OpenAIRes return deepEqual(leftSnapshot, rightSnapshot); }; -const annotationToIR = (coordinateIndex: () => TextCoordinateIndex, annotation: OpenAIResponsesAnnotation): IRSourceCitation | undefined => { +const annotationToIR = (coordinateIndex: () => TextCoordinateIndex, annotation: OpenAIResponsesAnnotation): IROutputSourceCitation | undefined => { if (annotation.type === 'url_citation') { return { type: 'source_citation', source_kind: 'url', source: annotation.url, source_label: annotation.title, - output_text_range: coordinateIndex().codePointRangeToIR(annotation.start_index, annotation.end_index), + output_text_range: coordinateIndex().codePointRangeToUTF16(annotation.start_index, annotation.end_index), }; } if (annotation.type === 'file_citation') { @@ -117,7 +111,7 @@ const annotationToIR = (coordinateIndex: () => TextCoordinateIndex, annotation: source_kind: 'document', source: annotation.file_id, source_label: annotation.filename, - output_text_range: coordinateIndex().codePointRangeToIR(annotation.start_index, annotation.end_index), + output_text_range: coordinateIndex().codePointRangeToUTF16(annotation.start_index, annotation.end_index), }; } if (annotation.type === 'file_path') return { type: 'source_citation', source_kind: 'document', source: annotation.file_id }; @@ -143,7 +137,7 @@ const irItemSeed = (item: OpenAIResponsesOutputItemEx): Record const itemId = (item: OpenAIResponsesOutputItemEx): unknown => 'id' in item ? item.id : undefined; -export const irFromOpenAIResponses = async function* ( +export const readOpenAIResponsesEvents = async function* ( frames: AsyncIterable>, options: { roundTrip: RoundTripReader<'openaiResponses'> }, ): AsyncGenerator { @@ -246,7 +240,7 @@ export const irFromOpenAIResponses = async function* ( }; const reconcileAppend = (target: ResponsesTextTarget, snapshot: unknown): string => { const { handle, path } = target; - const absolutePath = handle.resolve(path); + const absolutePath = handle.absolutePath(path); if (typeof snapshot !== 'string') throw new TypeError(`Responses text snapshot must be a string at ${JSON.stringify(absolutePath)}`); const current = handle.current(path); // Responses done events are final snapshots in the official SDK. Downstream @@ -285,7 +279,7 @@ export const irFromOpenAIResponses = async function* ( const current = handle.current(path); if (current === undefined) handle.assign(path, snapshot); else if (!deepEqual(current, snapshot)) { - throw new TypeError(`Responses snapshot conflicts with streamed value at ${JSON.stringify(handle.resolve(path))}`); + throw new TypeError(`Responses snapshot conflicts with streamed value at ${JSON.stringify(handle.absolutePath(path))}`); } }; @@ -515,7 +509,7 @@ export const irFromOpenAIResponses = async function* ( if (state.parts.size === 0) { const handle = state.item.part({ type: 'image', - image: { data: item.result, ...(item.output_format == null ? {} : { mime_type: `image/${item.output_format}` }) }, + image: { data: item.result, ...(item.output_format == null ? {} : { media_type: `image/${item.output_format}` }) }, }); state.parts.set(0, { type: 'image', handle }); } else { @@ -524,7 +518,7 @@ export const irFromOpenAIResponses = async function* ( const { handle } = part; reconcileAssign({ handle, path: ['image', 'data'] }, item.result); if (item.output_format != null) { - reconcileAssign({ handle, path: ['image', 'mime_type'] }, `image/${item.output_format}`); + reconcileAssign({ handle, path: ['image', 'media_type'] }, `image/${item.output_format}`); } } } @@ -601,7 +595,7 @@ export const irFromOpenAIResponses = async function* ( audioItem.end(); } const policy = response.error?.code === 'cyber_policy' ? 'cyber' : response.error?.code === 'bio_policy' ? 'bio' : undefined; - if (policy !== undefined) ir.choice(0).assign('refusal', { category: policy, explanation: response.error?.message }); + if (policy !== undefined) ir.choice(0).assign('refusal_details', { category: policy, explanation: response.error?.message }); outputState.length = terminalOutput.length; const input = toOpenAIResponsesInputAssistantTurn(terminalOutput, outputState); options.roundTrip.onAssistantTurn(0, input.turn, input.state); diff --git a/packages/translate/src/protocols/openai-responses/as-target/usage.ts b/packages/translate/src/protocols/openai-responses/as-target/usage.ts index 0e00c3a745..fd90b723f0 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/usage.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/usage.ts @@ -1,4 +1,4 @@ -import type { IRUsage } from '../../../ir/types.ts'; +import type { IROutputUsage } from '../../../ir/output/types.ts'; import { requireTokenCount } from '../../../shared/token-count.ts'; import { splitInclusiveInputTokens } from '@floway-dev/protocols/common'; @@ -18,9 +18,9 @@ interface OpenAIResponsesUsageFields { output_tokens_details?: { reasoning_tokens?: number; audio_tokens?: number; text_tokens?: number }; } -export const usageToIR = (usage: OpenAIResponsesUsageFields): IRUsage => { - const result: Partial> = {}; - const put = (key: keyof IRUsage, value: number | undefined | null): void => { if (value != null) result[key] = requireTokenCount(key, value); }; +export const usageToIR = (usage: OpenAIResponsesUsageFields): IROutputUsage => { + const result: Partial> = {}; + const put = (key: keyof IROutputUsage, value: number | undefined | null): void => { if (value != null) result[key] = requireTokenCount(key, value); }; put('input_tokens_inclusive', usage.input_tokens); put('output_tokens_inclusive', usage.output_tokens); put('total_tokens', usage.total_tokens); diff --git a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts index 2450fc20f0..b21b9da9ad 100644 --- a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts +++ b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts @@ -1,13 +1,13 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromAnthropicMessages } from '../../anthropic-messages/as-target/sse-from.ts'; +import { readAnthropicMessagesEvents } from '../../anthropic-messages/as-target/events.ts'; +import { writeOpenAIResponsesEvents } from '../as-source/events.ts'; import type { NamespaceToolNames } from '../as-source/namespace-tools.ts'; -import { openaiResponsesFromIR } from '../as-source/sse-to.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; export const translateToSourceEvents = (frames: AsyncIterable>, responseId: string, customToolNames: ReadonlySet, codec: SidecarCodec, namespaceToolNames: NamespaceToolNames): AsyncIterable> => { const stream = createRoundTripStream('openaiResponses', 'anthropicMessages', codec); - return openaiResponsesFromIR(irFromAnthropicMessages(frames, { customToolNames, roundTrip: stream.reader }), { id: responseId, namespaceToolNames, roundTrip: stream.writer }); + return writeOpenAIResponsesEvents(readAnthropicMessagesEvents(frames, { customToolNames, roundTrip: stream.reader }), { id: responseId, namespaceToolNames, roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts index 28f41fb471..44ac3b6ec0 100644 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts +++ b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts @@ -1,13 +1,13 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromOpenAIChatCompletions } from '../../openai-chat-completions/as-target/sse-from.ts'; +import { readOpenAIChatCompletionsEvents } from '../../openai-chat-completions/as-target/events.ts'; +import { writeOpenAIResponsesEvents } from '../as-source/events.ts'; import type { NamespaceToolNames } from '../as-source/namespace-tools.ts'; -import { openaiResponsesFromIR } from '../as-source/sse-to.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; export const translateToSourceEvents = (frames: AsyncIterable>, customToolNames: ReadonlySet, codec: SidecarCodec, namespaceToolNames: NamespaceToolNames, maxChoices = 1): AsyncIterable> => { const stream = createRoundTripStream('openaiResponses', 'openaiChatCompletions', codec); - return openaiResponsesFromIR(irFromOpenAIChatCompletions(frames, { customToolNames, maxChoices, roundTrip: stream.reader }), { namespaceToolNames, roundTrip: stream.writer }); + return writeOpenAIResponsesEvents(readOpenAIChatCompletionsEvents(frames, { customToolNames, maxChoices, roundTrip: stream.reader }), { namespaceToolNames, roundTrip: stream.writer }); }; diff --git a/packages/translate/src/round-trip/reference/assistant-turn.ts b/packages/translate/src/round-trip/reference/assistant-turn.ts index 86cfa647e5..fe11524e10 100644 --- a/packages/translate/src/round-trip/reference/assistant-turn.ts +++ b/packages/translate/src/round-trip/reference/assistant-turn.ts @@ -1,4 +1,4 @@ -import type { IRJSONObject } from '../../ir/types.ts'; +import type { IRJSONObject } from '../../ir/common/json.ts'; import type { AnthropicMessagesAssistantState, AnthropicMessagesReferenceableAssistantTurn } from '../../protocols/anthropic-messages/as-target/assistant-turn.ts'; import type { GeminiGenerateContentAssistantState, GeminiGenerateContentReferenceableAssistantTurn } from '../../protocols/gemini-generate-content/as-target/assistant-turn.ts'; import type { OpenAIChatCompletionsAssistantState, OpenAIChatCompletionsReferenceableAssistantTurn } from '../../protocols/openai-chat-completions/as-target/assistant-turn.ts'; diff --git a/packages/translate/src/round-trip/reference/reference-builder.ts b/packages/translate/src/round-trip/reference/reference-builder.ts index f4a584c816..4bebe685a7 100644 --- a/packages/translate/src/round-trip/reference/reference-builder.ts +++ b/packages/translate/src/round-trip/reference/reference-builder.ts @@ -1,5 +1,5 @@ import { ContentReference, type AssistantTurnStateFor, type ContentReferenceSegment } from './assistant-turn.ts'; -import type { IRJSONObject } from '../../ir/types.ts'; +import type { IRJSONObject } from '../../ir/common/json.ts'; import { parseJSONObject } from '../../shared/json/object.ts'; import type { AssistantTurnByProtocol, AssistantTurnFor } from '../assistant-turn.ts'; import type { TextPlacement, TextPlacementIndex } from '../text-groups.ts'; diff --git a/packages/translate/src/round-trip/reference/reference-selection.ts b/packages/translate/src/round-trip/reference/reference-selection.ts index 87114abb1d..3d8f10818e 100644 --- a/packages/translate/src/round-trip/reference/reference-selection.ts +++ b/packages/translate/src/round-trip/reference/reference-selection.ts @@ -1,6 +1,6 @@ import { ContentReference, type AssistantTurnStateFor, type ContentReferenceSegment, type ReferenceableAssistantTurnFor } from './assistant-turn.ts'; import { createReferenceFinalizer, isFieldState, pairedChildren, type FieldState, type FieldStates, type ReferenceBuilder } from './reference-builder.ts'; -import type { IRJSONObject } from '../../ir/types.ts'; +import type { IRJSONObject } from '../../ir/common/json.ts'; import { requireJSONObject } from '../../shared/json/object.ts'; import { rawJSON } from '../../shared/json/raw-json.ts'; import type { AssistantTurnByProtocol, AssistantTurnFor } from '../assistant-turn.ts'; diff --git a/packages/translate/src/round-trip/stream.ts b/packages/translate/src/round-trip/stream.ts index 17d393f9c0..d216fe5db1 100644 --- a/packages/translate/src/round-trip/stream.ts +++ b/packages/translate/src/round-trip/stream.ts @@ -57,9 +57,3 @@ export const createRoundTripStream = < }; return { reader, writer }; }; - -export interface RoundTripOutputOptions { - id?: string; - created?: number; - roundTrip: RoundTripWriter; -} diff --git a/packages/translate/src/round-trip/turn-digest.ts b/packages/translate/src/round-trip/turn-digest.ts index 065d3b8982..a6688cbfb6 100644 --- a/packages/translate/src/round-trip/turn-digest.ts +++ b/packages/translate/src/round-trip/turn-digest.ts @@ -1,4 +1,4 @@ -import type { IRJSONObject, IRJSONValue } from '../ir/types.ts'; +import type { IRJSONObject, IRJSONValue } from '../ir/common/json.ts'; export type RoundTripTurnDigest = Uint8Array; diff --git a/packages/translate/src/shared/event-writer-options.ts b/packages/translate/src/shared/event-writer-options.ts new file mode 100644 index 0000000000..5db95628da --- /dev/null +++ b/packages/translate/src/shared/event-writer-options.ts @@ -0,0 +1,8 @@ +import type { AssistantTurnByProtocol } from '../round-trip/assistant-turn.ts'; +import type { RoundTripWriter } from '../round-trip/stream.ts'; + +export interface SourceEventWriterOptions { + id?: string; + created?: number; + roundTrip: RoundTripWriter; +} diff --git a/packages/translate/src/shared/json/completion.ts b/packages/translate/src/shared/json/completion.ts new file mode 100644 index 0000000000..bb15600b62 --- /dev/null +++ b/packages/translate/src/shared/json/completion.ts @@ -0,0 +1,8 @@ +import { parseJSONObject } from './object.ts'; + +export const parseJSONObjectUnlessTruncated = ( + text: string, + completion: 'complete' | 'truncated', + parser?: (text: string) => unknown, +): { readonly completion: 'truncated' } | { readonly completion: 'complete'; readonly value: ReturnType } => + completion === 'truncated' ? { completion } : { completion, value: parseJSONObject(text, parser) }; diff --git a/packages/translate/src/shared/json/object.ts b/packages/translate/src/shared/json/object.ts index 4032f2a5e5..7edcfe3426 100644 --- a/packages/translate/src/shared/json/object.ts +++ b/packages/translate/src/shared/json/object.ts @@ -1,5 +1,5 @@ import { rawJSON } from './raw-json.ts'; -import type { IRJSONObject } from '../../ir/types.ts'; +import type { IRJSONObject } from '../../ir/common/json.ts'; import { isJsonObject, parseJSONWithRawNumbers } from '@floway-dev/protocols/common'; export const isPlainJSONObject = (value: unknown): value is IRJSONObject => isJsonObject(value) && !rawJSON.isRawJSON(value); @@ -23,10 +23,3 @@ export const isCompleteJSONObject = (text: string): boolean => { throw error; } }; - -export const parseJSONObjectAtCompletion = ( - text: string, - completion: 'complete' | 'truncated', - parser?: (text: string) => unknown, -): { readonly completion: 'truncated' } | { readonly completion: 'complete'; readonly value: ReturnType } => - completion === 'truncated' ? { completion } : { completion, value: parseJSONObject(text, parser) }; diff --git a/packages/translate/src/shared/text-coordinate-index.ts b/packages/translate/src/shared/text-coordinate-index.ts index 563a955c04..dd8bb9d4e9 100644 --- a/packages/translate/src/shared/text-coordinate-index.ts +++ b/packages/translate/src/shared/text-coordinate-index.ts @@ -1,4 +1,4 @@ -import type { IRUTF16TextRange } from '../ir/types.ts'; +import type { TextRange } from './text-range.ts'; type TextCoordinateMode = 'code-point-to-utf16' | 'utf16-to-code-point' | 'utf16-to-utf8'; @@ -118,34 +118,34 @@ export class TextCoordinateIndex { this.#trailingHighSurrogate = isHighSurrogate(delta.charCodeAt(delta.length - 1)); } - codePointRangeToIR(start: number, end: number): IRUTF16TextRange { + codePointRangeToUTF16(start: number, end: number): TextRange { if (this.#mode !== 'code-point-to-utf16') throw new TypeError('Text coordinate index does not map code points to UTF-16'); if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start) throw new RangeError('Invalid code-point range'); if (end >= this.#boundaries.length) throw new RangeError('Code-point range exceeds text'); return { start: this.#boundaries.get(start), end_exclusive: this.#boundaries.get(end) }; } - irRangeToCodePoints(range: IRUTF16TextRange): { start: number; end: number } { + utf16RangeToCodePoints(range: TextRange): { start: number; end: number } { if (this.#mode !== 'utf16-to-code-point') throw new TypeError('Text coordinate index does not map UTF-16 to code points'); const [start, end] = this.#utf16Range(range); return { start, end }; } - irRangeToUTF8(range: IRUTF16TextRange): { start: number; end: number } { + utf16RangeToUTF8(range: TextRange): { start: number; end: number } { if (this.#mode !== 'utf16-to-utf8') throw new TypeError('Text coordinate index does not map UTF-16 to UTF-8'); if (this.#unpairedSurrogates !== 0) throw new TypeError('GenerateContent byte coordinates require well-formed Unicode'); const [start, end] = this.#utf16Range(range); return { start, end }; } - #utf16Range(range: IRUTF16TextRange): readonly [number, number] { + #utf16Range(range: TextRange): readonly [number, number] { if (!Number.isInteger(range.start) || !Number.isInteger(range.end_exclusive) || range.start < 0 || range.end_exclusive < range.start || range.end_exclusive > this.#utf16Length) { - throw new RangeError('IR range is outside text or splits a surrogate pair'); + throw new RangeError('output IR range is outside text or splits a surrogate pair'); } const start = this.#boundaries.get(range.start); const end = this.#boundaries.get(range.end_exclusive); - if (start === INVALID_BOUNDARY || end === INVALID_BOUNDARY) throw new RangeError('IR range is outside text or splits a surrogate pair'); + if (start === INVALID_BOUNDARY || end === INVALID_BOUNDARY) throw new RangeError('output IR range is outside text or splits a surrogate pair'); return [start, end]; } } diff --git a/packages/translate/src/shared/text-range.ts b/packages/translate/src/shared/text-range.ts new file mode 100644 index 0000000000..add60b4335 --- /dev/null +++ b/packages/translate/src/shared/text-range.ts @@ -0,0 +1,7 @@ +// A range over a JavaScript string, counted in UTF-16 code units so it slices +// the stored value exactly, including isolated surrogates. Native protocols may +// count code points or UTF-8 bytes; their readers convert. +export interface TextRange { + readonly start: number; + readonly end_exclusive: number; +}