From c94b6c4a8cf0adc646b7cebe9f564cebb4ded83e Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 04:56:59 +0800 Subject: [PATCH 1/5] refactor(translate): give output IR modules explicit ownership Separate output construction, records, accumulation, lifecycle and path operations. Name output model types explicitly and share JSON and text-range shapes through their owners. Move protocol fixtures and assertions out of output IR tests. Let unit queues own their links and remove dead builder and path-query APIs. Preserve configured choice limits and native SSE, error and sidecar behavior. Validate with 612 focused tests, 67 baseline differential probes, 39 logical correspondences, scoped lint and the owning TypeScript project. --- packages/translate/AGENTS.md | 6 +- .../translate/__tests__/ir/collector_test.ts | 82 ------- .../translate/__tests__/ir/initialize_test.ts | 50 ---- .../__tests__/ir/output/collector_test.ts | 82 +++++++ .../ir/{ => output}/copy-ownership_test.ts | 39 ++- .../__tests__/ir/{ => output}/handles_test.ts | 13 +- .../translate/__tests__/ir/output/helpers.ts | 61 +++++ .../__tests__/ir/output/initialize_test.ts | 32 +++ .../__tests__/ir/{ => output}/part-id_test.ts | 2 +- .../__tests__/ir/{ => output}/schema_test.ts | 15 +- .../__tests__/ir/output/unit-queue_test.ts | 36 +++ .../unit-registry_test.ts} | 21 +- .../as-source/sse-to_test.ts | 6 +- .../as-target/sse-from_test.ts | 2 +- .../events_test.ts | 2 +- .../events-protocol_test.ts | 2 +- .../via-openai-responses/events_test.ts | 2 +- .../protocols/chat-legacy-round-trip_test.ts | 2 +- .../{ir => protocols}/edge-cases_test.ts | 10 +- .../fixtures.ts} | 2 +- .../as-source/sse-to_test.ts | 6 +- .../via-anthropic-messages/events_test.ts | 2 +- .../events_test.ts | 2 +- .../via-openai-responses/events_test.ts | 2 +- .../__tests__/{ir => protocols}/helpers.ts | 64 +---- .../initial-envelope_test.ts | 0 .../{ir => protocols}/lifecycle_test.ts | 10 +- .../as-source}/continuous-usage_test.ts | 8 +- .../as-source/sse-to_test.ts | 6 +- .../as-target/sse-from_test.ts | 2 +- .../via-anthropic-messages/events_test.ts | 2 +- .../via-openai-responses/events_test.ts | 4 +- .../as-source/initialize_test.ts | 22 ++ .../openai-responses/as-source/sse-to_test.ts | 6 +- .../as-target/snapshots_test.ts} | 6 +- .../openai-responses/fixtures.ts} | 0 .../events-protocol_test.ts | 2 +- .../via-anthropic-messages/events_test.ts | 2 +- .../events_test.ts | 2 +- .../{ir => protocols}/ownership_test.ts | 17 +- .../protocols/round-trip-receive_test.ts | 2 +- .../__tests__/protocols/routes_test.ts | 4 +- .../{ir => protocols}/streams_test.ts | 23 +- .../__tests__/{ir => protocols}/usage_test.ts | 9 +- .../reference/reference-builder_test.ts | 2 +- .../reference/reference-cost-json_test.ts | 2 +- .../translate/__tests__/shared/iterables.ts | 5 + .../shared/text-coordinate-index_test.ts | 12 +- packages/translate/src/ir/collector.ts | 223 ------------------ packages/translate/src/ir/common/json.ts | 6 + packages/translate/src/ir/ordinal-mapper.ts | 27 --- packages/translate/src/ir/output/absent.ts | 3 + .../src/ir/{stream.ts => output/builder.ts} | 72 +----- packages/translate/src/ir/output/collector.ts | 105 +++++++++ packages/translate/src/ir/output/error.ts | 8 + packages/translate/src/ir/output/lifecycle.ts | 48 ++++ .../src/ir/{ => output}/operation-paths.ts | 43 +--- packages/translate/src/ir/output/records.ts | 17 ++ .../translate/src/ir/{ => output}/schema.ts | 19 +- packages/translate/src/ir/output/stream.ts | 30 +++ .../translate/src/ir/{ => output}/types.ts | 153 +++++------- .../translate/src/ir/output/unit-index.ts | 27 +++ .../translate/src/ir/output/unit-queue.ts | 27 +++ .../translate/src/ir/output/unit-registry.ts | 21 ++ .../src/ir/output/value-validation.ts | 79 +++++++ packages/translate/src/ir/unit-queue.ts | 46 ---- .../as-source/round-trip.ts | 2 +- .../anthropic-messages/as-source/sse-to.ts | 32 +-- .../anthropic-messages/as-source/usage.ts | 6 +- .../as-target/assistant-turn.ts | 2 +- .../anthropic-messages/as-target/sse-from.ts | 11 +- .../anthropic-messages/as-target/usage.ts | 8 +- .../as-source/round-trip.ts | 2 +- .../as-source/sse-to.ts | 36 +-- .../as-source/usage.ts | 8 +- .../as-source/round-trip.ts | 2 +- .../as-source/sse-to.ts | 35 +-- .../as-source/usage.ts | 6 +- .../as-target/sse-from.ts | 18 +- .../as-target/usage.ts | 8 +- .../openai-responses/as-source/round-trip.ts | 2 +- .../openai-responses/as-source/sse-to.ts | 66 +++--- .../openai-responses/as-source/usage.ts | 6 +- .../openai-responses/as-target/sse-from.ts | 28 +-- .../openai-responses/as-target/usage.ts | 8 +- .../round-trip/reference/assistant-turn.ts | 2 +- .../round-trip/reference/reference-builder.ts | 2 +- .../reference/reference-selection.ts | 2 +- .../translate/src/round-trip/turn-digest.ts | 2 +- packages/translate/src/shared/json/object.ts | 2 +- .../src/shared/text-coordinate-index.ts | 10 +- packages/translate/src/shared/text-range.ts | 7 + 92 files changed, 984 insertions(+), 974 deletions(-) delete mode 100644 packages/translate/__tests__/ir/collector_test.ts delete mode 100644 packages/translate/__tests__/ir/initialize_test.ts create mode 100644 packages/translate/__tests__/ir/output/collector_test.ts rename packages/translate/__tests__/ir/{ => output}/copy-ownership_test.ts (62%) rename packages/translate/__tests__/ir/{ => output}/handles_test.ts (83%) create mode 100644 packages/translate/__tests__/ir/output/helpers.ts create mode 100644 packages/translate/__tests__/ir/output/initialize_test.ts rename packages/translate/__tests__/ir/{ => output}/part-id_test.ts (94%) rename packages/translate/__tests__/ir/{ => output}/schema_test.ts (78%) create mode 100644 packages/translate/__tests__/ir/output/unit-queue_test.ts rename packages/translate/__tests__/ir/{unit-queue_test.ts => output/unit-registry_test.ts} (61%) rename packages/translate/__tests__/{ir => protocols}/edge-cases_test.ts (97%) rename packages/translate/__tests__/{ir/translation-cases.ts => protocols/fixtures.ts} (99%) rename packages/translate/__tests__/{ir => protocols}/helpers.ts (54%) rename packages/translate/__tests__/{ir => protocols}/initial-envelope_test.ts (100%) rename packages/translate/__tests__/{ir => protocols}/lifecycle_test.ts (98%) rename packages/translate/__tests__/{ir => protocols/openai-chat-completions/as-source}/continuous-usage_test.ts (93%) create mode 100644 packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts rename packages/translate/__tests__/{ir/snapshot-policy_test.ts => protocols/openai-responses/as-target/snapshots_test.ts} (98%) rename packages/translate/__tests__/{ir/responses-fixture.ts => protocols/openai-responses/fixtures.ts} (100%) rename packages/translate/__tests__/{ir => protocols}/ownership_test.ts (88%) rename packages/translate/__tests__/{ir => protocols}/streams_test.ts (96%) rename packages/translate/__tests__/{ir => protocols}/usage_test.ts (95%) create mode 100644 packages/translate/__tests__/shared/iterables.ts delete mode 100644 packages/translate/src/ir/collector.ts create mode 100644 packages/translate/src/ir/common/json.ts delete mode 100644 packages/translate/src/ir/ordinal-mapper.ts create mode 100644 packages/translate/src/ir/output/absent.ts rename packages/translate/src/ir/{stream.ts => output/builder.ts} (66%) create mode 100644 packages/translate/src/ir/output/collector.ts create mode 100644 packages/translate/src/ir/output/error.ts create mode 100644 packages/translate/src/ir/output/lifecycle.ts rename packages/translate/src/ir/{ => output}/operation-paths.ts (80%) create mode 100644 packages/translate/src/ir/output/records.ts rename packages/translate/src/ir/{ => output}/schema.ts (89%) create mode 100644 packages/translate/src/ir/output/stream.ts rename packages/translate/src/ir/{ => output}/types.ts (54%) create mode 100644 packages/translate/src/ir/output/unit-index.ts create mode 100644 packages/translate/src/ir/output/unit-queue.ts create mode 100644 packages/translate/src/ir/output/unit-registry.ts create mode 100644 packages/translate/src/ir/output/value-validation.ts delete mode 100644 packages/translate/src/ir/unit-queue.ts create mode 100644 packages/translate/src/shared/text-range.ts diff --git a/packages/translate/AGENTS.md b/packages/translate/AGENTS.md index 8a7031c696..e2e20bbff5 100644 --- a/packages/translate/AGENTS.md +++ b/packages/translate/AGENTS.md @@ -18,8 +18,8 @@ import the source protocol's subpath and call protocol's wire mapping in the matching `via-*` route. Retained files use functional names without another protocol name. 5. **Output IR** — keep private types, its path schema, stream operations, - validation, accumulation, operation paths, and native ordinal mapping under - `ir/`. Native SSE adapters + validation, accumulation, operation paths, and native unit index allocation under + `ir/output/`. Native SSE adapters belong to their protocol: `as-target/sse-from.ts` consumes upstream SSE and `as-source/sse-to.ts` emits client SSE with its sidecar. The return path is upstream SSE → IR SSE → client SSE with sidecar. IR stays internal to translate. @@ -33,7 +33,7 @@ import the source protocol's subpath and call ## Current Production Subdirectories -- `ir/` — output model, its path schema, and protocol-independent streaming state. +- `ir/output/` — output model, its path schema, and protocol-independent streaming state. - `round-trip/` — authenticated assistant-turn sidecars, replay validation, and native stream coordination. - `protocols/anthropic-messages/as-source/` — interprets Messages requests and emits Messages SSE. - `protocols/anthropic-messages/as-target/` — maps requests to Messages and consumes Messages SSE. diff --git a/packages/translate/__tests__/ir/collector_test.ts b/packages/translate/__tests__/ir/collector_test.ts deleted file mode 100644 index 21c29b5b0b..0000000000 --- a/packages/translate/__tests__/ir/collector_test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, test } from 'vitest'; - -import { createIRCollector } from '../../src/ir/collector.ts'; -import { IRAbsent } from '../../src/ir/types.ts'; - -describe('IR accumulator', () => { - test('stores plain accumulated state without publication metadata', () => { - const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['usage'], value: {} }); - collector.apply({ type: 'write', kind: 'assign', path: ['usage', 'output_tokens_inclusive'], value: 0 }); - expect(collector.value(['usage'])).toEqual({ output_tokens_inclusive: 0 }); - }); - - test('enforces static append targets', () => { - const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: { type: 'text', text: '' } }); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'x' })).toThrow('append target cannot be assigned'); - expect(() => collector.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 1 } as any)).toThrow('append requires a string value'); - collector.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'x' }); - expect(collector.value(['choices', 0, 'items', 0, 'content', 0, 'text'])).toBe('x'); - - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1], value: { type: 'reasoning', content: [''] } }); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 1, 'content', 0], value: 1 })).toThrow('append target cannot be assigned'); - }); - - test('rejects undefined and only assigns IRAbsent to absent fields', () => { - const collector = createIRCollector(); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['model'], value: IRAbsent })).toThrow('IRAbsent is not allowed'); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call', name: 'f', arguments: '' } }); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'call_id'], value: undefined as any })).toThrow('undefined'); - collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'call_id'], value: IRAbsent }); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'call_id'], value: IRAbsent })).toThrow('absent target'); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'refusal'], value: { category: IRAbsent, explanation: null } as any })).toThrow('assigned directly'); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'refusal'], value: { category: undefined, explanation: null } as any })).toThrow('cannot contain undefined'); - expect(() => collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1], value: { type: 'reasoning', summary: [IRAbsent] } })).toThrow('append target must initialize to an empty string'); - }); - - test('enforces append ownership by item type', () => { - const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call', name: 'f', arguments: '' } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1], value: { type: 'custom_tool_call', name: 'c', input: '' } }); - expect(() => collector.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'input'], value: 'x' })).toThrow('append-target path'); - expect(() => collector.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 1, 'arguments'], value: 'x' })).toThrow('append-target path'); - }); - - test('rejects an unallocated choice end', () => { - const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - expect(() => collector.applyEvent({ type: 'choice_end', choice: 99, finish_reason: 'stop' })).toThrow('does not exist'); - }); - - test('rejects operations after part and item end', () => { - const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: { type: 'text', text: '' } }); - collector.applyEvent({ type: 'part_end', choice: 0, item: 0, part: 0 }); - expect(() => collector.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'late' })).toThrow('closed part'); - collector.applyEvent({ type: 'item_end', choice: 0, item: 0 }); - expect(() => collector.apply({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'type'], value: 'message' })).toThrow('closed item'); - }); - - test('rejects ended units that omit required schema fields', () => { - const incompleteItem = createIRCollector(); - incompleteItem.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - incompleteItem.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call' } }); - expect(() => incompleteItem.applyEvent({ type: 'item_end', choice: 0, item: 0 })).toThrow('requires field name'); - - const incompleteMessage = createIRCollector(); - incompleteMessage.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - incompleteMessage.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message' } }); - expect(() => incompleteMessage.applyEvent({ type: 'item_end', choice: 0, item: 0 })).toThrow('requires field content'); - - const incompleteChoice = createIRCollector(); - incompleteChoice.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: {} }); - expect(() => incompleteChoice.applyEvent({ type: 'choice_end', choice: 0, finish_reason: 'stop' })).toThrow('requires field items'); - }); -}); diff --git a/packages/translate/__tests__/ir/initialize_test.ts b/packages/translate/__tests__/ir/initialize_test.ts deleted file mode 100644 index eff27b313b..0000000000 --- a/packages/translate/__tests__/ir/initialize_test.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { expect, test } from 'vitest'; - -import { collect, iterate, openaiResponsesFromIR } from './helpers.ts'; -import { createIRCollector } from '../../src/ir/collector.ts'; -import { createIRBuilder, type IRRecord } from '../../src/ir/stream.ts'; - -test('initialize carries real required fields while append targets start empty', async () => { - const records: IRRecord[] = [ - { type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }, - { type: 'write', kind: 'assign', path: ['model'], value: 'model' }, - { type: 'start', id: 'source' }, - { type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call', call_id: 'call', name: 'real-name', arguments: '' } }, - { type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'arguments'], value: '{}' }, - { type: 'item_end', choice: 0, item: 0 }, - { type: 'choice_end', choice: 0, finish_reason: 'tool_calls' }, - { type: 'finish', status: 'completed' }, - ]; - const output = await collect(openaiResponsesFromIR(iterate(records))); - const serialized = JSON.stringify(output); - expect(serialized).toContain('real-name'); - expect(serialized).toContain('{}'); -}); - -test('initialize arrays require the exact next index and never replace entries', () => { - const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - expect(() => collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } })).toThrow('absent target'); - for (const index of [-1, 0.5, 2]) expect(() => collector.apply({ type: 'write', kind: 'initialize', path: ['choices', index], value: { items: [] } })).toThrow(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 1], value: { items: [] } }); - expect(collector.state.choices).toHaveLength(2); -}); - -test('append accepts only statically declared string fields', () => { - const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: { type: 'text', text: '' } }); - expect(() => collector.apply({ type: 'write', kind: 'append', path: ['model'], value: 'x' })).toThrow('append-target path'); - collector.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'x' }); - expect(collector.value(['choices', 0, 'items', 0, 'content', 0, 'text'])).toBe('x'); -}); - -test('builder initializes items without separate start events', () => { - const builder = createIRBuilder(); - builder.item(0, { type: 'message', content: [] }); - expect(builder.drain()).toEqual([ - { type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }, - { type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }, - ]); -}); diff --git a/packages/translate/__tests__/ir/output/collector_test.ts b/packages/translate/__tests__/ir/output/collector_test.ts new file mode 100644 index 0000000000..0ad6b217e4 --- /dev/null +++ b/packages/translate/__tests__/ir/output/collector_test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from 'vitest'; + +import { IRAbsent } from '../../../src/ir/output/absent.ts'; +import { createIRCollector } from '../../../src/ir/output/collector.ts'; + +describe('IR accumulator', () => { + test('stores plain accumulated state without publication metadata', () => { + const collector = createIRCollector(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['usage'], value: {} }); + collector.applyWrite({ type: 'write', kind: 'assign', path: ['usage', 'output_tokens_inclusive'], value: 0 }); + expect(collector.value(['usage'])).toEqual({ output_tokens_inclusive: 0 }); + }); + + test('enforces static append targets', () => { + const collector = createIRCollector(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: { type: 'text', text: '' } }); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'x' })).toThrow('append target cannot be assigned'); + expect(() => collector.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 1 } as any)).toThrow('append requires a string value'); + collector.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'x' }); + expect(collector.value(['choices', 0, 'items', 0, 'content', 0, 'text'])).toBe('x'); + + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1], value: { type: 'reasoning', content: [''] } }); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 1, 'content', 0], value: 1 })).toThrow('append target cannot be assigned'); + }); + + test('rejects undefined and only assigns IRAbsent to absent fields', () => { + const collector = createIRCollector(); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['model'], value: IRAbsent })).toThrow('IRAbsent is not allowed'); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call', name: 'f', arguments: '' } }); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'call_id'], value: undefined as any })).toThrow('undefined'); + collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'call_id'], value: IRAbsent }); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'call_id'], value: IRAbsent })).toThrow('absent target'); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'refusal_details'], value: { category: IRAbsent, explanation: null } as any })).toThrow('assigned directly'); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'refusal_details'], value: { category: undefined, explanation: null } as any })).toThrow('cannot contain undefined'); + expect(() => collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1], value: { type: 'reasoning', summary: [IRAbsent] } })).toThrow('append target must initialize to an empty string'); + }); + + test('enforces append ownership by item type', () => { + const collector = createIRCollector(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call', name: 'f', arguments: '' } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1], value: { type: 'custom_tool_call', name: 'c', input: '' } }); + expect(() => collector.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'input'], value: 'x' })).toThrow('append-target path'); + expect(() => collector.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 1, 'arguments'], value: 'x' })).toThrow('append-target path'); + }); + + test('rejects an unallocated choice end', () => { + const collector = createIRCollector(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + expect(() => collector.applyEvent({ type: 'choice_end', choice: 99, finish_reason: 'stop' })).toThrow('does not exist'); + }); + + test('rejects operations after part and item end', () => { + const collector = createIRCollector(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: { type: 'text', text: '' } }); + collector.applyEvent({ type: 'part_end', choice: 0, item: 0, part: 0 }); + expect(() => collector.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'late' })).toThrow('closed part'); + collector.applyEvent({ type: 'item_end', choice: 0, item: 0 }); + expect(() => collector.applyWrite({ type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'type'], value: 'message' })).toThrow('closed item'); + }); + + test('rejects ended units that omit required schema fields', () => { + const incompleteItem = createIRCollector(); + incompleteItem.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + incompleteItem.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call' } }); + expect(() => incompleteItem.applyEvent({ type: 'item_end', choice: 0, item: 0 })).toThrow('requires field name'); + + const incompleteMessage = createIRCollector(); + incompleteMessage.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + incompleteMessage.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message' } }); + expect(() => incompleteMessage.applyEvent({ type: 'item_end', choice: 0, item: 0 })).toThrow('requires field content'); + + const incompleteChoice = createIRCollector(); + incompleteChoice.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: {} }); + expect(() => incompleteChoice.applyEvent({ type: 'choice_end', choice: 0, finish_reason: 'stop' })).toThrow('requires field items'); + }); +}); diff --git a/packages/translate/__tests__/ir/copy-ownership_test.ts b/packages/translate/__tests__/ir/output/copy-ownership_test.ts similarity index 62% rename from packages/translate/__tests__/ir/copy-ownership_test.ts rename to packages/translate/__tests__/ir/output/copy-ownership_test.ts index e5d88f05e6..741ef8a64b 100644 --- a/packages/translate/__tests__/ir/copy-ownership_test.ts +++ b/packages/translate/__tests__/ir/output/copy-ownership_test.ts @@ -1,14 +1,14 @@ import { expect, test } from 'vitest'; -import { collect, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, iterate } from './helpers.ts'; -import { createIRCollector } from '../../src/ir/collector.ts'; -import { consumeIRRecords, createIRBuilder } from '../../src/ir/stream.ts'; -import { eventFrame } from '@floway-dev/protocols/common'; +import { createIRBuilder } from '../../../src/ir/output/builder.ts'; +import { createIRCollector } from '../../../src/ir/output/collector.ts'; +import { consumeIRRecords } from '../../../src/ir/output/stream.ts'; +import { collect, iterate } from '../../shared/iterables.ts'; const initializeTextPart = (collector: ReturnType, part: object): void => { - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: part as any }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: part as any }); }; test('collectors borrow record trees and copy only containers first written by that collector', () => { @@ -21,13 +21,13 @@ test('collectors borrow record trees and copy only containers first written by t expect(first.state.choices[0].items[0].type === 'message' && first.state.choices[0].items[0].content[0]).toBe(part); expect(second.state.choices[0].items[0].type === 'message' && second.state.choices[0].items[0].content[0]).toBe(part); - first.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'one' }); + first.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'one' }); const firstPart = first.state.choices[0].items[0].type === 'message' ? first.state.choices[0].items[0].content[0] : undefined; expect(firstPart).not.toBe(part); expect(firstPart?.type === 'text' && firstPart.annotations).toBe(annotations); expect(second.state.choices[0].items[0].type === 'message' && second.state.choices[0].items[0].content[0]).toBe(part); - second.apply({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'other' }); + second.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'other' }); expect(first.value(['choices', 0, 'items', 0, 'content', 0, 'text'])).toBe('one'); expect(second.value(['choices', 0, 'items', 0, 'content', 0, 'text'])).toBe('other'); expect(part.text).toBe(''); @@ -62,32 +62,21 @@ test('builder transfers payloads without copying and later operations never modi test('array writes copy the array while retaining immutable sibling values', () => { const sibling = Object.freeze({ items: Object.freeze([]) }); const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 0], value: sibling }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: sibling }); const preceding = collector.state.choices; - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 1], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 1], value: { items: [] } }); expect(collector.state.choices).toBe(preceding); expect(collector.state.choices[0]).toBe(sibling); const owned = collector.state.choices; - collector.apply({ type: 'write', kind: 'initialize', path: ['choices', 2], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 2], value: { items: [] } }); expect(collector.state.choices).toBe(owned); }); test('value exposes the accumulator live readonly view', () => { const collector = createIRCollector(); - collector.apply({ type: 'write', kind: 'initialize', path: ['usage'], value: {} }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['usage'], value: {} }); const value = collector.value>(['usage'])!; - collector.apply({ type: 'write', kind: 'assign', path: ['usage', 'output_tokens_inclusive'], value: 1 }); + collector.applyWrite({ type: 'write', kind: 'assign', path: ['usage', 'output_tokens_inclusive'], value: 1 }); expect(value).toEqual({}); expect(collector.value(['usage'])).toEqual({ output_tokens_inclusive: 1 }); }); - -test.each([ - ['ChatCompletions', irFromOpenAIChatCompletions, (error: object) => ({ id: 'chat', model: 'm', choices: [], error })], - ['Responses', irFromOpenAIResponses, (error: object) => ({ type: 'error', error })], - ['Messages', irFromAnthropicMessages, (error: object) => ({ type: 'error', error })], -] as const)('%s reader transfers immutable error objects without copying', async (_name, reader, event) => { - const error = Object.freeze({ type: 'api_error', message: 'original', details: Object.freeze([Object.freeze({ text: 'original' })]) }); - const frames = await collect(reader(iterate([eventFrame(event(error) as any)]))); - const record = frames.find(record => record.type === 'error'); - expect(record?.error).toBe(error); -}); diff --git a/packages/translate/__tests__/ir/handles_test.ts b/packages/translate/__tests__/ir/output/handles_test.ts similarity index 83% rename from packages/translate/__tests__/ir/handles_test.ts rename to packages/translate/__tests__/ir/output/handles_test.ts index a7d9055259..a474d2db90 100644 --- a/packages/translate/__tests__/ir/handles_test.ts +++ b/packages/translate/__tests__/ir/output/handles_test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; -import { createIRBuilder } from '../../src/ir/stream.ts'; +import { createIRBuilder } from '../../../src/ir/output/builder.ts'; test('write handles build unit paths and ensure fields once', () => { const builder = createIRBuilder(); @@ -15,7 +15,7 @@ test('write handles build unit paths and ensure fields once', () => { expect(choice.path).toEqual(['choices', 0]); expect(item.path).toEqual(['choices', 0, 'items', 0]); - expect(item.resolve(['summary', 0])).toEqual(['choices', 0, 'items', 0, 'summary', 0]); + expect(item.absolutePath(['summary', 0])).toEqual(['choices', 0, 'items', 0, 'summary', 0]); expect(builder.drain()).toEqual([ { type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }, { type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'reasoning' } }, @@ -47,9 +47,9 @@ test('ending an item closes only its open parts and is idempotent', () => { ]); }); -test('handles expose accumulated values needed for snapshot reconciliation and annotation coordinates', () => { +test('a handle reads back the value accumulated from earlier upstream events', () => { const builder = createIRBuilder(); - const choice = builder.choice(0); + builder.choice(0); const first = builder.item(0, { type: 'message', content: [] }); const firstText = first.part({ type: 'text', text: '' }); firstText.append('text', 'first'); @@ -58,8 +58,5 @@ test('handles expose accumulated values needed for snapshot reconciliation and a secondText.append('text', 'second'); expect(firstText.current('text')).toBe('first'); - expect(choice.textParts()).toEqual([ - { item: first, part: firstText, text: 'first' }, - { item: second, part: secondText, text: 'second' }, - ]); + expect(secondText.current('text')).toBe('second'); }); diff --git a/packages/translate/__tests__/ir/output/helpers.ts b/packages/translate/__tests__/ir/output/helpers.ts new file mode 100644 index 0000000000..80bfc12a59 --- /dev/null +++ b/packages/translate/__tests__/ir/output/helpers.ts @@ -0,0 +1,61 @@ +import { createIRBuilder } from '../../../src/ir/output/builder.ts'; +import type { IRRecord } from '../../../src/ir/output/records.ts'; +import { consumeIRRecords } from '../../../src/ir/output/stream.ts'; +import type { IROutput, IROutputItem } from '../../../src/ir/output/types.ts'; + +export const completeIR = (value: readonly IROutputItem[]): IRRecord[] => { + const b = createIRBuilder(); + b.choice(0); + b.assign(['model'], 'model'); + b.event({ type: 'start', id: 'source' }); + for (const item of value) { + const index = b.state.choices[0].items.length; + const path = ['choices', 0, 'items', index] as const; + if (item.type === 'message') { + b.initialize(path, { type: 'message', content: [] }); + item.content.forEach((part, partIndex) => { + const partPath = [...path, 'content', partIndex] as const; + if (part.type === 'text') { + b.initialize(partPath, { type: 'text', text: '', ...(part.annotations === undefined ? {} : { annotations: part.annotations }) }); + b.append([...partPath, 'text'], part.text); + } else if (part.type === 'refusal') { + b.initialize(partPath, { type: 'refusal', refusal: '' }); + b.append([...partPath, 'refusal'], part.refusal); + } else if (part.type === 'image') b.initialize(partPath, part); + else { + b.initialize(partPath, { type: 'audio', audio: { ...(part.audio.media_type === undefined ? {} : { media_type: part.audio.media_type }) } }); + for (const field of ['data', 'transcript'] as const) if (part.audio[field] !== undefined) { + const value = part.audio[field]; + if (value === undefined) continue; + b.initialize([...partPath, 'audio', field], ''); + b.append([...partPath, 'audio', field], value); + } + } + b.event({ type: 'part_end', choice: 0, item: index, part: partIndex }); + }); + } else if (item.type === 'reasoning') { + b.initialize(path, { type: 'reasoning', ...(item.summary === undefined ? {} : { summary: item.summary.map(() => '') }), ...(item.content === undefined ? {} : { content: item.content.map(() => '') }) }); + item.summary?.forEach((text, part) => b.append([...path, 'summary', part], text)); + item.content?.forEach((text, part) => b.append([...path, 'content', part], text)); + } else if (item.type === 'function_call') { + b.initialize(path, { type: 'function_call', ...(item.call_id === undefined ? {} : { call_id: item.call_id }), name: item.name, arguments: '' }); + b.append([...path, 'arguments'], item.arguments); + } else { + b.initialize(path, { type: 'custom_tool_call', ...(item.call_id === undefined ? {} : { call_id: item.call_id }), name: item.name, input: '' }); + b.append([...path, 'input'], item.input); + } + b.event({ type: 'item_end', choice: 0, item: index }); + } + b.event({ type: 'choice_end', choice: 0, finish_reason: 'stop' }); + b.event({ type: 'finish', status: 'completed' }); + return [...b.drain()]; +}; + +export const collectIR = async (records: AsyncIterable): Promise => { + let state: IROutput = { choices: [] }; + for await (const value of consumeIRRecords(records)) { + state = value.collector.state; + if (value.record.type === 'error') throw new Error('IR generation failed', { cause: value.record.error }); + } + return state; +}; diff --git a/packages/translate/__tests__/ir/output/initialize_test.ts b/packages/translate/__tests__/ir/output/initialize_test.ts new file mode 100644 index 0000000000..94900f974a --- /dev/null +++ b/packages/translate/__tests__/ir/output/initialize_test.ts @@ -0,0 +1,32 @@ +import { expect, test } from 'vitest'; + +import { createIRBuilder } from '../../../src/ir/output/builder.ts'; +import { createIRCollector } from '../../../src/ir/output/collector.ts'; + +test('initialize arrays require the exact next index and never replace entries', () => { + const collector = createIRCollector(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + expect(() => collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } })).toThrow('absent target'); + for (const index of [-1, 0.5, 2]) expect(() => collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', index], value: { items: [] } })).toThrow(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 1], value: { items: [] } }); + expect(collector.state.choices).toHaveLength(2); +}); + +test('append accepts only statically declared string fields', () => { + const collector = createIRCollector(); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }); + collector.applyWrite({ type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0, 'content', 0], value: { type: 'text', text: '' } }); + expect(() => collector.applyWrite({ type: 'write', kind: 'append', path: ['model'], value: 'x' })).toThrow('append-target path'); + collector.applyWrite({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'x' }); + expect(collector.value(['choices', 0, 'items', 0, 'content', 0, 'text'])).toBe('x'); +}); + +test('builder initializes items without separate start events', () => { + const builder = createIRBuilder(); + builder.item(0, { type: 'message', content: [] }); + expect(builder.drain()).toEqual([ + { type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }, + { type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'message', content: [] } }, + ]); +}); diff --git a/packages/translate/__tests__/ir/part-id_test.ts b/packages/translate/__tests__/ir/output/part-id_test.ts similarity index 94% rename from packages/translate/__tests__/ir/part-id_test.ts rename to packages/translate/__tests__/ir/output/part-id_test.ts index f6450cc581..17d81054c0 100644 --- a/packages/translate/__tests__/ir/part-id_test.ts +++ b/packages/translate/__tests__/ir/output/part-id_test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; -import { createIRBuilder } from '../../src/ir/stream.ts'; +import { createIRBuilder } from '../../../src/ir/output/builder.ts'; test('carries optional source part IDs on append operations', () => { const builder = createIRBuilder(); diff --git a/packages/translate/__tests__/ir/schema_test.ts b/packages/translate/__tests__/ir/output/schema_test.ts similarity index 78% rename from packages/translate/__tests__/ir/schema_test.ts rename to packages/translate/__tests__/ir/output/schema_test.ts index 143f6774c1..901e737119 100644 --- a/packages/translate/__tests__/ir/schema_test.ts +++ b/packages/translate/__tests__/ir/output/schema_test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'vitest'; -import { irEndedUnit, irItemPath, irPartPath, irRecordOwnerPaths, irUnitChain, irUnitPath, isIRAppendTarget, isIRItemPath, isIRAbsentTarget, isIRPartPath } from '../../src/ir/operation-paths.ts'; +import { irEndedUnit, irItemPath, irPartPath, irRecordOwnerPaths, irUnitChain, irUnitPath, isIRItemPath, isIRPartPath, resolveIRPath } from '../../../src/ir/output/operation-paths.ts'; const root = { choices: [{ @@ -19,7 +19,7 @@ describe('IR schema queries', () => { [item(0, 'content', 0, 'text'), true], [item(0, 'content', 1, 'audio', 'data'), true], [item(0, 'content', 1, 'audio', 'transcript'), true], - [item(0, 'content', 1, 'audio', 'mime_type'), false], + [item(0, 'content', 1, 'audio', 'media_type'), false], [item(1, 'summary', 0), true], [item(1, 'content', 0), true], [item(2, 'arguments'), true], @@ -28,14 +28,15 @@ describe('IR schema queries', () => { [item(2, 'input'), false], [['model'], false], ])('append target at %j is %s', (path, expected) => { - expect(isIRAppendTarget(path, root)).toBe(expected); + const node = resolveIRPath(path, root)?.node; + expect(node?.kind === 'string' && node.append === true).toBe(expected); }); test('only tool call IDs may be IRAbsent', () => { - expect(isIRAbsentTarget(item(2, 'call_id'), root)).toBe(true); - expect(isIRAbsentTarget(item(3, 'call_id'), root)).toBe(true); - expect(isIRAbsentTarget(item(2, 'name'), root)).toBe(false); - expect(isIRAbsentTarget(item(0, 'call_id'), root)).toBe(false); + expect(resolveIRPath(item(2, 'call_id'), root)?.node).toMatchObject({ kind: 'string', absent: true }); + expect(resolveIRPath(item(3, 'call_id'), root)?.node).toMatchObject({ kind: 'string', absent: true }); + expect(resolveIRPath(item(2, 'name'), root)?.node).not.toMatchObject({ kind: 'string', absent: true }); + expect(resolveIRPath(item(0, 'call_id'), root)?.node).not.toMatchObject({ kind: 'string', absent: true }); }); test('items and parts are the lifecycle units of a choice', () => { diff --git a/packages/translate/__tests__/ir/output/unit-queue_test.ts b/packages/translate/__tests__/ir/output/unit-queue_test.ts new file mode 100644 index 0000000000..a81542bcb3 --- /dev/null +++ b/packages/translate/__tests__/ir/output/unit-queue_test.ts @@ -0,0 +1,36 @@ +import { expect, test } from 'vitest'; + +import { createIRUnitQueue } from '../../../src/ir/output/unit-queue.ts'; + +interface Unit { readonly name: string } + +test('unit queue dequeues units in the order they were enqueued', () => { + const queue = createIRUnitQueue(); + const [a, b] = [{ name: 'a' }, { name: 'b' }]; + queue.enqueue(a); + queue.enqueue(b); + expect(queue.head).toBe(a); + queue.dropHead(); + expect(queue.head).toBe(b); + queue.dropHead(); + expect(queue.head).toBeUndefined(); + const c = { name: 'c' }; + queue.enqueue(c); + expect(queue.head).toBe(c); +}); + +test('unit queue owns its links without changing payloads or coupling independent queues', () => { + const unit = Object.freeze({ name: 'shared' }); + const first = createIRUnitQueue(); + const second = createIRUnitQueue(); + first.enqueue(unit); + first.enqueue(unit); + second.enqueue(unit); + first.dropHead(); + expect(first.head).toBe(unit); + expect(second.head).toBe(unit); + first.dropHead(); + expect(first.head).toBeUndefined(); + expect(second.head).toBe(unit); + expect(Object.keys(unit)).toEqual(['name']); +}); diff --git a/packages/translate/__tests__/ir/unit-queue_test.ts b/packages/translate/__tests__/ir/output/unit-registry_test.ts similarity index 61% rename from packages/translate/__tests__/ir/unit-queue_test.ts rename to packages/translate/__tests__/ir/output/unit-registry_test.ts index 500bb6ef1d..e68845953a 100644 --- a/packages/translate/__tests__/ir/unit-queue_test.ts +++ b/packages/translate/__tests__/ir/output/unit-registry_test.ts @@ -1,24 +1,7 @@ import { expect, test } from 'vitest'; -import type { IRRecord } from '../../src/ir/stream.ts'; -import { createIRUnitQueue, createIRUnitRegistry, type IRQueuedUnit } from '../../src/ir/unit-queue.ts'; - -interface Unit extends IRQueuedUnit { readonly name: string } - -test('unit queue dequeues units in the order they were enqueued', () => { - const queue = createIRUnitQueue(); - const [a, b] = [{ name: 'a' }, { name: 'b' }]; - queue.enqueue(a); - queue.enqueue(b); - expect(queue.head).toBe(a); - queue.dequeue(); - expect(queue.head).toBe(b); - queue.dequeue(); - expect(queue.head).toBeUndefined(); - const c = { name: 'c' }; - queue.enqueue(c); - expect(queue.head).toBe(c); -}); +import type { IRRecord } from '../../../src/ir/output/records.ts'; +import { createIRUnitRegistry } from '../../../src/ir/output/unit-registry.ts'; test('unit registry finds the innermost registered owner of a record', () => { const registry = createIRUnitRegistry(); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/sse-to_test.ts index 20ab1c350c..40fe708d69 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-source/sse-to_test.ts @@ -1,11 +1,11 @@ import { expect, test } from 'vitest'; -import { createIRBuilder } from '../../../../src/ir/stream.ts'; +import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; import { anthropicMessagesFromIR } from '../../../../src/protocols/anthropic-messages/as-source/sse-to.ts'; import { appendField, collectContentViews, createReferenceBuilder, createFieldState, createReferenceFinalizer } from '../../../../src/round-trip/reference/reference-builder.ts'; -import { type RoundTripWriter } from '../../../../src/round-trip/stream.ts'; +import type { RoundTripWriter } from '../../../../src/round-trip/stream.ts'; import { hashRoundTripContent } from '../../../../src/round-trip/turn-digest.ts'; -import { type AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; +import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; test.each(['text', 'refusal', 'audio'] as const)('resolves blocked %s against the emitted Messages text field', async kind => { const references = createReferenceBuilder(); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-target/sse-from_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-target/sse-from_test.ts index db364566b8..cff11a8ab1 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-target/sse-from_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-target/sse-from_test.ts @@ -4,7 +4,7 @@ import type { AnthropicMessagesAssistantState, AnthropicMessagesInputAssistantTu import { irFromAnthropicMessages } from '../../../../src/protocols/anthropic-messages/as-target/sse-from.ts'; import { appendContent, collectContentViews, createContentState, createReferenceBuilder, type FieldStates } from '../../../../src/round-trip/reference/reference-builder.ts'; import { finalizeAssistantTurnReferences } from '../../../../src/round-trip/reference/reference-selection.ts'; -import { fixtureFrames } from '../../../ir/translation-cases.ts'; +import { fixtureFrames } from '../../fixtures.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events_test.ts index e65ea405a7..a03fca1f03 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events_test.ts @@ -1,4 +1,4 @@ import { translateToSourceEvents } from '../../../../src/protocols/anthropic-messages/via-openai-chat-completions/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; testOutputTranslation('anthropic-messages', 'openai-chat-completions', (frames, codec) => translateToSourceEvents(frames, codec)); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts index 079e83e658..f3d783b4e0 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts @@ -1,8 +1,8 @@ import { test } from 'vitest'; import { translateToSourceEvents } from '../../../../src/protocols/anthropic-messages/via-openai-responses/events.ts'; -import { structuredResponsesFixture } from '../../../ir/responses-fixture.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { structuredResponsesFixture } from '../../openai-responses/fixtures.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { openaiResponsesResultToEvents, type OpenAIResponsesResultEx, type OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events_test.ts index 0f05aec1df..3234ee801f 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events_test.ts @@ -1,8 +1,8 @@ import { expect, test } from 'vitest'; import { translateToSourceEvents } from '../../../../src/protocols/anthropic-messages/via-openai-responses/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; import { parseAnthropicMessagesStream, collectAnthropicMessagesProtocolEventsToResult, anthropicMessagesProtocolFrameToSSEFrame } from '@floway-dev/protocols/anthropic-messages'; import { parseOpenAIResponsesStream } from '@floway-dev/protocols/openai-responses'; diff --git a/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts b/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts index 0e54d49260..7e786f5648 100644 --- a/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts +++ b/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts @@ -1,8 +1,8 @@ import { expect, test } from 'vitest'; import { toOpenAIResponsesInputAssistantTurn } from '../../src/protocols/openai-responses/as-target/assistant-turn.ts'; -import { iterate } from '../ir/helpers.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { iterate } from '../shared/iterables.ts'; import { collectAnthropicMessagesProtocolEventsToResult, type AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { collectGeminiGenerateContentProtocolEventsToResult, type GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; diff --git a/packages/translate/__tests__/ir/edge-cases_test.ts b/packages/translate/__tests__/protocols/edge-cases_test.ts similarity index 97% rename from packages/translate/__tests__/ir/edge-cases_test.ts rename to packages/translate/__tests__/protocols/edge-cases_test.ts index 652013558f..28ffbb2025 100644 --- a/packages/translate/__tests__/ir/edge-cases_test.ts +++ b/packages/translate/__tests__/protocols/edge-cases_test.ts @@ -1,8 +1,8 @@ import { expect, test, vi } from 'vitest'; -import { anthropicMessagesFromIR, collectIR, collect, completeIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, iterate, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; -import { fixtureFrames, nativeResult } from './translation-cases.ts'; -import { createIRBuilder } from '../../src/ir/stream.ts'; +import { fixtureFrames, nativeResult } from './fixtures.ts'; +import { anthropicMessagesFromIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; +import { createIRBuilder } from '../../src/ir/output/builder.ts'; import { translateToSourceEvents as messagesViaChat } from '../../src/protocols/anthropic-messages/via-openai-chat-completions/events.ts'; import { buildTargetRequest as messagesRequestViaChat } from '../../src/protocols/anthropic-messages/via-openai-chat-completions/request.ts'; import { translateToSourceEvents as messagesViaResponses } from '../../src/protocols/anthropic-messages/via-openai-responses/events.ts'; @@ -11,8 +11,10 @@ import { translateToSourceEvents as generateContentViaResponses } from '../../sr import { irFromOpenAIChatCompletions as irFromOpenAIChatCompletionsProduction } from '../../src/protocols/openai-chat-completions/as-target/sse-from.ts'; import { translateToSourceEvents as responsesViaMessages } from '../../src/protocols/openai-responses/via-anthropic-messages/events.ts'; import { translateToSourceEvents as responsesViaChat } from '../../src/protocols/openai-responses/via-openai-chat-completions/events.ts'; +import { collectIR, completeIR } from '../ir/output/helpers.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { createRoundTripReaderCapture, emptyNamespaceToolNames } from '../round-trip/test-helpers.ts'; +import { collect, iterate } from '../shared/iterables.ts'; import { eventFrame } from '@floway-dev/protocols/common'; for (const [code, category] of [['bio_policy', 'bio'], ['cyber_policy', 'cyber']] as const) { @@ -135,7 +137,7 @@ test('Messages and GenerateContent reject custom tool calls they cannot represen }); test('writers omit output they cannot represent', async () => { - const image = completeIR([{ type: 'message', content: [{ type: 'text', text: 'before' }, { type: 'image', image: { data: 'aW1n', mime_type: 'image/png' } }] }]); + const image = completeIR([{ type: 'message', content: [{ type: 'text', text: 'before' }, { type: 'image', image: { data: 'aW1n', media_type: 'image/png' } }] }]); expect(JSON.stringify(await collect(anthropicMessagesFromIR(iterate(image))))).toContain('before'); expect(JSON.stringify(await collect(openaiChatCompletionsFromIR(iterate(image))))).toContain('before'); diff --git a/packages/translate/__tests__/ir/translation-cases.ts b/packages/translate/__tests__/protocols/fixtures.ts similarity index 99% rename from packages/translate/__tests__/ir/translation-cases.ts rename to packages/translate/__tests__/protocols/fixtures.ts index eacb60b174..6146d453d9 100644 --- a/packages/translate/__tests__/ir/translation-cases.ts +++ b/packages/translate/__tests__/protocols/fixtures.ts @@ -1,9 +1,9 @@ import { expect, test } from 'vitest'; -import { collect, iterate } from './helpers.ts'; import { decodeRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; import type { RoundTripProtocol } from '../../src/round-trip/sidecar.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { collect, iterate } from '../shared/iterables.ts'; import { collectAnthropicMessagesProtocolEventsToResult } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, doneFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { collectGeminiGenerateContentProtocolEventsToResult } from '@floway-dev/protocols/gemini-generate-content'; diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/sse-to_test.ts index a354be3ec3..38fab0a504 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/sse-to_test.ts @@ -1,9 +1,9 @@ import { expect, test } from 'vitest'; -import { createIRBuilder } from '../../../../src/ir/stream.ts'; +import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; import { geminiGenerateContentFromIR } from '../../../../src/protocols/gemini-generate-content/as-source/sse-to.ts'; import { appendField, collectContentViews, createReferenceBuilder, createFieldState, createReferenceFinalizer } from '../../../../src/round-trip/reference/reference-builder.ts'; -import { type RoundTripWriter } from '../../../../src/round-trip/stream.ts'; +import type { RoundTripWriter } from '../../../../src/round-trip/stream.ts'; test('resolves delayed Gemini audio atoms within the complete emitted data field', async () => { const references = createReferenceBuilder(); @@ -14,7 +14,7 @@ test('resolves delayed Gemini audio atoms within the complete emitted data field source.assign(['model'], 'model'); source.event({ type: 'start', id: 'response' }); const message = source.item(0, { type: 'message', content: [] }); - const audio = message.part({ type: 'audio', audio: { data: '', mime_type: 'audio/wav' } }); + const audio = message.part({ type: 'audio', audio: { data: '', media_type: 'audio/wav' } }); audio.append(['audio', 'data'], 'Y'); audio.append(['audio', 'data'], 'Q==', [partId]); audio.end(); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/events_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/events_test.ts index 4c350535f3..9576b13585 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/events_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/events_test.ts @@ -1,4 +1,4 @@ import { translateToSourceEvents } from '../../../../src/protocols/gemini-generate-content/via-anthropic-messages/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; testOutputTranslation('gemini-generate-content', 'anthropic-messages', (frames, codec) => translateToSourceEvents(frames, codec)); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/events_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/events_test.ts index 4fd9041eff..803a949a56 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/events_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/events_test.ts @@ -1,4 +1,4 @@ import { translateToSourceEvents } from '../../../../src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; testOutputTranslation('gemini-generate-content', 'openai-chat-completions', (frames, codec) => translateToSourceEvents(frames, codec)); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/events_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/events_test.ts index b9f72afc5d..da065b0d58 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/events_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/events_test.ts @@ -1,4 +1,4 @@ import { translateToSourceEvents } from '../../../../src/protocols/gemini-generate-content/via-openai-responses/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; testOutputTranslation('gemini-generate-content', 'openai-responses', (frames, codec) => translateToSourceEvents(frames, codec)); diff --git a/packages/translate/__tests__/ir/helpers.ts b/packages/translate/__tests__/protocols/helpers.ts similarity index 54% rename from packages/translate/__tests__/ir/helpers.ts rename to packages/translate/__tests__/protocols/helpers.ts index 396b11e24d..864df006d6 100644 --- a/packages/translate/__tests__/ir/helpers.ts +++ b/packages/translate/__tests__/protocols/helpers.ts @@ -1,5 +1,4 @@ -import { consumeIRRecords, createIRBuilder, type IRRecord } from '../../src/ir/stream.ts'; -import type { IR, IRItem } from '../../src/ir/types.ts'; +import type { IRRecord } from '../../src/ir/output/records.ts'; import { anthropicMessagesFromIR as anthropicMessagesFromIRProduction } from '../../src/protocols/anthropic-messages/as-source/sse-to.ts'; import { irFromAnthropicMessages as irFromAnthropicMessagesProduction } from '../../src/protocols/anthropic-messages/as-target/sse-from.ts'; import { geminiGenerateContentFromIR as geminiGenerateContentFromIRProduction } from '../../src/protocols/gemini-generate-content/as-source/sse-to.ts'; @@ -10,11 +9,6 @@ import { openaiResponsesFromIR as openaiResponsesFromIRProduction } from '../../ import { irFromOpenAIResponses as irFromOpenAIResponsesProduction } from '../../src/protocols/openai-responses/as-target/sse-from.ts'; import { createReferenceBuilder } from '../../src/round-trip/reference/reference-builder.ts'; import type { RoundTripReader, RoundTripWriter } from '../../src/round-trip/stream.ts'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; - -export const iterate = async function* (values: readonly T[]): AsyncGenerator { yield* values; }; -export const collect = async (values: AsyncIterable): Promise => { const result: T[] = []; for await (const value of values) result.push(value); return result; }; -export const events = async function* (frames: AsyncIterable>): AsyncGenerator { for await (const frame of frames) if (frame.type === 'event') yield frame.event; }; const reader = (): RoundTripReader => ({ builder: createReferenceBuilder(), @@ -53,59 +47,3 @@ export const openaiResponsesFromIR = (records: Parameters(), namespaceToolNames }); export const geminiGenerateContentFromIR = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => geminiGenerateContentFromIRProduction(records, { ...options, roundTrip: writer<'geminiGenerateContent'>() }); -export const completeIR = (value: readonly IRItem[]): IRRecord[] => { - const b = createIRBuilder(); - b.choice(0); - b.assign(['model'], 'model'); - b.event({ type: 'start', id: 'source' }); - for (const item of value) { - const index = b.state.choices[0].items.length; - const path = ['choices', 0, 'items', index] as const; - if (item.type === 'message') { - b.initialize(path, { type: 'message', content: [] }); - item.content.forEach((part, partIndex) => { - const partPath = [...path, 'content', partIndex] as const; - if (part.type === 'text') { - b.initialize(partPath, { type: 'text', text: '', ...(part.annotations === undefined ? {} : { annotations: part.annotations }) }); - b.append([...partPath, 'text'], part.text); - } else if (part.type === 'refusal') { - b.initialize(partPath, { type: 'refusal', refusal: '' }); - b.append([...partPath, 'refusal'], part.refusal); - } else if (part.type === 'image') b.initialize(partPath, part); - else { - b.initialize(partPath, { type: 'audio', audio: { ...(part.audio.mime_type === undefined ? {} : { mime_type: part.audio.mime_type }) } }); - for (const field of ['data', 'transcript'] as const) if (part.audio[field] !== undefined) { - const value = part.audio[field]; - if (value === undefined) continue; - b.initialize([...partPath, 'audio', field], ''); - b.append([...partPath, 'audio', field], value); - } - } - b.event({ type: 'part_end', choice: 0, item: index, part: partIndex }); - }); - } else if (item.type === 'reasoning') { - b.initialize(path, { type: 'reasoning', ...(item.summary === undefined ? {} : { summary: item.summary.map(() => '') }), ...(item.content === undefined ? {} : { content: item.content.map(() => '') }) }); - item.summary?.forEach((text, part) => b.append([...path, 'summary', part], text)); - item.content?.forEach((text, part) => b.append([...path, 'content', part], text)); - } else if (item.type === 'function_call') { - b.initialize(path, { type: 'function_call', ...(item.call_id === undefined ? {} : { call_id: item.call_id }), name: item.name, arguments: '' }); - b.append([...path, 'arguments'], item.arguments); - } else { - b.initialize(path, { type: 'custom_tool_call', ...(item.call_id === undefined ? {} : { call_id: item.call_id }), name: item.name, input: '' }); - b.append([...path, 'input'], item.input); - } - b.event({ type: 'item_end', choice: 0, item: index }); - } - b.event({ type: 'choice_end', choice: 0, finish_reason: 'stop' }); - b.event({ type: 'finish', status: 'completed' }); - return [...b.drain()]; -}; - -export const collectIR = async (records: AsyncIterable): Promise => { - let state: IR = { choices: [] }; - for await (const value of consumeIRRecords(records)) { - state = value.collector.state; - if (value.record.type === 'error') throw new Error('IR generation failed', { cause: value.record.error }); - } - return state; -}; diff --git a/packages/translate/__tests__/ir/initial-envelope_test.ts b/packages/translate/__tests__/protocols/initial-envelope_test.ts similarity index 100% rename from packages/translate/__tests__/ir/initial-envelope_test.ts rename to packages/translate/__tests__/protocols/initial-envelope_test.ts diff --git a/packages/translate/__tests__/ir/lifecycle_test.ts b/packages/translate/__tests__/protocols/lifecycle_test.ts similarity index 98% rename from packages/translate/__tests__/ir/lifecycle_test.ts rename to packages/translate/__tests__/protocols/lifecycle_test.ts index 18e5df52ea..7614c6f6ca 100644 --- a/packages/translate/__tests__/ir/lifecycle_test.ts +++ b/packages/translate/__tests__/protocols/lifecycle_test.ts @@ -1,9 +1,11 @@ import { expect, test, vi } from 'vitest'; -import { anthropicMessagesFromIR, collect, collectIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, iterate, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; -import { nativeResult } from './translation-cases.ts'; -import type { IRRecord } from '../../src/ir/stream.ts'; -import { IRAbsent } from '../../src/ir/types.ts'; +import { nativeResult } from './fixtures.ts'; +import { anthropicMessagesFromIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; +import { IRAbsent } from '../../src/ir/output/absent.ts'; +import type { IRRecord } from '../../src/ir/output/records.ts'; +import { collectIR } from '../ir/output/helpers.ts'; +import { collect, iterate } from '../shared/iterables.ts'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; const chat = (delta: any, finish_reason: string | null = null) => eventFrame({ id: 'chatcmpl_lifecycle', model: 'served', created: 1, choices: [{ index: 0, delta, finish_reason }] } as any); diff --git a/packages/translate/__tests__/ir/continuous-usage_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/continuous-usage_test.ts similarity index 93% rename from packages/translate/__tests__/ir/continuous-usage_test.ts rename to packages/translate/__tests__/protocols/openai-chat-completions/as-source/continuous-usage_test.ts index a64739675a..20a2995bf6 100644 --- a/packages/translate/__tests__/ir/continuous-usage_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/continuous-usage_test.ts @@ -1,9 +1,9 @@ import { expect, test } from 'vitest'; -import { collect, iterate } from './helpers.ts'; -import { fixtureFrames } from './translation-cases.ts'; -import { routeOpenAIChatCompletions } from '../../src/protocols/openai-chat-completions/index.ts'; -import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { routeOpenAIChatCompletions } from '../../../../src/protocols/openai-chat-completions/index.ts'; +import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { collect, iterate } from '../../../shared/iterables.ts'; +import { fixtureFrames } from '../../fixtures.ts'; import type { OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; const protocols = ['anthropic-messages', 'openai-responses'] as const; diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts index 7fe2a02421..2ca92d6702 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts @@ -1,9 +1,9 @@ import { expect, test } from 'vitest'; -import { createIRBuilder } from '../../../../src/ir/stream.ts'; +import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; import { openaiChatCompletionsFromIR } from '../../../../src/protocols/openai-chat-completions/as-source/sse-to.ts'; import { createReferenceBuilder, collectContentViews, createFieldState, createReferenceFinalizer, appendField } from '../../../../src/round-trip/reference/reference-builder.ts'; -import { type RoundTripWriter } from '../../../../src/round-trip/stream.ts'; +import type { RoundTripWriter } from '../../../../src/round-trip/stream.ts'; test('prepares each Chat choice independently while another choice has partial audio', async () => { const source = createIRBuilder(2); @@ -13,7 +13,7 @@ test('prepares each Chat choice independently while another choice has partial a source.event({ type: 'start', id: 'response' }); source.initialize(['choices', 1, 'items', 0], { type: 'message', content: [] }); - source.initialize(['choices', 1, 'items', 0, 'content', 0], { type: 'audio', audio: { mime_type: 'audio/wav' } }); + source.initialize(['choices', 1, 'items', 0, 'content', 0], { type: 'audio', audio: { media_type: 'audio/wav' } }); source.initialize(['choices', 1, 'items', 0, 'content', 0, 'audio', 'data'], ''); source.append(['choices', 1, 'items', 0, 'content', 0, 'audio', 'data'], 'YQ=='); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts index 134b73403f..f73b26fe4a 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; -import type { IRRecord } from '../../../../src/ir/stream.ts'; +import type { IRRecord } from '../../../../src/ir/output/records.ts'; import type { OpenAIChatCompletionsInputAssistantTurn } from '../../../../src/protocols/openai-chat-completions/as-target/assistant-turn.ts'; import { irFromOpenAIChatCompletions } from '../../../../src/protocols/openai-chat-completions/as-target/sse-from.ts'; import { appendContent, collectContentViews, createContentState, createReferenceBuilder } from '../../../../src/round-trip/reference/reference-builder.ts'; diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events_test.ts index 7a6cdd62a2..3fdcfbc082 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events_test.ts @@ -1,4 +1,4 @@ import { translateToSourceEvents } from '../../../../src/protocols/openai-chat-completions/via-anthropic-messages/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; testOutputTranslation('openai-chat-completions', 'anthropic-messages', (frames, codec) => translateToSourceEvents(frames, {}, codec)); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts index beafe30ffa..a7d7f70afe 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts @@ -2,9 +2,9 @@ import { expect, test } from 'vitest'; import { translateToSourceEvents } from '../../../../src/protocols/openai-chat-completions/via-openai-responses/events.ts'; import { buildTargetRequest } from '../../../../src/protocols/openai-chat-completions/via-openai-responses/request.ts'; -import { structuredResponsesFixture } from '../../../ir/responses-fixture.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; +import { structuredResponsesFixture } from '../../openai-responses/fixtures.ts'; import { eventFrame, type ProtocolFrame, type SseFrame, sseFrame } from '@floway-dev/protocols/common'; import { collectOpenAIChatCompletionsProtocolEventsToResult, parseOpenAIChatCompletionsStream, openaiChatCompletionsProtocolFrameToSSEFrame as writeChatFrame, type OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; import { openaiResponsesResultToEvents, parseOpenAIResponsesStream, collectOpenAIResponsesProtocolEventsToResult, type OpenAIResponsesResultEx, type OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts new file mode 100644 index 0000000000..f023e6d8bc --- /dev/null +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts @@ -0,0 +1,22 @@ +import { expect, test } from 'vitest'; + +import type { IRRecord } from '../../../../src/ir/output/records.ts'; +import { collect, iterate } from '../../../shared/iterables.ts'; +import { openaiResponsesFromIR } from '../../helpers.ts'; + +test('initialize carries real required fields while append targets start empty', async () => { + const records: IRRecord[] = [ + { type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }, + { type: 'write', kind: 'assign', path: ['model'], value: 'model' }, + { type: 'start', id: 'source' }, + { type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 0], value: { type: 'function_call', call_id: 'call', name: 'real-name', arguments: '' } }, + { type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'arguments'], value: '{}' }, + { type: 'item_end', choice: 0, item: 0 }, + { type: 'choice_end', choice: 0, finish_reason: 'tool_calls' }, + { type: 'finish', status: 'completed' }, + ]; + const output = await collect(openaiResponsesFromIR(iterate(records))); + const serialized = JSON.stringify(output); + expect(serialized).toContain('real-name'); + expect(serialized).toContain('{}'); +}); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/sse-to_test.ts index 70cc9ccb60..728f7046f7 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/sse-to_test.ts @@ -1,10 +1,10 @@ import { expect, test } from 'vitest'; -import { createIRBuilder } from '../../../../src/ir/stream.ts'; +import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; import { flattenNamespaceTools } from '../../../../src/protocols/openai-responses/as-source/namespace-tools.ts'; import { openaiResponsesFromIR } from '../../../../src/protocols/openai-responses/as-source/sse-to.ts'; import { appendField, collectContentViews, createReferenceBuilder, createFieldState, createReferenceFinalizer } from '../../../../src/round-trip/reference/reference-builder.ts'; -import { type RoundTripWriter } from '../../../../src/round-trip/stream.ts'; +import type { RoundTripWriter } from '../../../../src/round-trip/stream.ts'; test('collects sparse namespace-restored Responses state from emitted IR atoms', async () => { const namespace = flattenNamespaceTools({ @@ -72,7 +72,7 @@ test('collects sparse namespace-restored Responses state from emitted IR atoms', builder.event({ type: 'item_end', choice: 0, item: 3 }); builder.initialize(['choices', 0, 'items', 4], { type: 'message', content: [] }); - builder.initialize(['choices', 0, 'items', 4, 'content', 0], { type: 'image', image: { data: 'literal', mime_type: 'image/png' } }); + builder.initialize(['choices', 0, 'items', 4, 'content', 0], { type: 'image', image: { data: 'literal', media_type: 'image/png' } }); builder.event({ type: 'part_end', choice: 0, item: 4, part: 0 }); builder.event({ type: 'item_end', choice: 0, item: 4 }); diff --git a/packages/translate/__tests__/ir/snapshot-policy_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts similarity index 98% rename from packages/translate/__tests__/ir/snapshot-policy_test.ts rename to packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts index c93e4ec4ec..73980cb9f8 100644 --- a/packages/translate/__tests__/ir/snapshot-policy_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts @@ -1,7 +1,9 @@ import { expect, test } from 'vitest'; -import { collect, collectIR, irFromOpenAIResponses, iterate } from './helpers.ts'; -import { createRoundTripReaderCapture } from '../round-trip/test-helpers.ts'; +import { collectIR } from '../../../ir/output/helpers.ts'; +import { createRoundTripReaderCapture } from '../../../round-trip/test-helpers.ts'; +import { collect, iterate } from '../../../shared/iterables.ts'; +import { irFromOpenAIResponses } from '../../helpers.ts'; import { eventFrame } from '@floway-dev/protocols/common'; const response = (output: any[] = []) => ({ id: 'resp', model: 'served', created_at: 1, status: 'completed', output, usage: null, error: null, incomplete_details: null }); diff --git a/packages/translate/__tests__/ir/responses-fixture.ts b/packages/translate/__tests__/protocols/openai-responses/fixtures.ts similarity index 100% rename from packages/translate/__tests__/ir/responses-fixture.ts rename to packages/translate/__tests__/protocols/openai-responses/fixtures.ts diff --git a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts b/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts index d956a51402..c47c4cd04e 100644 --- a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts @@ -1,9 +1,9 @@ import { test } from 'vitest'; import { translateToSourceEvents } from '../../../../src/protocols/openai-responses/via-anthropic-messages/events.ts'; -import { fixtureFrames } from '../../../ir/translation-cases.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; import { emptyNamespaceToolNames } from '../../../round-trip/test-helpers.ts'; +import { fixtureFrames } from '../../fixtures.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; diff --git a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events_test.ts b/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events_test.ts index 9793ccb088..d8873d7dfa 100644 --- a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events_test.ts @@ -1,5 +1,5 @@ import { translateToSourceEvents } from '../../../../src/protocols/openai-responses/via-anthropic-messages/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; import { emptyNamespaceToolNames } from '../../../round-trip/test-helpers.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; testOutputTranslation('openai-responses', 'anthropic-messages', (frames, codec) => translateToSourceEvents(frames, 'resp_test', new Set(), codec, emptyNamespaceToolNames())); diff --git a/packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/events_test.ts b/packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/events_test.ts index 71e8cf633c..c1ffc06400 100644 --- a/packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/events_test.ts @@ -1,5 +1,5 @@ import { translateToSourceEvents } from '../../../../src/protocols/openai-responses/via-openai-chat-completions/events.ts'; -import { testOutputTranslation } from '../../../ir/translation-cases.ts'; import { emptyNamespaceToolNames } from '../../../round-trip/test-helpers.ts'; +import { testOutputTranslation } from '../../fixtures.ts'; testOutputTranslation('openai-responses', 'openai-chat-completions', (frames, codec) => translateToSourceEvents(frames, new Set(), codec, emptyNamespaceToolNames())); diff --git a/packages/translate/__tests__/ir/ownership_test.ts b/packages/translate/__tests__/protocols/ownership_test.ts similarity index 88% rename from packages/translate/__tests__/ir/ownership_test.ts rename to packages/translate/__tests__/protocols/ownership_test.ts index 3e98ca9cc0..96b0dc75af 100644 --- a/packages/translate/__tests__/ir/ownership_test.ts +++ b/packages/translate/__tests__/protocols/ownership_test.ts @@ -1,7 +1,9 @@ import { expect, test, vi } from 'vitest'; -import { collect, collectIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, iterate, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; -import { nativeResult } from './translation-cases.ts'; +import { nativeResult } from './fixtures.ts'; +import { geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; +import { collectIR } from '../ir/output/helpers.ts'; +import { collect, iterate } from '../shared/iterables.ts'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; const chunk = (delta: any, extra: any = {}) => eventFrame({ id: 'chat_owner', model: 'served', created: 1, choices: [{ index: 0, delta, finish_reason: null, ...extra }] } as any); @@ -107,3 +109,14 @@ test('range-less Messages citations do not expand to whole-part ChatCompletions const generateContentFrames = await collect(geminiGenerateContentFromIR(irFromAnthropicMessages(iterate(messagesCitationFrames('source quote'))))); expect(generateContentFrames.some(frame => frame.type === 'event' && 'candidates' in frame.event && frame.event.candidates?.some(candidate => candidate.groundingMetadata !== undefined))).toBe(false); }); + +test.each([ + ['ChatCompletions', irFromOpenAIChatCompletions, (error: object) => ({ id: 'chat', model: 'm', choices: [], error })], + ['Responses', irFromOpenAIResponses, (error: object) => ({ type: 'error', error })], + ['Messages', irFromAnthropicMessages, (error: object) => ({ type: 'error', error })], +] as const)('%s reader transfers immutable error objects without copying', async (_name, reader, event) => { + const error = Object.freeze({ type: 'api_error', message: 'original', details: Object.freeze([Object.freeze({ text: 'original' })]) }); + const frames = await collect(reader(iterate([eventFrame(event(error) as any)]))); + const record = frames.find(record => record.type === 'error'); + expect(record?.error).toBe(error); +}); diff --git a/packages/translate/__tests__/protocols/round-trip-receive_test.ts b/packages/translate/__tests__/protocols/round-trip-receive_test.ts index ef74461ed8..deacc16be5 100644 --- a/packages/translate/__tests__/protocols/round-trip-receive_test.ts +++ b/packages/translate/__tests__/protocols/round-trip-receive_test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; -import type { IRJSONObject } from '../../src/ir/types.ts'; +import type { IRJSONObject } from '../../src/ir/common/json.ts'; import { computeAnthropicMessagesTurnDigest } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; import { computeGeminiGenerateContentTurnDigest } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; import { computeOpenAIChatCompletionsTurnDigest } from '../../src/protocols/openai-chat-completions/as-source/round-trip.ts'; diff --git a/packages/translate/__tests__/protocols/routes_test.ts b/packages/translate/__tests__/protocols/routes_test.ts index 129b7a61c5..9c462a803d 100644 --- a/packages/translate/__tests__/protocols/routes_test.ts +++ b/packages/translate/__tests__/protocols/routes_test.ts @@ -1,5 +1,6 @@ import { test } from 'vitest'; +import { fixtureFrames, type nativeResult } from './fixtures.ts'; import { partitionAnthropicMessagesTurns } from '../../src/protocols/anthropic-messages/as-source/conversation-turns.ts'; import { findAnthropicMessagesSidecarCarriers } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; import { findGeminiGenerateContentSidecarCarriers } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; @@ -11,9 +12,8 @@ import { findOpenAIResponsesSidecarCarriers } from '../../src/protocols/openai-r import { toOpenAIResponsesInputAssistantTurn } from '../../src/protocols/openai-responses/as-target/assistant-turn.ts'; import { decodeRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; import { requireJSONObject } from '../../src/shared/json/object.ts'; -import { collect, iterate } from '../ir/helpers.ts'; -import { fixtureFrames, type nativeResult } from '../ir/translation-cases.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { collect, iterate } from '../shared/iterables.ts'; import { collectAnthropicMessagesProtocolEventsToResult, type AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; import { collectGeminiGenerateContentProtocolEventsToResult, type GeminiGenerateContentContent, type GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; import { collectOpenAIChatCompletionsProtocolEventsToResult, type OpenAIChatCompletionsAssistantMessageEx, type OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; diff --git a/packages/translate/__tests__/ir/streams_test.ts b/packages/translate/__tests__/protocols/streams_test.ts similarity index 96% rename from packages/translate/__tests__/ir/streams_test.ts rename to packages/translate/__tests__/protocols/streams_test.ts index 7040a5fdb2..98fafde2e7 100644 --- a/packages/translate/__tests__/ir/streams_test.ts +++ b/packages/translate/__tests__/protocols/streams_test.ts @@ -1,13 +1,16 @@ import { describe, expect, it } from 'vitest'; -import { anthropicMessagesFromIR, collectIR, collect, completeIR, events, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, iterate, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; -import { createIRBuilder } from '../../src/ir/stream.ts'; -import type { IRItem, IRJSONObject } from '../../src/ir/types.ts'; +import { anthropicMessagesFromIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; +import type { IRJSONObject } from '../../src/ir/common/json.ts'; +import { createIRBuilder } from '../../src/ir/output/builder.ts'; +import type { IROutputItem } from '../../src/ir/output/types.ts'; import { usageFromIR as messagesUsageFromIR } from '../../src/protocols/anthropic-messages/as-source/usage.ts'; import { usageToIR as messagesUsageToIR } from '../../src/protocols/anthropic-messages/as-target/usage.ts'; import { usageFromIR as responsesUsageFromIR } from '../../src/protocols/openai-responses/as-source/usage.ts'; import { usageToIR as responsesUsageToIR } from '../../src/protocols/openai-responses/as-target/usage.ts'; import { TextCoordinateIndex } from '../../src/shared/text-coordinate-index.ts'; +import { collectIR, completeIR } from '../ir/output/helpers.ts'; +import { collect, events, iterate } from '../shared/iterables.ts'; import { reassembleAnthropicMessagesEvents, type AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; import { reassembleGeminiGenerateContentEvents } from '@floway-dev/protocols/gemini-generate-content'; @@ -190,10 +193,10 @@ describe('IR streaming', () => { const value = await collectIR(chat()); const item = value.choices[0].items.find(i => i.type === 'message'); expect(item?.type === 'message' && item.content[0].type === 'text' && item.content[0].annotations?.[0].output_text_range).toEqual({ start: 1, end_exclusive: 4 }); - expect(TextCoordinateIndex.codePointsToUTF16('A😀中B').codePointRangeToIR(1, 3)).toEqual({ start: 1, end_exclusive: 4 }); + expect(TextCoordinateIndex.codePointsToUTF16('A😀中B').codePointRangeToUTF16(1, 3)).toEqual({ start: 1, end_exclusive: 4 }); const index = TextCoordinateIndex.utf16ToCodePoints('A😀中B'); - expect(index.irRangeToCodePoints({ start: 1, end_exclusive: 4 })).toEqual({ start: 1, end: 3 }); - expect(() => index.irRangeToCodePoints({ start: 2, end_exclusive: 4 })).toThrow(); + expect(index.utf16RangeToCodePoints({ start: 1, end_exclusive: 4 })).toEqual({ start: 1, end: 3 }); + expect(() => index.utf16RangeToCodePoints({ start: 2, end_exclusive: 4 })).toThrow(); }); it('delivers text before pulling the upstream completion', async () => { @@ -324,7 +327,7 @@ describe('IR streaming', () => { b.event({ type: 'part_end', choice: 0, item: 1, part: 0 }); b.event({ type: 'item_end', choice: 0, item: 1 }); b.initialize(['choices', 0, 'items', 2], { type: 'message', content: [] }); - b.initialize(['choices', 0, 'items', 2, 'content', 0], { type: 'audio', audio: { mime_type: 'audio/wav' } }); + b.initialize(['choices', 0, 'items', 2, 'content', 0], { type: 'audio', audio: { media_type: 'audio/wav' } }); b.initialize(['choices', 0, 'items', 2, 'content', 0, 'audio', 'data'], ''); b.append(['choices', 0, 'items', 2, 'content', 0, 'audio', 'data'], 'YQ=='); b.event({ type: 'part_end', choice: 0, item: 2, part: 0 }); @@ -354,7 +357,7 @@ describe('IR streaming', () => { }); it('retains text-image-text order in Responses output', async () => { - const frames = openaiResponsesFromIR(iterate(completeIR([{ type: 'message', content: [{ type: 'text', text: 'before' }, { type: 'image', image: { data: 'YQ==', mime_type: 'image/png' } }, { type: 'text', text: 'after' }] }])), options); + const frames = openaiResponsesFromIR(iterate(completeIR([{ type: 'message', content: [{ type: 'text', text: 'before' }, { type: 'image', image: { data: 'YQ==', media_type: 'image/png' } }, { type: 'text', text: 'after' }] }])), options); const result = await reassembleOpenAIResponsesEvents(events(frames)); expect(result.output.map(item => item.type)).toEqual(['message', 'image_generation_call', 'message', 'reasoning']); }); @@ -419,7 +422,7 @@ describe('IR streaming', () => { expect(result.output[0]).toMatchObject({ content: [{ logprobs: tokens }] }); }); - it.each<{ items: readonly IRItem[] }>([{ items: [] }, { items: [{ type: 'reasoning', content: ['thought'] }] }])('exposes invalid logprob item ownership %j', async ({ items }) => { + it.each<{ items: readonly IROutputItem[] }>([{ items: [] }, { items: [{ type: 'reasoning', content: ['thought'] }] }])('exposes invalid logprob item ownership %j', async ({ items }) => { const frames = completeIR(items); const end = frames.findIndex(record => record.type === 'choice_end'); const logprobs = [ @@ -438,7 +441,7 @@ describe('IR streaming', () => { }); it('preserves Responses generated image format', async () => { - const result = await reassembleOpenAIResponsesEvents(events(openaiResponsesFromIR(iterate(completeIR([{ type: 'message', content: [{ type: 'image', image: { data: 'YQ==', mime_type: 'image/jpeg' } }] }])), options))); + const result = await reassembleOpenAIResponsesEvents(events(openaiResponsesFromIR(iterate(completeIR([{ type: 'message', content: [{ type: 'image', image: { data: 'YQ==', media_type: 'image/jpeg' } }] }])), options))); expect(result.output[0]).toMatchObject({ type: 'image_generation_call', output_format: 'jpeg' }); }); diff --git a/packages/translate/__tests__/ir/usage_test.ts b/packages/translate/__tests__/protocols/usage_test.ts similarity index 95% rename from packages/translate/__tests__/ir/usage_test.ts rename to packages/translate/__tests__/protocols/usage_test.ts index 3067b168c4..b6348755b9 100644 --- a/packages/translate/__tests__/ir/usage_test.ts +++ b/packages/translate/__tests__/protocols/usage_test.ts @@ -1,14 +1,17 @@ import { expect, test } from 'vitest'; -import { collectIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, iterate, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; -import { fixtureFrames } from './translation-cases.ts'; +import { fixtureFrames } from './fixtures.ts'; +import { geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; import { usageFromIR as messagesUsageFromIR } from '../../src/protocols/anthropic-messages/as-source/usage.ts'; import { usageToIR as messagesUsageToIR } from '../../src/protocols/anthropic-messages/as-target/usage.ts'; import { usageFromIR } from '../../src/protocols/gemini-generate-content/as-source/usage.ts'; import { usageFromIR as chatUsageFromIR } from '../../src/protocols/openai-chat-completions/as-source/usage.ts'; import { usageToIR as chatUsageToIR } from '../../src/protocols/openai-chat-completions/as-target/usage.ts'; import { usageFromIR as responsesUsageFromIR } from '../../src/protocols/openai-responses/as-source/usage.ts'; -import { usageToIR as responsesUsageToIR } from '../../src/protocols/openai-responses/as-target/usage.ts';import { eventFrame } from '@floway-dev/protocols/common'; +import { usageToIR as responsesUsageToIR } from '../../src/protocols/openai-responses/as-target/usage.ts'; +import { collectIR } from '../ir/output/helpers.ts'; +import { iterate } from '../shared/iterables.ts'; +import { eventFrame } from '@floway-dev/protocols/common'; const initialUsage = { input_tokens: 17, output_tokens: 2, total_tokens: 19, input_tokens_details: { cached_tokens: 3, cache_write_tokens: 4 } }; const sources = [ diff --git a/packages/translate/__tests__/round-trip/reference/reference-builder_test.ts b/packages/translate/__tests__/round-trip/reference/reference-builder_test.ts index d57e84ffb2..e7eec86f9d 100644 --- a/packages/translate/__tests__/round-trip/reference/reference-builder_test.ts +++ b/packages/translate/__tests__/round-trip/reference/reference-builder_test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import type { IRJSONObject } from '../../../src/ir/types.ts'; +import type { IRJSONObject } from '../../../src/ir/common/json.ts'; import { collectAnthropicMessagesTextViews } from '../../../src/protocols/anthropic-messages/as-source/round-trip.ts'; import type { AnthropicMessagesReferenceableAssistantTurn } from '../../../src/protocols/anthropic-messages/as-target/assistant-turn.ts'; import { collectGeminiGenerateContentTextViews } from '../../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; diff --git a/packages/translate/__tests__/round-trip/reference/reference-cost-json_test.ts b/packages/translate/__tests__/round-trip/reference/reference-cost-json_test.ts index d6f73aa6de..3dd3f55936 100644 --- a/packages/translate/__tests__/round-trip/reference/reference-cost-json_test.ts +++ b/packages/translate/__tests__/round-trip/reference/reference-cost-json_test.ts @@ -1,6 +1,6 @@ import { expect, it } from 'vitest'; -import type { IRJSONObject } from '../../../src/ir/types.ts'; +import type { IRJSONObject } from '../../../src/ir/common/json.ts'; import type { AnthropicMessagesInputAssistantTurn } from '../../../src/protocols/anthropic-messages/as-target/assistant-turn.ts'; import { ContentReference } from '../../../src/round-trip/reference/assistant-turn.ts'; import { appendContent, appendField, collectContentViews, createContentState, createFieldState, createReferenceBuilder } from '../../../src/round-trip/reference/reference-builder.ts'; diff --git a/packages/translate/__tests__/shared/iterables.ts b/packages/translate/__tests__/shared/iterables.ts new file mode 100644 index 0000000000..b56dd306ad --- /dev/null +++ b/packages/translate/__tests__/shared/iterables.ts @@ -0,0 +1,5 @@ +import type { ProtocolFrame } from '@floway-dev/protocols/common'; + +export const iterate = async function* (values: readonly T[]): AsyncGenerator { yield* values; }; +export const collect = async (values: AsyncIterable): Promise => { const result: T[] = []; for await (const value of values) result.push(value); return result; }; +export const events = async function* (frames: AsyncIterable>): AsyncGenerator { for await (const frame of frames) if (frame.type === 'event') yield frame.event; }; diff --git a/packages/translate/__tests__/shared/text-coordinate-index_test.ts b/packages/translate/__tests__/shared/text-coordinate-index_test.ts index 01aa76a666..c978a18a73 100644 --- a/packages/translate/__tests__/shared/text-coordinate-index_test.ts +++ b/packages/translate/__tests__/shared/text-coordinate-index_test.ts @@ -6,22 +6,22 @@ test('maps code-point ranges to UTF-16 and follows later appends', () => { const index = TextCoordinateIndex.codePointsToUTF16('A\ud83d'); index.append('\ude00中B'); index.assertUTF16Length('A😀中B'.length); - expect(index.codePointRangeToIR(1, 3)).toEqual({ start: 1, end_exclusive: 4 }); - expect(() => index.codePointRangeToIR(0, 5)).toThrow('exceeds text'); + expect(index.codePointRangeToUTF16(1, 3)).toEqual({ start: 1, end_exclusive: 4 }); + expect(() => index.codePointRangeToUTF16(0, 5)).toThrow('exceeds text'); }); test('maps UTF-16 ranges to code points and rejects surrogate interiors', () => { const index = TextCoordinateIndex.utf16ToCodePoints('A\ud83d'); index.append('\ude00中B'); - expect(index.irRangeToCodePoints({ start: 1, end_exclusive: 4 })).toEqual({ start: 1, end: 3 }); - expect(() => index.irRangeToCodePoints({ start: 2, end_exclusive: 4 })).toThrow('splits a surrogate pair'); + expect(index.utf16RangeToCodePoints({ start: 1, end_exclusive: 4 })).toEqual({ start: 1, end: 3 }); + expect(() => index.utf16RangeToCodePoints({ start: 2, end_exclusive: 4 })).toThrow('splits a surrogate pair'); }); test('maps UTF-16 ranges to UTF-8 bytes and requires well-formed text', () => { const index = TextCoordinateIndex.utf16ToUTF8('A\ud83d'); - expect(() => index.irRangeToUTF8({ start: 0, end_exclusive: 2 })).toThrow('well-formed Unicode'); + expect(() => index.utf16RangeToUTF8({ start: 0, end_exclusive: 2 })).toThrow('well-formed Unicode'); index.append('\ude00中B'); - expect(index.irRangeToUTF8({ start: 1, end_exclusive: 4 })).toEqual({ start: 1, end: 8 }); + expect(index.utf16RangeToUTF8({ start: 1, end_exclusive: 4 })).toEqual({ start: 1, end: 8 }); }); test('detects a missed append at the protocol owner', () => { diff --git a/packages/translate/src/ir/collector.ts b/packages/translate/src/ir/collector.ts deleted file mode 100644 index 5d7bd28b85..0000000000 --- a/packages/translate/src/ir/collector.ts +++ /dev/null @@ -1,223 +0,0 @@ -import { irEndedUnit, irPathKey, resolveIRPath, unionVariantNode, valueAt, type IRUnit } from './operation-paths.ts'; -import type { IRSchemaNode } from './schema.ts'; -import type { IREvent, IRWrite, IRPath } from './stream.ts'; -import { IRAbsent, type IR, type IRValue } from './types.ts'; - -type IRValueVisitor = (path: IRPath, node: IRSchemaNode, value: IRValue) => void; - -const isPlainObject = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); - -// Opaque values are replaced as a whole, so only what no IR value may contain is rejected. -const assertPlainValue = (path: IRPath, value: unknown): void => { - if (value === undefined) throw new TypeError(`IR values cannot contain undefined: ${irPathKey(path)}`); - if (value === IRAbsent) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); - if (typeof value !== 'object' || value === null) return; - for (const [key, child] of Object.entries(value)) assertPlainValue([...path, Array.isArray(value) ? Number(key) : key], child); -}; - -// Walks a value that is about to be written together with the schema node describing it. -// `IRAbsent` is accepted where the schema allows it unless `allowNone` is off. -const walkValue = (path: IRPath, node: IRSchemaNode, value: IRValue, visitor: IRValueVisitor, allowNone: boolean): void => { - if (value === undefined) throw new TypeError(`IR values cannot contain undefined: ${irPathKey(path)}`); - if (node.kind === 'nullable') { - if (value === null) return visitor(path, node, value); - node = node.of; - } - visitor(path, node, value); - switch (node.kind) { - case 'string': - if (value === IRAbsent) { - if (!allowNone) throw new TypeError('IRAbsent must be assigned directly to call_id'); - if (node.absent !== true) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); - } else if (typeof value !== 'string') throw new TypeError(`IR value must be a string: ${irPathKey(path)}`); - return; - case 'number': - if (typeof value !== 'number') throw new TypeError(`IR value must be a number: ${irPathKey(path)}`); - return; - case 'array': - if (!Array.isArray(value)) throw new TypeError(`IR value must be an array: ${irPathKey(path)}`); - value.forEach((child, index) => walkValue([...path, index], node.of, child, visitor, allowNone)); - return; - case 'object': - case 'union': { - if (!isPlainObject(value)) throw new TypeError(`IR value must be an object: ${irPathKey(path)}`); - const variant = unionVariantNode(node, value); - if (variant?.kind !== 'object') throw new TypeError(`IR value has an unknown variant: ${irPathKey(path)}`); - for (const [key, child] of Object.entries(value)) { - const field = node.kind === 'union' && key === node.discriminant ? { kind: 'string' } as const : Object.hasOwn(variant.fields, key) ? variant.fields[key] : undefined; - if (field === undefined) throw new TypeError(`IR value has no field ${key}: ${irPathKey(path)}`); - walkValue([...path, key], field, child, visitor, allowNone); - } - return; - } - case 'opaque': - assertPlainValue(path, value); - return; - default: - } -}; - -// Fails if a unit that has ended lacks any field required by its schema. -const assertComplete = (path: IRPath, node: IRSchemaNode, value: unknown): void => { - const actual = unionVariantNode(node, value); - if (actual === undefined || typeof value !== 'object' || value === null) return; - if (actual.kind === 'array') (value as unknown[]).forEach((child, index) => assertComplete([...path, index], actual.of, child)); - else if (actual.kind === 'object') { - const present = value as Record; - if (actual.requiresAnyOf !== undefined && !actual.requiresAnyOf.some(key => present[key] !== undefined)) { - throw new TypeError(`IR value requires one of ${actual.requiresAnyOf.join(', ')}: ${irPathKey(path)}`); - } - for (const [key, field] of Object.entries(actual.fields)) { - if (!Object.hasOwn(present, key)) { - if (!actual.optional.includes(key)) throw new TypeError(`IR value requires field ${key}: ${irPathKey(path)}`); - continue; - } - assertComplete([...path, key], field, present[key]); - } - } -}; - -const createCounter = () => { - const counts = new Map(); - return { - get: (key: K): number => counts.get(key) ?? 0, - increment: (key: K): void => { counts.set(key, (counts.get(key) ?? 0) + 1); }, - decrement: (key: K): void => { - const count = counts.get(key); - if (count === undefined) throw new TypeError(`IR lifecycle counter underflow: ${String(key)}`); - if (count === 1) counts.delete(key); - else counts.set(key, count - 1); - }, - }; -}; - -// Tracks which units (choices, items and parts) are open or closed. -const createLifecycle = () => { - const open = new Set(); - const closed = new Set(); - const openChildren = createCounter(); - - const assertWritable = (units: readonly IRUnit[], path: IRPath): void => { - for (const unit of units) if (closed.has(irPathKey(unit.path))) throw new TypeError(`IR write targets a closed ${unit.kind}: ${irPathKey(path)}`); - }; - - // `units` ends with the unit that was initialized. - const begin = (units: readonly IRUnit[]): void => { - open.add(irPathKey(units[units.length - 1].path)); - if (units.length > 1) openChildren.increment(irPathKey(units[units.length - 2].path)); - }; - - const end = (units: readonly IRUnit[]): void => { - const unit = units[units.length - 1]; - const key = irPathKey(unit.path); - if (!open.delete(key)) throw new TypeError(`IR ${unit.kind} is not open: ${key}`); - if (openChildren.get(key) !== 0) throw new TypeError(`IR ${unit.kind} ended with an open unit inside it: ${key}`); - closed.add(key); - if (units.length > 1) openChildren.decrement(irPathKey(units[units.length - 2].path)); - }; - - const assertFinishable = (): void => { - if (open.size !== 0) throw new TypeError('IR finish arrived with open units'); - }; - - return { assertWritable, begin, end, assertFinishable, isOpen: (path: IRPath): boolean => open.has(irPathKey(path)), isClosed: (path: IRPath): boolean => closed.has(irPathKey(path)) }; -}; - -export const createIRCollector = () => { - const state: IR = { choices: [] }; - // Containers created by this collector. Everything else is borrowed from a record and - // is copied before its first write. - const owned = new WeakSet([state, state.choices]); - const lifecycle = createLifecycle(); - - const writableContainer = (path: IRPath): Record => { - let target = state as unknown as Record; - for (const segment of path) { - const child = target[segment] as object; - if (!owned.has(child)) { - const copy = Array.isArray(child) ? [...child] : { ...child }; - owned.add(copy); - Object.defineProperty(target, segment, { value: copy, writable: true, enumerable: true, configurable: true }); - } - target = target[segment] as Record; - } - return target; - }; - - // Returns whether the write changed the state. - const apply = (write: IRWrite): boolean => { - if (write.value === undefined) throw new TypeError('IR writes cannot carry undefined'); - const { path } = write; - const resolution = resolveIRPath(path, state); - if (resolution !== undefined) lifecycle.assertWritable(resolution.units, path); - const key = path.at(-1); - if (key === undefined) throw new TypeError('IR writes require a property path'); - const parentPath = path.slice(0, -1); - const parent = valueAt(state, parentPath); - if (parent === undefined) throw new TypeError(`Missing IR path: ${irPathKey(parentPath)}`); - if (typeof parent !== 'object' || parent === null) throw new TypeError('IR write parent must be an object or array'); - const present = Object.hasOwn(parent, key); - if (write.kind === 'initialize' && present) throw new TypeError('IR initialize requires an absent target'); - if (Array.isArray(parent)) { - if (typeof key !== 'number' || !Number.isInteger(key) || key < 0 || key > parent.length) throw new TypeError('IR array write requires a contiguous nonnegative index'); - if (!present && key !== parent.length) throw new TypeError('IR array insertion must use the next index'); - if (write.kind === 'initialize' && key !== parent.length) throw new TypeError('IR array initialization must use the next index'); - } - const current = (parent as Record)[key]; - const node = resolution?.node; - - if (write.kind === 'append') { - if (node?.kind !== 'string' || node.append !== true) throw new TypeError(`IR append requires an append-target path: ${irPathKey(path)}`); - if (typeof current !== 'string') throw new TypeError('IR append requires a string target'); - if (typeof write.value !== 'string') throw new TypeError('IR append requires a string value'); - if (write.value === '') return false; - } else { - if (node === undefined) throw new TypeError(`IR path is not part of the IR schema: ${irPathKey(path)}`); - if (write.kind === 'initialize') { - walkValue(path, node, write.value, (childPath, childNode, child) => { - if (childNode.kind === 'string' && childNode.append === true && child !== '') throw new TypeError(`IR append target must initialize to an empty string: ${irPathKey(childPath)}`); - }, true); - } else if (write.value === IRAbsent) { - if (node.kind !== 'string' || node.absent !== true) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); - if (present) throw new TypeError('IRAbsent may only be assigned to an absent target'); - } else { - walkValue(path, node, write.value, (childPath, childNode) => { - if (childNode.kind === 'string' && childNode.append === true) throw new TypeError(`IR append target cannot be assigned: ${irPathKey(childPath)}`); - }, false); - if (present && Object.is(current, write.value)) return false; - } - } - - const target = writableContainer(parentPath); - if (write.kind === 'append') target[key] = (target[key] as string) + write.value; - else Object.defineProperty(target, key, { value: write.value, writable: true, enumerable: true, configurable: true }); - if (write.kind === 'initialize' && resolution !== undefined) { - const unit = resolution.units.at(-1); - if (unit !== undefined && unit.path.length === path.length) lifecycle.begin(resolution.units); - } - return true; - }; - - const applyEvent = (event: IREvent): void => { - const ended = irEndedUnit(event); - if (ended !== undefined) { - const resolution = resolveIRPath(ended.path, state); - if (resolution === undefined || valueAt(state, ended.path) === undefined) throw new TypeError(`IR ${ended.kind} does not exist: ${irPathKey(ended.path)}`); - lifecycle.end(resolution.units); - assertComplete(ended.path, resolution.node, valueAt(state, ended.path)); - } else if (event.type === 'finish') lifecycle.assertFinishable(); - }; - - const value = (path: IRPath): T | undefined => valueAt(state, path) as T | undefined; - // A tool call that closed without a call ID has none to wait for. - const optional = (path: IRPath): T | typeof IRAbsent | undefined => { - const actual = value(path); - if (actual !== undefined) return actual; - const resolution = resolveIRPath(path, state); - const owner = resolution?.units.at(-1); - return resolution?.node.kind === 'string' && resolution.node.absent === true && owner !== undefined && lifecycle.isClosed(owner.path) ? IRAbsent : undefined; - }; - return { state, apply, applyEvent, value, isOpen: lifecycle.isOpen, optional }; -}; - -export type IRCollector = ReturnType; diff --git a/packages/translate/src/ir/common/json.ts b/packages/translate/src/ir/common/json.ts new file mode 100644 index 0000000000..5a8c1cd4d7 --- /dev/null +++ b/packages/translate/src/ir/common/json.ts @@ -0,0 +1,6 @@ +// The JSON value shape, shared by complete JSON and output values that admit IRAbsent. +export type JSONValueOf = Extra | null | boolean | number | string | readonly JSONValueOf[] | { readonly [key: string]: JSONValueOf }; + +export type IRJSONValue = JSONValueOf; + +export type IRJSONObject = { readonly [key: string]: IRJSONValue }; diff --git a/packages/translate/src/ir/ordinal-mapper.ts b/packages/translate/src/ir/ordinal-mapper.ts deleted file mode 100644 index afd2cf8424..0000000000 --- a/packages/translate/src/ir/ordinal-mapper.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { irPathKey } from './operation-paths.ts'; -import type { IRPath } from './stream.ts'; - -// Numbers the native units (blocks, output items, tool calls) of a stream in the order -// they open, and forgets a unit when it closes. -export const createIROrdinalMapper = () => { - const ordinals = new Map(); - let next = 0; - const open = (path: IRPath): number => { - const key = irPathKey(path); - if (ordinals.has(key)) throw new TypeError(`IR unit is already open: ${key}`); - const ordinal = next++; - ordinals.set(key, ordinal); - return ordinal; - }; - const get = (path: IRPath): number => { - const key = irPathKey(path); - const ordinal = ordinals.get(key); - if (ordinal === undefined) throw new TypeError(`Unknown open IR unit: ${key}`); - return ordinal; - }; - const close = (path: IRPath): void => { - get(path); - ordinals.delete(irPathKey(path)); - }; - return { open, get, close }; -}; diff --git a/packages/translate/src/ir/output/absent.ts b/packages/translate/src/ir/output/absent.ts new file mode 100644 index 0000000000..0db8f19542 --- /dev/null +++ b/packages/translate/src/ir/output/absent.ts @@ -0,0 +1,3 @@ +export const IRAbsent: unique symbol = Symbol('IRAbsent'); + +export type IRAbsent = typeof IRAbsent; diff --git a/packages/translate/src/ir/stream.ts b/packages/translate/src/ir/output/builder.ts similarity index 66% rename from packages/translate/src/ir/stream.ts rename to packages/translate/src/ir/output/builder.ts index fec713ba24..f0c8653261 100644 --- a/packages/translate/src/ir/stream.ts +++ b/packages/translate/src/ir/output/builder.ts @@ -1,21 +1,7 @@ -import { createIRCollector, type IRCollector } from './collector.ts'; +import { createIRCollector } from './collector.ts'; import { irItemPath, irPartPath, irPathKey, irUnitPath } from './operation-paths.ts'; -import type { IRErrorPayload, IRUsage, IRValue } from './types.ts'; - -export type IRPath = readonly (string | number)[]; -export type IRWrite = Readonly< - | { type: 'write'; kind: 'initialize'; path: IRPath; value: IRValue } - | { type: 'write'; kind: 'assign'; path: IRPath; value: IRValue } - | { type: 'write'; kind: 'append'; path: IRPath; value: string; referencePieceIds?: readonly number[] }>; -export type IREvent = Readonly< - | { type: 'start'; id?: string; created?: number } - | { type: 'item_end'; choice: number; item: number; status?: 'completed' | 'incomplete' } - | { type: 'part_end'; choice: number; item: number; part: number } - | { type: 'choice_end'; choice: number; finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter' } - | { type: 'finish'; status: 'completed' | 'incomplete' } - | { type: 'error'; error: IRErrorPayload } - | { type: 'ping' }>; -export type IRRecord = IRWrite | IREvent; +import type { IREvent, IRPath, IRRecord, IRWrite } from './records.ts'; +import type { IROutputUsage, IROutputValue } from './types.ts'; export type IRRelativePath = string | number | IRPath; @@ -26,7 +12,7 @@ export interface IRWriteHandle { assign: (path: IRRelativePath, value: unknown) => void; initialize: (path: IRRelativePath, value: unknown) => void; ensure: (path: IRRelativePath, initialValue: unknown) => void; - resolve: (path: IRRelativePath) => IRPath; + absolutePath: (path: IRRelativePath) => IRPath; // Snapshot reconciliation needs the value accumulated from earlier upstream events. current: (path: IRRelativePath) => T | undefined; } @@ -45,8 +31,6 @@ export interface IRItemHandle extends IRWriteHandle { export interface IRChoiceHandle extends IRWriteHandle { readonly isOpen: boolean; end: (finishReason: 'stop' | 'length' | 'tool_calls' | 'content_filter') => void; - // ChatCompletions annotations use coordinates across all text emitted for a choice. - textParts: () => readonly { item: IRItemHandle; part: IRPartHandle; text: string }[]; } export const createIRBuilder = (maxChoices = 1) => { @@ -56,8 +40,6 @@ export const createIRBuilder = (maxChoices = 1) => { let records: IRRecord[] = []; const initialized = new Set(); const choices = new Map(); - const items = new Map(); - const parts = new Map(); const itemCounts = new Map(); const partCounts = new Map(); @@ -66,13 +48,13 @@ export const createIRBuilder = (maxChoices = 1) => { if (typeof value !== 'object' || value === null) return; for (const [key, child] of Object.entries(value)) remember([...path, Array.isArray(value) ? Number(key) : key], child); }; - const write = (value: IRWrite): void => { if (collector.apply(value)) records.push(value); }; + const write = (value: IRWrite): void => { if (collector.applyWrite(value)) records.push(value); }; const assign = (path: IRPath, value: unknown): void => { - write({ type: 'write', kind: 'assign', path, value: value as IRValue }); + write({ type: 'write', kind: 'assign', path, value: value as IROutputValue }); remember(path, value); }; const initialize = (path: IRPath, value: unknown): void => { - write({ type: 'write', kind: 'initialize', path, value: value as IRValue }); + write({ type: 'write', kind: 'initialize', path, value: value as IROutputValue }); remember(path, value); }; const append = (path: IRPath, value: string, referencePieceIds?: readonly number[]): void => write({ @@ -96,11 +78,11 @@ export const createIRBuilder = (maxChoices = 1) => { if (initialized.has(irPathKey(target))) return; initialize(target, initialValue); }, - resolve: subpath => relativePath(path, subpath), + absolutePath: subpath => relativePath(path, subpath), current: subpath => collector.value(relativePath(path, subpath)), }); // Usage arrives as cumulative snapshots, so each reported field replaces the previous one. - const usage = (fields: IRUsage): void => { + const usage = (fields: IROutputUsage): void => { if (state.usage === undefined) initialize(['usage'], {}); for (const [key, value] of Object.entries(fields)) assign(['usage', key], value); }; @@ -119,12 +101,6 @@ export const createIRBuilder = (maxChoices = 1) => { event({ type: 'choice_end', choice: choiceIndex, finish_reason: finishReason }); open = false; }, - textParts: () => [...parts.values()].flatMap(part => { - const itemHandle = items.get(irPathKey(part.path.slice(0, -2))); - if (itemHandle?.path[1] !== choiceIndex) return []; - const text = part.current('text'); - return text === undefined ? [] : [{ item: itemHandle, part, text }]; - }), }; choices.set(choiceIndex, handle); itemCounts.set(choiceIndex, 0); @@ -159,7 +135,6 @@ export const createIRBuilder = (maxChoices = 1) => { }, }; openParts.push(partHandle); - parts.set(irPathKey(partPath), partHandle); return partHandle; }, end: status => { @@ -169,36 +144,7 @@ export const createIRBuilder = (maxChoices = 1) => { open = false; }, }; - items.set(irPathKey(path), handle); return handle; }; return { collector, state, initialize, assign, append, event, drain, usage, choice, item }; }; - -// Applies each record to a fresh collector and yields it together with the collector, so a -// writer sees the state as of that record. A well-formed stream is exactly one `start`, then -// records, then `finish` or `error`. -export const consumeIRRecords = async function* (records: AsyncIterable): AsyncGenerator<{ record: IRRecord; collector: IRCollector }> { - const collector = createIRCollector(); - let started = false; - let finished = false; - for await (const record of records) { - if (finished) throw new Error('IR record arrived after finish'); - if (record.type === 'write') { - collector.apply(record); - } else { - if (record.type === 'start') { - if (started) throw new Error('Duplicate IR start'); - started = true; - } else if (record.type === 'finish') { - if (!started) throw new Error('IR finish arrived before start'); - finished = true; - } else if (record.type === 'error') { - finished = true; - } - collector.applyEvent(record); - } - yield { record, collector }; - } - if (!finished) throw new Error('IR stream ended without finish'); -}; diff --git a/packages/translate/src/ir/output/collector.ts b/packages/translate/src/ir/output/collector.ts new file mode 100644 index 0000000000..94944ee6c5 --- /dev/null +++ b/packages/translate/src/ir/output/collector.ts @@ -0,0 +1,105 @@ +import { IRAbsent } from './absent.ts'; +import { createLifecycle } from './lifecycle.ts'; +import { irEndedUnit, irPathKey, resolveIRPath, valueAt } from './operation-paths.ts'; +import type { IREvent, IRWrite, IRPath } from './records.ts'; +import type { IROutput } from './types.ts'; +import { assertComplete, walkValue } from './value-validation.ts'; + +export const createIRCollector = () => { + const state: IROutput = { choices: [] }; + // Containers created by this collector. Everything else is borrowed from a record and + // is copied before its first write. + const owned = new WeakSet([state, state.choices]); + const lifecycle = createLifecycle(); + + const writableContainer = (path: IRPath): Record => { + let target: Record = state; + for (const segment of path) { + const child = target[segment] as object; + if (!owned.has(child)) { + const copy = Array.isArray(child) ? [...child] : { ...child }; + owned.add(copy); + Object.defineProperty(target, segment, { value: copy, writable: true, enumerable: true, configurable: true }); + } + target = target[segment] as Record; + } + return target; + }; + + // Returns whether the write changed the state. + const applyWrite = (write: IRWrite): boolean => { + if (write.value === undefined) throw new TypeError('IR writes cannot carry undefined'); + const { path } = write; + const resolution = resolveIRPath(path, state); + if (resolution !== undefined) lifecycle.assertWritable(resolution.units, path); + const key = path.at(-1); + if (key === undefined) throw new TypeError('IR writes require a property path'); + const parentPath = path.slice(0, -1); + const parent = valueAt(state, parentPath); + if (parent === undefined) throw new TypeError(`Missing IR path: ${irPathKey(parentPath)}`); + if (typeof parent !== 'object' || parent === null) throw new TypeError('IR write parent must be an object or array'); + const present = Object.hasOwn(parent, key); + if (write.kind === 'initialize' && present) throw new TypeError('IR initialize requires an absent target'); + if (Array.isArray(parent)) { + if (typeof key !== 'number' || !Number.isInteger(key) || key < 0 || key > parent.length) throw new TypeError('IR array write requires a contiguous nonnegative index'); + if (!present && key !== parent.length) throw new TypeError('IR array insertion must use the next index'); + if (write.kind === 'initialize' && key !== parent.length) throw new TypeError('IR array initialization must use the next index'); + } + const current = (parent as Record)[key]; + const node = resolution?.node; + + if (write.kind === 'append') { + if (node?.kind !== 'string' || node.append !== true) throw new TypeError(`IR append requires an append-target path: ${irPathKey(path)}`); + if (typeof current !== 'string') throw new TypeError('IR append requires a string target'); + if (typeof write.value !== 'string') throw new TypeError('IR append requires a string value'); + if (write.value === '') return false; + } else { + if (node === undefined) throw new TypeError(`IR path is not part of the IR schema: ${irPathKey(path)}`); + if (write.kind === 'initialize') { + walkValue(path, node, write.value, (childPath, childNode, child) => { + if (childNode.kind === 'string' && childNode.append === true && child !== '') throw new TypeError(`IR append target must initialize to an empty string: ${irPathKey(childPath)}`); + }, { allowAbsent: true }); + } else if (write.value === IRAbsent) { + if (node.kind !== 'string' || node.absent !== true) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); + if (present) throw new TypeError('IRAbsent may only be assigned to an absent target'); + } else { + walkValue(path, node, write.value, (childPath, childNode) => { + if (childNode.kind === 'string' && childNode.append === true) throw new TypeError(`IR append target cannot be assigned: ${irPathKey(childPath)}`); + }, { allowAbsent: false }); + if (present && Object.is(current, write.value)) return false; + } + } + + const target = writableContainer(parentPath); + if (write.kind === 'append') target[key] = (target[key] as string) + write.value; + else Object.defineProperty(target, key, { value: write.value, writable: true, enumerable: true, configurable: true }); + if (write.kind === 'initialize' && resolution !== undefined) { + const unit = resolution.units.at(-1); + if (unit !== undefined && unit.path.length === path.length) lifecycle.openUnit(resolution.units); + } + return true; + }; + + const applyEvent = (event: IREvent): void => { + const ended = irEndedUnit(event); + if (ended !== undefined) { + const resolution = resolveIRPath(ended.path, state); + if (resolution === undefined || valueAt(state, ended.path) === undefined) throw new TypeError(`IR ${ended.kind} does not exist: ${irPathKey(ended.path)}`); + lifecycle.closeUnit(resolution.units); + assertComplete(ended.path, resolution.node, valueAt(state, ended.path)); + } else if (event.type === 'finish') lifecycle.assertFinishable(); + }; + + const value = (path: IRPath): T | undefined => valueAt(state, path) as T | undefined; + // A tool call that closed without a call ID has none to wait for. + const valueOrAbsent = (path: IRPath): T | typeof IRAbsent | undefined => { + const actual = value(path); + if (actual !== undefined) return actual; + const resolution = resolveIRPath(path, state); + const owner = resolution?.units.at(-1); + return resolution?.node.kind === 'string' && resolution.node.absent === true && owner !== undefined && lifecycle.isClosed(owner.path) ? IRAbsent : undefined; + }; + return { state, applyWrite, applyEvent, value, isOpen: lifecycle.isOpen, valueOrAbsent }; +}; + +export type IRCollector = ReturnType; diff --git a/packages/translate/src/ir/output/error.ts b/packages/translate/src/ir/output/error.ts new file mode 100644 index 0000000000..edd52c87e6 --- /dev/null +++ b/packages/translate/src/ir/output/error.ts @@ -0,0 +1,8 @@ +// The error fields upstream protocols agree on; each writer picks the ones its protocol defines. +export interface IRErrorPayload { + readonly message: string; + readonly type?: string; + readonly code?: string | null; + readonly param?: string | null; + readonly provider_specific_fields?: Readonly>; +} diff --git a/packages/translate/src/ir/output/lifecycle.ts b/packages/translate/src/ir/output/lifecycle.ts new file mode 100644 index 0000000000..25754c73b4 --- /dev/null +++ b/packages/translate/src/ir/output/lifecycle.ts @@ -0,0 +1,48 @@ +import { irPathKey, type IRUnit } from './operation-paths.ts'; +import type { IRPath } from './records.ts'; + +const createCounter = () => { + const counts = new Map(); + return { + get: (key: K): number => counts.get(key) ?? 0, + increment: (key: K): void => { counts.set(key, (counts.get(key) ?? 0) + 1); }, + decrement: (key: K): void => { + const count = counts.get(key); + if (count === undefined) throw new TypeError(`IR lifecycle counter underflow: ${String(key)}`); + if (count === 1) counts.delete(key); + else counts.set(key, count - 1); + }, + }; +}; + +// Tracks which units (choices, items and parts) are open or closed. +export const createLifecycle = () => { + const open = new Set(); + const closed = new Set(); + const openChildren = createCounter(); + + const assertWritable = (units: readonly IRUnit[], path: IRPath): void => { + for (const unit of units) if (closed.has(irPathKey(unit.path))) throw new TypeError(`IR write targets a closed ${unit.kind}: ${irPathKey(path)}`); + }; + + // `units` ends with the unit that was initialized. + const openUnit = (units: readonly IRUnit[]): void => { + open.add(irPathKey(units[units.length - 1].path)); + if (units.length > 1) openChildren.increment(irPathKey(units[units.length - 2].path)); + }; + + const closeUnit = (units: readonly IRUnit[]): void => { + const unit = units[units.length - 1]; + const key = irPathKey(unit.path); + if (!open.delete(key)) throw new TypeError(`IR ${unit.kind} is not open: ${key}`); + if (openChildren.get(key) !== 0) throw new TypeError(`IR ${unit.kind} ended with an open unit inside it: ${key}`); + closed.add(key); + if (units.length > 1) openChildren.decrement(irPathKey(units[units.length - 2].path)); + }; + + const assertFinishable = (): void => { + if (open.size !== 0) throw new TypeError('IR finish arrived with open units'); + }; + + return { assertWritable, openUnit, closeUnit, assertFinishable, isOpen: (path: IRPath): boolean => open.has(irPathKey(path)), isClosed: (path: IRPath): boolean => closed.has(irPathKey(path)) }; +}; diff --git a/packages/translate/src/ir/operation-paths.ts b/packages/translate/src/ir/output/operation-paths.ts similarity index 80% rename from packages/translate/src/ir/operation-paths.ts rename to packages/translate/src/ir/output/operation-paths.ts index 23a652b93f..07be9869b2 100644 --- a/packages/translate/src/ir/operation-paths.ts +++ b/packages/translate/src/ir/output/operation-paths.ts @@ -1,5 +1,5 @@ +import type { IREvent, IRPath, IRRecord } from './records.ts'; import { irSchema, type IRObjectNode, type IRSchemaNode, type IRUnionNode, type IRUnitKind } from './schema.ts'; -import type { IREvent, IRPath, IRRecord } from './stream.ts'; // Everything here is derived from the schema in schema.ts. A path is resolved against the // schema, and the answers come from the traits of the node it lands on. Where the node @@ -89,16 +89,6 @@ export const isIRItemPath = (path: IRPath, root: unknown): boolean => unitAt(pat export const isIRPartPath = (path: IRPath, root: unknown): boolean => unitAt(path, root) === 'part'; -export const isIRAppendTarget = (path: IRPath, root: unknown): boolean => { - const node = resolveIRPath(path, root)?.node; - return node?.kind === 'string' && node.append === true; -}; - -export const isIRAbsentTarget = (path: IRPath, root: unknown): boolean => { - const node = resolveIRPath(path, root)?.node; - return node?.kind === 'string' && node.absent === true; -}; - const WILDCARD = Symbol('index'); type TemplateSegment = string | typeof WILDCARD; @@ -125,28 +115,6 @@ const findUnitTemplate = (node: IRSchemaNode, kind: IRUnitKind, prefix: readonly const unitTemplates = new Map(); -// The units enclosing a unit, outermost first, followed by the unit itself. -const unitKindsAlong = (kind: IRUnitKind): readonly IRUnitKind[] => { - const kinds: IRUnitKind[] = []; - const visit = (node: IRSchemaNode): boolean => { - const own = unitKindOf(node); - if (own !== undefined) kinds.push(own); - if (own === kind) return true; - let found = false; - switch (node.kind) { - case 'nullable': found = visit(node.of); break; - case 'array': found = visit(node.of); break; - case 'object': found = Object.values(node.fields).some(visit); break; - case 'union': found = Object.values(node.cases).some(visit); break; - default: - } - if (!found && own !== undefined) kinds.pop(); - return found; - }; - visit(irSchema); - return kinds; -}; - // The path of a unit from the indices of it and its enclosing units, outermost first. export const irUnitPath = (kind: IRUnitKind, ...indices: readonly number[]): IRPath => { let template = unitTemplates.get(kind); @@ -167,11 +135,10 @@ export const irPartPath = (choice: number, item: number, part: number): IRPath = // An `_end` event names the unit it ends by the indices of that unit and the units around it. export const irEndedUnit = (event: IREvent): IRUnit | undefined => { - if (!event.type.endsWith('_end')) return undefined; - const kind = event.type.slice(0, -'_end'.length) as IRUnitKind; - const kinds = unitKindsAlong(kind); - if (kinds.at(-1) !== kind) return undefined; - return { kind, path: irUnitPath(kind, ...kinds.map(unit => (event as unknown as Record)[unit])) }; + if (event.type === 'choice_end') return { kind: 'choice', path: irUnitPath('choice', event.choice) }; + if (event.type === 'item_end') return { kind: 'item', path: irUnitPath('item', event.choice, event.item) }; + if (event.type === 'part_end') return { kind: 'part', path: irUnitPath('part', event.choice, event.item, event.part) }; + return undefined; }; // The units a record belongs to, innermost first. diff --git a/packages/translate/src/ir/output/records.ts b/packages/translate/src/ir/output/records.ts new file mode 100644 index 0000000000..67a7617e08 --- /dev/null +++ b/packages/translate/src/ir/output/records.ts @@ -0,0 +1,17 @@ +import type { IRErrorPayload } from './error.ts'; +import type { IROutputValue } from './types.ts'; + +export type IRPath = readonly (string | number)[]; +export type IRWrite = Readonly< + | { type: 'write'; kind: 'initialize'; path: IRPath; value: IROutputValue } + | { type: 'write'; kind: 'assign'; path: IRPath; value: IROutputValue } + | { type: 'write'; kind: 'append'; path: IRPath; value: string; referencePieceIds?: readonly number[] }>; +export type IREvent = Readonly< + | { type: 'start'; id?: string; created?: number } + | { type: 'item_end'; choice: number; item: number; status?: 'completed' | 'incomplete' } + | { type: 'part_end'; choice: number; item: number; part: number } + | { type: 'choice_end'; choice: number; finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter' } + | { type: 'finish'; status: 'completed' | 'incomplete' } + | { type: 'error'; error: IRErrorPayload } + | { type: 'ping' }>; +export type IRRecord = IRWrite | IREvent; diff --git a/packages/translate/src/ir/schema.ts b/packages/translate/src/ir/output/schema.ts similarity index 89% rename from packages/translate/src/ir/schema.ts rename to packages/translate/src/ir/output/schema.ts index dfafbd0bbb..1ab93b5261 100644 --- a/packages/translate/src/ir/schema.ts +++ b/packages/translate/src/ir/output/schema.ts @@ -1,4 +1,5 @@ -import type { IR, IRAbsent, IRSourceCitation, IRTokenLogprob } from './types.ts'; +import type { IRAbsent } from './absent.ts'; +import type { IROutput, IROutputSourceCitation, IROutputTokenLogprob } from './types.ts'; // The structure of the IR, declared once. Every question about a path (is it an append // target, may it hold `IRAbsent`, which lifecycle unit does it belong to) is answered by @@ -54,11 +55,11 @@ const union = ({ kind: 'union', discriminant, cases, ...unit === undefined ? {} : { unit } }); const contentPart = union('type', { - text: object({ text: appendString, annotations: nullable(array(opaque())) }, { optional: ['annotations'] }), + text: object({ text: appendString, annotations: nullable(array(opaque())) }, { optional: ['annotations'] }), refusal: object({ refusal: appendString }), - image: object({ image: object({ data: string, mime_type: string }, { optional: ['mime_type'] }) }), + image: object({ image: object({ data: string, media_type: string }, { optional: ['media_type'] }) }), audio: object({ - audio: object({ data: appendString, transcript: appendString, mime_type: string }, { optional: ['data', 'transcript', 'mime_type'], requiresAnyOf: ['data', 'transcript'] }), + audio: object({ data: appendString, transcript: appendString, media_type: string }, { optional: ['data', 'transcript', 'media_type'], requiresAnyOf: ['data', 'transcript'] }), }), }, 'part'); @@ -73,16 +74,16 @@ const item = union('type', { }, 'item'); const logprobs = nullable(array(union('scope', { - choice: object({ tokens: array(opaque()) }), - text_part: object({ item_index: number, content_index: number, tokens: array(opaque()) }), + choice: object({ tokens: array(opaque()) }), + text_part: object({ item_index: number, content_index: number, tokens: array(opaque()) }), }))); export const irSchema = object({ choices: array(object({ items: array(item), - refusal: object({ category: nullable(string), explanation: nullable(string) }), + refusal_details: object({ category: nullable(string), explanation: nullable(string) }), logprobs, - }, { optional: ['refusal', 'logprobs'], unit: 'choice' })), + }, { optional: ['refusal_details', 'logprobs'], unit: 'choice' })), model: string, service_tier: string, usage: allOptional({ @@ -120,4 +121,4 @@ type Mutual = [A] extends [B] ? ([B] extends [A] ? true : false) : false; // Compile-time proof that the schema and the IR types describe the same values: a field the // schema declares optional, nullable, appendable or `IRAbsent`-capable differs from the IR type // exactly when this stops being `true`. -export const irSchemaMatchesIRTypes: Mutual, IR> = true; +export const irSchemaMatchesIRTypes: Mutual, IROutput> = true; diff --git a/packages/translate/src/ir/output/stream.ts b/packages/translate/src/ir/output/stream.ts new file mode 100644 index 0000000000..e5dce93ac3 --- /dev/null +++ b/packages/translate/src/ir/output/stream.ts @@ -0,0 +1,30 @@ +import { createIRCollector, type IRCollector } from './collector.ts'; +import type { IRRecord } from './records.ts'; + +// Applies each record to a fresh collector and yields it together with the collector, so a +// writer sees the state as of that record. A well-formed stream is exactly one `start`, then +// records, then `finish` or `error`. +export const consumeIRRecords = async function* (records: AsyncIterable): AsyncGenerator<{ record: IRRecord; collector: IRCollector }> { + const collector = createIRCollector(); + let started = false; + let finished = false; + for await (const record of records) { + if (finished) throw new Error('IR record arrived after finish'); + if (record.type === 'write') { + collector.applyWrite(record); + } else { + if (record.type === 'start') { + if (started) throw new Error('Duplicate IR start'); + started = true; + } else if (record.type === 'finish') { + if (!started) throw new Error('IR finish arrived before start'); + finished = true; + } else if (record.type === 'error') { + finished = true; + } + collector.applyEvent(record); + } + yield { record, collector }; + } + if (!finished) throw new Error('IR stream ended without finish'); +}; diff --git a/packages/translate/src/ir/types.ts b/packages/translate/src/ir/output/types.ts similarity index 54% rename from packages/translate/src/ir/types.ts rename to packages/translate/src/ir/output/types.ts index 9e55e6e097..e1991f9c86 100644 --- a/packages/translate/src/ir/types.ts +++ b/packages/translate/src/ir/output/types.ts @@ -1,132 +1,112 @@ -// The JSON value shape, declared once; IRValue adds the IRAbsent marker wherever a leaf may hold it. -type IRValueOf = Extra | null | boolean | number | string | readonly IRValueOf[] | { readonly [key: string]: IRValueOf }; +import type { IRAbsent } from './absent.ts'; +import type { TextRange } from '../../shared/text-range.ts'; +import type { JSONValueOf } from '../common/json.ts'; // All -export type IRJSONValue = IRValueOf; +export type IROutputValue = JSONValueOf; // All -export type IRJSONObject = { readonly [key: string]: IRJSONValue }; - -// The error fields upstream protocols agree on; each writer picks the ones its protocol defines. -export interface IRErrorPayload { - readonly message: string; - readonly type?: string; - readonly code?: string | null; - readonly param?: string | null; - readonly provider_specific_fields?: Readonly>; -} - -// All -export const IRAbsent: unique symbol = Symbol('IRAbsent'); - -// All -export type IRAbsent = typeof IRAbsent; - -// All -export type IRValue = IRValueOf; - -// All -export interface IR { - readonly choices: readonly IRChoice[]; // All +export type IROutput = { + readonly choices: readonly IROutputChoice[]; // All readonly model?: string; // All readonly service_tier?: string; // All - readonly usage?: IRUsage; // All -} + readonly usage?: IROutputUsage; // All +}; // All -interface IRChoice { - readonly items: readonly IRItem[]; // All - readonly refusal?: IRClassifierRefusal; // All - readonly logprobs?: readonly IRLogprobGroup[] | null; // ChatCompletions, Responses, GenerateContent -} +type IROutputChoice = { + readonly items: readonly IROutputItem[]; // All + readonly refusal_details?: IROutputRefusalDetails; // All + readonly logprobs?: readonly IROutputLogprobGroup[] | null; // ChatCompletions, Responses, GenerateContent +}; // All -interface IRClassifierRefusal { +type IROutputRefusalDetails = { readonly category: string | null; // Messages, Responses readonly explanation: string | null; // All -} +}; // All -export type IRItem = IRMessageItem | IRReasoningItem | IRFunctionCallItem | IRCustomToolCallItem; +export type IROutputItem = IROutputMessageItem | IROutputReasoningItem | IROutputFunctionCallItem | IROutputCustomToolCallItem; // All -export interface IRMessageItem { +export type IROutputMessageItem = { readonly type: 'message'; // All - readonly content: readonly IRContentPart[]; // All -} + readonly content: readonly IROutputContentPart[]; // All +}; // All -type IRContentPart = IRTextPart | IRRefusalPart | IRImagePart | IRAudioPart; +type IROutputContentPart = IROutputTextPart | IROutputRefusalPart | IROutputImagePart | IROutputAudioPart; // All -interface IRTextPart { +type IROutputTextPart = { readonly type: 'text'; // All readonly text: string; // All - readonly annotations?: readonly IRSourceCitation[] | null; // All -} + readonly annotations?: readonly IROutputSourceCitation[] | null; // All +}; // All -interface IRRefusalPart { +type IROutputRefusalPart = { readonly type: 'refusal'; // All readonly refusal: string; // All -} +}; // All -export interface IRReasoningItem { +export type IROutputReasoningItem = { readonly type: 'reasoning'; // All readonly summary?: readonly string[]; // All readonly content?: readonly string[]; // All -} +}; // All -interface IRFunctionCallItem { +type IROutputFunctionCallItem = { readonly type: 'function_call'; // All readonly call_id?: string | IRAbsent; // All readonly name: string; // All readonly arguments: string; // All -} +}; // ChatCompletions, Responses, Messages -interface IRCustomToolCallItem { +type IROutputCustomToolCallItem = { readonly type: 'custom_tool_call'; // ChatCompletions, Responses, Messages readonly call_id?: string | IRAbsent; // ChatCompletions, Responses, Messages readonly name: string; // ChatCompletions, Responses, Messages readonly input: string; // ChatCompletions, Responses, Messages -} +}; // Responses, GenerateContent -interface IRImagePart { +type IROutputImagePart = { readonly type: 'image'; // Responses, GenerateContent - readonly image: IRImage; // Responses, GenerateContent -} + readonly image: IROutputImage; // Responses, GenerateContent +}; // Responses, GenerateContent -interface IRImage { +type IROutputImage = { readonly data: string; // Responses, GenerateContent - readonly mime_type?: string; // Responses, GenerateContent -} + readonly media_type?: string; // Responses, GenerateContent +}; // ChatCompletions, Responses, GenerateContent -export interface IRAudioPart { +export type IROutputAudioPart = { readonly type: 'audio'; // ChatCompletions, Responses, GenerateContent - readonly audio: IRAudio; // ChatCompletions, Responses, GenerateContent -} + readonly audio: IROutputAudio; // ChatCompletions, Responses, GenerateContent +}; // ChatCompletions, Responses, GenerateContent -interface IRAudio { +type IROutputAudio = { readonly data?: string; // ChatCompletions, Responses, GenerateContent readonly transcript?: string; // ChatCompletions, Responses, GenerateContent // No current upstream reader produces it; reserved for a GenerateContent upstream. - readonly mime_type?: string; // GenerateContent -} + readonly media_type?: string; // GenerateContent +}; // All -export type IRSourceCitation = { +export type IROutputSourceCitation = { readonly type: 'source_citation'; // All readonly source_label?: string | null; // All readonly source_text?: string; // Messages, GenerateContent - readonly source_page_range?: IRSourcePageRange; // Messages, GenerateContent - readonly output_text_range?: IRUTF16TextRange; // ChatCompletions, Responses, GenerateContent + readonly source_page_range?: IROutputSourcePageRange; // Messages, GenerateContent + readonly output_text_range?: TextRange; // ChatCompletions, Responses, GenerateContent } & ( | { readonly source_kind: 'url' | 'document' | 'search_result'; // All @@ -140,52 +120,43 @@ export type IRSourceCitation = { ); // Messages, GenerateContent -interface IRSourcePageRange { +type IROutputSourcePageRange = { readonly start_one_based: number; // Messages, GenerateContent readonly end_one_based_exclusive: number; // Messages -} - -// ChatCompletions, Responses, GenerateContent -export interface IRUTF16TextRange { - readonly start: number; // ChatCompletions, Responses, GenerateContent - readonly end_exclusive: number; // ChatCompletions, Responses, GenerateContent -} +}; // ChatCompletions, Responses, GenerateContent -type IRLogprobGroup = IRChoiceLogprobs | IRTextPartLogprobs; +type IROutputLogprobGroup = IROutputChoiceLogprobs | IROutputTextPartLogprobs; // Reserved for a GenerateContent upstream: the current upstream readers only produce text-part logprobs. // ChatCompletions, GenerateContent -interface IRChoiceLogprobs { +type IROutputChoiceLogprobs = { readonly scope: 'choice'; // ChatCompletions, GenerateContent - readonly tokens: readonly IRTokenLogprob[]; // ChatCompletions, GenerateContent -} + readonly tokens: readonly IROutputTokenLogprob[]; // ChatCompletions, GenerateContent +}; // ChatCompletions, Responses -interface IRTextPartLogprobs { +type IROutputTextPartLogprobs = { readonly scope: 'text_part'; // ChatCompletions, Responses readonly item_index: number; // ChatCompletions, Responses readonly content_index: number; // ChatCompletions, Responses - readonly tokens: readonly IRTokenLogprob[]; // ChatCompletions, Responses -} + readonly tokens: readonly IROutputTokenLogprob[]; // ChatCompletions, Responses +}; // ChatCompletions, Responses, GenerateContent -export interface IRTokenLogprob { - readonly token: string; // ChatCompletions, Responses, GenerateContent - readonly logprob: number; // ChatCompletions, Responses, GenerateContent - readonly bytes?: readonly number[] | null; // ChatCompletions, Responses - readonly top_logprobs?: readonly IRTokenLogprobAlternative[]; // ChatCompletions, Responses, GenerateContent -} +export type IROutputTokenLogprob = IROutputTokenLogprobAlternative & { + readonly top_logprobs?: readonly IROutputTokenLogprobAlternative[]; // ChatCompletions, Responses, GenerateContent +}; // ChatCompletions, Responses, GenerateContent -interface IRTokenLogprobAlternative { +type IROutputTokenLogprobAlternative = { readonly token: string; // ChatCompletions, Responses, GenerateContent readonly logprob: number; // ChatCompletions, Responses, GenerateContent readonly bytes?: readonly number[] | null; // ChatCompletions, Responses -} +}; // All -export interface IRUsage { +export type IROutputUsage = { readonly input_tokens_inclusive?: number; // All readonly output_tokens_inclusive?: number; // All readonly total_tokens?: number; // ChatCompletions, Responses, GenerateContent @@ -197,4 +168,4 @@ export interface IRUsage { readonly input_text_tokens?: number; // ChatCompletions, Responses, GenerateContent readonly output_audio_tokens?: number; // ChatCompletions, Responses, GenerateContent readonly output_text_tokens?: number; // ChatCompletions, Responses, GenerateContent -} +}; diff --git a/packages/translate/src/ir/output/unit-index.ts b/packages/translate/src/ir/output/unit-index.ts new file mode 100644 index 0000000000..6590fdd066 --- /dev/null +++ b/packages/translate/src/ir/output/unit-index.ts @@ -0,0 +1,27 @@ +import { irPathKey } from './operation-paths.ts'; +import type { IRPath } from './records.ts'; + +// Numbers the native units (blocks, output items, tool calls) of a stream in the order +// they open, and forgets a unit when it closes. +export const createIRUnitIndexAllocator = () => { + const indices = new Map(); + let next = 0; + const open = (path: IRPath): number => { + const key = irPathKey(path); + if (indices.has(key)) throw new TypeError(`IR unit is already open: ${key}`); + const index = next++; + indices.set(key, index); + return index; + }; + const get = (path: IRPath): number => { + const key = irPathKey(path); + const index = indices.get(key); + if (index === undefined) throw new TypeError(`Unknown open IR unit: ${key}`); + return index; + }; + const close = (path: IRPath): void => { + get(path); + indices.delete(irPathKey(path)); + }; + return { open, get, close }; +}; diff --git a/packages/translate/src/ir/output/unit-queue.ts b/packages/translate/src/ir/output/unit-queue.ts new file mode 100644 index 0000000000..ae997416de --- /dev/null +++ b/packages/translate/src/ir/output/unit-queue.ts @@ -0,0 +1,27 @@ +// The queue's own link, so the units it carries stay plain values. +interface QueuedNode { + readonly value: T; + next?: QueuedNode; +} + +// Units wait here until the writer can open them, and are opened in output IR order. +export const createIRUnitQueue = () => { + let head: QueuedNode | undefined; + let tail: QueuedNode | undefined; + return { + get head(): T | undefined { return head?.value; }, + enqueue: (unit: T): void => { + const node: QueuedNode = { value: unit }; + if (tail === undefined) head = node; + else tail.next = node; + tail = node; + }, + // Removes the head unit, which the caller has just opened or discarded. + dropHead: (): void => { + head = head!.next; + if (head === undefined) tail = undefined; + }, + }; +}; + +export type IRUnitQueue = ReturnType>; diff --git a/packages/translate/src/ir/output/unit-registry.ts b/packages/translate/src/ir/output/unit-registry.ts new file mode 100644 index 0000000000..228e553441 --- /dev/null +++ b/packages/translate/src/ir/output/unit-registry.ts @@ -0,0 +1,21 @@ +import { irPathKey, irRecordOwnerPaths } from './operation-paths.ts'; +import type { IRPath, IRRecord } from './records.ts'; + +// Finds the unit that a record belongs to. A writer registers each unit at the path +// where its IR structure was initialized. +export const createIRUnitRegistry = () => { + const units = new Map(); + return { + set: (path: IRPath, unit: T): void => { units.set(irPathKey(path), unit); }, + get: (path: IRPath): T | undefined => units.get(irPathKey(path)), + has: (path: IRPath): boolean => units.has(irPathKey(path)), + delete: (path: IRPath): void => { units.delete(irPathKey(path)); }, + forRecord: (record: IRRecord, root: unknown): T | undefined => { + for (const path of irRecordOwnerPaths(record, root)) { + const unit = units.get(irPathKey(path)); + if (unit !== undefined) return unit; + } + return undefined; + }, + }; +}; diff --git a/packages/translate/src/ir/output/value-validation.ts b/packages/translate/src/ir/output/value-validation.ts new file mode 100644 index 0000000000..5cb73c903f --- /dev/null +++ b/packages/translate/src/ir/output/value-validation.ts @@ -0,0 +1,79 @@ +import { IRAbsent } from './absent.ts'; +import { irPathKey, unionVariantNode } from './operation-paths.ts'; +import type { IRPath } from './records.ts'; +import type { IRSchemaNode } from './schema.ts'; +import type { IROutputValue } from './types.ts'; + +type IROutputValueVisitor = (path: IRPath, node: IRSchemaNode, value: IROutputValue) => void; + +const isPlainObject = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); + +// Opaque values are replaced as a whole, so only what no IR value may contain is rejected. +const assertPlainValue = (path: IRPath, value: unknown): void => { + if (value === undefined) throw new TypeError(`IR values cannot contain undefined: ${irPathKey(path)}`); + if (value === IRAbsent) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); + if (typeof value !== 'object' || value === null) return; + for (const [key, child] of Object.entries(value)) assertPlainValue([...path, Array.isArray(value) ? Number(key) : key], child); +}; + +// Walks a value that is about to be written together with the schema node describing it. +// `IRAbsent` is accepted where the schema allows it unless `allowAbsent` is off. +export const walkValue = (path: IRPath, node: IRSchemaNode, value: IROutputValue, visitor: IROutputValueVisitor, { allowAbsent }: { allowAbsent: boolean }): void => { + if (value === undefined) throw new TypeError(`IR values cannot contain undefined: ${irPathKey(path)}`); + if (node.kind === 'nullable') { + if (value === null) return visitor(path, node, value); + node = node.of; + } + visitor(path, node, value); + switch (node.kind) { + case 'string': + if (value === IRAbsent) { + if (!allowAbsent) throw new TypeError('IRAbsent must be assigned directly to call_id'); + if (node.absent !== true) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); + } else if (typeof value !== 'string') throw new TypeError(`IR value must be a string: ${irPathKey(path)}`); + return; + case 'number': + if (typeof value !== 'number') throw new TypeError(`IR value must be a number: ${irPathKey(path)}`); + return; + case 'array': + if (!Array.isArray(value)) throw new TypeError(`IR value must be an array: ${irPathKey(path)}`); + value.forEach((child, index) => walkValue([...path, index], node.of, child, visitor, { allowAbsent })); + return; + case 'object': + case 'union': { + if (!isPlainObject(value)) throw new TypeError(`IR value must be an object: ${irPathKey(path)}`); + const variant = unionVariantNode(node, value); + if (variant?.kind !== 'object') throw new TypeError(`IR value has an unknown variant: ${irPathKey(path)}`); + for (const [key, child] of Object.entries(value)) { + const field = node.kind === 'union' && key === node.discriminant ? { kind: 'string' } as const : Object.hasOwn(variant.fields, key) ? variant.fields[key] : undefined; + if (field === undefined) throw new TypeError(`IR value has no field ${key}: ${irPathKey(path)}`); + walkValue([...path, key], field, child, visitor, { allowAbsent }); + } + return; + } + case 'opaque': + assertPlainValue(path, value); + return; + default: + } +}; + +// Fails if a unit that has ended lacks any field required by its schema. +export const assertComplete = (path: IRPath, node: IRSchemaNode, value: unknown): void => { + const actual = unionVariantNode(node, value); + if (actual === undefined || typeof value !== 'object' || value === null) return; + if (actual.kind === 'array') (value as unknown[]).forEach((child, index) => assertComplete([...path, index], actual.of, child)); + else if (actual.kind === 'object') { + const present = value as Record; + if (actual.requiresAnyOf !== undefined && !actual.requiresAnyOf.some(key => present[key] !== undefined)) { + throw new TypeError(`IR value requires one of ${actual.requiresAnyOf.join(', ')}: ${irPathKey(path)}`); + } + for (const [key, field] of Object.entries(actual.fields)) { + if (!Object.hasOwn(present, key)) { + if (!actual.optional.includes(key)) throw new TypeError(`IR value requires field ${key}: ${irPathKey(path)}`); + continue; + } + assertComplete([...path, key], field, present[key]); + } + } +}; diff --git a/packages/translate/src/ir/unit-queue.ts b/packages/translate/src/ir/unit-queue.ts deleted file mode 100644 index 4b73fad318..0000000000 --- a/packages/translate/src/ir/unit-queue.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { irPathKey, irRecordOwnerPaths } from './operation-paths.ts'; -import type { IRPath, IRRecord } from './stream.ts'; - -// Finds the unit that a record belongs to. A writer registers each unit at the path -// where its IR structure was initialized. -export const createIRUnitRegistry = () => { - const units = new Map(); - return { - set: (path: IRPath, unit: T): void => { units.set(irPathKey(path), unit); }, - get: (path: IRPath): T | undefined => units.get(irPathKey(path)), - has: (path: IRPath): boolean => units.has(irPathKey(path)), - delete: (path: IRPath): void => { units.delete(irPathKey(path)); }, - forRecord: (record: IRRecord, root: unknown): T | undefined => { - for (const path of irRecordOwnerPaths(record, root)) { - const unit = units.get(irPathKey(path)); - if (unit !== undefined) return unit; - } - return undefined; - }, - }; -}; - -// Units wait here until the writer can open them, and are opened in IR order. -export interface IRQueuedUnit { nextPending?: T } - -export const createIRUnitQueue = >() => { - let head: T | undefined; - let tail: T | undefined; - return { - get head(): T | undefined { return head; }, - enqueue: (unit: T): void => { - if (tail === undefined) head = unit; - else tail.nextPending = unit; - tail = unit; - }, - // Removes the head unit, which the caller has just opened or discarded. - dequeue: (): void => { - const unit = head!; - head = unit.nextPending; - unit.nextPending = undefined; - if (head === undefined) tail = undefined; - }, - }; -}; - -export type IRUnitQueue> = ReturnType>; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts b/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts index 8eb74e8204..93eb4db66f 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/round-trip.ts @@ -1,4 +1,4 @@ -import type { IRJSONObject, IRJSONValue } from '../../../ir/types.ts'; +import type { IRJSONObject, IRJSONValue } from '../../../ir/common/json.ts'; import type { ReplayContent } from '../../../round-trip/reference/assistant-turn.ts'; import type { EncodedRoundTripSidecar } from '../../../round-trip/resolve.ts'; import { collectTextGroups, collectTextPlacementIndex, type TextContribution, type TextPlacementIndex } from '../../../round-trip/text-groups.ts'; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts index d31c3cf762..456e5d9a5e 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts @@ -1,11 +1,16 @@ import { collectAnthropicMessagesTextViews, computeAnthropicMessagesTurnDigest, createAnthropicMessagesSidecarCarrier } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; -import type { IRCollector } from '../../../ir/collector.ts'; -import { isIRItemPath, isIRPartPath, irPathKey } from '../../../ir/operation-paths.ts'; -import { createIROrdinalMapper } from '../../../ir/ordinal-mapper.ts'; -import { consumeIRRecords, type IRPath, type IRRecord } from '../../../ir/stream.ts'; -import { IRAbsent, type IR, type IRErrorPayload, type IRJSONObject } from '../../../ir/types.ts'; -import { createIRUnitQueue, createIRUnitRegistry } from '../../../ir/unit-queue.ts'; +import type { IRJSONObject } from '../../../ir/common/json.ts'; +import { IRAbsent } from '../../../ir/output/absent.ts'; +import type { IRCollector } from '../../../ir/output/collector.ts'; +import type { IRErrorPayload } from '../../../ir/output/error.ts'; +import { isIRItemPath, isIRPartPath, irPathKey } from '../../../ir/output/operation-paths.ts'; +import type { IRPath, IRRecord } from '../../../ir/output/records.ts'; +import { consumeIRRecords } from '../../../ir/output/stream.ts'; +import type { IROutput } from '../../../ir/output/types.ts'; +import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; +import { createIRUnitQueue } from '../../../ir/output/unit-queue.ts'; +import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; import { appendContent, createContentState, type ContentState, type ContentStates } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; @@ -46,7 +51,6 @@ interface MessagesUnit { textContentState?: ContentState<'text'>; // Keep source intervals until their native field is emitted, including terminal JSON normalization. pendingText?: Map; - nextPending?: MessagesUnit; } const isRecord = (value: unknown): value is Readonly> => @@ -96,7 +100,7 @@ export const anthropicMessagesFromIR = async function* ( let finishReason: NonNullable = 'end_turn'; const units = createIRUnitRegistry(); const pending = createIRUnitQueue(); - const ordinals = createIROrdinalMapper(); + const ordinals = createIRUnitIndexAllocator(); const nativeAccumulator = createAnthropicMessagesEventAccumulator(); const contentState: NonNullable['content']> = []; let nextBlockIndex = 0; @@ -111,7 +115,7 @@ export const anthropicMessagesFromIR = async function* ( const blockDelta = (index: number, delta: AnthropicMessagesContentBlockDeltaEvent['delta']): EventFrame => emit({ type: 'content_block_delta', index, delta }); - const usage = (state: IR): AnthropicMessagesUsage => { + const usage = (state: IROutput): AnthropicMessagesUsage => { const value = usageFromIR(state.usage ?? {}); if (state.service_tier === 'fast') value.speed = 'fast'; else if (typeof state.service_tier === 'string') value.service_tier = state.service_tier; @@ -119,7 +123,7 @@ export const anthropicMessagesFromIR = async function* ( }; // Queues the content blocks that the IR structures initialized by a record will become. - const registerUnits = (record: IRRecord, state: IR): void => { + const registerUnits = (record: IRRecord, state: IROutput): void => { if (record.type === 'write' && record.kind === 'initialize') { let path: IRPath | undefined; let kind: MessagesUnit['kind'] | undefined; @@ -155,7 +159,7 @@ export const anthropicMessagesFromIR = async function* ( }; // Opens the waiting units in IR order, until one still lacks what its start event needs. - const openPendingUnits = (state: IR, collector: IRCollector): AnthropicMessagesStreamEventEx[] => { + const openPendingUnits = (state: IROutput, collector: IRCollector): AnthropicMessagesStreamEventEx[] => { const pendingEvents: AnthropicMessagesStreamEventEx[] = []; while (pending.head !== undefined) { const unit = pending.head; @@ -179,12 +183,12 @@ export const anthropicMessagesFromIR = async function* ( else wait = !closed; } else if (item.type === 'function_call') { if (item.name === '') throw new TypeError('Completed tool calls require a name'); - const callId = collector.optional([...unit.path, 'call_id']); + const callId = collector.valueOrAbsent([...unit.path, 'call_id']); if (callId === undefined) wait = true; else block = { type: 'tool_use', id: callId === IRAbsent ? `${metadata.id}_${itemIndex}` : callId, name: item.name, input: {} }; } if (wait) break; - pending.dequeue(); + pending.dropHead(); if (block === undefined) { units.delete(unit.path); continue; } unit.opened = true; const index = ordinals.open(unit.path); @@ -361,7 +365,7 @@ export const anthropicMessagesFromIR = async function* ( yield pendingBlockStop; pendingBlockStop = undefined; } - const refusal = state.choices[0]?.refusal; + const refusal = state.choices[0]?.refusal_details; yield emit({ type: 'message_delta', delta: { diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/usage.ts b/packages/translate/src/protocols/anthropic-messages/as-source/usage.ts index 10b65072dd..20061d6d57 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/usage.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/usage.ts @@ -1,9 +1,9 @@ -import type { IRUsage } from '../../../ir/types.ts'; +import type { IROutputUsage } from '../../../ir/output/types.ts'; import { requireTokenCount } from '../../../shared/token-count.ts'; import { type AnthropicMessagesUsage, createAnthropicMessagesUsage } from '@floway-dev/protocols/anthropic-messages'; import { splitInclusiveInputTokens } from '@floway-dev/protocols/common'; -export const usageFromIR = (usage: IRUsage): AnthropicMessagesUsage => { - const count = (key: keyof IRUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); +export const usageFromIR = (usage: IROutputUsage): AnthropicMessagesUsage => { + const count = (key: keyof IROutputUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); const input = count('input_tokens_inclusive') ?? 0; const output = count('output_tokens_inclusive') ?? 0; const cached = count('cached_input_tokens') ?? 0; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/assistant-turn.ts b/packages/translate/src/protocols/anthropic-messages/as-target/assistant-turn.ts index 0358b85b89..4604e380af 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/assistant-turn.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/assistant-turn.ts @@ -1,4 +1,4 @@ -import type { IRJSONObject } from '../../../ir/types.ts'; +import type { IRJSONObject } from '../../../ir/common/json.ts'; import type { ReferenceState, ReferenceStateLeaves, ReferenceableValue, TextRule, JSONRule } from '../../../round-trip/reference/assistant-turn.ts'; import type { AnthropicMessagesAssistantContentBlock } from '@floway-dev/protocols/anthropic-messages'; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/sse-from.ts b/packages/translate/src/protocols/anthropic-messages/as-target/sse-from.ts index bc83751297..4225141cee 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/sse-from.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/sse-from.ts @@ -1,7 +1,8 @@ import type { AnthropicMessagesAssistantState } from './assistant-turn.ts'; import { type AnthropicMessagesInputCounts, usageToIR } from './usage.ts'; -import { createIRBuilder, type IRItemHandle, type IRPartHandle, type IRRecord } from '../../../ir/stream.ts'; -import type { IRSourceCitation } from '../../../ir/types.ts'; +import { createIRBuilder, type IRItemHandle, type IRPartHandle } from '../../../ir/output/builder.ts'; +import type { IRRecord } from '../../../ir/output/records.ts'; +import type { IROutputSourceCitation } from '../../../ir/output/types.ts'; import { appendField, createFieldState, type FieldState, type FieldStates } from '../../../round-trip/reference/reference-builder.ts'; import type { RoundTripReader } from '../../../round-trip/stream.ts'; import { requireJSONObject } from '../../../shared/json/object.ts'; @@ -15,7 +16,7 @@ import { } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; -const messagesCitationToIR = (citation: AnthropicMessagesTextCitation): IRSourceCitation | undefined => { +const messagesCitationToIR = (citation: AnthropicMessagesTextCitation): IROutputSourceCitation | undefined => { const isSearchLocation = citation.type === 'web_search_result_location' || citation.type === 'search_result_location'; const source = citation.type === 'web_search_result_location' ? citation.url : citation.type === 'search_result_location' ? citation.source : citation.file_id ?? undefined; @@ -31,7 +32,7 @@ const messagesCitationToIR = (citation: AnthropicMessagesTextCitation): IRSource ...(citation.type === 'page_location' ? { source_page_range: { start_one_based: citation.start_page_number, end_one_based_exclusive: citation.end_page_number } } : {}), - } as IRSourceCitation; + } as IROutputSourceCitation; }; // Blocks that open no IR item; their content is not translated. @@ -214,7 +215,7 @@ export const irFromAnthropicMessages = async function* ( assignUsage(e.usage); if (e.delta.stop_reason === 'compaction') throw new TypeError('Cannot translate Messages compaction stop'); if (e.delta.stop_reason === 'refusal') { - ir.choice(0).assign('refusal', { + ir.choice(0).assign('refusal_details', { category: e.delta.stop_details?.category ?? null, explanation: e.delta.stop_details?.explanation ?? null, }); diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/usage.ts b/packages/translate/src/protocols/anthropic-messages/as-target/usage.ts index 68ecb081b4..cffe87e8cc 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/usage.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/usage.ts @@ -1,4 +1,4 @@ -import type { IRUsage } from '../../../ir/types.ts'; +import type { IROutputUsage } from '../../../ir/output/types.ts'; import { requireTokenCount } from '../../../shared/token-count.ts'; import { type AnthropicMessagesUsageDeltaEx, splitAnthropicMessagesCacheCreationTokens } from '@floway-dev/protocols/anthropic-messages'; import { splitInclusiveInputTokens } from '@floway-dev/protocols/common'; @@ -11,10 +11,10 @@ export interface AnthropicMessagesInputCounts { cacheWrite?: number; } -export const usageToIR = (usage: Partial | null | undefined, counts: AnthropicMessagesInputCounts): IRUsage => { - const result: Partial> = {}; +export const usageToIR = (usage: Partial | null | undefined, counts: AnthropicMessagesInputCounts): IROutputUsage => { + const result: Partial> = {}; if (usage == null) return result; - const put = (key: keyof IRUsage, value: number | undefined | null): void => { if (value != null) result[key] = requireTokenCount(key, value); }; + const put = (key: keyof IROutputUsage, value: number | undefined | null): void => { if (value != null) result[key] = requireTokenCount(key, value); }; if (usage.input_tokens != null) counts.input = requireTokenCount('input_tokens', usage.input_tokens); if (usage.cache_read_input_tokens != null) counts.cacheRead = requireTokenCount('cache_read_input_tokens', usage.cache_read_input_tokens); const hasCacheWrite = usage.cache_creation_input_tokens != null || usage.cache_creation != null; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts index c7a6839460..f2f24c54c2 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts @@ -1,4 +1,4 @@ -import type { IRJSONObject } from '../../../ir/types.ts'; +import type { IRJSONObject } from '../../../ir/common/json.ts'; import type { ReplayContent } from '../../../round-trip/reference/assistant-turn.ts'; import type { EncodedRoundTripSidecar } from '../../../round-trip/resolve.ts'; import { collectTextGroups, collectTextPlacementIndex, type TextContribution, type TextPlacementIndex } from '../../../round-trip/text-groups.ts'; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts index 7e224bee09..545f856c1a 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts @@ -1,10 +1,15 @@ import { collectGeminiGenerateContentTextViews, computeGeminiGenerateContentTurnDigest, createGeminiGenerateContentSidecarCarrier } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; -import type { IRCollector } from '../../../ir/collector.ts'; -import { irItemPath, irPartPath, irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/operation-paths.ts'; -import { consumeIRRecords, type IRPath, type IRRecord } from '../../../ir/stream.ts'; -import { IRAbsent, type IR, type IRErrorPayload, type IRJSONObject, type IRSourceCitation, type IRTokenLogprob } from '../../../ir/types.ts'; -import { createIRUnitQueue, createIRUnitRegistry, type IRUnitQueue } from '../../../ir/unit-queue.ts'; +import type { IRJSONObject } from '../../../ir/common/json.ts'; +import { IRAbsent } from '../../../ir/output/absent.ts'; +import type { IRCollector } from '../../../ir/output/collector.ts'; +import type { IRErrorPayload } from '../../../ir/output/error.ts'; +import { irItemPath, irPartPath, irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/output/operation-paths.ts'; +import type { IRPath, IRRecord } from '../../../ir/output/records.ts'; +import { consumeIRRecords } from '../../../ir/output/stream.ts'; +import type { IROutput, IROutputSourceCitation, IROutputTokenLogprob } from '../../../ir/output/types.ts'; +import { createIRUnitQueue, type IRUnitQueue } from '../../../ir/output/unit-queue.ts'; +import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; import { appendContent, createContentState, type ContentStates, type ContentState as ReferenceContentState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; @@ -37,7 +42,6 @@ interface GenerateContentUnit { opened: boolean; pendingData?: TextProvenanceSpan[]; pendingArguments?: TextProvenanceSpan[]; - nextPending?: GenerateContentUnit; } interface PendingTextQueue { @@ -186,7 +190,7 @@ export const geminiGenerateContentFromIR = async function* ( }; // Queues the units that the IR structures initialized by a record will become. - const registerUnits = (record: IRRecord, state: IR): void => { + const registerUnits = (record: IRRecord, state: IROutput): void => { if (record.type === 'write' && record.kind === 'initialize') { const choice = record.path[1]; if (typeof choice !== 'number') return; @@ -206,7 +210,7 @@ export const geminiGenerateContentFromIR = async function* ( }; // Opens each choice's waiting units in IR order, until one still lacks what its part needs. - const openPendingUnits = function* (record: IRRecord, state: IR, collector: IRCollector): Generator> { + const openPendingUnits = function* (record: IRRecord, state: IROutput, collector: IRCollector): Generator> { const endingChoice = record.type === 'choice_end' ? record : undefined; for (const [choice, queue] of queues) { const choiceEnding = endingChoice?.choice === choice ? endingChoice : undefined; @@ -220,9 +224,9 @@ export const geminiGenerateContentFromIR = async function* ( if (item.type !== 'message') throw new TypeError('GenerateContent message unit requires a message item'); const content = item.content[unit.path[5] as number]; const inlineMedia = content.type === 'image' - ? { mimeType: content.image.mime_type, data: content.image.data } + ? { mimeType: content.image.media_type, data: content.image.data } : content.type === 'audio' && content.audio.data !== undefined - ? { mimeType: content.audio.mime_type, data: content.audio.data } + ? { mimeType: content.audio.media_type, data: content.audio.data } : undefined; if (content.type === 'text' || content.type === 'refusal') { part = undefined; @@ -287,7 +291,7 @@ export const geminiGenerateContentFromIR = async function* ( } } if (wait) break; - queue.dequeue(); + queue.dropHead(); unit.opened = true; if (part !== undefined) yield emitPart(choice, part); for (const pendingPart of drainPendingText(choice, collector)) yield emitPart(choice, pendingPart); @@ -298,7 +302,7 @@ export const geminiGenerateContentFromIR = async function* ( }; // The terminal candidate of a choice: finish reason, grounding built from its citations, and logprobs. - const terminalCandidate = (record: Extract, state: IR, collector: IRCollector): GeminiGenerateContentCandidate => { + const terminalCandidate = (record: Extract, state: IROutput, collector: IRCollector): GeminiGenerateContentCandidate => { const choice = state.choices[record.choice]; const partTexts = new Map(); for (const [partIndex, segments] of segmentsByChoice.get(record.choice) ?? []) { @@ -331,7 +335,7 @@ export const geminiGenerateContentFromIR = async function* ( const range = { start: segment.targetStart + start - segment.sourceStart, end_exclusive: segment.targetStart + end - segment.sourceStart }; partText.coordinateIndex ??= TextCoordinateIndex.utf16ToUTF8(partText.text); partText.coordinateIndex.assertUTF16Length(partText.text.length); - const bytes = partText.coordinateIndex.irRangeToUTF8(range); + const bytes = partText.coordinateIndex.utf16RangeToUTF8(range); supports.push({ groundingChunkIndices: [chunkIndex], segment: { partIndex: segment.partIndex, startIndex: bytes.start, endIndex: bytes.end, text: partText.text.slice(range.start, range.end_exclusive) }, @@ -342,7 +346,7 @@ export const geminiGenerateContentFromIR = async function* ( }); const tokens = choice.logprobs?.flatMap(group => group.tokens); return { - ...(choice.refusal?.explanation == null ? {} : { finishMessage: choice.refusal.explanation }), + ...(choice.refusal_details?.explanation == null ? {} : { finishMessage: choice.refusal_details.explanation }), index: record.choice, finishReason: record.finish_reason === 'length' ? 'MAX_TOKENS' : record.finish_reason === 'content_filter' ? 'SAFETY' : 'STOP', ...(chunks.length === 0 ? {} : { groundingMetadata: { groundingChunks: chunks, groundingSupports: supports } }), @@ -430,14 +434,14 @@ export const geminiGenerateContentFromIR = async function* ( } }; -const logprobsResult = (tokens: readonly IRTokenLogprob[]): NonNullable => ({ +const logprobsResult = (tokens: readonly IROutputTokenLogprob[]): NonNullable => ({ chosenCandidates: tokens.map(token => ({ token: token.token, logProbability: token.logprob })), topCandidates: tokens.map(token => ({ candidates: (token.top_logprobs ?? []).map(alternative => ({ token: alternative.token, logProbability: alternative.logprob })), })), }); -const groundingChunk = (citation: IRSourceCitation): GeminiGenerateContentGroundingChunk => { +const groundingChunk = (citation: IROutputSourceCitation): GeminiGenerateContentGroundingChunk => { if (citation.source_kind === 'url') return { web: { uri: citation.source, title: citation.source_label ?? undefined } }; return { retrievedContext: { diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/usage.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/usage.ts index febb58e32f..bb5a802f2c 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/usage.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/usage.ts @@ -1,15 +1,15 @@ -import type { IRUsage } from '../../../ir/types.ts'; +import type { IROutputUsage } from '../../../ir/output/types.ts'; import { requireTokenCount } from '../../../shared/token-count.ts'; import { splitInclusiveOutputTokens } from '@floway-dev/protocols/common'; import type { GeminiGenerateContentModalityTokenCount, GeminiGenerateContentUsageMetadata } from '@floway-dev/protocols/gemini-generate-content'; -export const usageFromIR = (usage: IRUsage): GeminiGenerateContentUsageMetadata => { - const count = (key: keyof IRUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); +export const usageFromIR = (usage: IROutputUsage): GeminiGenerateContentUsageMetadata => { + const count = (key: keyof IROutputUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); const input = count('input_tokens_inclusive') ?? 0; const output = count('output_tokens_inclusive') ?? 0; const reasoning = count('reasoning_tokens'); const split = splitInclusiveOutputTokens(output, reasoning); - const modalityTokens = (keys: readonly (readonly [modality: string, key: keyof IRUsage])[]): GeminiGenerateContentModalityTokenCount[] => + const modalityTokens = (keys: readonly (readonly [modality: string, key: keyof IROutputUsage])[]): GeminiGenerateContentModalityTokenCount[] => keys.flatMap(([modality, key]) => usage[key] === undefined ? [] : [{ modality, tokenCount: requireTokenCount(key, usage[key]) }]); const promptDetails = modalityTokens([['AUDIO', 'input_audio_tokens'], ['IMAGE', 'input_image_tokens'], ['TEXT', 'input_text_tokens']]); const candidatesDetails = modalityTokens([['AUDIO', 'output_audio_tokens'], ['TEXT', 'output_text_tokens']]); diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts index c9577cba28..0ef3b5905a 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts @@ -1,4 +1,4 @@ -import type { IRJSONObject, IRJSONValue } from '../../../ir/types.ts'; +import type { IRJSONObject, IRJSONValue } from '../../../ir/common/json.ts'; import type { ReplayContent } from '../../../round-trip/reference/assistant-turn.ts'; import type { EncodedRoundTripSidecar } from '../../../round-trip/resolve.ts'; import { collectTextGroups, collectTextPlacementIndex, type TextContribution, type TextPlacementIndex } from '../../../round-trip/text-groups.ts'; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts index 9cba106198..74dfdade36 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts @@ -1,9 +1,12 @@ import { collectOpenAIChatCompletionsTextViews, computeOpenAIChatCompletionsTurnDigest, createOpenAIChatCompletionsSidecarCarrier } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; -import { irItemPath, irPartPath, irPathKey } from '../../../ir/operation-paths.ts'; -import { createIROrdinalMapper } from '../../../ir/ordinal-mapper.ts'; -import { consumeIRRecords, type IRRecord } from '../../../ir/stream.ts'; -import { IRAbsent, type IRErrorPayload, type IRItem, type IRMessageItem, type IRTokenLogprob } from '../../../ir/types.ts'; +import { IRAbsent } from '../../../ir/output/absent.ts'; +import type { IRErrorPayload } from '../../../ir/output/error.ts'; +import { irItemPath, irPartPath, irPathKey } from '../../../ir/output/operation-paths.ts'; +import type { IRRecord } from '../../../ir/output/records.ts'; +import { consumeIRRecords } from '../../../ir/output/stream.ts'; +import type { IROutputItem, IROutputMessageItem, IROutputTokenLogprob } from '../../../ir/output/types.ts'; +import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; import { appendContent, createContentState, type ContentStates, type ContentState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; @@ -35,12 +38,12 @@ interface OpenAIChatCompletionsSSEOutputOptions extends RoundTripOutputOptions<' const tierFields = (tier?: string): { service_tier?: string } => tier === undefined ? {} : { service_tier: tier }; -type IRToolCallItem = Extract; +type IRToolCallItem = Extract; const isRecord = (value: unknown): value is Readonly> => typeof value === 'object' && value !== null && !Array.isArray(value); -const isIRItem = (value: unknown): value is IRItem => { +const isIRItem = (value: unknown): value is IROutputItem => { if (!isRecord(value)) return false; switch (value.type) { case 'message': return Array.isArray(value.content); @@ -51,7 +54,7 @@ const isIRItem = (value: unknown): value is IRItem => { } }; -const partType = (value: unknown): IRMessageItem['content'][number]['type'] | undefined => { +const partType = (value: unknown): IROutputMessageItem['content'][number]['type'] | undefined => { if (!isRecord(value)) return undefined; return value.type === 'text' || value.type === 'refusal' || value.type === 'image' || value.type === 'audio' ? value.type : undefined; }; @@ -71,7 +74,7 @@ const logprobMetadata = (value: unknown): LogprobMetadata | undefined => { return undefined; }; -const isIRTokenLogprob = (value: unknown): value is IRTokenLogprob => +const isIRTokenLogprob = (value: unknown): value is IROutputTokenLogprob => isRecord(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; // Emitted deltas carry the assistant extension fields (reasoning, annotations) beside the standard ones. @@ -86,10 +89,10 @@ export const openaiChatCompletionsFromIR = async function* ( let metadata = { id: '', model: '', created: 0 }; let started = false; const startedChoices = new Set(); - const tools = new Map>(); + const tools = new Map>(); const startedAudio = new Set(); - const itemTypes = new Map(); - const partTypes = new Map(); + const itemTypes = new Map(); + const partTypes = new Map(); const logprobRefusal = new Map(); const nativeAccumulators = new Map>(); const nativeAccumulator = (choice: number): ReturnType => { @@ -153,7 +156,7 @@ export const openaiChatCompletionsFromIR = async function* ( const ensureChoice = (choice: number, tier?: string): ProtocolFrame | undefined => { if (startedChoices.has(choice)) return undefined; startedChoices.add(choice); - tools.set(choice, createIROrdinalMapper()); + tools.set(choice, createIRUnitIndexAllocator()); return deltaFrame(choice, { role: 'assistant', content: '' }, tier); }; @@ -377,7 +380,7 @@ export const openaiChatCompletionsFromIR = async function* ( const range = annotation.output_text_range; coordinateIndex ??= TextCoordinateIndex.utf16ToCodePoints(flattened); coordinateIndex.assertUTF16Length(flattened.length); - const offsets = coordinateIndex.irRangeToCodePoints({ start: base + range.start, end_exclusive: base + range.end_exclusive }); + const offsets = coordinateIndex.utf16RangeToCodePoints({ start: base + range.start, end_exclusive: base + range.end_exclusive }); annotations.push({ type: 'url_citation', url_citation: { start_index: offsets.start, end_index: offsets.end, title: annotation.source_label ?? annotation.source, url: annotation.source }, @@ -386,9 +389,9 @@ export const openaiChatCompletionsFromIR = async function* ( }); }); if (annotations.length > 0) yield deltaFrame(record.choice, { annotations }, tier); - if (choice.refusal?.explanation != null) { - appendContent(options.roundTrip.builder, textState(record.choice, 'refusal'), choice.refusal.explanation); - yield deltaFrame(record.choice, { refusal: choice.refusal.explanation }, tier); + if (choice.refusal_details?.explanation != null) { + appendContent(options.roundTrip.builder, textState(record.choice, 'refusal'), choice.refusal_details.explanation); + yield deltaFrame(record.choice, { refusal: choice.refusal_details.explanation }, tier); } const summary = nativeAccumulator(record.choice).result().choices.find(choice => choice.index === record.choice)?.message.reasoning_text; if (typeof summary === 'string' && summary !== '') { diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/usage.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/usage.ts index c6ad0844aa..029893fee6 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/usage.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/usage.ts @@ -1,9 +1,9 @@ -import type { IRUsage } from '../../../ir/types.ts'; +import type { IROutputUsage } from '../../../ir/output/types.ts'; import { requireTokenCount } from '../../../shared/token-count.ts'; import type { OpenAIChatCompletionsUsageEx } from '@floway-dev/protocols/openai-chat-completions'; -export const usageFromIR = (usage: IRUsage): OpenAIChatCompletionsUsageEx => { - const count = (key: keyof IRUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); +export const usageFromIR = (usage: IROutputUsage): OpenAIChatCompletionsUsageEx => { + const count = (key: keyof IROutputUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); const input = count('input_tokens_inclusive') ?? 0; const output = count('output_tokens_inclusive') ?? 0; const inputDetails: NonNullable = {}; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts index 26e65ac120..534a901812 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts @@ -1,14 +1,10 @@ import { toOpenAIChatCompletionsInputAssistantTurn, type OpenAIChatCompletionsOutputAssistantState } from './assistant-turn.ts'; import { usageToIR } from './usage.ts'; -import { - createIRBuilder, - type IRRecord, - type IRItemHandle, - type IRPartHandle, - type IRWriteHandle, - type IRRelativePath, -} from '../../../ir/stream.ts'; -import { IRAbsent, type IRErrorPayload, type IRSourceCitation } from '../../../ir/types.ts'; +import { IRAbsent } from '../../../ir/output/absent.ts'; +import { createIRBuilder, type IRItemHandle, type IRPartHandle, type IRWriteHandle, type IRRelativePath } from '../../../ir/output/builder.ts'; +import type { IRErrorPayload } from '../../../ir/output/error.ts'; +import type { IRRecord } from '../../../ir/output/records.ts'; +import type { IROutputSourceCitation } from '../../../ir/output/types.ts'; import { appendField, createFieldState, type FieldStates, type FieldState, type ReferencePieceId } from '../../../round-trip/reference/reference-builder.ts'; import type { RoundTripReader } from '../../../round-trip/stream.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; @@ -226,12 +222,12 @@ export const irFromOpenAIChatCompletions = async function* ( } const start = Math.min(native.start_index, availableCodePoints); const end = Math.min(native.end_index, availableCodePoints); - return { annotation, range: state.coordinateIndex!.codePointRangeToIR(start, end), exceedsAvailableText: native.end_index > availableCodePoints }; + return { annotation, range: state.coordinateIndex!.codePointRangeToUTF16(start, end), exceedsAvailableText: native.end_index > availableCodePoints }; }); // Only the message that is still open can receive citations; earlier parts already ended. const openSegments = state.message?.isOpen === true ? state.textSegments.filter(segment => segment.item === state.message) : []; for (const segment of openSegments) { - const converted: IRSourceCitation[] = citations.flatMap(({ annotation, range }) => { + const converted: IROutputSourceCitation[] = citations.flatMap(({ annotation, range }) => { const start = Math.max(range.start, segment.start); const end = Math.min(range.end_exclusive, segment.end); if (start >= end) return []; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/usage.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/usage.ts index d140be125c..b0efbed8d8 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/usage.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/usage.ts @@ -1,11 +1,11 @@ -import type { IRUsage } from '../../../ir/types.ts'; +import type { IROutputUsage } from '../../../ir/output/types.ts'; import { requireTokenCount } from '../../../shared/token-count.ts'; import { splitInclusiveInputTokens } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsUsageEx } from '@floway-dev/protocols/openai-chat-completions'; -export const usageToIR = (usage: Partial): IRUsage => { - const result: Partial> = {}; - const put = (key: keyof IRUsage, value: number | undefined | null): void => { if (value != null) result[key] = requireTokenCount(key, value); }; +export const usageToIR = (usage: Partial): IROutputUsage => { + const result: Partial> = {}; + const put = (key: keyof IROutputUsage, value: number | undefined | null): void => { if (value != null) result[key] = requireTokenCount(key, value); }; put('input_tokens_inclusive', usage.prompt_tokens); put('output_tokens_inclusive', usage.completion_tokens); put('total_tokens', usage.total_tokens); diff --git a/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts b/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts index d58668c45b..623e1fa547 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/round-trip.ts @@ -1,4 +1,4 @@ -import type { IRJSONObject, IRJSONValue } from '../../../ir/types.ts'; +import type { IRJSONObject, IRJSONValue } from '../../../ir/common/json.ts'; import type { ReplayContent } from '../../../round-trip/reference/assistant-turn.ts'; import type { EncodedRoundTripSidecar } from '../../../round-trip/resolve.ts'; import { collectTextGroups, collectTextPlacementIndex, type TextContribution, type TextPlacementIndex } from '../../../round-trip/text-groups.ts'; diff --git a/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts b/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts index b08ce80dfe..78b2c93993 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts @@ -2,12 +2,15 @@ import { restoreNamespaceOutputItem, type NamespaceToolNames } from './namespace import { createRandomOpenAIResponsesResponseId } from './response-id.ts'; import { collectOpenAIResponsesTextViews, computeOpenAIResponsesTurnDigest, createOpenAIResponsesSidecarCarrier } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; -import type { IRCollector } from '../../../ir/collector.ts'; -import { irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/operation-paths.ts'; -import { createIROrdinalMapper } from '../../../ir/ordinal-mapper.ts'; -import { consumeIRRecords, type IRPath, type IRRecord } from '../../../ir/stream.ts'; -import { IRAbsent, type IR, type IRItem, type IRMessageItem, type IRSourceCitation, type IRTokenLogprob } from '../../../ir/types.ts'; -import { createIRUnitQueue, createIRUnitRegistry } from '../../../ir/unit-queue.ts'; +import { IRAbsent } from '../../../ir/output/absent.ts'; +import type { IRCollector } from '../../../ir/output/collector.ts'; +import { irPathKey, isIRItemPath, isIRPartPath } from '../../../ir/output/operation-paths.ts'; +import type { IRPath, IRRecord } from '../../../ir/output/records.ts'; +import { consumeIRRecords } from '../../../ir/output/stream.ts'; +import type { IROutput, IROutputItem, IROutputMessageItem, IROutputSourceCitation, IROutputTokenLogprob } from '../../../ir/output/types.ts'; +import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; +import { createIRUnitQueue } from '../../../ir/output/unit-queue.ts'; +import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; import { appendContent, createContentState, type ContentState, type ContentStates } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; @@ -29,7 +32,7 @@ import { interface ResponsesOutputUnitBase { readonly path: IRPath; readonly parts: IRPath[]; - readonly contentOrdinals: ReturnType; + readonly contentOrdinals: ReturnType; readonly annotationProgress: Map; readonly coordinateIndexes: Map; pendingText?: Map; @@ -38,7 +41,6 @@ interface ResponsesOutputUnitBase { id?: string; callId?: string; status?: 'completed' | 'incomplete'; - nextPending?: ResponsesOutputUnit; } type ResponsesOutputUnit = ResponsesOutputUnitBase & ( @@ -53,7 +55,7 @@ type OpenAIResponsesSSEOutputOptions = RoundTripOutputOptions<'openaiResponses'> const IMAGE_OUTPUT_FORMATS = { 'image/png': 'png', 'image/jpeg': 'jpeg', 'image/webp': 'webp' } as const; type IRWriteRecord = Extract; -type IRToolCallItem = Extract; +type IRToolCallItem = Extract; // The item an opened unit announces, and the events that bring it up to the content IR already holds. interface OpenedUnit { readonly added: OpenAIResponsesOutputItemEx; @@ -61,7 +63,7 @@ interface OpenedUnit { } type ReasoningField = 'summary' | 'content'; -type MessagePart = IRMessageItem['content'][number]; +type MessagePart = IROutputMessageItem['content'][number]; type ResponsesMessageState = OpenAIResponsesItemState, ContentStates>; type ResponsesReasoningState = OpenAIResponsesItemState, ContentStates>; @@ -69,7 +71,7 @@ type ResponsesReasoningState = OpenAIResponsesItemState> => typeof value === 'object' && value !== null && !Array.isArray(value); -const initializedType = (value: unknown): IRItem['type'] | MessagePart['type'] | undefined => { +const initializedType = (value: unknown): IROutputItem['type'] | MessagePart['type'] | undefined => { if (!isRecord(value)) return undefined; return value.type === 'message' || value.type === 'reasoning' @@ -92,7 +94,7 @@ const textPartLogprobMetadata = (value: unknown): TextPartLogprobMetadata | unde : undefined; }; -const isIRTokenLogprob = (value: unknown): value is IRTokenLogprob => +const isIRTokenLogprob = (value: unknown): value is IROutputTokenLogprob => isRecord(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; const reasoningPartAdded = (field: ReasoningField, itemId: string, outputIndex: number, index: number): OpenAIResponsesStreamEventEx => @@ -168,7 +170,7 @@ const emptyContentPart = (type: 'text' | 'refusal'): OpenAIResponsesOutputConten : { type: 'refusal', refusal: '' }; // The text or refusal part a message unit covers; other part kinds never join a message unit. -const textOrRefusalPart = (state: IR, path: IRPath): Extract => { +const textOrRefusalPart = (state: IROutput, path: IRPath): Extract => { const item = state.choices[0].items[path[3] as number]; if (item.type !== 'message') throw new TypeError('Responses content unit requires a message item'); const part = item.content[path[5] as number]; @@ -249,10 +251,10 @@ export const openaiResponsesFromIR = async function* ( return index; }; - const convertAnnotation = (coordinateIndex: () => TextCoordinateIndex, annotation: IRSourceCitation): OpenAIResponsesAnnotation | undefined => { + const convertAnnotation = (coordinateIndex: () => TextCoordinateIndex, annotation: IROutputSourceCitation): OpenAIResponsesAnnotation | undefined => { if ((annotation.source_kind === 'url' || annotation.source_kind === 'search_result') && annotation.source != null) { if (annotation.output_text_range === undefined) return undefined; - const range = coordinateIndex().irRangeToCodePoints(annotation.output_text_range); + const range = coordinateIndex().utf16RangeToCodePoints(annotation.output_text_range); return { type: 'url_citation', url: annotation.source, @@ -284,11 +286,11 @@ export const openaiResponsesFromIR = async function* ( return unit.outputIndex; }; - const contentPart = (state: IR, unit: ResponsesOutputUnit, path: IRPath): OpenAIResponsesOutputRefusal | Required => { + const contentPart = (state: IROutput, unit: ResponsesOutputUnit, path: IRPath): OpenAIResponsesOutputRefusal | Required => { const part = textOrRefusalPart(state, path); if (part.type === 'refusal') return { type: 'refusal', refusal: part.refusal }; const logprobGroup = logprobGroups.get(irPathKey(path)); - let logprobs: readonly IRTokenLogprob[] = []; + let logprobs: readonly IROutputTokenLogprob[] = []; if (logprobGroup !== undefined) { const group = state.choices[0].logprobs?.[logprobGroup]; if (group === undefined) throw new TypeError(`Responses logprob group ${logprobGroup} is missing`); @@ -297,7 +299,7 @@ export const openaiResponsesFromIR = async function* ( return { type: 'output_text', text: part.text, annotations: convertAnnotations(unit, path, part), logprobs: [...logprobs] }; }; - const outputItem = (state: IR, unit: ResponsesOutputUnit): OpenAIResponsesOutputItemEx => { + const outputItem = (state: IROutput, unit: ResponsesOutputUnit): OpenAIResponsesOutputItemEx => { const itemIndex = unit.path[3]; if (typeof itemIndex !== 'number') throw new TypeError('Responses output unit has no item index'); const item = state.choices[0].items[itemIndex]; @@ -310,7 +312,7 @@ export const openaiResponsesFromIR = async function* ( const part = item.content[unit.path[5] as number]; if (part.type !== 'image') throw new TypeError('Responses image unit requires an image part'); if (status !== 'completed') throw new TypeError('Responses image unit can only complete'); - const outputFormat = part.image.mime_type === undefined ? undefined : IMAGE_OUTPUT_FORMATS[part.image.mime_type as keyof typeof IMAGE_OUTPUT_FORMATS]; + const outputFormat = part.image.media_type === undefined ? undefined : IMAGE_OUTPUT_FORMATS[part.image.media_type as keyof typeof IMAGE_OUTPUT_FORMATS]; return { type: 'image_generation_call', id, status, result: part.image.data, ...(outputFormat === undefined ? {} : { output_format: outputFormat }) }; } if (unit.kind === 'reasoning') { @@ -330,7 +332,7 @@ export const openaiResponsesFromIR = async function* ( return { type: 'function_call', id, call_id: unit.callId, name: item.name, arguments: item.arguments, status }; }; - const response = (state: IR, status: OpenAIResponsesResultEx['status'], usage: OpenAIResponsesResultEx['usage']): OpenAIResponsesResultEx => ({ + const response = (state: IROutput, status: OpenAIResponsesResultEx['status'], usage: OpenAIResponsesResultEx['usage']): OpenAIResponsesResultEx => ({ id: metadata.id, object: 'response', created_at: metadata.created, @@ -344,7 +346,7 @@ export const openaiResponsesFromIR = async function* ( }); // Creates the output units that the IR structures initialized by a record will become. - const registerUnits = (record: IRRecord, state: IR): void => { + const registerUnits = (record: IRRecord, state: IROutput): void => { if (record.type === 'write' && record.kind === 'initialize') { if (record.path[0] === 'choices' && record.path[1] !== 0) throw new Error('Responses cannot represent multiple choices'); if (record.path.length === 4 && record.path[0] === 'choices' && record.path[2] === 'logprobs') { @@ -361,7 +363,7 @@ export const openaiResponsesFromIR = async function* ( const unit: ResponsesOutputUnitBase = { path: record.path, parts: [], - contentOrdinals: createIROrdinalMapper(), + contentOrdinals: createIRUnitIndexAllocator(), annotationProgress: new Map(), coordinateIndexes: new Map(), opened: false, @@ -387,7 +389,7 @@ export const openaiResponsesFromIR = async function* ( path: record.path, kind: 'message', parts: [], - contentOrdinals: createIROrdinalMapper(), + contentOrdinals: createIRUnitIndexAllocator(), annotationProgress: new Map(), coordinateIndexes: new Map(), contentState: {}, @@ -403,7 +405,7 @@ export const openaiResponsesFromIR = async function* ( path: record.path, kind: 'image', parts: [record.path], - contentOrdinals: createIROrdinalMapper(), + contentOrdinals: createIRUnitIndexAllocator(), annotationProgress: new Map(), coordinateIndexes: new Map(), opened: false, @@ -413,7 +415,7 @@ export const openaiResponsesFromIR = async function* ( } }; - const openMessageUnit = (unit: ResponsesOutputUnit, state: IR, outputIndex: number): OpenedUnit => { + const openMessageUnit = (unit: ResponsesOutputUnit, state: IROutput, outputIndex: number): OpenedUnit => { const id = createRandomOpenAIResponsesItemId('message'); unit.id = id; const followUps: OpenAIResponsesStreamEventEx[] = []; @@ -464,7 +466,7 @@ export const openaiResponsesFromIR = async function* ( return { added: { type: 'image_generation_call', id, status: 'in_progress', result: null }, followUps: [] }; }; - const openReasoningUnit = (unit: ResponsesOutputUnit, item: IRItem, outputIndex: number): OpenedUnit => { + const openReasoningUnit = (unit: ResponsesOutputUnit, item: IROutputItem, outputIndex: number): OpenedUnit => { const reasoning = item.type === 'reasoning' ? item : undefined; const id = createRandomOpenAIResponsesItemId('reasoning'); unit.id = id; @@ -502,7 +504,7 @@ export const openaiResponsesFromIR = async function* ( }; // Opens the waiting units in IR order, until one still lacks what its opening event needs. - const openPendingUnits = (state: IR, collector: IRCollector): { events: OpenAIResponsesStreamEventEx[]; justOpened: Set } => { + const openPendingUnits = (state: IROutput, collector: IRCollector): { events: OpenAIResponsesStreamEventEx[]; justOpened: Set } => { const justOpened = new Set(); const pendingEvents: OpenAIResponsesStreamEventEx[] = []; while (pending.head !== undefined) { @@ -511,11 +513,11 @@ export const openaiResponsesFromIR = async function* ( let tool: { item: IRToolCallItem; callId: string | typeof IRAbsent } | undefined; if (unit.kind === 'function_call' || unit.kind === 'custom_tool_call') { if ((item.type !== 'function_call' && item.type !== 'custom_tool_call') || item.name === '') throw new TypeError('Completed tool calls require a name'); - const callId = collector.optional([...unit.path, 'call_id']); + const callId = collector.valueOrAbsent([...unit.path, 'call_id']); if (callId === undefined) break; tool = { item, callId }; } - pending.dequeue(); + pending.dropHead(); const outputIndex = outputUnits.length; unit.outputIndex = outputIndex; if (unit.kind === 'message' || unit.kind === 'reasoning') nativeState[outputIndex] = unit.contentState; @@ -531,7 +533,7 @@ export const openaiResponsesFromIR = async function* ( return { events: pendingEvents, justOpened }; }; - const messageUnitEvents = (unit: ResponsesOutputUnit, outputIndex: number, record: IRWriteRecord, state: IR): OpenAIResponsesStreamEventEx[] => { + const messageUnitEvents = (unit: ResponsesOutputUnit, outputIndex: number, record: IRWriteRecord, state: IROutput): OpenAIResponsesStreamEventEx[] => { const itemId = requireId(unit); if (record.kind === 'initialize' && record.path.length === 6) { const type = initializedType(record.value); @@ -600,7 +602,7 @@ export const openaiResponsesFromIR = async function* ( }; // The events for an operation on a unit that is already open. - const openedUnitEvents = (unit: ResponsesOutputUnit, record: IRWriteRecord, state: IR): OpenAIResponsesStreamEventEx[] => { + const openedUnitEvents = (unit: ResponsesOutputUnit, record: IRWriteRecord, state: IROutput): OpenAIResponsesStreamEventEx[] => { const outputIndex = requireOutputIndex(unit); switch (unit.kind) { case 'message': return messageUnitEvents(unit, outputIndex, record, state); @@ -799,7 +801,7 @@ export const openaiResponsesFromIR = async function* ( } if (record.type === 'finish') { - const refusal = state.choices[0].refusal; + const refusal = state.choices[0].refusal_details; const finalUsage = state.usage === undefined ? null : usageFromIR(state.usage); if (refusal?.category === 'cyber' || refusal?.category === 'bio') { const declined = refusal.explanation == null || refusal.explanation.trim() === ''; diff --git a/packages/translate/src/protocols/openai-responses/as-source/usage.ts b/packages/translate/src/protocols/openai-responses/as-source/usage.ts index b985f13c5d..d8a3f7c195 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/usage.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/usage.ts @@ -1,12 +1,12 @@ -import type { IRUsage } from '../../../ir/types.ts'; +import type { IROutputUsage } from '../../../ir/output/types.ts'; import { requireTokenCount } from '../../../shared/token-count.ts'; import type { OpenAIResponsesResultEx } from '@floway-dev/protocols/openai-responses'; type OpenAIResponsesUsage = NonNullable; type OpenAIResponsesInputTokensDetails = NonNullable; -export const usageFromIR = (usage: IRUsage): OpenAIResponsesUsage => { - const count = (key: keyof IRUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); +export const usageFromIR = (usage: IROutputUsage): OpenAIResponsesUsage => { + const count = (key: keyof IROutputUsage): number | undefined => usage[key] === undefined ? undefined : requireTokenCount(key, usage[key]); const input = count('input_tokens_inclusive') ?? 0; const output = count('output_tokens_inclusive') ?? 0; const inputDetails: Partial = {}; diff --git a/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts b/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts index 8521bdc743..3eba5a9b29 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts @@ -1,14 +1,8 @@ import { toOpenAIResponsesInputAssistantTurn, type OpenAIResponsesItemState, type OpenAIResponsesOutputAssistantState } from './assistant-turn.ts'; import { usageToIR } from './usage.ts'; -import { - createIRBuilder, - type IRRecord, - type IRItemHandle, - type IRPartHandle, - type IRRelativePath, - type IRWriteHandle, -} from '../../../ir/stream.ts'; -import type { IRSourceCitation } from '../../../ir/types.ts'; +import { createIRBuilder, type IRItemHandle, type IRPartHandle, type IRRelativePath, type IRWriteHandle } from '../../../ir/output/builder.ts'; +import type { IRRecord } from '../../../ir/output/records.ts'; +import type { IROutputSourceCitation } from '../../../ir/output/types.ts'; import { appendField, createFieldState, invalidateField, type FieldStates } from '../../../round-trip/reference/reference-builder.ts'; import type { RoundTripReader } from '../../../round-trip/stream.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; @@ -98,14 +92,14 @@ const closedSnapshotEqual = (left: OpenAIResponsesOutputItemEx, right: OpenAIRes return deepEqual(leftSnapshot, rightSnapshot); }; -const annotationToIR = (coordinateIndex: () => TextCoordinateIndex, annotation: OpenAIResponsesAnnotation): IRSourceCitation | undefined => { +const annotationToIR = (coordinateIndex: () => TextCoordinateIndex, annotation: OpenAIResponsesAnnotation): IROutputSourceCitation | undefined => { if (annotation.type === 'url_citation') { return { type: 'source_citation', source_kind: 'url', source: annotation.url, source_label: annotation.title, - output_text_range: coordinateIndex().codePointRangeToIR(annotation.start_index, annotation.end_index), + output_text_range: coordinateIndex().codePointRangeToUTF16(annotation.start_index, annotation.end_index), }; } if (annotation.type === 'file_citation') { @@ -117,7 +111,7 @@ const annotationToIR = (coordinateIndex: () => TextCoordinateIndex, annotation: source_kind: 'document', source: annotation.file_id, source_label: annotation.filename, - output_text_range: coordinateIndex().codePointRangeToIR(annotation.start_index, annotation.end_index), + output_text_range: coordinateIndex().codePointRangeToUTF16(annotation.start_index, annotation.end_index), }; } if (annotation.type === 'file_path') return { type: 'source_citation', source_kind: 'document', source: annotation.file_id }; @@ -246,7 +240,7 @@ export const irFromOpenAIResponses = async function* ( }; const reconcileAppend = (target: ResponsesTextTarget, snapshot: unknown): string => { const { handle, path } = target; - const absolutePath = handle.resolve(path); + const absolutePath = handle.absolutePath(path); if (typeof snapshot !== 'string') throw new TypeError(`Responses text snapshot must be a string at ${JSON.stringify(absolutePath)}`); const current = handle.current(path); // Responses done events are final snapshots in the official SDK. Downstream @@ -285,7 +279,7 @@ export const irFromOpenAIResponses = async function* ( const current = handle.current(path); if (current === undefined) handle.assign(path, snapshot); else if (!deepEqual(current, snapshot)) { - throw new TypeError(`Responses snapshot conflicts with streamed value at ${JSON.stringify(handle.resolve(path))}`); + throw new TypeError(`Responses snapshot conflicts with streamed value at ${JSON.stringify(handle.absolutePath(path))}`); } }; @@ -515,7 +509,7 @@ export const irFromOpenAIResponses = async function* ( if (state.parts.size === 0) { const handle = state.item.part({ type: 'image', - image: { data: item.result, ...(item.output_format == null ? {} : { mime_type: `image/${item.output_format}` }) }, + image: { data: item.result, ...(item.output_format == null ? {} : { media_type: `image/${item.output_format}` }) }, }); state.parts.set(0, { type: 'image', handle }); } else { @@ -524,7 +518,7 @@ export const irFromOpenAIResponses = async function* ( const { handle } = part; reconcileAssign({ handle, path: ['image', 'data'] }, item.result); if (item.output_format != null) { - reconcileAssign({ handle, path: ['image', 'mime_type'] }, `image/${item.output_format}`); + reconcileAssign({ handle, path: ['image', 'media_type'] }, `image/${item.output_format}`); } } } @@ -601,7 +595,7 @@ export const irFromOpenAIResponses = async function* ( audioItem.end(); } const policy = response.error?.code === 'cyber_policy' ? 'cyber' : response.error?.code === 'bio_policy' ? 'bio' : undefined; - if (policy !== undefined) ir.choice(0).assign('refusal', { category: policy, explanation: response.error?.message }); + if (policy !== undefined) ir.choice(0).assign('refusal_details', { category: policy, explanation: response.error?.message }); outputState.length = terminalOutput.length; const input = toOpenAIResponsesInputAssistantTurn(terminalOutput, outputState); options.roundTrip.onAssistantTurn(0, input.turn, input.state); diff --git a/packages/translate/src/protocols/openai-responses/as-target/usage.ts b/packages/translate/src/protocols/openai-responses/as-target/usage.ts index 0e00c3a745..fd90b723f0 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/usage.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/usage.ts @@ -1,4 +1,4 @@ -import type { IRUsage } from '../../../ir/types.ts'; +import type { IROutputUsage } from '../../../ir/output/types.ts'; import { requireTokenCount } from '../../../shared/token-count.ts'; import { splitInclusiveInputTokens } from '@floway-dev/protocols/common'; @@ -18,9 +18,9 @@ interface OpenAIResponsesUsageFields { output_tokens_details?: { reasoning_tokens?: number; audio_tokens?: number; text_tokens?: number }; } -export const usageToIR = (usage: OpenAIResponsesUsageFields): IRUsage => { - const result: Partial> = {}; - const put = (key: keyof IRUsage, value: number | undefined | null): void => { if (value != null) result[key] = requireTokenCount(key, value); }; +export const usageToIR = (usage: OpenAIResponsesUsageFields): IROutputUsage => { + const result: Partial> = {}; + const put = (key: keyof IROutputUsage, value: number | undefined | null): void => { if (value != null) result[key] = requireTokenCount(key, value); }; put('input_tokens_inclusive', usage.input_tokens); put('output_tokens_inclusive', usage.output_tokens); put('total_tokens', usage.total_tokens); diff --git a/packages/translate/src/round-trip/reference/assistant-turn.ts b/packages/translate/src/round-trip/reference/assistant-turn.ts index 86cfa647e5..fe11524e10 100644 --- a/packages/translate/src/round-trip/reference/assistant-turn.ts +++ b/packages/translate/src/round-trip/reference/assistant-turn.ts @@ -1,4 +1,4 @@ -import type { IRJSONObject } from '../../ir/types.ts'; +import type { IRJSONObject } from '../../ir/common/json.ts'; import type { AnthropicMessagesAssistantState, AnthropicMessagesReferenceableAssistantTurn } from '../../protocols/anthropic-messages/as-target/assistant-turn.ts'; import type { GeminiGenerateContentAssistantState, GeminiGenerateContentReferenceableAssistantTurn } from '../../protocols/gemini-generate-content/as-target/assistant-turn.ts'; import type { OpenAIChatCompletionsAssistantState, OpenAIChatCompletionsReferenceableAssistantTurn } from '../../protocols/openai-chat-completions/as-target/assistant-turn.ts'; diff --git a/packages/translate/src/round-trip/reference/reference-builder.ts b/packages/translate/src/round-trip/reference/reference-builder.ts index f4a584c816..4bebe685a7 100644 --- a/packages/translate/src/round-trip/reference/reference-builder.ts +++ b/packages/translate/src/round-trip/reference/reference-builder.ts @@ -1,5 +1,5 @@ import { ContentReference, type AssistantTurnStateFor, type ContentReferenceSegment } from './assistant-turn.ts'; -import type { IRJSONObject } from '../../ir/types.ts'; +import type { IRJSONObject } from '../../ir/common/json.ts'; import { parseJSONObject } from '../../shared/json/object.ts'; import type { AssistantTurnByProtocol, AssistantTurnFor } from '../assistant-turn.ts'; import type { TextPlacement, TextPlacementIndex } from '../text-groups.ts'; diff --git a/packages/translate/src/round-trip/reference/reference-selection.ts b/packages/translate/src/round-trip/reference/reference-selection.ts index 87114abb1d..3d8f10818e 100644 --- a/packages/translate/src/round-trip/reference/reference-selection.ts +++ b/packages/translate/src/round-trip/reference/reference-selection.ts @@ -1,6 +1,6 @@ import { ContentReference, type AssistantTurnStateFor, type ContentReferenceSegment, type ReferenceableAssistantTurnFor } from './assistant-turn.ts'; import { createReferenceFinalizer, isFieldState, pairedChildren, type FieldState, type FieldStates, type ReferenceBuilder } from './reference-builder.ts'; -import type { IRJSONObject } from '../../ir/types.ts'; +import type { IRJSONObject } from '../../ir/common/json.ts'; import { requireJSONObject } from '../../shared/json/object.ts'; import { rawJSON } from '../../shared/json/raw-json.ts'; import type { AssistantTurnByProtocol, AssistantTurnFor } from '../assistant-turn.ts'; diff --git a/packages/translate/src/round-trip/turn-digest.ts b/packages/translate/src/round-trip/turn-digest.ts index 065d3b8982..a6688cbfb6 100644 --- a/packages/translate/src/round-trip/turn-digest.ts +++ b/packages/translate/src/round-trip/turn-digest.ts @@ -1,4 +1,4 @@ -import type { IRJSONObject, IRJSONValue } from '../ir/types.ts'; +import type { IRJSONObject, IRJSONValue } from '../ir/common/json.ts'; export type RoundTripTurnDigest = Uint8Array; diff --git a/packages/translate/src/shared/json/object.ts b/packages/translate/src/shared/json/object.ts index 4032f2a5e5..76f05749c7 100644 --- a/packages/translate/src/shared/json/object.ts +++ b/packages/translate/src/shared/json/object.ts @@ -1,5 +1,5 @@ import { rawJSON } from './raw-json.ts'; -import type { IRJSONObject } from '../../ir/types.ts'; +import type { IRJSONObject } from '../../ir/common/json.ts'; import { isJsonObject, parseJSONWithRawNumbers } from '@floway-dev/protocols/common'; export const isPlainJSONObject = (value: unknown): value is IRJSONObject => isJsonObject(value) && !rawJSON.isRawJSON(value); diff --git a/packages/translate/src/shared/text-coordinate-index.ts b/packages/translate/src/shared/text-coordinate-index.ts index 563a955c04..74cc96296c 100644 --- a/packages/translate/src/shared/text-coordinate-index.ts +++ b/packages/translate/src/shared/text-coordinate-index.ts @@ -1,4 +1,4 @@ -import type { IRUTF16TextRange } from '../ir/types.ts'; +import type { TextRange } from './text-range.ts'; type TextCoordinateMode = 'code-point-to-utf16' | 'utf16-to-code-point' | 'utf16-to-utf8'; @@ -118,27 +118,27 @@ export class TextCoordinateIndex { this.#trailingHighSurrogate = isHighSurrogate(delta.charCodeAt(delta.length - 1)); } - codePointRangeToIR(start: number, end: number): IRUTF16TextRange { + codePointRangeToUTF16(start: number, end: number): TextRange { if (this.#mode !== 'code-point-to-utf16') throw new TypeError('Text coordinate index does not map code points to UTF-16'); if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start) throw new RangeError('Invalid code-point range'); if (end >= this.#boundaries.length) throw new RangeError('Code-point range exceeds text'); return { start: this.#boundaries.get(start), end_exclusive: this.#boundaries.get(end) }; } - irRangeToCodePoints(range: IRUTF16TextRange): { start: number; end: number } { + utf16RangeToCodePoints(range: TextRange): { start: number; end: number } { if (this.#mode !== 'utf16-to-code-point') throw new TypeError('Text coordinate index does not map UTF-16 to code points'); const [start, end] = this.#utf16Range(range); return { start, end }; } - irRangeToUTF8(range: IRUTF16TextRange): { start: number; end: number } { + utf16RangeToUTF8(range: TextRange): { start: number; end: number } { if (this.#mode !== 'utf16-to-utf8') throw new TypeError('Text coordinate index does not map UTF-16 to UTF-8'); if (this.#unpairedSurrogates !== 0) throw new TypeError('GenerateContent byte coordinates require well-formed Unicode'); const [start, end] = this.#utf16Range(range); return { start, end }; } - #utf16Range(range: IRUTF16TextRange): readonly [number, number] { + #utf16Range(range: TextRange): readonly [number, number] { if (!Number.isInteger(range.start) || !Number.isInteger(range.end_exclusive) || range.start < 0 || range.end_exclusive < range.start || range.end_exclusive > this.#utf16Length) { throw new RangeError('IR range is outside text or splits a surrogate pair'); diff --git a/packages/translate/src/shared/text-range.ts b/packages/translate/src/shared/text-range.ts new file mode 100644 index 0000000000..add60b4335 --- /dev/null +++ b/packages/translate/src/shared/text-range.ts @@ -0,0 +1,7 @@ +// A range over a JavaScript string, counted in UTF-16 code units so it slices +// the stored value exactly, including isolated surrogates. Native protocols may +// count code points or UTF-8 bytes; their readers convert. +export interface TextRange { + readonly start: number; + readonly end_exclusive: number; +} From cdb444b2e6d69e3e215cca375d03caa6655d285a Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 05:52:48 +0800 Subject: [PATCH 2/5] refactor(translate): finalize output event ownership Use directional events modules and read/write entrypoints for the seven native output adapters, and mirror those owners in their tests. Move the unchanged source writer options shape to its shared owner. Finish schema phantom, absent-string and type-equality names so the input work has no remaining output-only naming migration. Preserve native behavior with 622 named tests, 67 controlled main/final wire and replay probes, 66 runtime/declaration/test-body correspondences, 98 scoped lint targets and the translate TypeScript project. --- packages/translate/AGENTS.md | 6 +- .../{sse-to_test.ts => events_test.ts} | 8 +- .../{sse-from_test.ts => events_test.ts} | 4 +- .../__tests__/protocols/edge-cases_test.ts | 54 ++++++------ .../{sse-to_test.ts => events_test.ts} | 6 +- .../translate/__tests__/protocols/helpers.ts | 44 +++++----- .../__tests__/protocols/lifecycle_test.ts | 60 ++++++------- .../{sse-to_test.ts => events_test.ts} | 6 +- .../{sse-from_test.ts => events_test.ts} | 6 +- .../{sse-to_test.ts => events_test.ts} | 4 +- .../as-source/initialize_test.ts | 4 +- .../{sse-from_test.ts => events_test.ts} | 4 +- .../as-target/snapshots_test.ts | 8 +- .../__tests__/protocols/ownership_test.ts | 34 ++++---- .../__tests__/protocols/streams_test.ts | 86 +++++++++---------- .../__tests__/protocols/usage_test.ts | 20 ++--- packages/translate/src/ir/output/schema.ts | 12 +-- .../as-source/{sse-to.ts => events.ts} | 8 +- .../as-target/{sse-from.ts => events.ts} | 2 +- .../via-openai-chat-completions/events.ts | 6 +- .../via-openai-responses/events.ts | 6 +- .../as-source/{sse-to.ts => events.ts} | 8 +- .../via-anthropic-messages/events.ts | 6 +- .../via-openai-chat-completions/events.ts | 6 +- .../via-openai-responses/events.ts | 6 +- .../as-source/{sse-to.ts => events.ts} | 8 +- .../as-target/{sse-from.ts => events.ts} | 2 +- .../via-anthropic-messages/events.ts | 6 +- .../via-openai-responses/events.ts | 6 +- .../as-source/{sse-to.ts => events.ts} | 8 +- .../as-target/{sse-from.ts => events.ts} | 2 +- .../via-anthropic-messages/events.ts | 6 +- .../via-openai-chat-completions/events.ts | 6 +- packages/translate/src/round-trip/stream.ts | 6 -- .../src/shared/event-writer-options.ts | 8 ++ 35 files changed, 237 insertions(+), 235 deletions(-) rename packages/translate/__tests__/protocols/anthropic-messages/as-source/{sse-to_test.ts => events_test.ts} (90%) rename packages/translate/__tests__/protocols/anthropic-messages/as-target/{sse-from_test.ts => events_test.ts} (93%) rename packages/translate/__tests__/protocols/gemini-generate-content/as-source/{sse-to_test.ts => events_test.ts} (86%) rename packages/translate/__tests__/protocols/openai-chat-completions/as-source/{sse-to_test.ts => events_test.ts} (92%) rename packages/translate/__tests__/protocols/openai-chat-completions/as-target/{sse-from_test.ts => events_test.ts} (95%) rename packages/translate/__tests__/protocols/openai-responses/as-source/{sse-to_test.ts => events_test.ts} (95%) rename packages/translate/__tests__/protocols/openai-responses/as-target/{sse-from_test.ts => events_test.ts} (98%) rename packages/translate/src/protocols/anthropic-messages/as-source/{sse-to.ts => events.ts} (98%) rename packages/translate/src/protocols/anthropic-messages/as-target/{sse-from.ts => events.ts} (99%) rename packages/translate/src/protocols/gemini-generate-content/as-source/{sse-to.ts => events.ts} (98%) rename packages/translate/src/protocols/openai-chat-completions/as-source/{sse-to.ts => events.ts} (98%) rename packages/translate/src/protocols/openai-chat-completions/as-target/{sse-from.ts => events.ts} (99%) rename packages/translate/src/protocols/openai-responses/as-source/{sse-to.ts => events.ts} (99%) rename packages/translate/src/protocols/openai-responses/as-target/{sse-from.ts => events.ts} (99%) create mode 100644 packages/translate/src/shared/event-writer-options.ts diff --git a/packages/translate/AGENTS.md b/packages/translate/AGENTS.md index e2e20bbff5..78a20dd6ce 100644 --- a/packages/translate/AGENTS.md +++ b/packages/translate/AGENTS.md @@ -20,9 +20,9 @@ import the source protocol's subpath and call 5. **Output IR** — keep private types, its path schema, stream operations, validation, accumulation, operation paths, and native unit index allocation under `ir/output/`. Native SSE adapters - belong to their protocol: `as-target/sse-from.ts` consumes upstream SSE and - `as-source/sse-to.ts` emits client SSE with its sidecar. The return path is - upstream SSE → IR SSE → client SSE with sidecar. IR stays internal to translate. + belong to their protocol: `as-target/events.ts` reads upstream protocol events and + `as-source/events.ts` writes client protocol events with its sidecar. The return path is + upstream events → IR records → client events with sidecar. IR stays internal to translate. 6. **Assistant-turn round trips** — keep owner-specific sidecar validation, replay validation, and native stream coordination under `round-trip/`. Source replay inspection and carriers belong in `as-source/round-trip.ts`; native assistant-turn diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/events_test.ts similarity index 90% rename from packages/translate/__tests__/protocols/anthropic-messages/as-source/sse-to_test.ts rename to packages/translate/__tests__/protocols/anthropic-messages/as-source/events_test.ts index 40fe708d69..2f7ea48519 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-source/events_test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; -import { anthropicMessagesFromIR } from '../../../../src/protocols/anthropic-messages/as-source/sse-to.ts'; +import { writeAnthropicMessagesEvents } from '../../../../src/protocols/anthropic-messages/as-source/events.ts'; import { appendField, collectContentViews, createReferenceBuilder, createFieldState, createReferenceFinalizer } from '../../../../src/round-trip/reference/reference-builder.ts'; import type { RoundTripWriter } from '../../../../src/round-trip/stream.ts'; import { hashRoundTripContent } from '../../../../src/round-trip/turn-digest.ts'; @@ -42,7 +42,7 @@ test.each(['text', 'refusal', 'audio'] as const)('resolves blocked %s against th }, }; const events: AnthropicMessagesStreamEventEx[] = []; - for await (const frame of anthropicMessagesFromIR((async function* () { yield* source.drain(); })(), { roundTrip })) events.push(frame.event); + for await (const frame of writeAnthropicMessagesEvents((async function* () { yield* source.drain(); })(), { roundTrip })) events.push(frame.event); expect(events.flatMap(event => event.type === 'content_block_delta' && event.delta.type === 'text_delta' ? [event.delta.text] : [])).toEqual(['Hi']); expect(prepared).toBe(true); }); @@ -78,7 +78,7 @@ test('resolves blocked reasoning in emitted summary/content order before live ap prepareSidecar: async (...args) => { prepared = args; return 'sidecar'; }, }; const events: AnthropicMessagesStreamEventEx[] = []; - for await (const frame of anthropicMessagesFromIR((async function* () { yield* source.drain(); })(), { roundTrip })) events.push(frame.event); + for await (const frame of writeAnthropicMessagesEvents((async function* () { yield* source.drain(); })(), { roundTrip })) events.push(frame.event); expect(events.flatMap(event => event.type === 'content_block_delta' && event.delta.type === 'thinking_delta' ? [event.delta.thinking] : [])).toEqual(['ABX', 'C']); if (prepared === undefined) throw new Error('Messages sidecar was not prepared'); const [, , turn, state] = prepared; @@ -114,6 +114,6 @@ test.each(['{"x":1,}', '{"x": 1}'])('tracks the emitted canonical JSON view with return 'sidecar'; }, }; - for await (const _frame of anthropicMessagesFromIR((async function* () { yield* source.drain(); })(), { roundTrip, parseToolArguments: original.endsWith(',}') ? () => ({ x: 1 }) : undefined })) {} + for await (const _frame of writeAnthropicMessagesEvents((async function* () { yield* source.drain(); })(), { roundTrip, parseToolArguments: original.endsWith(',}') ? () => ({ x: 1 }) : undefined })) {} expect(prepared).toBe(true); }); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-target/sse-from_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-target/events_test.ts similarity index 93% rename from packages/translate/__tests__/protocols/anthropic-messages/as-target/sse-from_test.ts rename to packages/translate/__tests__/protocols/anthropic-messages/as-target/events_test.ts index cff11a8ab1..eab9b2ffb8 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-target/sse-from_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-target/events_test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; import type { AnthropicMessagesAssistantState, AnthropicMessagesInputAssistantTurn } from '../../../../src/protocols/anthropic-messages/as-target/assistant-turn.ts'; -import { irFromAnthropicMessages } from '../../../../src/protocols/anthropic-messages/as-target/sse-from.ts'; +import { readAnthropicMessagesEvents } from '../../../../src/protocols/anthropic-messages/as-target/events.ts'; import { appendContent, collectContentViews, createContentState, createReferenceBuilder, type FieldStates } from '../../../../src/round-trip/reference/reference-builder.ts'; import { finalizeAssistantTurnReferences } from '../../../../src/round-trip/reference/reference-selection.ts'; import { fixtureFrames } from '../../fixtures.ts'; @@ -26,7 +26,7 @@ test('keeps native-shaped field state aligned through sparse block ordering and const builder = createReferenceBuilder(); const content = createContentState(builder, 'text'); let captured: { turn: AnthropicMessagesInputAssistantTurn; state: AnthropicMessagesAssistantState } | undefined; - for await (const record of irFromAnthropicMessages((async function* () { yield* frames; })(), { + for await (const record of readAnthropicMessagesEvents((async function* () { yield* frames; })(), { roundTrip: { builder, onAssistantTurn: (_choice, turn, state) => { captured = { turn, state }; } }, })) { if (record.type === 'write' && record.kind === 'append' && record.path.at(-1) === 'text') appendContent(builder, content, record.value, record.referencePieceIds); diff --git a/packages/translate/__tests__/protocols/edge-cases_test.ts b/packages/translate/__tests__/protocols/edge-cases_test.ts index 28ffbb2025..41e83be185 100644 --- a/packages/translate/__tests__/protocols/edge-cases_test.ts +++ b/packages/translate/__tests__/protocols/edge-cases_test.ts @@ -1,14 +1,14 @@ import { expect, test, vi } from 'vitest'; import { fixtureFrames, nativeResult } from './fixtures.ts'; -import { anthropicMessagesFromIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; +import { writeAnthropicMessagesEvents, writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; import { createIRBuilder } from '../../src/ir/output/builder.ts'; import { translateToSourceEvents as messagesViaChat } from '../../src/protocols/anthropic-messages/via-openai-chat-completions/events.ts'; import { buildTargetRequest as messagesRequestViaChat } from '../../src/protocols/anthropic-messages/via-openai-chat-completions/request.ts'; import { translateToSourceEvents as messagesViaResponses } from '../../src/protocols/anthropic-messages/via-openai-responses/events.ts'; import { translateToSourceEvents as generateContentViaChat } from '../../src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts'; import { translateToSourceEvents as generateContentViaResponses } from '../../src/protocols/gemini-generate-content/via-openai-responses/events.ts'; -import { irFromOpenAIChatCompletions as irFromOpenAIChatCompletionsProduction } from '../../src/protocols/openai-chat-completions/as-target/sse-from.ts'; +import { readOpenAIChatCompletionsEvents as readOpenAIChatCompletionsEventsProduction } from '../../src/protocols/openai-chat-completions/as-target/events.ts'; import { translateToSourceEvents as responsesViaMessages } from '../../src/protocols/openai-responses/via-anthropic-messages/events.ts'; import { translateToSourceEvents as responsesViaChat } from '../../src/protocols/openai-responses/via-openai-chat-completions/events.ts'; import { collectIR, completeIR } from '../ir/output/helpers.ts'; @@ -48,7 +48,7 @@ test('rate limiting becomes a native GenerateContent error', async () => { }); test('ChatCompletions ignores empty identity frames and starts when a model appears', async () => { - const frames = await collect(irFromOpenAIChatCompletions(iterate([ + const frames = await collect(readOpenAIChatCompletionsEvents(iterate([ eventFrame({ id: '', object: '', created: 0, model: '', choices: [], prompt_filter_results: [{}], usage: null } as any), eventFrame({ id: 'chat-real', object: 'chat.completion.chunk', created: 10, model: 'served', choices: [] } as any), eventFrame({ id: 'chat-real', object: 'chat.completion.chunk', created: 10, model: 'served', choices: [{ index: 0, delta: { content: 'answer' }, finish_reason: null }] } as any), @@ -59,7 +59,7 @@ test('ChatCompletions ignores empty identity frames and starts when a model appe }); test('ChatCompletions rejects generated content before any non-empty model', async () => { - await expect(collectIR(irFromOpenAIChatCompletions(iterate([ + await expect(collectIR(readOpenAIChatCompletionsEvents(iterate([ eventFrame({ id: '', object: '', created: 0, model: '', choices: [] } as any), eventFrame({ id: '', object: '', created: 0, model: '', choices: [{ index: 0, delta: { content: 'answer' }, finish_reason: null }] } as any), ])))).rejects.toThrow('before a non-empty model'); @@ -67,7 +67,7 @@ test('ChatCompletions rejects generated content before any non-empty model', asy test('ChatCompletions role-only choices still require finish_reason without creating IR records', async () => { const role = eventFrame({ id: 'chat_role', model: 'served', choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] } as any); - await expect(collectIR(irFromOpenAIChatCompletions(iterate([role])))).rejects.toThrow('ended without finish_reason'); + await expect(collectIR(readOpenAIChatCompletionsEvents(iterate([role])))).rejects.toThrow('ended without finish_reason'); let reached!: () => void; let release!: () => void; @@ -80,7 +80,7 @@ test('ChatCompletions role-only choices still require finish_reason without crea yield eventFrame({ id: 'chat_role', model: 'served', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] } as any); }; const output: any[] = []; - const run = (async () => { for await (const record of irFromOpenAIChatCompletions(source())) output.push(record); })(); + const run = (async () => { for await (const record of readOpenAIChatCompletionsEvents(source())) output.push(record); })(); await atGate; expect(output.some(record => record.type === 'write' && record.path[0] === 'choices')).toBe(false); release(); @@ -89,13 +89,13 @@ test('ChatCompletions role-only choices still require finish_reason without crea test.each([['id', 'an id'], ['model', 'a model']] as const)('Messages message_start requires a string %s', async (field, expected) => { const message = { id: 'msg', model: 'claude-test', content: [], usage: { input_tokens: 0, output_tokens: 0 }, [field]: 42 }; - await expect(collectIR(irFromAnthropicMessages(iterate([ + await expect(collectIR(readAnthropicMessagesEvents(iterate([ eventFrame({ type: 'message_start', message } as any), ])))).rejects.toThrow(`requires ${expected}`); }); test('Messages takes tool input only from input_json_delta', async () => { - const ir = await collectIR(irFromAnthropicMessages(iterate([ + const ir = await collectIR(readAnthropicMessagesEvents(iterate([ eventFrame({ type: 'message_start', message: { id: 'msg', model: 'claude-test', content: [], usage: { input_tokens: 0, output_tokens: 0 } } } as any), eventFrame({ type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'call', name: 'lookup', input: { ignored: true } } } as any), eventFrame({ type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '{"q":1}' } } as any), @@ -112,46 +112,46 @@ test('ChatCompletions ignores late identity changes and accepts later model upda eventFrame({ id: 'chat-late', object: 'other', created: 20, model: 'fallback', choices: [{ index: 0, delta: { content: 'B' }, finish_reason: null }] } as any), eventFrame({ id: '', object: '', created: 0, model: '', choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] } as any), ]; - const frames = await collect(irFromOpenAIChatCompletions(iterate(input))); + const frames = await collect(readOpenAIChatCompletionsEvents(iterate(input))); const start = frames.find(record => record.type === 'start'); expect(start).toEqual({ type: 'start', id: 'chat-first', created: 10 }); - expect((await collectIR(irFromOpenAIChatCompletions(iterate(input)))).model).toBe('fallback'); + expect((await collectIR(readOpenAIChatCompletionsEvents(iterate(input)))).model).toBe('fallback'); }); test('ChatCompletions choice indexes must fit the request choice count', async () => { - await expect(collectIR(irFromOpenAIChatCompletions(iterate([ + await expect(collectIR(readOpenAIChatCompletionsEvents(iterate([ eventFrame({ id: 'chat_choices', model: 'served', choices: [{ index: 1, delta: {}, finish_reason: 'stop' }] } as any), ]), { maxChoices: 1 }))).rejects.toThrow('configured choice count'); }); test('Responses rejects output events before the initial envelope', async () => { - await expect(collectIR(irFromOpenAIResponses(iterate([ + await expect(collectIR(readOpenAIResponsesEvents(iterate([ eventFrame({ type: 'response.output_item.added', output_index: 0, item: { type: 'message', id: 'msg', role: 'assistant', content: [] } } as any), ])))).rejects.toThrow('before the initial response envelope'); }); test('Messages and GenerateContent reject custom tool calls they cannot represent', async () => { const frames = completeIR([{ type: 'custom_tool_call', call_id: 'call_custom', name: 'run_code', input: 'input' }]); - await expect(collect(anthropicMessagesFromIR(iterate(frames)))).rejects.toThrow('cannot represent custom tool calls'); - await expect(collect(geminiGenerateContentFromIR(iterate(frames)))).rejects.toThrow('cannot represent custom tool calls'); + await expect(collect(writeAnthropicMessagesEvents(iterate(frames)))).rejects.toThrow('cannot represent custom tool calls'); + await expect(collect(writeGeminiGenerateContentEvents(iterate(frames)))).rejects.toThrow('cannot represent custom tool calls'); }); test('writers omit output they cannot represent', async () => { const image = completeIR([{ type: 'message', content: [{ type: 'text', text: 'before' }, { type: 'image', image: { data: 'aW1n', media_type: 'image/png' } }] }]); - expect(JSON.stringify(await collect(anthropicMessagesFromIR(iterate(image))))).toContain('before'); - expect(JSON.stringify(await collect(openaiChatCompletionsFromIR(iterate(image))))).toContain('before'); + expect(JSON.stringify(await collect(writeAnthropicMessagesEvents(iterate(image))))).toContain('before'); + expect(JSON.stringify(await collect(writeOpenAIChatCompletionsEvents(iterate(image))))).toContain('before'); const documentCitation = completeIR([{ type: 'message', content: [{ type: 'text', text: 'source', annotations: [{ type: 'source_citation', source_kind: 'document', source: 'file_1', source_label: 'Document', output_text_range: { start: 0, end_exclusive: 6 } }] }] }]); - expect(JSON.stringify(await collect(openaiChatCompletionsFromIR(iterate(documentCitation))))).not.toContain('url_citation'); + expect(JSON.stringify(await collect(writeOpenAIChatCompletionsEvents(iterate(documentCitation))))).not.toContain('url_citation'); const ranged = completeIR([{ type: 'message', content: [{ type: 'text', text: 'source', annotations: [{ type: 'source_citation', source_kind: 'url', source: 'https://example.com', output_text_range: { start: 0, end_exclusive: 6 } }] }] }]); - expect(JSON.stringify(await collect(anthropicMessagesFromIR(iterate(ranged))))).toContain('source'); + expect(JSON.stringify(await collect(writeAnthropicMessagesEvents(iterate(ranged))))).toContain('source'); const documentForResponses = completeIR([{ type: 'message', content: [{ type: 'text', text: 'source', annotations: [{ type: 'source_citation', source_kind: 'document', source: 'file_1', source_label: 'Document', output_text_range: { start: 0, end_exclusive: 6 } }] }] }]); - expect(JSON.stringify(await collect(openaiResponsesFromIR(iterate(documentForResponses))))).not.toContain('citation'); + expect(JSON.stringify(await collect(writeOpenAIResponsesEvents(iterate(documentForResponses))))).not.toContain('citation'); const sourceLess = completeIR([{ type: 'message', content: [{ type: 'text', text: 'source', annotations: [{ type: 'source_citation', source_kind: 'document', source: null, source_text: 'quote' }] }] }]); - expect(JSON.stringify(await collect(openaiResponsesFromIR(iterate(sourceLess))))).not.toContain('citation'); + expect(JSON.stringify(await collect(writeOpenAIResponsesEvents(iterate(sourceLess))))).not.toContain('citation'); }); test('single-choice writers reject a second choice when it is initialized', async () => { @@ -160,12 +160,12 @@ test('single-choice writers reject a second choice when it is initialized', asyn b.event({ type: 'start', id: 'source' }); b.choice(1); const frames = [...b.drain()]; - await expect(collect(anthropicMessagesFromIR(iterate(frames)))).rejects.toThrow('cannot represent multiple choices'); - await expect(collect(openaiResponsesFromIR(iterate(frames)))).rejects.toThrow('cannot represent multiple choices'); + await expect(collect(writeAnthropicMessagesEvents(iterate(frames)))).rejects.toThrow('cannot represent multiple choices'); + await expect(collect(writeOpenAIResponsesEvents(iterate(frames)))).rejects.toThrow('cannot represent multiple choices'); }); test('Responses initial envelope requires a model', async () => { - await expect(collectIR(irFromOpenAIResponses(iterate([ + await expect(collectIR(readOpenAIResponsesEvents(iterate([ eventFrame({ type: 'response.created', response: { id: 'resp_missing_model', output: [], usage: null } } as any), ])))).rejects.toThrow('requires a model'); }); @@ -192,8 +192,8 @@ for (const source of ['openai-chat-completions', 'anthropic-messages'] as const) test('ChatCompletions EOF succeeds after explicit choice completion and fails before it', async () => { const frames = fixtureFrames('openai-chat-completions', { text: ['answer'] }); - expect((await collectIR(irFromOpenAIChatCompletionsProduction(iterate(frames.slice(0, -1)), { roundTrip: createRoundTripReaderCapture().roundTrip }))).choices[0].items).toMatchObject([{ content: [{ text: 'answer' }] }]); - await expect(collectIR(irFromOpenAIChatCompletionsProduction(iterate(frames.slice(0, -2)), { roundTrip: createRoundTripReaderCapture().roundTrip }))).rejects.toThrow('finish_reason'); + expect((await collectIR(readOpenAIChatCompletionsEventsProduction(iterate(frames.slice(0, -1)), { roundTrip: createRoundTripReaderCapture().roundTrip }))).choices[0].items).toMatchObject([{ content: [{ text: 'answer' }] }]); + await expect(collectIR(readOpenAIChatCompletionsEventsProduction(iterate(frames.slice(0, -2)), { roundTrip: createRoundTripReaderCapture().roundTrip }))).rejects.toThrow('finish_reason'); }); test.each([messagesViaChat, generateContentViaChat])('completed empty function JSON is rejected by object-requiring destinations', async translate => { @@ -226,7 +226,7 @@ test('ignores vendor object arrays and retains scalar reasoning items', async () const frames = fixtureFrames('openai-chat-completions', { thinking: ['ab'], text: ['answer'] }); const records = [{ type: 'vendor_record', id: 'record_a', segments: ['a'] }, { type: 'vendor_record', id: 'record_b', segments: ['b'] }]; frames.splice(-1, 0, eventFrame({ id: 'chatcmpl_test', model: 'served-model', created: 100, choices: [{ index: 0, delta: { vendor_records: records }, finish_reason: null }] })); - const ir = await collectIR(irFromOpenAIChatCompletionsProduction(iterate(frames), { roundTrip: createRoundTripReaderCapture().roundTrip })); + const ir = await collectIR(readOpenAIChatCompletionsEventsProduction(iterate(frames), { roundTrip: createRoundTripReaderCapture().roundTrip })); expect(ir.choices[0].items.filter(item => item.type === 'reasoning')).toEqual([{ type: 'reasoning', summary: ['ab'] }]); const result = await nativeResult('openai-responses', await collect(responsesViaChat(iterate(frames), new Set(), createTestSidecarCodec(), emptyNamespaceToolNames()))); expect(result.output.filter((item: any) => item.type === 'reasoning' && item.encrypted_content === undefined)).toMatchObject([{ summary: [{ text: 'ab' }] }]); @@ -237,7 +237,7 @@ test('ignores vendor object arrays and retains scalar reasoning items', async () test('late vendor extensions preserve messages on either side of reasoning', async () => { const chunk = (delta: any, finish_reason: string | null = null) => eventFrame({ id: 'chatcmpl_order', model: 'served-model', created: 100, choices: [{ index: 0, delta, finish_reason }] }); const frames = [chunk({ content: 'before' }), chunk({ reasoning_text: 'think' }), chunk({ content: 'after' }), chunk({}, 'stop'), chunk({ vendor_records: [{ type: 'vendor_record', id: 'record_order', segments: ['think'] }] }), { type: 'done' as const }]; - const ir = await collectIR(irFromOpenAIChatCompletionsProduction(iterate(frames as any), { roundTrip: createRoundTripReaderCapture().roundTrip })); + const ir = await collectIR(readOpenAIChatCompletionsEventsProduction(iterate(frames as any), { roundTrip: createRoundTripReaderCapture().roundTrip })); expect(ir.choices[0].items).toEqual([{ type: 'message', content: [{ type: 'text', text: 'before' }] }, { type: 'reasoning', summary: ['think'] }, { type: 'message', content: [{ type: 'text', text: 'after' }] }]); }); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/events_test.ts similarity index 86% rename from packages/translate/__tests__/protocols/gemini-generate-content/as-source/sse-to_test.ts rename to packages/translate/__tests__/protocols/gemini-generate-content/as-source/events_test.ts index 38fab0a504..5025f19e9b 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/events_test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; -import { geminiGenerateContentFromIR } from '../../../../src/protocols/gemini-generate-content/as-source/sse-to.ts'; +import { writeGeminiGenerateContentEvents } from '../../../../src/protocols/gemini-generate-content/as-source/events.ts'; import { appendField, collectContentViews, createReferenceBuilder, createFieldState, createReferenceFinalizer } from '../../../../src/round-trip/reference/reference-builder.ts'; import type { RoundTripWriter } from '../../../../src/round-trip/stream.ts'; @@ -33,7 +33,7 @@ test('resolves delayed Gemini audio atoms within the complete emitted data field return 'sidecar'; }, }; - for await (const _frame of geminiGenerateContentFromIR((async function* () { yield* source.drain(); })(), { roundTrip })) {} + for await (const _frame of writeGeminiGenerateContentEvents((async function* () { yield* source.drain(); })(), { roundTrip })) {} expect(prepared).toBe(true); }); @@ -62,6 +62,6 @@ test.each(['{"x":1,}', '{"x": 1}'])('tracks the emitted canonical JSON view with return 'sidecar'; }, }; - for await (const _frame of geminiGenerateContentFromIR((async function* () { yield* source.drain(); })(), { roundTrip, parseToolArguments: original.endsWith(',}') ? () => ({ x: 1 }) : undefined })) {} + for await (const _frame of writeGeminiGenerateContentEvents((async function* () { yield* source.drain(); })(), { roundTrip, parseToolArguments: original.endsWith(',}') ? () => ({ x: 1 }) : undefined })) {} expect(prepared).toBe(true); }); diff --git a/packages/translate/__tests__/protocols/helpers.ts b/packages/translate/__tests__/protocols/helpers.ts index 864df006d6..dbb08188b1 100644 --- a/packages/translate/__tests__/protocols/helpers.ts +++ b/packages/translate/__tests__/protocols/helpers.ts @@ -1,12 +1,12 @@ import type { IRRecord } from '../../src/ir/output/records.ts'; -import { anthropicMessagesFromIR as anthropicMessagesFromIRProduction } from '../../src/protocols/anthropic-messages/as-source/sse-to.ts'; -import { irFromAnthropicMessages as irFromAnthropicMessagesProduction } from '../../src/protocols/anthropic-messages/as-target/sse-from.ts'; -import { geminiGenerateContentFromIR as geminiGenerateContentFromIRProduction } from '../../src/protocols/gemini-generate-content/as-source/sse-to.ts'; -import { openaiChatCompletionsFromIR as openaiChatCompletionsFromIRProduction } from '../../src/protocols/openai-chat-completions/as-source/sse-to.ts'; -import { irFromOpenAIChatCompletions as irFromOpenAIChatCompletionsProduction } from '../../src/protocols/openai-chat-completions/as-target/sse-from.ts'; +import { writeAnthropicMessagesEvents as writeAnthropicMessagesEventsProduction } from '../../src/protocols/anthropic-messages/as-source/events.ts'; +import { readAnthropicMessagesEvents as readAnthropicMessagesEventsProduction } from '../../src/protocols/anthropic-messages/as-target/events.ts'; +import { writeGeminiGenerateContentEvents as writeGeminiGenerateContentEventsProduction } from '../../src/protocols/gemini-generate-content/as-source/events.ts'; +import { writeOpenAIChatCompletionsEvents as writeOpenAIChatCompletionsEventsProduction } from '../../src/protocols/openai-chat-completions/as-source/events.ts'; +import { readOpenAIChatCompletionsEvents as readOpenAIChatCompletionsEventsProduction } from '../../src/protocols/openai-chat-completions/as-target/events.ts'; +import { writeOpenAIResponsesEvents as writeOpenAIResponsesEventsProduction } from '../../src/protocols/openai-responses/as-source/events.ts'; import { flattenNamespaceTools } from '../../src/protocols/openai-responses/as-source/namespace-tools.ts'; -import { openaiResponsesFromIR as openaiResponsesFromIRProduction } from '../../src/protocols/openai-responses/as-source/sse-to.ts'; -import { irFromOpenAIResponses as irFromOpenAIResponsesProduction } from '../../src/protocols/openai-responses/as-target/sse-from.ts'; +import { readOpenAIResponsesEvents as readOpenAIResponsesEventsProduction } from '../../src/protocols/openai-responses/as-target/events.ts'; import { createReferenceBuilder } from '../../src/round-trip/reference/reference-builder.ts'; import type { RoundTripReader, RoundTripWriter } from '../../src/round-trip/stream.ts'; @@ -31,19 +31,19 @@ const withoutProvenance = async function* (records: AsyncIterable): As } }; -export const irFromAnthropicMessages = (frames: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => - withoutProvenance(irFromAnthropicMessagesProduction(frames, { ...options, roundTrip: reader<'anthropicMessages'>() })); -export const irFromOpenAIChatCompletions = (frames: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => - withoutProvenance(irFromOpenAIChatCompletionsProduction(frames, { ...options, roundTrip: reader<'openaiChatCompletions'>() })); -export const irFromOpenAIResponses = ( - frames: Parameters[0], +export const readAnthropicMessagesEvents = (frames: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => + withoutProvenance(readAnthropicMessagesEventsProduction(frames, { ...options, roundTrip: reader<'anthropicMessages'>() })); +export const readOpenAIChatCompletionsEvents = (frames: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => + withoutProvenance(readOpenAIChatCompletionsEventsProduction(frames, { ...options, roundTrip: reader<'openaiChatCompletions'>() })); +export const readOpenAIResponsesEvents = ( + frames: Parameters[0], roundTrip: RoundTripReader<'openaiResponses'> = reader<'openaiResponses'>(), -) => withoutProvenance(irFromOpenAIResponsesProduction(frames, { roundTrip })); -export const anthropicMessagesFromIR = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => - anthropicMessagesFromIRProduction(records, { ...options, roundTrip: writer<'anthropicMessages'>() }); -export const openaiChatCompletionsFromIR = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => - openaiChatCompletionsFromIRProduction(records, { ...options, roundTrip: writer<'openaiChatCompletions'>() }); -export const openaiResponsesFromIR = (records: Parameters[0], options: Omit[1], 'roundTrip' | 'namespaceToolNames'> = {}) => - openaiResponsesFromIRProduction(records, { ...options, roundTrip: writer<'openaiResponses'>(), namespaceToolNames }); -export const geminiGenerateContentFromIR = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => - geminiGenerateContentFromIRProduction(records, { ...options, roundTrip: writer<'geminiGenerateContent'>() }); +) => withoutProvenance(readOpenAIResponsesEventsProduction(frames, { roundTrip })); +export const writeAnthropicMessagesEvents = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => + writeAnthropicMessagesEventsProduction(records, { ...options, roundTrip: writer<'anthropicMessages'>() }); +export const writeOpenAIChatCompletionsEvents = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => + writeOpenAIChatCompletionsEventsProduction(records, { ...options, roundTrip: writer<'openaiChatCompletions'>() }); +export const writeOpenAIResponsesEvents = (records: Parameters[0], options: Omit[1], 'roundTrip' | 'namespaceToolNames'> = {}) => + writeOpenAIResponsesEventsProduction(records, { ...options, roundTrip: writer<'openaiResponses'>(), namespaceToolNames }); +export const writeGeminiGenerateContentEvents = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => + writeGeminiGenerateContentEventsProduction(records, { ...options, roundTrip: writer<'geminiGenerateContent'>() }); diff --git a/packages/translate/__tests__/protocols/lifecycle_test.ts b/packages/translate/__tests__/protocols/lifecycle_test.ts index 7614c6f6ca..d4d0bf46b9 100644 --- a/packages/translate/__tests__/protocols/lifecycle_test.ts +++ b/packages/translate/__tests__/protocols/lifecycle_test.ts @@ -1,7 +1,7 @@ import { expect, test, vi } from 'vitest'; import { nativeResult } from './fixtures.ts'; -import { anthropicMessagesFromIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; +import { writeAnthropicMessagesEvents, writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; import { IRAbsent } from '../../src/ir/output/absent.ts'; import type { IRRecord } from '../../src/ir/output/records.ts'; import { collectIR } from '../ir/output/helpers.ts'; @@ -28,7 +28,7 @@ const gated = async (prefix: T[], suffix: T[], convert: (source: AsyncIterabl }; test('ChatCompletions emits scalar reasoning before the next upstream frame', async () => { - const frames = await gated([chat({ reasoning_text: 'live' })], [chat({ content: 'body' }), chat({}, 'stop'), doneFrame()], irFromOpenAIChatCompletions, output => { + const frames = await gated([chat({ reasoning_text: 'live' })], [chat({ content: 'body' }), chat({}, 'stop'), doneFrame()], readOpenAIChatCompletionsEvents, output => { expect(output).toContainEqual(expect.objectContaining({ type: 'write', kind: 'append', value: 'live' })); }); const ir = await collectIR(iterate(frames)); @@ -37,7 +37,7 @@ test('ChatCompletions emits scalar reasoning before the next upstream frame', as test('ChatCompletions locks the first reasoning field independently for each choice', async () => { const chunk = (choices: any[]) => eventFrame({ id: 'chat', model: 'm', created: 1, choices } as any); - const ir = await collectIR(irFromOpenAIChatCompletions(iterate([ + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate([ chunk([{ index: 0, delta: { reasoning: 'A' } }, { index: 1, delta: { reasoning_content: 'X' } }]), chunk([{ index: 0, delta: { reasoning_text: 'B', reasoning: 'C' } }, { index: 1, delta: { reasoning: 'Y', reasoning_content: 'Z' } }]), chunk([{ index: 0, delta: {}, finish_reason: 'stop' }, { index: 1, delta: {}, finish_reason: 'stop' }]), doneFrame(), @@ -49,7 +49,7 @@ test('ChatCompletions locks the first reasoning field independently for each cho }); test('an empty first reasoning string locks the field without interrupting text', async () => { - const ir = await collectIR(irFromOpenAIChatCompletions(iterate([ + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate([ chat({ content: 'before' }), chat({ reasoning: '' }), chat({ reasoning_text: 'ignored', content: 'after' }), chat({ reasoning: 'selected' }), chat({}, 'stop'), doneFrame(), ]))); @@ -60,17 +60,17 @@ test('an empty first reasoning string locks the field without interrupting text' }); test('ChatCompletions creates separate reasoning runs at every text interruption', async () => { - const ir = await collectIR(irFromOpenAIChatCompletions(iterate([chat({ reasoning_text: 'r1' }), chat({ content: 'body' }), chat({ reasoning_text: 'r2' }), chat({ content: 'tail' }), chat({}, 'stop'), doneFrame()]))); + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate([chat({ reasoning_text: 'r1' }), chat({ content: 'body' }), chat({ reasoning_text: 'r2' }), chat({ content: 'tail' }), chat({}, 'stop'), doneFrame()]))); expect(ir.choices[0].items).toEqual([{ type: 'reasoning', summary: ['r1'] }, { type: 'message', content: [{ type: 'text', text: 'body' }] }, { type: 'reasoning', summary: ['r2'] }, { type: 'message', content: [{ type: 'text', text: 'tail' }] }]); }); test('an old open tool update leaves the current ChatCompletions text run open', async () => { - const ir = await collectIR(irFromOpenAIChatCompletions(iterate([chat({ content: 'before' }), tool(0, '{"x":', 'call', 'lookup'), chat({ content: 'middle' }), tool(0, '1}'), chat({ content: 'after' }), chat({}, 'tool_calls'), doneFrame()]))); + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate([chat({ content: 'before' }), tool(0, '{"x":', 'call', 'lookup'), chat({ content: 'middle' }), tool(0, '1}'), chat({ content: 'after' }), chat({}, 'tool_calls'), doneFrame()]))); expect(ir.choices[0].items).toEqual([{ type: 'message', content: [{ type: 'text', text: 'before' }] }, { type: 'function_call', name: 'lookup', call_id: 'call', arguments: '{"x":1}' }, { type: 'message', content: [{ type: 'text', text: 'middleafter' }] }]); }); test('a complete JSON prefix stays open and preserves following argument text', async () => { - const frames = await gated([tool(0, '{"x":1}', 'call', 'lookup')], [tool(0, ',{"x":2}'), chat({}, 'tool_calls'), doneFrame()], irFromOpenAIChatCompletions, output => { + const frames = await gated([tool(0, '{"x":1}', 'call', 'lookup')], [tool(0, ',{"x":2}'), chat({}, 'tool_calls'), doneFrame()], readOpenAIChatCompletionsEvents, output => { expect(output.filter((record: any) => record.type === 'item_end')).toEqual([]); }); const ir = await collectIR(iterate(frames)); @@ -80,7 +80,7 @@ test('a complete JSON prefix stays open and preserves following argument text', test('a new tool closes the preceding function and later updates warn without reopening it', async () => { const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { - const frames = await gated([tool(0, '{"x":1}', 'call0', 'lookup'), tool(1, '{"y":', 'call1', 'lookup')], [tool(0, 'BAD'), tool(1, '2}'), chat({}, 'tool_calls'), doneFrame()], irFromOpenAIChatCompletions, output => { + const frames = await gated([tool(0, '{"x":1}', 'call0', 'lookup'), tool(1, '{"y":', 'call1', 'lookup')], [tool(0, 'BAD'), tool(1, '2}'), chat({}, 'tool_calls'), doneFrame()], readOpenAIChatCompletionsEvents, output => { expect(output.filter((record: any) => record.type === 'item_end')).toEqual([{ type: 'item_end', choice: 0, item: 0, status: 'completed' }]); }); expect(warning).toHaveBeenCalledTimes(1); @@ -91,7 +91,7 @@ test('a new tool closes the preceding function and later updates warn without re test('a native custom call stays open across subsequent tool starts', async () => { const custom = (input: string, first = false) => chat({ tool_calls: [{ index: 0, ...(first ? { id: 'custom0', type: 'custom' } : {}), custom: { ...(first ? { name: 'script' } : {}), input } }] }); - const frames = await gated([custom('first', true), tool(1, '{}', 'call1', 'lookup')], [custom('second'), chat({}, 'tool_calls'), doneFrame()], irFromOpenAIChatCompletions, output => { + const frames = await gated([custom('first', true), tool(1, '{}', 'call1', 'lookup')], [custom('second'), chat({}, 'tool_calls'), doneFrame()], readOpenAIChatCompletionsEvents, output => { expect(output.filter((record: any) => record.type === 'item_end')).toEqual([]); }); const ir = await collectIR(iterate(frames)); @@ -99,7 +99,7 @@ test('a native custom call stays open across subsequent tool starts', async () = }); test('a wrapped custom input is published when its native function boundary closes', async () => { - const frames = await gated([tool(0, '{"input":"text"}', 'call0', 'script'), tool(1, '{}', 'call1', 'lookup')], [chat({}, 'tool_calls'), doneFrame()], source => irFromOpenAIChatCompletions(source, { customToolNames: new Set(['script']) }), output => { + const frames = await gated([tool(0, '{"input":"text"}', 'call0', 'script'), tool(1, '{}', 'call1', 'lookup')], [chat({}, 'tool_calls'), doneFrame()], source => readOpenAIChatCompletionsEvents(source, { customToolNames: new Set(['script']) }), output => { expect(output).toContainEqual({ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'input'], value: 'text' }); expect(output).toContainEqual({ type: 'item_end', choice: 0, item: 0, status: 'completed' }); }); @@ -107,14 +107,14 @@ test('a wrapped custom input is published when its native function boundary clos }); test('a custom call start completes the preceding function', async () => { - const frames = await gated([tool(0, '{}', 'call0', 'lookup'), chat({ tool_calls: [{ index: 1, id: 'custom1', type: 'custom', custom: { name: 'script', input: 'text' } }] })], [chat({}, 'tool_calls'), doneFrame()], irFromOpenAIChatCompletions, output => { + const frames = await gated([tool(0, '{}', 'call0', 'lookup'), chat({ tool_calls: [{ index: 1, id: 'custom1', type: 'custom', custom: { name: 'script', input: 'text' } }] })], [chat({}, 'tool_calls'), doneFrame()], readOpenAIChatCompletionsEvents, output => { expect(output.filter((record: any) => record.type === 'item_end')).toEqual([{ type: 'item_end', choice: 0, item: 0, status: 'completed' }]); }); expect((await collectIR(iterate(frames))).choices[0].items).toHaveLength(2); }); -test.each([['Responses', openaiResponsesFromIR], ['Messages', anthropicMessagesFromIR]] as const)('%s waits for the actual tool identity before publishing its start', async (name, writer) => { - const frames = await gated([tool(0, '{"x":', undefined, 'lookup')], [tool(0, '1}', 'call_real'), chat({}, 'tool_calls'), doneFrame()], source => writer(irFromOpenAIChatCompletions(source)), output => { +test.each([['Responses', writeOpenAIResponsesEvents], ['Messages', writeAnthropicMessagesEvents]] as const)('%s waits for the actual tool identity before publishing its start', async (name, writer) => { + const frames = await gated([tool(0, '{"x":', undefined, 'lookup')], [tool(0, '1}', 'call_real'), chat({}, 'tool_calls'), doneFrame()], source => writer(readOpenAIChatCompletionsEvents(source)), output => { expect(output.some((frame: any) => frame.event?.type === 'response.output_item.added' || frame.event?.type === 'content_block_start')).toBe(false); }); const result: any = await nativeResult(name === 'Responses' ? 'openai-responses' : 'anthropic-messages', frames); @@ -135,7 +135,7 @@ const irSuffix = (): IRRecord[] => [ { type: 'finish', status: 'completed' }, ]; -test.each([['Responses', openaiResponsesFromIR], ['Messages', anthropicMessagesFromIR]] as const)('%s opens a pending call on the late call_id record', async (name, writer) => { +test.each([['Responses', writeOpenAIResponsesEvents], ['Messages', writeAnthropicMessagesEvents]] as const)('%s opens a pending call on the late call_id record', async (name, writer) => { const prefix: IRRecord[] = [...irPrefix(), { type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1], value: { type: 'message', content: [] } }, { type: 'write', kind: 'initialize', path: ['choices', 0, 'items', 1, 'content', 0], value: { type: 'text', text: '' } }, @@ -156,13 +156,13 @@ test.each([['Responses', openaiResponsesFromIR], ['Messages', anthropicMessagesF expect(name === 'Responses' ? result.output[0].call_id : result.content[0].id).toBe('call_real'); }); -test.each([['Responses', openaiResponsesFromIR], ['Messages', anthropicMessagesFromIR], ['ChatCompletions', openaiChatCompletionsFromIR]] as const)('%s opens call_id IRAbsent without waiting', async (_name, writer) => { +test.each([['Responses', writeOpenAIResponsesEvents], ['Messages', writeAnthropicMessagesEvents], ['ChatCompletions', writeOpenAIChatCompletionsEvents]] as const)('%s opens call_id IRAbsent without waiting', async (_name, writer) => { await gated(irPrefix(IRAbsent), irSuffix(), writer, prefix => { expect(prefix.some((frame: any) => frame.event?.type === 'response.output_item.added' || frame.event?.type === 'content_block_start' || frame.event?.choices?.[0]?.delta.tool_calls !== undefined)).toBe(true); }); }); -test.each([['Responses', openaiResponsesFromIR], ['Messages', anthropicMessagesFromIR]] as const)('%s synthesizes a call ID when the item closes with it absent', async (_name, writer) => { +test.each([['Responses', writeOpenAIResponsesEvents], ['Messages', writeAnthropicMessagesEvents]] as const)('%s synthesizes a call ID when the item closes with it absent', async (_name, writer) => { const frames = await gated(irPrefix(), irSuffix(), writer, prefix => { expect(prefix.some((frame: any) => frame.event?.type === 'response.output_item.added' || frame.event?.type === 'content_block_start')).toBe(false); }); @@ -170,7 +170,7 @@ test.each([['Responses', openaiResponsesFromIR], ['Messages', anthropicMessagesF }); test('GenerateContent emits a completed call without waiting for a call ID', async () => { - const result: any = await nativeResult('gemini-generate-content', await collect(geminiGenerateContentFromIR(iterate([...irPrefix(), ...irSuffix()])))); + const result: any = await nativeResult('gemini-generate-content', await collect(writeGeminiGenerateContentEvents(iterate([...irPrefix(), ...irSuffix()])))); expect(result.candidates[0].content.parts[0].functionCall).toEqual({ name: 'tool', args: { x: 1 } }); }); @@ -181,8 +181,8 @@ const call = (index: number, arguments_ = '', status = 'in_progress') => ({ type const args = (index: number, delta: string) => eventFrame({ type: 'response.function_call_arguments.delta', output_index: index, delta } as any); const endCall = (index: number, arguments_: string, status = 'completed') => eventFrame({ type: 'response.output_item.done', output_index: index, item: call(index, arguments_, status) } as any); -test.each([['ChatCompletions', openaiChatCompletionsFromIR], ['Messages', anthropicMessagesFromIR]] as const)('%s streams the second tool while the first remains open', async (name, writer) => { - await gated([responseStart, add(0, call(0)), args(0, '{"x":'), add(1, call(1)), args(1, '{"y":2}'), endCall(1, '{"y":2}')], [args(0, '1}'), endCall(0, '{"x":1}'), eventFrame({ type: 'response.completed', response: response([call(0, '{"x":1}', 'completed'), call(1, '{"y":2}', 'completed')]) } as any)], source => writer(irFromOpenAIResponses(source)), output => { +test.each([['ChatCompletions', writeOpenAIChatCompletionsEvents], ['Messages', writeAnthropicMessagesEvents]] as const)('%s streams the second tool while the first remains open', async (name, writer) => { + await gated([responseStart, add(0, call(0)), args(0, '{"x":'), add(1, call(1)), args(1, '{"y":2}'), endCall(1, '{"y":2}')], [args(0, '1}'), endCall(0, '{"x":1}'), eventFrame({ type: 'response.completed', response: response([call(0, '{"x":1}', 'completed'), call(1, '{"y":2}', 'completed')]) } as any)], source => writer(readOpenAIResponsesEvents(source)), output => { expect(output.some((frame: any) => name === 'Messages' ? frame.event?.type === 'content_block_delta' && frame.event.index === 1 && frame.event.delta?.partial_json === '{"y":2}' : frame.event?.choices?.some((choice: any) => choice.delta.tool_calls?.some((tool: any) => tool.index === 1 && tool.function.arguments === '{"y":2}')))).toBe(true); @@ -191,7 +191,7 @@ test.each([['ChatCompletions', openaiChatCompletionsFromIR], ['Messages', anthro test('Messages closes an earlier concurrent block immediately and the frontier block after its sidecar', async () => { const source = [responseStart, add(0, call(0)), args(0, '{"x":'), add(1, call(1)), args(1, '{"y":2}'), endCall(1, '{"y":2}'), args(0, '1}'), endCall(0, '{"x":1}'), eventFrame({ type: 'response.completed', response: response([call(0, '{"x":1}', 'completed'), call(1, '{"y":2}', 'completed')]) } as any)]; - const frames: any[] = await collect(anthropicMessagesFromIR(irFromOpenAIResponses(iterate(source)))); + const frames: any[] = await collect(writeAnthropicMessagesEvents(readOpenAIResponsesEvents(iterate(source)))); const lifecycle = frames.flatMap(frame => frame.event?.type === 'content_block_start' ? [`start:${frame.event.index}:${frame.event.content_block.type}`] : frame.event?.type === 'content_block_stop' ? [`stop:${frame.event.index}`] : []); @@ -205,24 +205,24 @@ test('Messages closes an earlier concurrent block immediately and the frontier b ]); }); -test.each([['ChatCompletions', openaiChatCompletionsFromIR], ['Messages', anthropicMessagesFromIR], ['GenerateContent', geminiGenerateContentFromIR]] as const)('%s flattens reasoning content and summary in arrival order', async (name, writer) => { +test.each([['ChatCompletions', writeOpenAIChatCompletionsEvents], ['Messages', writeAnthropicMessagesEvents], ['GenerateContent', writeGeminiGenerateContentEvents]] as const)('%s flattens reasoning content and summary in arrival order', async (name, writer) => { const item = { type: 'reasoning', id: 'r', summary: [] }; const source = [responseStart, add(0, item), eventFrame({ type: 'response.content_part.added', output_index: 0, content_index: 0, part: { type: 'reasoning_text', text: '' } } as any), eventFrame({ type: 'response.reasoning_text.delta', output_index: 0, content_index: 0, delta: 'C' } as any), eventFrame({ type: 'response.reasoning_summary_part.added', output_index: 0, summary_index: 0, part: { type: 'summary_text', text: '' } } as any), eventFrame({ type: 'response.reasoning_summary_text.delta', output_index: 0, summary_index: 0, delta: 'S' } as any), eventFrame({ type: 'response.completed', response: response([{ ...item, content: [{ type: 'reasoning_text', text: 'C' }], summary: [{ type: 'summary_text', text: 'S' }] }]) } as any)]; - const frames: any[] = await collect(writer(irFromOpenAIResponses(iterate(source)))); + const frames: any[] = await collect(writer(readOpenAIResponsesEvents(iterate(source)))); const text = frames.map(frame => name === 'ChatCompletions' ? frame.event?.choices?.[0]?.delta.reasoning_text ?? '' : name === 'Messages' ? frame.event.delta?.thinking ?? '' : frame.event.candidates?.[0]?.content?.parts?.filter((part: any) => part.thought).map((part: any) => part.text).join('') ?? '').join(''); expect(text).toBe('CS'); }); test('GenerateContent preserves completed tool order when the second finishes first', async () => { - const frames = await gated([responseStart, add(0, call(0)), args(0, '{"x":'), add(1, call(1)), args(1, '{}'), endCall(1, '{}')], [args(0, '1}'), endCall(0, '{"x":1}'), eventFrame({ type: 'response.completed', response: response([call(0, '{"x":1}', 'completed'), call(1, '{}', 'completed')]) } as any)], source => geminiGenerateContentFromIR(irFromOpenAIResponses(source)), output => { + const frames = await gated([responseStart, add(0, call(0)), args(0, '{"x":'), add(1, call(1)), args(1, '{}'), endCall(1, '{}')], [args(0, '1}'), endCall(0, '{"x":1}'), eventFrame({ type: 'response.completed', response: response([call(0, '{"x":1}', 'completed'), call(1, '{}', 'completed')]) } as any)], source => writeGeminiGenerateContentEvents(readOpenAIResponsesEvents(source)), output => { expect(output).toEqual([]); }); expect(frames.flatMap((frame: any) => frame.event.candidates?.flatMap((candidate: any) => candidate.content?.parts ?? []) ?? []).filter((part: any) => part.functionCall).map((part: any) => part.functionCall.id)).toEqual(['call_0', 'call_1']); }); -test.each([['ChatCompletions', openaiChatCompletionsFromIR], ['Responses', openaiResponsesFromIR], ['GenerateContent', geminiGenerateContentFromIR]] as const)('%s preserves Messages token cutoff without parsing incomplete input', async (name, writer) => { +test.each([['ChatCompletions', writeOpenAIChatCompletionsEvents], ['Responses', writeOpenAIResponsesEvents], ['GenerateContent', writeGeminiGenerateContentEvents]] as const)('%s preserves Messages token cutoff without parsing incomplete input', async (name, writer) => { const source = [messageStart, eventFrame({ type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'call', name: 'lookup', input: {} } } as any), eventFrame({ type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '{"x":' } } as any), eventFrame({ type: 'content_block_stop', index: 0 } as any), ...messageEnd('max_tokens')]; - const frames: any[] = await collect(writer(irFromAnthropicMessages(iterate(source)))); + const frames: any[] = await collect(writer(readAnthropicMessagesEvents(iterate(source)))); if (name === 'ChatCompletions') { expect(frames.some(frame => frame.event?.choices?.[0]?.finish_reason === 'length')).toBe(true); expect(frames.flatMap(frame => frame.event?.choices?.[0]?.delta.tool_calls ?? []).map(tool => tool.function.arguments ?? '').join('')).toBe('{"x":'); @@ -236,7 +236,7 @@ test.each([['ChatCompletions', openaiChatCompletionsFromIR], ['Responses', opena }); test('empty ChatCompletions scalar deltas do not interrupt reasoning', async () => { - const ir = await collectIR(irFromOpenAIChatCompletions(iterate([chat({ role: 'assistant', content: '' }), chat({ reasoning_text: 'r1' }), chat({ content: '' }), chat({ reasoning_text: 'r2' }), chat({}, 'stop'), doneFrame()]))); + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate([chat({ role: 'assistant', content: '' }), chat({ reasoning_text: 'r1' }), chat({ content: '' }), chat({ reasoning_text: 'r2' }), chat({}, 'stop'), doneFrame()]))); expect(ir.choices[0].items).toEqual([{ type: 'reasoning', summary: ['r1r2'] }]); }); @@ -253,7 +253,7 @@ test('Responses closes only the addressed text part while a refusal remains open { type: 'part_end', choice: 0, item: 0, part: 0 }, ]; const suffix: IRRecord[] = [{ type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'content', 1, 'refusal'], value: 'C' }, { type: 'part_end', choice: 0, item: 0, part: 1 }, { type: 'item_end', choice: 0, item: 0 }, { type: 'choice_end', choice: 0, finish_reason: 'stop' }, { type: 'finish', status: 'completed' }]; - const frames = await gated(prefix, suffix, openaiResponsesFromIR, output => { + const frames = await gated(prefix, suffix, writeOpenAIResponsesEvents, output => { expect(output.filter(frame => frame.event.type === 'response.content_part.done').map(frame => frame.event.content_index)).toEqual([0]); }); const result: any = await nativeResult('openai-responses', frames); @@ -263,7 +263,7 @@ test('Responses closes only the addressed text part while a refusal remains open test('GenerateContent retains reasoning arrival order while it waits for a prior tool', async () => { const reasoning = { type: 'reasoning', id: 'r', content: [{ type: 'reasoning_text', text: 'C' }], summary: [] }; const source = [responseStart, add(0, call(0)), args(0, '{"x":'), add(1, reasoning), eventFrame({ type: 'response.reasoning_summary_part.added', output_index: 1, summary_index: 0, part: { type: 'summary_text', text: 'S' } } as any), eventFrame({ type: 'response.output_item.done', output_index: 1, item: { ...reasoning, summary: [{ type: 'summary_text', text: 'S' }] } } as any), args(0, '1}'), endCall(0, '{"x":1}'), eventFrame({ type: 'response.completed', response: response([call(0, '{"x":1}', 'completed'), { ...reasoning, summary: [{ type: 'summary_text', text: 'S' }] }]) } as any)]; - const frames = await collect(geminiGenerateContentFromIR(irFromOpenAIResponses(iterate(source)))); + const frames = await collect(writeGeminiGenerateContentEvents(readOpenAIResponsesEvents(iterate(source)))); const parts = frames.flatMap(frame => (frame.event as any).candidates?.flatMap((candidate: any) => candidate.content?.parts ?? []) ?? []); expect(parts[0].functionCall.id).toBe('call_0'); expect(parts.filter((part: any) => part.thought).map((part: any) => part.text).join('')).toBe('CS'); @@ -276,7 +276,7 @@ test.each([false, true])('GenerateContent keeps parallel text arrival order with const offset = buffered ? 1 : 0; const prefix = [messageStart, ...(buffered ? [eventFrame({ type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'call', name: 'lookup', input: {} } } as any), eventFrame({ type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '{"x":' } } as any)] : []), block(offset), text(offset, 'a'), block(offset + 1), text(offset + 1, 'b'), text(offset, 'c')]; const suffix = [...(buffered ? [eventFrame({ type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '1}' } } as any), stop(0)] : []), stop(offset + 1), stop(offset), ...messageEnd(buffered ? 'tool_use' : 'end_turn')]; - const frames = await gated(prefix, suffix, source => geminiGenerateContentFromIR(irFromAnthropicMessages(source)), output => { + const frames = await gated(prefix, suffix, source => writeGeminiGenerateContentEvents(readAnthropicMessagesEvents(source)), output => { const text = output.flatMap(frame => frame.event.candidates?.flatMap((candidate: any) => candidate.content?.parts ?? []) ?? []).map((part: any) => part.text ?? '').join(''); expect(text).toBe(buffered ? '' : 'abc'); }); @@ -292,7 +292,7 @@ test('Messages publishes cumulative usage progress before pulling the rest of th first.event.usage = { prompt_tokens: 10, completion_tokens: 1, total_tokens: 11 }; const last = chat({ content: 'B' }, 'stop'); last.event.usage = { prompt_tokens: 10, completion_tokens: 2, total_tokens: 12 }; - const output = await gated([initial, first], [last, doneFrame()], frames => anthropicMessagesFromIR(irFromOpenAIChatCompletions(frames)), prefix => { + const output = await gated([initial, first], [last, doneFrame()], frames => writeAnthropicMessagesEvents(readOpenAIChatCompletionsEvents(frames)), prefix => { expect(prefix).toContainEqual(expect.objectContaining({ event: { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'A' } } })); expect(prefix.some(frame => frame.event?.type === 'message_delta' && frame.event.delta.stop_reason === null && frame.event.usage.output_tokens === 1)).toBe(true); expect(prefix.some(frame => frame.event?.delta?.stop_reason === 'end_turn')).toBe(false); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/events_test.ts similarity index 92% rename from packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts rename to packages/translate/__tests__/protocols/openai-chat-completions/as-source/events_test.ts index 2ca92d6702..e57603e70f 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/events_test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; -import { openaiChatCompletionsFromIR } from '../../../../src/protocols/openai-chat-completions/as-source/sse-to.ts'; +import { writeOpenAIChatCompletionsEvents } from '../../../../src/protocols/openai-chat-completions/as-source/events.ts'; import { createReferenceBuilder, collectContentViews, createFieldState, createReferenceFinalizer, appendField } from '../../../../src/round-trip/reference/reference-builder.ts'; import type { RoundTripWriter } from '../../../../src/round-trip/stream.ts'; @@ -37,7 +37,7 @@ test('prepares each Chat choice independently while another choice has partial a prepareSidecar: async choice => { prepared.push(choice); return `sidecar-${choice}`; }, }; const frames = []; - for await (const frame of openaiChatCompletionsFromIR((async function* () { yield* source.drain(); })(), { + for await (const frame of writeOpenAIChatCompletionsEvents((async function* () { yield* source.drain(); })(), { roundTrip, audioMetadata: choice => ({ id: `audio-${choice}`, expires_at: 10 }), })) frames.push(frame); @@ -79,7 +79,7 @@ test.each([false, true])('resolves delayed transcript provenance at actual proje builder: references, prepareSidecar: async (...args) => { prepared = args; return 'sidecar'; }, }; - for await (const _frame of openaiChatCompletionsFromIR((async function* () { yield* source.drain(); })(), { + for await (const _frame of writeOpenAIChatCompletionsEvents((async function* () { yield* source.drain(); })(), { roundTrip, audioMetadata: () => ({ id: 'audio', expires_at: 10 }), })) {} if (prepared === undefined) throw new Error('ChatCompletions sidecar was not prepared'); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts similarity index 95% rename from packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts rename to packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts index f73b26fe4a..decf5625ac 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/sse-from_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts @@ -2,7 +2,7 @@ import { expect, test } from 'vitest'; import type { IRRecord } from '../../../../src/ir/output/records.ts'; import type { OpenAIChatCompletionsInputAssistantTurn } from '../../../../src/protocols/openai-chat-completions/as-target/assistant-turn.ts'; -import { irFromOpenAIChatCompletions } from '../../../../src/protocols/openai-chat-completions/as-target/sse-from.ts'; +import { readOpenAIChatCompletionsEvents } from '../../../../src/protocols/openai-chat-completions/as-target/events.ts'; import { appendContent, collectContentViews, createContentState, createReferenceBuilder } from '../../../../src/round-trip/reference/reference-builder.ts'; import { finalizeAssistantTurnReferences } from '../../../../src/round-trip/reference/reference-selection.ts'; import { restoreAssistantTurn } from '../../../../src/round-trip/reference/restore.ts'; @@ -20,7 +20,7 @@ const drain = async (frames: ReturnType[]): Promise { turns.set(choice, turn); }, }; - for await (const _record of irFromOpenAIChatCompletions((async function* () { yield* frames; yield doneFrame(); })(), { maxChoices: 2, roundTrip })) {} + for await (const _record of readOpenAIChatCompletionsEvents((async function* () { yield* frames; yield doneFrame(); })(), { maxChoices: 2, roundTrip })) {} return turns; }; @@ -82,7 +82,7 @@ const traceTools = async (frames: ReturnType[], options: { prefix? builder, onAssistantTurn: (...args) => { captured = args; }, }; - for await (const record of irFromOpenAIChatCompletions((async function* () { yield* frames; yield doneFrame(); })(), { roundTrip, customToolNames: options.customToolNames })) { + for await (const record of readOpenAIChatCompletionsEvents((async function* () { yield* frames; yield doneFrame(); })(), { roundTrip, customToolNames: options.customToolNames })) { if (record.type === 'write' && record.kind === 'assign') assigns.push(record); if (record.type !== 'write' || record.kind !== 'append') continue; const field = record.path.at(-1); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/sse-to_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts similarity index 95% rename from packages/translate/__tests__/protocols/openai-responses/as-source/sse-to_test.ts rename to packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts index 728f7046f7..453b5b2b6e 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/sse-to_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts @@ -1,8 +1,8 @@ import { expect, test } from 'vitest'; import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; +import { writeOpenAIResponsesEvents } from '../../../../src/protocols/openai-responses/as-source/events.ts'; import { flattenNamespaceTools } from '../../../../src/protocols/openai-responses/as-source/namespace-tools.ts'; -import { openaiResponsesFromIR } from '../../../../src/protocols/openai-responses/as-source/sse-to.ts'; import { appendField, collectContentViews, createReferenceBuilder, createFieldState, createReferenceFinalizer } from '../../../../src/round-trip/reference/reference-builder.ts'; import type { RoundTripWriter } from '../../../../src/round-trip/stream.ts'; @@ -79,7 +79,7 @@ test('collects sparse namespace-restored Responses state from emitted IR atoms', builder.event({ type: 'choice_end', choice: 0, finish_reason: 'tool_calls' }); builder.event({ type: 'finish', status: 'completed' }); - for await (const _frame of openaiResponsesFromIR((async function* () { yield* builder.drain(); })(), { roundTrip, namespaceToolNames: namespace.names })) {} + for await (const _frame of writeOpenAIResponsesEvents((async function* () { yield* builder.drain(); })(), { roundTrip, namespaceToolNames: namespace.names })) {} if (prepared === undefined) throw new Error('Responses sidecar was not prepared'); const [, , turn, state] = prepared; diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts index f023e6d8bc..42fa12acb2 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/initialize_test.ts @@ -2,7 +2,7 @@ import { expect, test } from 'vitest'; import type { IRRecord } from '../../../../src/ir/output/records.ts'; import { collect, iterate } from '../../../shared/iterables.ts'; -import { openaiResponsesFromIR } from '../../helpers.ts'; +import { writeOpenAIResponsesEvents } from '../../helpers.ts'; test('initialize carries real required fields while append targets start empty', async () => { const records: IRRecord[] = [ @@ -15,7 +15,7 @@ test('initialize carries real required fields while append targets start empty', { type: 'choice_end', choice: 0, finish_reason: 'tool_calls' }, { type: 'finish', status: 'completed' }, ]; - const output = await collect(openaiResponsesFromIR(iterate(records))); + const output = await collect(writeOpenAIResponsesEvents(iterate(records))); const serialized = JSON.stringify(output); expect(serialized).toContain('real-name'); expect(serialized).toContain('{}'); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-target/sse-from_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts similarity index 98% rename from packages/translate/__tests__/protocols/openai-responses/as-target/sse-from_test.ts rename to packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts index b37e944ccb..c9d61a3a24 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-target/sse-from_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts @@ -1,6 +1,6 @@ import { expect, test } from 'vitest'; -import { irFromOpenAIResponses } from '../../../../src/protocols/openai-responses/as-target/sse-from.ts'; +import { readOpenAIResponsesEvents } from '../../../../src/protocols/openai-responses/as-target/events.ts'; import { appendContent, collectContentViews, createContentState } from '../../../../src/round-trip/reference/reference-builder.ts'; import { finalizeAssistantTurnReferences } from '../../../../src/round-trip/reference/reference-selection.ts'; import { restoreAssistantTurn } from '../../../../src/round-trip/reference/restore.ts'; @@ -46,7 +46,7 @@ const collectProvenance = async (events: readonly OpenAIResponsesStreamEventEx[] for (const event of events) yield eventFrame(event); })(); - for await (const record of irFromOpenAIResponses(frames, { roundTrip: capture.roundTrip })) { + for await (const record of readOpenAIResponsesEvents(frames, { roundTrip: capture.roundTrip })) { if (record.type !== 'write' || record.kind !== 'append' || record.path.at(-1) !== field) continue; appends.push({ text: record.value, ...(record.referencePieceIds === undefined ? {} : { referencePieceIds: record.referencePieceIds }) }); appendContent(capture.builder, content, record.value, record.referencePieceIds); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts index 73980cb9f8..8a57aa52e4 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-target/snapshots_test.ts @@ -3,11 +3,11 @@ import { expect, test } from 'vitest'; import { collectIR } from '../../../ir/output/helpers.ts'; import { createRoundTripReaderCapture } from '../../../round-trip/test-helpers.ts'; import { collect, iterate } from '../../../shared/iterables.ts'; -import { irFromOpenAIResponses } from '../../helpers.ts'; +import { readOpenAIResponsesEvents } from '../../helpers.ts'; import { eventFrame } from '@floway-dev/protocols/common'; const response = (output: any[] = []) => ({ id: 'resp', model: 'served', created_at: 1, status: 'completed', output, usage: null, error: null, incomplete_details: null }); -const read = async (events: any[]) => await collectIR(irFromOpenAIResponses(iterate(events.map(event => eventFrame(event as any))))); +const read = async (events: any[]) => await collectIR(readOpenAIResponsesEvents(iterate(events.map(event => eventFrame(event as any))))); test.each([-1, 1.5, Number.MAX_SAFE_INTEGER + 1])('output indexes must be nonnegative safe integers: %s', async outputIndex => { await expect(read([ @@ -238,7 +238,7 @@ test('output item snapshots require text in every declared part', async () => { }); test('Responses retains the initial response id when later envelopes rotate it', async () => { - const records = await collect(irFromOpenAIResponses(iterate([ + const records = await collect(readOpenAIResponsesEvents(iterate([ eventFrame({ type: 'response.created', response: response([]) } as any), eventFrame({ type: 'response.in_progress', response: { ...response([]), id: 'resp_rotated' } } as any), eventFrame({ type: 'response.completed', response: { ...response([]), id: 'resp_terminal' } } as any), @@ -286,7 +286,7 @@ test('an empty terminal Responses output captures closed snapshots for round tri const added = { type: 'message', id: 'msg', role: 'assistant', status: 'in_progress', content: [] }; const done = { ...added, status: 'completed', content: [{ type: 'output_text', text: 'answer', annotations: [], logprobs: [] }] }; const capture = createRoundTripReaderCapture<'openaiResponses'>(); - await collectIR(irFromOpenAIResponses(iterate([ + await collectIR(readOpenAIResponsesEvents(iterate([ eventFrame({ type: 'response.created', response: response([]) } as any), eventFrame({ type: 'response.output_item.added', output_index: 0, item: added } as any), eventFrame({ type: 'response.output_item.done', output_index: 0, item: done } as any), diff --git a/packages/translate/__tests__/protocols/ownership_test.ts b/packages/translate/__tests__/protocols/ownership_test.ts index 96b0dc75af..05d7a849e8 100644 --- a/packages/translate/__tests__/protocols/ownership_test.ts +++ b/packages/translate/__tests__/protocols/ownership_test.ts @@ -1,7 +1,7 @@ import { expect, test, vi } from 'vitest'; import { nativeResult } from './fixtures.ts'; -import { geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; +import { writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; import { collectIR } from '../ir/output/helpers.ts'; import { collect, iterate } from '../shared/iterables.ts'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; @@ -22,10 +22,10 @@ const messagesCitationFrames = (citedText?: string) => [ test('ChatCompletions logprobs retain their text owner when a tool appears in the same chunk', async () => { const token = { token: 'hello', logprob: -0.1, bytes: [104, 101, 108, 108, 111], top_logprobs: [] }; const source = [chunk({ content: 'hello', annotations: [citation(0, 5)], tool_calls: [{ index: 0, id: 'call', type: 'function', function: { name: 'lookup', arguments: '{}' } }] }, { logprobs: { content: [token], refusal: null }, finish_reason: 'tool_calls' }), doneFrame()]; - const ir = await collectIR(irFromOpenAIChatCompletions(iterate(source))); + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate(source))); expect(ir.choices[0].items).toHaveLength(2); expect(ir.choices[0].logprobs).toEqual([{ scope: 'text_part', item_index: 0, content_index: 0, tokens: [token] }]); - const frames = await collect(openaiResponsesFromIR(irFromOpenAIChatCompletions(iterate(source)))); + const frames = await collect(writeOpenAIResponsesEvents(readOpenAIChatCompletionsEvents(iterate(source)))); expect(frames.find(frame => frame.type === 'event' && frame.event.type === 'response.output_text.done')).toMatchObject({ event: { logprobs: [token] } }); const annotation = frames.findIndex(frame => frame.type === 'event' && frame.event.type === 'response.output_text.annotation.added'); const end = frames.findIndex(frame => frame.type === 'event' && frame.event.type === 'response.output_item.done'); @@ -39,10 +39,10 @@ test('ChatCompletions logprobs retain their text owner when a tool appears in th test('ChatCompletions citations rebase cumulative Unicode coordinates across interrupted items', async () => { const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const source = [chunk({ content: 'A😀' }), chunk({ reasoning_text: 'R' }), chunk({ content: '中B' }), chunk({ annotations: [citation(1, 3)] }, { finish_reason: 'stop' }), doneFrame()]; - const ir = await collectIR(irFromOpenAIChatCompletions(iterate(source))); + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate(source))); expect(ir.choices[0].items[0]).toMatchObject({ content: [{ text: 'A😀' }] }); expect(ir.choices[0].items[2]).toMatchObject({ content: [{ text: '中B', annotations: [{ output_text_range: { start: 0, end_exclusive: 1 } }] }] }); - const result = await nativeResult('openai-responses', await collect(openaiResponsesFromIR(irFromOpenAIChatCompletions(iterate(source))))); + const result = await nativeResult('openai-responses', await collect(writeOpenAIResponsesEvents(readOpenAIChatCompletionsEvents(iterate(source))))); expect(result.output[2].content[0].annotations).toEqual([{ type: 'url_citation', url: 'https://example.com', title: 'Source', start_index: 0, end_index: 1 }]); expect(warning).toHaveBeenCalledWith('Clamping ChatCompletions citation to open text', expect.objectContaining({ choice: 0 })); warning.mockRestore(); @@ -58,7 +58,7 @@ test('Responses converts only new citations after indexed text keeps streaming', chunk({ annotations: [first, second] }, { finish_reason: 'stop' }), doneFrame(), ]; - const frames = await collect(openaiResponsesFromIR(irFromOpenAIChatCompletions(iterate(source)))); + const frames = await collect(writeOpenAIResponsesEvents(readOpenAIChatCompletionsEvents(iterate(source)))); const annotations = frames.flatMap(frame => frame.type === 'event' && frame.event.type === 'response.output_text.annotation.added' ? [frame.event] : []); expect(annotations.map(event => event.annotation_index)).toEqual([0, 1]); expect(annotations.map(event => event.annotation)).toEqual([ @@ -70,7 +70,7 @@ test('Responses converts only new citations after indexed text keeps streaming', test('late ChatCompletions citations on closed text are dropped', async () => { const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined); const source = [chunk({ content: 'A😀' }), chunk({ reasoning_text: 'R' }), chunk({ content: '中B' }), chunk({ reasoning_text: 'tail' }), chunk({ annotations: [citation(1, 3)] }, { finish_reason: 'stop' }), doneFrame()]; - const ir = await collectIR(irFromOpenAIChatCompletions(iterate(source))); + const ir = await collectIR(readOpenAIChatCompletionsEvents(iterate(source))); expect(ir.choices[0].items.filter(item => item.type === 'message').every(item => item.content.every(part => part.type !== 'text' || part.annotations === undefined))).toBe(true); expect(warning).toHaveBeenCalledWith('Dropping ChatCompletions citation outside open text', expect.objectContaining({ choice: 0 })); warning.mockRestore(); @@ -78,42 +78,42 @@ test('late ChatCompletions citations on closed text are dropped', async () => { test('ChatCompletions rejects a reversed citation before clamping it to available text', async () => { const source = [chunk({ content: 'abc' }), chunk({ annotations: [citation(5, 3)] }, { finish_reason: 'stop' }), doneFrame()]; - await expect(collectIR(irFromOpenAIChatCompletions(iterate(source)))).rejects.toThrow('Invalid code-point range'); + await expect(collectIR(readOpenAIChatCompletionsEvents(iterate(source)))).rejects.toThrow('Invalid code-point range'); }); test('Messages cited text remains source text without inventing output coordinates', async () => { - const ir = await collectIR(irFromAnthropicMessages(iterate(messagesCitationFrames('source quote')))); + const ir = await collectIR(readAnthropicMessagesEvents(iterate(messagesCitationFrames('source quote')))); const item = ir.choices[0].items[0]; if (item.type !== 'message' || item.content[0].type !== 'text') throw new Error('expected cited text part'); expect(item.content[0].annotations?.[0]).toMatchObject({ source_text: 'source quote' }); expect(item.content[0].annotations?.[0]).not.toHaveProperty('output_text_range'); - const result = await nativeResult('openai-responses', await collect(openaiResponsesFromIR(irFromAnthropicMessages(iterate(messagesCitationFrames('source quote')))))); + const result = await nativeResult('openai-responses', await collect(writeOpenAIResponsesEvents(readAnthropicMessagesEvents(iterate(messagesCitationFrames('source quote')))))); expect(result.output[0].content[0].annotations).toEqual([]); }); test('Messages URL citations without cited text do not expand to the entire Responses text', async () => { - const ir = await collectIR(irFromAnthropicMessages(iterate(messagesCitationFrames()))); + const ir = await collectIR(readAnthropicMessagesEvents(iterate(messagesCitationFrames()))); const item = ir.choices[0].items[0]; expect(item).toMatchObject({ content: [{ annotations: [{ source: 'https://docs.example.com/page-1' }] }] }); if (item.type !== 'message' || item.content[0].type !== 'text') throw new Error('expected cited text part'); expect(item.content[0].annotations?.[0]).not.toHaveProperty('output_text_range'); - const result = await nativeResult('openai-responses', await collect(openaiResponsesFromIR(irFromAnthropicMessages(iterate(messagesCitationFrames()))))); + const result = await nativeResult('openai-responses', await collect(writeOpenAIResponsesEvents(readAnthropicMessagesEvents(iterate(messagesCitationFrames()))))); expect(result.output[0].content[0].annotations).toEqual([]); }); test('range-less Messages citations do not expand to whole-part ChatCompletions or GenerateContent citations', async () => { - const chatFrames = await collect(openaiChatCompletionsFromIR(irFromAnthropicMessages(iterate(messagesCitationFrames('source quote'))), { id: 'chat_target', created: 1 })); + const chatFrames = await collect(writeOpenAIChatCompletionsEvents(readAnthropicMessagesEvents(iterate(messagesCitationFrames('source quote'))), { id: 'chat_target', created: 1 })); expect(chatFrames.some(frame => frame.type === 'event' && !('error' in frame.event) && frame.event.choices.some(choice => 'annotations' in choice.delta))).toBe(false); - const generateContentFrames = await collect(geminiGenerateContentFromIR(irFromAnthropicMessages(iterate(messagesCitationFrames('source quote'))))); + const generateContentFrames = await collect(writeGeminiGenerateContentEvents(readAnthropicMessagesEvents(iterate(messagesCitationFrames('source quote'))))); expect(generateContentFrames.some(frame => frame.type === 'event' && 'candidates' in frame.event && frame.event.candidates?.some(candidate => candidate.groundingMetadata !== undefined))).toBe(false); }); test.each([ - ['ChatCompletions', irFromOpenAIChatCompletions, (error: object) => ({ id: 'chat', model: 'm', choices: [], error })], - ['Responses', irFromOpenAIResponses, (error: object) => ({ type: 'error', error })], - ['Messages', irFromAnthropicMessages, (error: object) => ({ type: 'error', error })], + ['ChatCompletions', readOpenAIChatCompletionsEvents, (error: object) => ({ id: 'chat', model: 'm', choices: [], error })], + ['Responses', readOpenAIResponsesEvents, (error: object) => ({ type: 'error', error })], + ['Messages', readAnthropicMessagesEvents, (error: object) => ({ type: 'error', error })], ] as const)('%s reader transfers immutable error objects without copying', async (_name, reader, event) => { const error = Object.freeze({ type: 'api_error', message: 'original', details: Object.freeze([Object.freeze({ text: 'original' })]) }); const frames = await collect(reader(iterate([eventFrame(event(error) as any)]))); diff --git a/packages/translate/__tests__/protocols/streams_test.ts b/packages/translate/__tests__/protocols/streams_test.ts index 98fafde2e7..28128aae4b 100644 --- a/packages/translate/__tests__/protocols/streams_test.ts +++ b/packages/translate/__tests__/protocols/streams_test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { anthropicMessagesFromIR, geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; +import { writeAnthropicMessagesEvents, writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; import type { IRJSONObject } from '../../src/ir/common/json.ts'; import { createIRBuilder } from '../../src/ir/output/builder.ts'; import type { IROutputItem } from '../../src/ir/output/types.ts'; @@ -19,7 +19,7 @@ import { openaiResponsesResultToEvents, reassembleOpenAIResponsesEvents, type Op const options = { id: 'target', created: 1 }; const chatFrame = (choices: any[], usage?: any) => eventFrame({ id: 'source', object: 'chat.completion.chunk', model: 'model', created: 1, choices, ...(usage === undefined ? {} : { usage }) } as OpenAIChatCompletionsStreamEvent); -const chat = () => irFromOpenAIChatCompletions(iterate([ +const chat = () => readOpenAIChatCompletionsEvents(iterate([ chatFrame([{ index: 0, delta: { reasoning_text: 'think' } }]), chatFrame([{ index: 0, delta: { content: 'A😀' } }]), chatFrame([{ index: 0, delta: { content: '中B', annotations: [{ type: 'url_citation', url_citation: { start_index: 1, end_index: 3, url: 'https://example.com', title: 'Source' } }] } }]), @@ -35,8 +35,8 @@ const responseFrames = () => iterate([...openaiResponsesResultToEvents({ { type: 'function_call', id: 'f', call_id: 'call', name: 'tool', arguments: '{"a":1}', status: 'completed' }, ], usage: { input_tokens: 11, output_tokens: 7, total_tokens: 18, input_tokens_details: { cached_tokens: 3 }, output_tokens_details: { reasoning_tokens: 2 } }, } as OpenAIResponsesResultEx), doneFrame()]); -const responses = () => irFromOpenAIResponses(responseFrames()); -const messages = () => irFromAnthropicMessages(iterate([ +const responses = () => readOpenAIResponsesEvents(responseFrames()); +const messages = () => readAnthropicMessagesEvents(iterate([ { type: 'message_start', message: { id: 'source', type: 'message', role: 'assistant', model: 'model', content: [], usage: { input_tokens: 6, output_tokens: 0, cache_read_input_tokens: 3, cache_creation_input_tokens: 2 } } }, { type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: '', signature: '' } }, { type: 'content_block_delta', index: 0, delta: { type: 'thinking_delta', thinking: 'think' } }, @@ -55,11 +55,11 @@ const messages = () => irFromAnthropicMessages(iterate([ ].map(value => eventFrame(value as AnthropicMessagesStreamEventEx)))); const translateChatFrames = async (nativeFrames: readonly any[]) => { - const irFrames = await collect(irFromOpenAIChatCompletions(iterate(nativeFrames as any[]))); - const chatFrames = await collect(openaiChatCompletionsFromIR(iterate(irFrames))); - const responsesFrames = await collect(openaiResponsesFromIR(iterate(irFrames))); - const messagesFrames = await collect(anthropicMessagesFromIR(iterate(irFrames))); - const generateContentFrames = await collect(geminiGenerateContentFromIR(iterate(irFrames))); + const irFrames = await collect(readOpenAIChatCompletionsEvents(iterate(nativeFrames as any[]))); + const chatFrames = await collect(writeOpenAIChatCompletionsEvents(iterate(irFrames))); + const responsesFrames = await collect(writeOpenAIResponsesEvents(iterate(irFrames))); + const messagesFrames = await collect(writeAnthropicMessagesEvents(iterate(irFrames))); + const generateContentFrames = await collect(writeGeminiGenerateContentEvents(iterate(irFrames))); return { irFrames, chatFrames, @@ -90,13 +90,13 @@ describe('IR streaming', () => { for (const [name, source] of [['ChatCompletions', chat], ['Responses', responses], ['Messages', messages]] as const) { it(`ChatCompletions via ${name} emits text and streamed function arguments`, async () => { - const result = await reassembleOpenAIChatCompletionsEvents(events(openaiChatCompletionsFromIR(source(), options))); + const result = await reassembleOpenAIChatCompletionsEvents(events(writeOpenAIChatCompletionsEvents(source(), options))); expect(result.choices[0].message.content).toBe('A😀中B'); expect(result.choices[0].message.tool_calls?.[0]).toMatchObject({ type: 'function', function: { name: 'tool', arguments: '{"a":1}' } }); expect(result.usage?.prompt_tokens).toBe(11); }); it(`Responses via ${name} completes item lifecycles`, async () => { - const frames = await collect(openaiResponsesFromIR(source(), options)); + const frames = await collect(writeOpenAIResponsesEvents(source(), options)); const result = await reassembleOpenAIResponsesEvents(events(iterate(frames))); expect(result.output.filter(item => item.type === 'message').flatMap(item => item.type === 'message' ? item.content.map(p => p.type === 'output_text' ? p.text : '') : []).join('')).toBe('A😀中B'); if (name === 'Responses') { @@ -112,13 +112,13 @@ describe('IR streaming', () => { expect(frames.at(-1)?.type).toBe('event'); }); it(`Messages via ${name} serializes blocks and parses tool input`, async () => { - const result = await reassembleAnthropicMessagesEvents(events(anthropicMessagesFromIR(source(), options))); + const result = await reassembleAnthropicMessagesEvents(events(writeAnthropicMessagesEvents(source(), options))); expect(result.content.filter(block => block.type === 'text').map(block => block.type === 'text' ? block.text : '').join('')).toBe('A😀中B'); expect(result.content.find(block => block.type === 'tool_use')).toMatchObject({ name: 'tool', input: { a: 1 } }); expect(result.usage.output_tokens).toBe(7); }); it(`GenerateContent via ${name} emits complete object args and final usage`, async () => { - const frames = await collect(geminiGenerateContentFromIR(source(), options)); + const frames = await collect(writeGeminiGenerateContentEvents(source(), options)); const result = await reassembleGeminiGenerateContentEvents(events(iterate(frames))); expect(result.candidates?.[0].content?.parts?.filter(p => p.text !== undefined && !p.thought).map(p => p.text).join('')).toBe('A😀中B'); expect(result.candidates?.[0].content?.parts?.find(p => p.functionCall !== undefined)?.functionCall).toMatchObject({ name: 'tool', args: { a: 1 } }); @@ -206,7 +206,7 @@ describe('IR streaming', () => { exhausted = true; yield chatFrame([{ index: 0, delta: {}, finish_reason: 'stop' }]); yield doneFrame(); }; - const stream = openaiChatCompletionsFromIR(irFromOpenAIChatCompletions(upstream()), options); + const stream = writeOpenAIChatCompletionsEvents(readOpenAIChatCompletionsEvents(upstream()), options); let found = false; for await (const frame of stream) if (frame.type === 'event' && !('error' in frame.event) && frame.event.choices[0]?.delta.content === 'first') { expect(exhausted).toBe(false); found = true; break; @@ -215,16 +215,16 @@ describe('IR streaming', () => { }); it('rejects missing terminals and unsafe replacement on an append-only downstream', async () => { - await expect(collectIR(irFromOpenAIChatCompletions(iterate([chatFrame([{ index: 0, delta: { content: 'x' } }])])))).rejects.toThrow('without finish_reason'); + await expect(collectIR(readOpenAIChatCompletionsEvents(iterate([chatFrame([{ index: 0, delta: { content: 'x' } }])])))).rejects.toThrow('without finish_reason'); const frames = completeIR([{ type: 'message', content: [{ type: 'text', text: 'a' }] }]); const part = frames.findIndex(record => record.type === 'write' && record.kind === 'initialize' && record.path.length === 6); frames.splice(part + 1, 0, { type: 'write', kind: 'assign', path: ['choices', 0, 'items', 0, 'content', 0, 'text'], value: 'b' }); - await expect(collect(openaiChatCompletionsFromIR(iterate(frames), options))).rejects.toThrow('append target cannot be assigned'); + await expect(collect(writeOpenAIChatCompletionsEvents(iterate(frames), options))).rejects.toThrow('append target cannot be assigned'); }); it('handles Responses terminal-only output and implicit audio events', async () => { const result: OpenAIResponsesResultEx = { id: 's', object: 'response', model: 'm', status: 'completed', output: [], error: null, incomplete_details: null }; - const source = irFromOpenAIResponses(iterate([ + const source = readOpenAIResponsesEvents(iterate([ eventFrame({ type: 'response.created', response: result } as OpenAIResponsesStreamEventEx), eventFrame({ type: 'response.audio.delta', delta: 'YWJj' } as OpenAIResponsesStreamEventEx), eventFrame({ type: 'response.audio.transcript.delta', delta: 'abc' } as OpenAIResponsesStreamEventEx), @@ -234,7 +234,7 @@ describe('IR streaming', () => { ])); const ir = await collectIR(source); expect(ir.choices[0].items[0]).toMatchObject({ type: 'message', content: [{ type: 'audio', audio: { data: 'YWJj', transcript: 'abc' } }] }); - const out = await collect(events(openaiResponsesFromIR(iterate(completeIR(ir.choices[0].items)), options))); + const out = await collect(events(writeOpenAIResponsesEvents(iterate(completeIR(ir.choices[0].items)), options))); expect(out).toContainEqual(expect.objectContaining({ type: 'response.audio.delta', delta: 'YWJj' })); }); @@ -260,7 +260,7 @@ describe('IR streaming', () => { b.event({ type: 'finish', status: 'completed' }); yield* b.drain(); }; - const output = events(geminiGenerateContentFromIR(source(), options)); + const output = events(writeGeminiGenerateContentEvents(source(), options)); const result = []; for (const [choice, text] of ['a', 'b'].entries()) { const body = await output.next(); @@ -292,7 +292,7 @@ describe('IR streaming', () => { }); it('ends at candidate termination without emitting an empty final frame when usage is absent', async () => { - const frames = await collect(events(geminiGenerateContentFromIR(iterate(completeIR([{ type: 'message', content: [{ type: 'text', text: 'answer' }] }])), options))); + const frames = await collect(events(writeGeminiGenerateContentEvents(iterate(completeIR([{ type: 'message', content: [{ type: 'text', text: 'answer' }] }])), options))); expect(frames).toEqual([ { responseId: 'target', modelVersion: 'model', candidates: [{ index: 0, content: { role: 'model', parts: [{ text: 'answer' }] } }] }, { responseId: 'target', modelVersion: 'model', candidates: [{ index: 0, content: { role: 'model', parts: [{ thoughtSignature: 'test-sidecar' }] } }] }, @@ -311,7 +311,7 @@ describe('IR streaming', () => { b.event({ type: 'choice_end', choice: 0, finish_reason: 'length' }); b.event({ type: 'choice_end', choice: 1, finish_reason: 'stop' }); b.event({ type: 'finish', status: 'incomplete' }); - await expect(collect(geminiGenerateContentFromIR(iterate(b.drain()), options))).rejects.toThrow(); + await expect(collect(writeGeminiGenerateContentEvents(iterate(b.drain()), options))).rejects.toThrow(); }); it('preserves GenerateContent item order when a tool delays later text and audio', async () => { @@ -336,7 +336,7 @@ describe('IR streaming', () => { b.event({ type: 'choice_end', choice: 0, finish_reason: 'tool_calls' }); b.event({ type: 'finish', status: 'completed' }); - const result = await reassembleGeminiGenerateContentEvents(events(geminiGenerateContentFromIR(iterate(b.drain()), options))); + const result = await reassembleGeminiGenerateContentEvents(events(writeGeminiGenerateContentEvents(iterate(b.drain()), options))); expect(result.candidates?.[0].content?.parts).toEqual([ { functionCall: { id: 'call', name: 'tool', args: {} } }, { text: 'after tool' }, @@ -352,18 +352,18 @@ describe('IR streaming', () => { it('uses target ChatCompletions audio replay metadata across deltas', async () => { const frames = [chatFrame([{ index: 0, delta: { audio: { id: 'audio', expires_at: 123, data: 'YQ==' } } }]), chatFrame([{ index: 0, delta: { audio: { transcript: 'word' } } }]), chatFrame([{ index: 0, delta: {}, finish_reason: 'stop' }]), doneFrame()]; - const result = await reassembleOpenAIChatCompletionsEvents(events(openaiChatCompletionsFromIR(irFromOpenAIChatCompletions(iterate(frames)), { ...options, audioMetadata: () => ({ id: 'target-audio', expires_at: 456 }) }))); + const result = await reassembleOpenAIChatCompletionsEvents(events(writeOpenAIChatCompletionsEvents(readOpenAIChatCompletionsEvents(iterate(frames)), { ...options, audioMetadata: () => ({ id: 'target-audio', expires_at: 456 }) }))); expect(result.choices[0].message.audio).toEqual({ id: 'target-audio', expires_at: 456, data: 'YQ==', transcript: 'word' }); }); it('retains text-image-text order in Responses output', async () => { - const frames = openaiResponsesFromIR(iterate(completeIR([{ type: 'message', content: [{ type: 'text', text: 'before' }, { type: 'image', image: { data: 'YQ==', media_type: 'image/png' } }, { type: 'text', text: 'after' }] }])), options); + const frames = writeOpenAIResponsesEvents(iterate(completeIR([{ type: 'message', content: [{ type: 'text', text: 'before' }, { type: 'image', image: { data: 'YQ==', media_type: 'image/png' } }, { type: 'text', text: 'after' }] }])), options); const result = await reassembleOpenAIResponsesEvents(events(frames)); expect(result.output.map(item => item.type)).toEqual(['message', 'image_generation_call', 'message', 'reasoning']); }); it('marks ChatCompletions length termination incomplete and exposes IR errors', async () => { - const frames = await collect(irFromOpenAIChatCompletions(iterate([chatFrame([{ index: 0, delta: { content: 'text' }, finish_reason: 'length' }]), doneFrame()]))); + const frames = await collect(readOpenAIChatCompletionsEvents(iterate([chatFrame([{ index: 0, delta: { content: 'text' }, finish_reason: 'length' }]), doneFrame()]))); expect(frames.find(record => record.type === 'finish')).toMatchObject({ status: 'incomplete' }); await expect(collectIR(iterate([{ type: 'error', error: { message: 'failure' } }]))).rejects.toThrow(); }); @@ -374,7 +374,7 @@ describe('IR streaming', () => { const resultImage = { ...image, status: success ? 'completed' : 'failed', result: success ? 'YQ==' : null }; const response: any = { id: 's', model: 'm', output: [resultImage, message], usage: null }; const input: any[] = [{ type: 'response.created', response: { ...response, output: [] } }, { type: 'response.output_item.added', output_index: 0, item: image }, { type: 'response.output_item.added', output_index: 1, item: message }, { type: 'response.output_item.done', output_index: 1, item: message }, { type: 'response.output_item.done', output_index: 0, item: resultImage }, { type: 'response.completed', response }]; - const frames = await collect(irFromOpenAIResponses(iterate(input.map(eventFrame)))); + const frames = await collect(readOpenAIResponsesEvents(iterate(input.map(eventFrame)))); const ir = await collectIR(iterate(frames)); const parts = ir.choices[0].items.flatMap(item => item.type === 'message' ? item.content : []); expect(parts.map(part => part.type)).toEqual(success ? ['image', 'text'] : ['text']); @@ -385,12 +385,12 @@ describe('IR streaming', () => { const item: any = { type: 'message', id: 'msg', role: 'assistant', status: 'completed', content: [{ type: 'output_text', text: 'text', annotations: [] }] }; const response: any = { id: 's', model: 'm', status: 'completed', output: [item], usage: null }; const input: any[] = [{ type: 'response.created', response: { ...response, output: [] } }, { type: 'response.output_item.added', output_index: 0, item: { ...item, status: 'in_progress', content: [] } }, { type: 'response.content_part.added', output_index: 0, content_index: 0, part: { type: 'output_text', text: '', annotations: [] } }, { type: 'response.output_text.delta', output_index: 0, content_index: 0, delta: 'text' }, { type: 'response.content_part.done', output_index: 0, content_index: 0, part: item.content[0] }, { type: 'response.output_item.done', output_index: 0, item }, { type: 'response.completed', response }]; - const records = await collect(irFromOpenAIResponses(iterate(input.map(eventFrame)))); + const records = await collect(readOpenAIResponsesEvents(iterate(input.map(eventFrame)))); expect(records.filter(record => record.type === 'part_end')).toEqual([{ type: 'part_end', choice: 0, item: 0, part: 0 }]); expect(records.findIndex(record => record.type === 'part_end')).toBeLessThan(records.findIndex(record => record.type === 'item_end')); }); - it.each([['Messages', anthropicMessagesFromIR, reassembleAnthropicMessagesEvents], ['GenerateContent', geminiGenerateContentFromIR, reassembleGeminiGenerateContentEvents]] as const)('allows configured JSON repair in %s', async (_name, adapter, reassemble) => { + it.each([['Messages', writeAnthropicMessagesEvents, reassembleAnthropicMessagesEvents], ['GenerateContent', writeGeminiGenerateContentEvents, reassembleGeminiGenerateContentEvents]] as const)('allows configured JSON repair in %s', async (_name, adapter, reassemble) => { const frames = completeIR([{ type: 'function_call', name: 'tool', call_id: 'c', arguments: '{"a":1' }]); const output = adapter(iterate(frames), { ...options, parseToolArguments: text => JSON.parse(`${text}}`) }); const result: any = await (reassemble as any)(events(output)); @@ -399,7 +399,7 @@ describe('IR streaming', () => { }); it('waits for function names before Responses item creation', async () => { - const frames = await collect(openaiResponsesFromIR(chat(), options)); + const frames = await collect(writeOpenAIResponsesEvents(chat(), options)); const added = frames.flatMap(frame => frame.type === 'event' && frame.event.type === 'response.output_item.added' && frame.event.item.type === 'function_call' ? [frame.event.item] : []); expect(added).toHaveLength(1); expect(added[0]).toMatchObject({ name: 'tool', call_id: 'call' }); @@ -415,7 +415,7 @@ describe('IR streaming', () => { { type: 'write', kind: 'initialize', path: ['choices', 0, 'logprobs'], value: [] }, { type: 'write', kind: 'initialize', path: ['choices', 0, 'logprobs', 0], value: { scope: 'text_part', item_index: 0, content_index: 0, tokens: [] } }, ...tokens.map((value, index) => ({ type: 'write' as const, kind: 'assign' as const, path: ['choices', 0, 'logprobs', 0, 'tokens', index], value }))); - const frames = await collect(openaiResponsesFromIR(iterate(records), options)); + const frames = await collect(writeOpenAIResponsesEvents(iterate(records), options)); const emitted = frames.flatMap(frame => frame.type === 'event' && frame.event.type === 'response.output_text.delta' ? frame.event.logprobs ?? [] : []); expect(emitted).toEqual(tokens); const result = await reassembleOpenAIResponsesEvents(events(iterate(frames))); @@ -431,7 +431,7 @@ describe('IR streaming', () => { { type: 'write', kind: 'assign', path: ['choices', 0, 'logprobs', 0, 'tokens', 0], value: { token: 'x', logprob: -1 } }, ] as const; frames.splice(end, 0, ...logprobs); - await expect(collect(openaiChatCompletionsFromIR(iterate(frames), options))).rejects.toThrow(); + await expect(collect(writeOpenAIChatCompletionsEvents(iterate(frames), options))).rejects.toThrow(); }); it('uses the same lifecycle validation when collecting IR', async () => { @@ -441,7 +441,7 @@ describe('IR streaming', () => { }); it('preserves Responses generated image format', async () => { - const result = await reassembleOpenAIResponsesEvents(events(openaiResponsesFromIR(iterate(completeIR([{ type: 'message', content: [{ type: 'image', image: { data: 'YQ==', media_type: 'image/jpeg' } }] }])), options))); + const result = await reassembleOpenAIResponsesEvents(events(writeOpenAIResponsesEvents(iterate(completeIR([{ type: 'message', content: [{ type: 'image', image: { data: 'YQ==', media_type: 'image/jpeg' } }] }])), options))); expect(result.output[0]).toMatchObject({ type: 'image_generation_call', output_format: 'jpeg' }); }); @@ -450,36 +450,36 @@ describe('IR streaming', () => { const transcript = { type: 'response.audio.transcript.delta', delta: 'hello' }; const response = { id: 's', model: 'm', output: [] }; const input: any[] = [{ type: 'response.created', response }, ...(transcriptFirst ? [transcript, data] : [data, transcript]), { type: 'response.audio.done' }, { type: 'response.audio.transcript.done' }, { type: 'response.completed', response }]; - const source = () => irFromOpenAIResponses(iterate(input.map(eventFrame))); - const messages = await reassembleAnthropicMessagesEvents(events(anthropicMessagesFromIR(source(), options))); + const source = () => readOpenAIResponsesEvents(iterate(input.map(eventFrame))); + const messages = await reassembleAnthropicMessagesEvents(events(writeAnthropicMessagesEvents(source(), options))); expect(messages.content.filter(block => block.type !== 'redacted_thinking')).toMatchObject([{ type: 'text', text: 'hello' }]); - const chat = await reassembleOpenAIChatCompletionsEvents(events(openaiChatCompletionsFromIR(source(), { ...options, audioMetadata: () => ({ id: 'a', expires_at: 123 }) }))); + const chat = await reassembleOpenAIChatCompletionsEvents(events(writeOpenAIChatCompletionsEvents(source(), { ...options, audioMetadata: () => ({ id: 'a', expires_at: 123 }) }))); expect(chat.choices[0].message.audio).toEqual({ id: 'a', expires_at: 123, data: 'YQ==', transcript: 'hello' }); expect(chat.choices[0].message.content).toBeNull(); - await expect(reassembleGeminiGenerateContentEvents(events(geminiGenerateContentFromIR(source(), options)))) + await expect(reassembleGeminiGenerateContentEvents(events(writeGeminiGenerateContentEvents(source(), options)))) .rejects.toThrow('GenerateContent inline data requires a MIME type'); }); it('emits a transcript-only audio part once in GenerateContent', async () => { - const result = await reassembleGeminiGenerateContentEvents(events(geminiGenerateContentFromIR(iterate(completeIR([{ type: 'message', content: [{ type: 'audio', audio: { transcript: 'hello' } }] }])), options))); + const result = await reassembleGeminiGenerateContentEvents(events(writeGeminiGenerateContentEvents(iterate(completeIR([{ type: 'message', content: [{ type: 'audio', audio: { transcript: 'hello' } }] }])), options))); expect(result.candidates?.[0].content?.parts).toEqual([{ text: 'hello' }, { thoughtSignature: 'test-sidecar' }]); }); it('emits transcript-only audio before ChatCompletions finish_reason', async () => { - const frames = await collect(openaiChatCompletionsFromIR(iterate(completeIR([{ type: 'message', content: [{ type: 'audio', audio: { transcript: 'hello' } }] }])), options)); + const frames = await collect(writeOpenAIChatCompletionsEvents(iterate(completeIR([{ type: 'message', content: [{ type: 'audio', audio: { transcript: 'hello' } }] }])), options)); const chunks = frames.flatMap(frame => frame.type === 'event' && !('error' in frame.event) ? frame.event.choices : []); expect(chunks.findIndex(choice => choice.delta.content === 'hello')).toBeLessThan(chunks.findIndex(choice => choice.finish_reason === 'stop')); }); - it.each([['Responses', openaiResponsesFromIR], ['Messages', anthropicMessagesFromIR]] as const)('rejects completed unnamed tools in %s', async (_name, adapter) => { + it.each([['Responses', writeOpenAIResponsesEvents], ['Messages', writeAnthropicMessagesEvents]] as const)('rejects completed unnamed tools in %s', async (_name, adapter) => { await expect(collect(adapter(iterate(completeIR([{ type: 'function_call', name: '', arguments: '{}' }])), options))).rejects.toThrow('require a name'); }); it.each([ - ['ChatCompletions', openaiChatCompletionsFromIR], - ['Responses', openaiResponsesFromIR], - ['Messages', anthropicMessagesFromIR], - ['GenerateContent', geminiGenerateContentFromIR], + ['ChatCompletions', writeOpenAIChatCompletionsEvents], + ['Responses', writeOpenAIResponsesEvents], + ['Messages', writeAnthropicMessagesEvents], + ['GenerateContent', writeGeminiGenerateContentEvents], ] as const)('%s rejects IR start without a model', async (_name, adapter) => { await expect(collect(adapter(iterate([{ type: 'start', id: 'source' }] as any), options))).rejects.toThrow('requires an IR model'); }); diff --git a/packages/translate/__tests__/protocols/usage_test.ts b/packages/translate/__tests__/protocols/usage_test.ts index b6348755b9..8ddcc21aaa 100644 --- a/packages/translate/__tests__/protocols/usage_test.ts +++ b/packages/translate/__tests__/protocols/usage_test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; import { fixtureFrames } from './fixtures.ts'; -import { geminiGenerateContentFromIR, irFromAnthropicMessages, irFromOpenAIChatCompletions, irFromOpenAIResponses, openaiChatCompletionsFromIR, openaiResponsesFromIR } from './helpers.ts'; +import { writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; import { usageFromIR as messagesUsageFromIR } from '../../src/protocols/anthropic-messages/as-source/usage.ts'; import { usageToIR as messagesUsageToIR } from '../../src/protocols/anthropic-messages/as-target/usage.ts'; import { usageFromIR } from '../../src/protocols/gemini-generate-content/as-source/usage.ts'; @@ -15,9 +15,9 @@ import { eventFrame } from '@floway-dev/protocols/common'; const initialUsage = { input_tokens: 17, output_tokens: 2, total_tokens: 19, input_tokens_details: { cached_tokens: 3, cache_write_tokens: 4 } }; const sources = [ - ['ChatCompletions', irFromOpenAIChatCompletions, { id: 'resp_usage', model: 'served', created: 1, choices: [], service_tier: 'priority', usage: { prompt_tokens: 17, completion_tokens: 2, total_tokens: 19, prompt_tokens_details: { cached_tokens: 3, cache_write_tokens: 4 } } }, 'priority'], - ['Responses', irFromOpenAIResponses, { type: 'response.created', response: { id: 'resp_usage', model: 'served', created_at: 1, status: 'in_progress', output: [], service_tier: 'fast', usage: initialUsage } }, 'fast'], - ['Messages', irFromAnthropicMessages, { type: 'message_start', message: { id: 'msg_usage', model: 'served', usage: { input_tokens: 10, output_tokens: 2, cache_read_input_tokens: 3, cache_creation_input_tokens: 4, speed: 'fast' } } }, 'fast'], + ['ChatCompletions', readOpenAIChatCompletionsEvents, { id: 'resp_usage', model: 'served', created: 1, choices: [], service_tier: 'priority', usage: { prompt_tokens: 17, completion_tokens: 2, total_tokens: 19, prompt_tokens_details: { cached_tokens: 3, cache_write_tokens: 4 } } }, 'priority'], + ['Responses', readOpenAIResponsesEvents, { type: 'response.created', response: { id: 'resp_usage', model: 'served', created_at: 1, status: 'in_progress', output: [], service_tier: 'fast', usage: initialUsage } }, 'fast'], + ['Messages', readAnthropicMessagesEvents, { type: 'message_start', message: { id: 'msg_usage', model: 'served', usage: { input_tokens: 10, output_tokens: 2, cache_read_input_tokens: 3, cache_creation_input_tokens: 4, speed: 'fast' } } }, 'fast'], ] as const; test.each(sources)('Responses via %s exposes known initial accounting before pulling another upstream frame', async (_name, reader, event, tier) => { @@ -27,7 +27,7 @@ test.each(sources)('Responses via %s exposes known initial accounting before pul pulledNext = true; throw new Error('The converter pulled past the initial upstream frame'); }; - const stream = openaiResponsesFromIR(reader(upstream())); + const stream = writeOpenAIResponsesEvents(reader(upstream())); try { for (const type of ['response.created', 'response.in_progress']) { const frame = (await stream.next()).value; @@ -45,7 +45,7 @@ test('Responses preserves absent initial accounting and later measured usage', a { type: 'response.completed', response: { id: 'resp_usage', model: 'served', created_at: 1, status: 'completed', output: [], service_tier: 'fast', usage: initialUsage } }, ].map(event => eventFrame(event as any)); const output = []; - for await (const frame of openaiResponsesFromIR(irFromOpenAIResponses(iterate(upstream)))) output.push(frame); + for await (const frame of writeOpenAIResponsesEvents(readOpenAIResponsesEvents(iterate(upstream)))) output.push(frame); expect(output[0]).toMatchObject({ event: { response: { usage: null } } }); expect(output[0].type === 'event' && output[0].event.type === 'response.created' && output[0].event.response).not.toHaveProperty('service_tier'); expect(output.at(-1)).toMatchObject({ event: { type: 'response.completed', response: { usage: initialUsage, service_tier: 'fast' } } }); @@ -58,7 +58,7 @@ test('ChatCompletions via Messages publishes its initial role with already-known pulledNext = true; throw new Error('The converter pulled past message_start'); }; - const stream = openaiChatCompletionsFromIR(irFromAnthropicMessages(upstream())); + const stream = writeOpenAIChatCompletionsEvents(readAnthropicMessagesEvents(upstream())); try { expect((await stream.next()).value).toMatchObject({ event: { model: 'served', service_tier: 'fast', choices: [{ delta: { role: 'assistant' } }] } }); expect(pulledNext).toBe(false); @@ -70,7 +70,7 @@ test('ChatCompletions via Messages publishes its initial role with already-known test.each([['fast', 'priority'], ['default', 'standard']])('GenerateContent maps the %s alias to its %s tier', async (tier, serviceTier) => { const source = fixtureFrames('openai-responses', { text: ['answer'], usage: { input_tokens: 1, output_tokens: 2, total_tokens: 3 }, tier }); const output = []; - for await (const frame of geminiGenerateContentFromIR(irFromOpenAIResponses(iterate(source)))) output.push(frame); + for await (const frame of writeGeminiGenerateContentEvents(readOpenAIResponsesEvents(iterate(source)))) output.push(frame); expect(output.at(-1)).toMatchObject({ event: { usageMetadata: { serviceTier } } }); }); @@ -105,7 +105,7 @@ test('Messages reader accepts a message_delta without usage', async () => { { type: 'message_delta', delta: { stop_reason: 'end_turn' } }, { type: 'message_stop' }, ].map(event => eventFrame(event as any)); - const result = await collectIR(irFromAnthropicMessages(iterate(upstream))); + const result = await collectIR(readAnthropicMessagesEvents(iterate(upstream))); expect(result.usage).toMatchObject({ input_tokens_inclusive: 1, output_tokens_inclusive: 0 }); }); @@ -137,6 +137,6 @@ test.each([ { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 2, ...late } }, { type: 'message_stop' }, ].map(event => eventFrame(event as any)); - const result = await collectIR(irFromAnthropicMessages(iterate(upstream))); + const result = await collectIR(readAnthropicMessagesEvents(iterate(upstream))); expect(result.usage).toMatchObject({ input_tokens_inclusive: 10 + total, output_tokens_inclusive: 2, cache_creation_input_tokens: total }); }); diff --git a/packages/translate/src/ir/output/schema.ts b/packages/translate/src/ir/output/schema.ts index 1ab93b5261..034e3fd3ea 100644 --- a/packages/translate/src/ir/output/schema.ts +++ b/packages/translate/src/ir/output/schema.ts @@ -34,14 +34,14 @@ export interface IRUnionNode { readonly kind: 'opaque'; readonly value?: V } +// A value that is only ever replaced as a whole. `phantom` exists for the type only. +export interface IROpaqueNode { readonly kind: 'opaque'; readonly phantom?: V } export type IRSchemaNode = IRStringNode | IRNumberNode | IRNullableNode | IRArrayNode | IRObjectNode | IRUnionNode | IROpaqueNode; export interface IRSchemaFields { readonly [key: string]: IRSchemaNode } const string = { kind: 'string' } as const; const appendString = { kind: 'string', append: true } as const; -const callId = { kind: 'string', absent: true } as const; +const absentString = { kind: 'string', absent: true } as const; const number = { kind: 'number' } as const; const nullable = (of: N): IRNullableNode => ({ kind: 'nullable', of }); const array = (of: N): IRArrayNode => ({ kind: 'array', of }); @@ -69,8 +69,8 @@ const item = union('type', { summary: array(appendString), content: array(appendString), }, { optional: ['summary', 'content'] }), - function_call: object({ call_id: callId, name: string, arguments: appendString }, { optional: ['call_id'] }), - custom_tool_call: object({ call_id: callId, name: string, input: appendString }, { optional: ['call_id'] }), + function_call: object({ call_id: absentString, name: string, arguments: appendString }, { optional: ['call_id'] }), + custom_tool_call: object({ call_id: absentString, name: string, input: appendString }, { optional: ['call_id'] }), }, 'item'); const logprobs = nullable(array(union('scope', { @@ -121,4 +121,4 @@ type Mutual = [A] extends [B] ? ([B] extends [A] ? true : false) : false; // Compile-time proof that the schema and the IR types describe the same values: a field the // schema declares optional, nullable, appendable or `IRAbsent`-capable differs from the IR type // exactly when this stops being `true`. -export const irSchemaMatchesIRTypes: Mutual, IROutput> = true; +export const irSchemaTypeEquality: Mutual, IROutput> = true; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/events.ts similarity index 98% rename from packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts rename to packages/translate/src/protocols/anthropic-messages/as-source/events.ts index 456e5d9a5e..7ed73dcdc3 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/sse-to.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/events.ts @@ -13,8 +13,8 @@ import { createIRUnitQueue } from '../../../ir/output/unit-queue.ts'; import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; import { appendContent, createContentState, type ContentState, type ContentStates } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; -import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; +import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; import { parseJSONObject, parseJSONObjectAtCompletion, requireJSONObject } from '../../../shared/json/object.ts'; import type { AnthropicMessagesAssistantState, AnthropicMessagesInputAssistantTurn } from '../as-target/assistant-turn.ts'; import { @@ -87,13 +87,13 @@ const outputTextKind = (unit: MessagesUnit, record: IRAppendRecord): 'text' | 't return undefined; }; -interface AnthropicMessagesSSEOutputOptions extends RoundTripOutputOptions<'anthropicMessages'> { +interface AnthropicMessagesEventWriterOptions extends SourceEventWriterOptions<'anthropicMessages'> { parseToolArguments?: (text: string) => IRJSONObject; } -export const anthropicMessagesFromIR = async function* ( +export const writeAnthropicMessagesEvents = async function* ( records: AsyncIterable, - options: AnthropicMessagesSSEOutputOptions, + options: AnthropicMessagesEventWriterOptions, ): AsyncGenerator> { let metadata = { id: '', model: '' }; let started = false; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/sse-from.ts b/packages/translate/src/protocols/anthropic-messages/as-target/events.ts similarity index 99% rename from packages/translate/src/protocols/anthropic-messages/as-target/sse-from.ts rename to packages/translate/src/protocols/anthropic-messages/as-target/events.ts index 4225141cee..00e981efe4 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/sse-from.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/events.ts @@ -61,7 +61,7 @@ interface MessagesBlockState { wrappedInput: string; } -export const irFromAnthropicMessages = async function* ( +export const readAnthropicMessagesEvents = async function* ( frames: AsyncIterable>, options: { customToolNames?: ReadonlySet; roundTrip: RoundTripReader<'anthropicMessages'> }, ): AsyncGenerator { diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts index efbdc00b2d..7d1a62d87a 100644 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts +++ b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts @@ -1,12 +1,12 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromOpenAIChatCompletions } from '../../openai-chat-completions/as-target/sse-from.ts'; -import { anthropicMessagesFromIR } from '../as-source/sse-to.ts'; +import { readOpenAIChatCompletionsEvents } from '../../openai-chat-completions/as-target/events.ts'; +import { writeAnthropicMessagesEvents } from '../as-source/events.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec, maxChoices = 1): AsyncIterable> => { const stream = createRoundTripStream('anthropicMessages', 'openaiChatCompletions', codec); - return anthropicMessagesFromIR(irFromOpenAIChatCompletions(frames, { maxChoices, roundTrip: stream.reader }), { roundTrip: stream.writer }); + return writeAnthropicMessagesEvents(readOpenAIChatCompletionsEvents(frames, { maxChoices, roundTrip: stream.reader }), { roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts index 777ceb4971..bc6d985145 100644 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts +++ b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts @@ -1,12 +1,12 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromOpenAIResponses } from '../../openai-responses/as-target/sse-from.ts'; -import { anthropicMessagesFromIR } from '../as-source/sse-to.ts'; +import { readOpenAIResponsesEvents } from '../../openai-responses/as-target/events.ts'; +import { writeAnthropicMessagesEvents } from '../as-source/events.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { const stream = createRoundTripStream('anthropicMessages', 'openaiResponses', codec); - return anthropicMessagesFromIR(irFromOpenAIResponses(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); + return writeAnthropicMessagesEvents(readOpenAIResponsesEvents(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts similarity index 98% rename from packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts rename to packages/translate/src/protocols/gemini-generate-content/as-source/events.ts index 545f856c1a..d72c8ed9c7 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/sse-to.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts @@ -12,8 +12,8 @@ import { createIRUnitQueue, type IRUnitQueue } from '../../../ir/output/unit-que import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; import { appendContent, createContentState, type ContentStates, type ContentState as ReferenceContentState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; -import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; +import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; import { isCompleteJSONObject, parseJSONObjectAtCompletion } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import type { GeminiGenerateContentAssistantState, GeminiGenerateContentInputAssistantTurn } from '../as-target/assistant-turn.ts'; @@ -57,13 +57,13 @@ interface TextSegment { targetStart: number; } -interface GeminiGenerateContentSSEOutputOptions extends RoundTripOutputOptions<'geminiGenerateContent'> { +interface GeminiGenerateContentEventWriterOptions extends SourceEventWriterOptions<'geminiGenerateContent'> { parseToolArguments?: (text: string) => IRJSONObject; } -export const geminiGenerateContentFromIR = async function* ( +export const writeGeminiGenerateContentEvents = async function* ( records: AsyncIterable, - options: GeminiGenerateContentSSEOutputOptions, + options: GeminiGenerateContentEventWriterOptions, ): AsyncGenerator> { let metadata = { id: '', model: '' }; let started = false; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts index b80a1d6eb8..0f574ddb00 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts @@ -1,12 +1,12 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromAnthropicMessages } from '../../anthropic-messages/as-target/sse-from.ts'; -import { geminiGenerateContentFromIR } from '../as-source/sse-to.ts'; +import { readAnthropicMessagesEvents } from '../../anthropic-messages/as-target/events.ts'; +import { writeGeminiGenerateContentEvents } from '../as-source/events.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { const stream = createRoundTripStream('geminiGenerateContent', 'anthropicMessages', codec); - return geminiGenerateContentFromIR(irFromAnthropicMessages(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); + return writeGeminiGenerateContentEvents(readAnthropicMessagesEvents(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts index 98467f304d..eeae06afe8 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts @@ -1,12 +1,12 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromOpenAIChatCompletions } from '../../openai-chat-completions/as-target/sse-from.ts'; -import { geminiGenerateContentFromIR } from '../as-source/sse-to.ts'; +import { readOpenAIChatCompletionsEvents } from '../../openai-chat-completions/as-target/events.ts'; +import { writeGeminiGenerateContentEvents } from '../as-source/events.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec, maxChoices = 1): AsyncIterable> => { const stream = createRoundTripStream('geminiGenerateContent', 'openaiChatCompletions', codec); - return geminiGenerateContentFromIR(irFromOpenAIChatCompletions(frames, { maxChoices, roundTrip: stream.reader }), { roundTrip: stream.writer }); + return writeGeminiGenerateContentEvents(readOpenAIChatCompletionsEvents(frames, { maxChoices, roundTrip: stream.reader }), { roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts index 7d472247a2..5fd713d617 100644 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts @@ -1,12 +1,12 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromOpenAIResponses } from '../../openai-responses/as-target/sse-from.ts'; -import { geminiGenerateContentFromIR } from '../as-source/sse-to.ts'; +import { readOpenAIResponsesEvents } from '../../openai-responses/as-target/events.ts'; +import { writeGeminiGenerateContentEvents } from '../as-source/events.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { const stream = createRoundTripStream('geminiGenerateContent', 'openaiResponses', codec); - return geminiGenerateContentFromIR(irFromOpenAIResponses(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); + return writeGeminiGenerateContentEvents(readOpenAIResponsesEvents(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts similarity index 98% rename from packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts rename to packages/translate/src/protocols/openai-chat-completions/as-source/events.ts index 74dfdade36..2941fd9b60 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/sse-to.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts @@ -9,7 +9,7 @@ import type { IROutputItem, IROutputMessageItem, IROutputTokenLogprob } from '.. import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; import { appendContent, createContentState, type ContentStates, type ContentState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; -import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; +import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIChatCompletionsInputAssistantTurn, type OpenAIChatCompletionsOutputAssistantState } from '../as-target/assistant-turn.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; @@ -30,7 +30,7 @@ const irChatError = (error: IRErrorPayload): IRErrorPayload => ({ ...(error.provider_specific_fields === undefined ? {} : { provider_specific_fields: error.provider_specific_fields }), }); -interface OpenAIChatCompletionsSSEOutputOptions extends RoundTripOutputOptions<'openaiChatCompletions'> { +interface OpenAIChatCompletionsEventWriterOptions extends SourceEventWriterOptions<'openaiChatCompletions'> { // Replay identity belongs to the newly emitted target audio, not the source IR content. audioMetadata?: (choice: number) => { id: string; expires_at: number }; continuousUsageStats?: boolean; @@ -82,9 +82,9 @@ type ChatChoiceChunk = Omit>; type ChatTextField = 'content' | 'refusal' | 'reasoning_text'; -export const openaiChatCompletionsFromIR = async function* ( +export const writeOpenAIChatCompletionsEvents = async function* ( records: AsyncIterable, - options: OpenAIChatCompletionsSSEOutputOptions, + options: OpenAIChatCompletionsEventWriterOptions, ): AsyncGenerator> { let metadata = { id: '', model: '', created: 0 }; let started = false; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/events.ts similarity index 99% rename from packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts rename to packages/translate/src/protocols/openai-chat-completions/as-target/events.ts index 534a901812..ee815569a3 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/sse-from.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/events.ts @@ -117,7 +117,7 @@ interface ChatChoiceState { incomplete: boolean; } -export const irFromOpenAIChatCompletions = async function* ( +export const readOpenAIChatCompletionsEvents = async function* ( frames: AsyncIterable>, options: { customToolNames?: ReadonlySet; maxChoices?: number; roundTrip: RoundTripReader<'openaiChatCompletions'> }, ): AsyncGenerator { diff --git a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts index 3a9743ea27..f391712fc0 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts @@ -1,7 +1,7 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromAnthropicMessages } from '../../anthropic-messages/as-target/sse-from.ts'; -import { openaiChatCompletionsFromIR } from '../as-source/sse-to.ts'; +import { readAnthropicMessagesEvents } from '../../anthropic-messages/as-target/events.ts'; +import { writeOpenAIChatCompletionsEvents } from '../as-source/events.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsStreamOptionsEx } from '@floway-dev/protocols/openai-chat-completions'; @@ -13,5 +13,5 @@ export const translateToSourceEvents = ( ): AsyncIterable> => { const outputOptions = { continuousUsageStats: streamOptions.include_usage === true && streamOptions.continuous_usage_stats === true }; const stream = createRoundTripStream('openaiChatCompletions', 'anthropicMessages', codec); - return openaiChatCompletionsFromIR(irFromAnthropicMessages(frames, { roundTrip: stream.reader }), { ...outputOptions, roundTrip: stream.writer }); + return writeOpenAIChatCompletionsEvents(readAnthropicMessagesEvents(frames, { roundTrip: stream.reader }), { ...outputOptions, roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts index b09f7e7e74..7b2b5171aa 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts @@ -1,7 +1,7 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromOpenAIResponses } from '../../openai-responses/as-target/sse-from.ts'; -import { openaiChatCompletionsFromIR } from '../as-source/sse-to.ts'; +import { readOpenAIResponsesEvents } from '../../openai-responses/as-target/events.ts'; +import { writeOpenAIChatCompletionsEvents } from '../as-source/events.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsStreamOptionsEx } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; @@ -13,5 +13,5 @@ export const translateToSourceEvents = ( ): AsyncIterable> => { const outputOptions = { continuousUsageStats: streamOptions.include_usage === true && streamOptions.continuous_usage_stats === true }; const stream = createRoundTripStream('openaiChatCompletions', 'openaiResponses', codec); - return openaiChatCompletionsFromIR(irFromOpenAIResponses(frames, { roundTrip: stream.reader }), { ...outputOptions, roundTrip: stream.writer }); + return writeOpenAIChatCompletionsEvents(readOpenAIResponsesEvents(frames, { roundTrip: stream.reader }), { ...outputOptions, roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts b/packages/translate/src/protocols/openai-responses/as-source/events.ts similarity index 99% rename from packages/translate/src/protocols/openai-responses/as-source/sse-to.ts rename to packages/translate/src/protocols/openai-responses/as-source/events.ts index 78b2c93993..64fc6b19df 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/sse-to.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/events.ts @@ -13,7 +13,7 @@ import { createIRUnitQueue } from '../../../ir/output/unit-queue.ts'; import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; import { appendContent, createContentState, type ContentState, type ContentStates } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; -import type { RoundTripOutputOptions } from '../../../round-trip/stream.ts'; +import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; import { isCompleteJSONObject, parseJSONObjectAtCompletion } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIResponsesInputAssistantTurn, type OpenAIResponsesItemState, type OpenAIResponsesOutputAssistantState } from '../as-target/assistant-turn.ts'; @@ -50,7 +50,7 @@ type ResponsesOutputUnit = ResponsesOutputUnitBase & ( | { readonly kind: 'function_call' | 'custom_tool_call'; toolContentState?: ContentState<'text'> } ); -type OpenAIResponsesSSEOutputOptions = RoundTripOutputOptions<'openaiResponses'> & { namespaceToolNames: NamespaceToolNames }; +type OpenAIResponsesEventWriterOptions = SourceEventWriterOptions<'openaiResponses'> & { namespaceToolNames: NamespaceToolNames }; const IMAGE_OUTPUT_FORMATS = { 'image/png': 'png', 'image/jpeg': 'jpeg', 'image/webp': 'webp' } as const; @@ -178,9 +178,9 @@ const textOrRefusalPart = (state: IROutput, path: IRPath): Extract, - options: OpenAIResponsesSSEOutputOptions, + options: OpenAIResponsesEventWriterOptions, ): AsyncGenerator> { let metadata = { id: '', model: '', created: 0 }; let started = false; diff --git a/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts b/packages/translate/src/protocols/openai-responses/as-target/events.ts similarity index 99% rename from packages/translate/src/protocols/openai-responses/as-target/sse-from.ts rename to packages/translate/src/protocols/openai-responses/as-target/events.ts index 3eba5a9b29..68b382a78f 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/sse-from.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/events.ts @@ -137,7 +137,7 @@ const irItemSeed = (item: OpenAIResponsesOutputItemEx): Record const itemId = (item: OpenAIResponsesOutputItemEx): unknown => 'id' in item ? item.id : undefined; -export const irFromOpenAIResponses = async function* ( +export const readOpenAIResponsesEvents = async function* ( frames: AsyncIterable>, options: { roundTrip: RoundTripReader<'openaiResponses'> }, ): AsyncGenerator { diff --git a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts index 2450fc20f0..b21b9da9ad 100644 --- a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts +++ b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts @@ -1,13 +1,13 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromAnthropicMessages } from '../../anthropic-messages/as-target/sse-from.ts'; +import { readAnthropicMessagesEvents } from '../../anthropic-messages/as-target/events.ts'; +import { writeOpenAIResponsesEvents } from '../as-source/events.ts'; import type { NamespaceToolNames } from '../as-source/namespace-tools.ts'; -import { openaiResponsesFromIR } from '../as-source/sse-to.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; export const translateToSourceEvents = (frames: AsyncIterable>, responseId: string, customToolNames: ReadonlySet, codec: SidecarCodec, namespaceToolNames: NamespaceToolNames): AsyncIterable> => { const stream = createRoundTripStream('openaiResponses', 'anthropicMessages', codec); - return openaiResponsesFromIR(irFromAnthropicMessages(frames, { customToolNames, roundTrip: stream.reader }), { id: responseId, namespaceToolNames, roundTrip: stream.writer }); + return writeOpenAIResponsesEvents(readAnthropicMessagesEvents(frames, { customToolNames, roundTrip: stream.reader }), { id: responseId, namespaceToolNames, roundTrip: stream.writer }); }; diff --git a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts index 28f41fb471..44ac3b6ec0 100644 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts +++ b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts @@ -1,13 +1,13 @@ import { createRoundTripStream } from '../../../round-trip/stream.ts'; import type { SidecarCodec } from '../../../types.ts'; -import { irFromOpenAIChatCompletions } from '../../openai-chat-completions/as-target/sse-from.ts'; +import { readOpenAIChatCompletionsEvents } from '../../openai-chat-completions/as-target/events.ts'; +import { writeOpenAIResponsesEvents } from '../as-source/events.ts'; import type { NamespaceToolNames } from '../as-source/namespace-tools.ts'; -import { openaiResponsesFromIR } from '../as-source/sse-to.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; export const translateToSourceEvents = (frames: AsyncIterable>, customToolNames: ReadonlySet, codec: SidecarCodec, namespaceToolNames: NamespaceToolNames, maxChoices = 1): AsyncIterable> => { const stream = createRoundTripStream('openaiResponses', 'openaiChatCompletions', codec); - return openaiResponsesFromIR(irFromOpenAIChatCompletions(frames, { customToolNames, maxChoices, roundTrip: stream.reader }), { namespaceToolNames, roundTrip: stream.writer }); + return writeOpenAIResponsesEvents(readOpenAIChatCompletionsEvents(frames, { customToolNames, maxChoices, roundTrip: stream.reader }), { namespaceToolNames, roundTrip: stream.writer }); }; diff --git a/packages/translate/src/round-trip/stream.ts b/packages/translate/src/round-trip/stream.ts index 17d393f9c0..d216fe5db1 100644 --- a/packages/translate/src/round-trip/stream.ts +++ b/packages/translate/src/round-trip/stream.ts @@ -57,9 +57,3 @@ export const createRoundTripStream = < }; return { reader, writer }; }; - -export interface RoundTripOutputOptions { - id?: string; - created?: number; - roundTrip: RoundTripWriter; -} diff --git a/packages/translate/src/shared/event-writer-options.ts b/packages/translate/src/shared/event-writer-options.ts new file mode 100644 index 0000000000..5db95628da --- /dev/null +++ b/packages/translate/src/shared/event-writer-options.ts @@ -0,0 +1,8 @@ +import type { AssistantTurnByProtocol } from '../round-trip/assistant-turn.ts'; +import type { RoundTripWriter } from '../round-trip/stream.ts'; + +export interface SourceEventWriterOptions { + id?: string; + created?: number; + roundTrip: RoundTripWriter; +} From 2e77d7c812747bfd1238f2c8f3b42517afa4b095 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 07:55:17 +0800 Subject: [PATCH 3/5] refactor(translate): complete output IR cleanup Keep output validation diagnostics, type refinements and reviewed comments with their output owner. Preserve configured choice limits and gap filling while narrowing relative paths to strings and explicit path arrays. Give completion-aware JSON parsing its own module and mirrored tests. Reuse the existing structural record guard from the JSON owner in native writers. Retain the reviewed sentinel and write-validation rationale. Validate 625 tests in 43 named files, 68 controlled main/final differential probes, 89 runtime/declaration/test correspondences, 101 scoped lint targets and the translate TypeScript project. Track diagnostic terminology changes explicitly while comparing complete wire, digest and native replay values. --- .../__tests__/ir/output/collector_test.ts | 2 +- .../translate/__tests__/ir/output/helpers.ts | 2 +- .../__tests__/ir/output/part-id_test.ts | 2 +- .../__tests__/ir/output/schema_test.ts | 2 +- .../__tests__/shared/json/completion_test.ts | 17 ++++++++++ .../__tests__/shared/json/object_test.ts | 18 ++--------- packages/translate/src/ir/output/absent.ts | 3 ++ packages/translate/src/ir/output/builder.ts | 7 ++-- packages/translate/src/ir/output/collector.ts | 32 +++++++++---------- packages/translate/src/ir/output/lifecycle.ts | 13 ++++---- .../src/ir/output/operation-paths.ts | 6 ++-- packages/translate/src/ir/output/schema.ts | 10 +++--- packages/translate/src/ir/output/stream.ts | 8 ++--- .../translate/src/ir/output/unit-index.ts | 4 +-- .../translate/src/ir/output/unit-registry.ts | 2 +- .../src/ir/output/value-validation.ts | 28 ++++++++-------- .../anthropic-messages/as-source/events.ts | 12 +++---- .../as-source/events.ts | 5 +-- .../as-source/events.ts | 12 +++---- .../openai-responses/as-source/events.ts | 14 ++++---- .../translate/src/shared/json/completion.ts | 8 +++++ packages/translate/src/shared/json/object.ts | 11 +++---- .../src/shared/text-coordinate-index.ts | 4 +-- 23 files changed, 116 insertions(+), 106 deletions(-) create mode 100644 packages/translate/__tests__/shared/json/completion_test.ts create mode 100644 packages/translate/src/shared/json/completion.ts diff --git a/packages/translate/__tests__/ir/output/collector_test.ts b/packages/translate/__tests__/ir/output/collector_test.ts index 0ad6b217e4..82b0a10cea 100644 --- a/packages/translate/__tests__/ir/output/collector_test.ts +++ b/packages/translate/__tests__/ir/output/collector_test.ts @@ -3,7 +3,7 @@ import { describe, expect, test } from 'vitest'; import { IRAbsent } from '../../../src/ir/output/absent.ts'; import { createIRCollector } from '../../../src/ir/output/collector.ts'; -describe('IR accumulator', () => { +describe('output IR accumulator', () => { test('stores plain accumulated state without publication metadata', () => { const collector = createIRCollector(); collector.applyWrite({ type: 'write', kind: 'initialize', path: ['usage'], value: {} }); diff --git a/packages/translate/__tests__/ir/output/helpers.ts b/packages/translate/__tests__/ir/output/helpers.ts index 80bfc12a59..1b598ae405 100644 --- a/packages/translate/__tests__/ir/output/helpers.ts +++ b/packages/translate/__tests__/ir/output/helpers.ts @@ -55,7 +55,7 @@ export const collectIR = async (records: AsyncIterable): Promise { ]); }); -test('retains every original ID when a native buffer is merged into one IR append', () => { +test('retains every original ID when a native buffer is merged into one output IR append', () => { const builder = createIRBuilder(); const part = builder.item(0, { type: 'message', content: [] }).part({ type: 'text', text: '' }); builder.drain(); diff --git a/packages/translate/__tests__/ir/output/schema_test.ts b/packages/translate/__tests__/ir/output/schema_test.ts index 901e737119..2b32283b04 100644 --- a/packages/translate/__tests__/ir/output/schema_test.ts +++ b/packages/translate/__tests__/ir/output/schema_test.ts @@ -14,7 +14,7 @@ const root = { }; const item = (index: number, ...rest: (string | number)[]) => ['choices', 0, 'items', index, ...rest]; -describe('IR schema queries', () => { +describe('output IR schema queries', () => { test.each([ [item(0, 'content', 0, 'text'), true], [item(0, 'content', 1, 'audio', 'data'), true], diff --git a/packages/translate/__tests__/shared/json/completion_test.ts b/packages/translate/__tests__/shared/json/completion_test.ts new file mode 100644 index 0000000000..572ee41fb1 --- /dev/null +++ b/packages/translate/__tests__/shared/json/completion_test.ts @@ -0,0 +1,17 @@ +import { expect, test, vi } from 'vitest'; + +import { parseJSONObjectUnlessTruncated } from '../../../src/shared/json/completion.ts'; + +test('truncated JSON completion does not invoke the parser', () => { + const parser = vi.fn(() => ({ repaired: true })); + expect(parseJSONObjectUnlessTruncated('{"value":', 'truncated', parser)).toEqual({ completion: 'truncated' }); + expect(parser).not.toHaveBeenCalled(); +}); + +test('complete JSON completion uses repair and still requires an object', () => { + expect(parseJSONObjectUnlessTruncated('{"value":', 'complete', text => JSON.parse(`${text}1}`))).toEqual({ + completion: 'complete', + value: { value: 1 }, + }); + expect(() => parseJSONObjectUnlessTruncated('[]', 'complete')).toThrow('Tool arguments require a JSON object'); +}); diff --git a/packages/translate/__tests__/shared/json/object_test.ts b/packages/translate/__tests__/shared/json/object_test.ts index 1da5567f41..8d9ece2940 100644 --- a/packages/translate/__tests__/shared/json/object_test.ts +++ b/packages/translate/__tests__/shared/json/object_test.ts @@ -1,23 +1,9 @@ -import { expect, test, vi } from 'vitest'; +import { expect, test } from 'vitest'; -import { parseJSONObject, parseJSONObjectAtCompletion } from '../../../src/shared/json/object.ts'; +import { parseJSONObject } from '../../../src/shared/json/object.ts'; test('default object parsing preserves number tokens that would serialize differently', () => { const source = '{"rounded":0.10000000000000001,"negativeZero":-0,"canonical":1.25}'; expect(JSON.stringify(parseJSONObject(source))).toBe(source); }); - -test('truncated JSON completion does not invoke the parser', () => { - const parser = vi.fn(() => ({ repaired: true })); - expect(parseJSONObjectAtCompletion('{"value":', 'truncated', parser)).toEqual({ completion: 'truncated' }); - expect(parser).not.toHaveBeenCalled(); -}); - -test('complete JSON completion uses repair and still requires an object', () => { - expect(parseJSONObjectAtCompletion('{"value":', 'complete', text => JSON.parse(`${text}1}`))).toEqual({ - completion: 'complete', - value: { value: 1 }, - }); - expect(() => parseJSONObjectAtCompletion('[]', 'complete')).toThrow('Tool arguments require a JSON object'); -}); diff --git a/packages/translate/src/ir/output/absent.ts b/packages/translate/src/ir/output/absent.ts index 0db8f19542..77c37e4609 100644 --- a/packages/translate/src/ir/output/absent.ts +++ b/packages/translate/src/ir/output/absent.ts @@ -1,3 +1,6 @@ +// A field whose absence is itself a fact: an optional value is now known never +// to arrive, as opposed to not having arrived yet. Only the output stream needs +// this, because a request arrives complete. export const IRAbsent: unique symbol = Symbol('IRAbsent'); export type IRAbsent = typeof IRAbsent; diff --git a/packages/translate/src/ir/output/builder.ts b/packages/translate/src/ir/output/builder.ts index f0c8653261..3264ff985d 100644 --- a/packages/translate/src/ir/output/builder.ts +++ b/packages/translate/src/ir/output/builder.ts @@ -3,7 +3,7 @@ import { irItemPath, irPartPath, irPathKey, irUnitPath } from './operation-paths import type { IREvent, IRPath, IRRecord, IRWrite } from './records.ts'; import type { IROutputUsage, IROutputValue } from './types.ts'; -export type IRRelativePath = string | number | IRPath; +export type IRRelativePath = string | IRPath; export interface IRWriteHandle { readonly path: IRPath; @@ -49,6 +49,9 @@ export const createIRBuilder = (maxChoices = 1) => { for (const [key, child] of Object.entries(value)) remember([...path, Array.isArray(value) ? Number(key) : key], child); }; const write = (value: IRWrite): void => { if (collector.applyWrite(value)) records.push(value); }; + // A handle accepts any caller value; the collector is what decides whether it + // is a legal output IR value, so the write record states what was handed over + // and validation runs on the way in. const assign = (path: IRPath, value: unknown): void => { write({ type: 'write', kind: 'assign', path, value: value as IROutputValue }); remember(path, value); @@ -66,7 +69,7 @@ export const createIRBuilder = (maxChoices = 1) => { }); const event = (value: IREvent): void => { collector.applyEvent(value); records.push(value); }; const drain = (): readonly IRRecord[] => { const drained = records; records = []; return drained; }; - const relativePath = (base: IRPath, path: IRRelativePath): IRPath => [...base, ...Array.isArray(path) ? path : [path]]; + const relativePath = (base: IRPath, path: IRRelativePath): IRPath => [...base, ...typeof path === 'string' ? [path] : path]; const writeHandle = (path: IRPath, index: number): IRWriteHandle => ({ path, index, diff --git a/packages/translate/src/ir/output/collector.ts b/packages/translate/src/ir/output/collector.ts index 94944ee6c5..7f7c594879 100644 --- a/packages/translate/src/ir/output/collector.ts +++ b/packages/translate/src/ir/output/collector.ts @@ -2,7 +2,7 @@ import { IRAbsent } from './absent.ts'; import { createLifecycle } from './lifecycle.ts'; import { irEndedUnit, irPathKey, resolveIRPath, valueAt } from './operation-paths.ts'; import type { IREvent, IRWrite, IRPath } from './records.ts'; -import type { IROutput } from './types.ts'; +import { type IROutput } from './types.ts'; import { assertComplete, walkValue } from './value-validation.ts'; export const createIRCollector = () => { @@ -28,43 +28,43 @@ export const createIRCollector = () => { // Returns whether the write changed the state. const applyWrite = (write: IRWrite): boolean => { - if (write.value === undefined) throw new TypeError('IR writes cannot carry undefined'); + if (write.value === undefined) throw new TypeError('output IR writes cannot carry undefined'); const { path } = write; const resolution = resolveIRPath(path, state); if (resolution !== undefined) lifecycle.assertWritable(resolution.units, path); const key = path.at(-1); - if (key === undefined) throw new TypeError('IR writes require a property path'); + if (key === undefined) throw new TypeError('output IR writes require a property path'); const parentPath = path.slice(0, -1); const parent = valueAt(state, parentPath); - if (parent === undefined) throw new TypeError(`Missing IR path: ${irPathKey(parentPath)}`); - if (typeof parent !== 'object' || parent === null) throw new TypeError('IR write parent must be an object or array'); + if (parent === undefined) throw new TypeError(`Missing output IR path: ${irPathKey(parentPath)}`); + if (typeof parent !== 'object' || parent === null) throw new TypeError('output IR write parent must be an object or array'); const present = Object.hasOwn(parent, key); - if (write.kind === 'initialize' && present) throw new TypeError('IR initialize requires an absent target'); + if (write.kind === 'initialize' && present) throw new TypeError('output IR initialize requires an absent target'); if (Array.isArray(parent)) { - if (typeof key !== 'number' || !Number.isInteger(key) || key < 0 || key > parent.length) throw new TypeError('IR array write requires a contiguous nonnegative index'); - if (!present && key !== parent.length) throw new TypeError('IR array insertion must use the next index'); - if (write.kind === 'initialize' && key !== parent.length) throw new TypeError('IR array initialization must use the next index'); + if (typeof key !== 'number' || !Number.isInteger(key) || key < 0 || key > parent.length) throw new TypeError('output IR array write requires a contiguous nonnegative index'); + if (!present && key !== parent.length) throw new TypeError('output IR array insertion must use the next index'); + if (write.kind === 'initialize' && key !== parent.length) throw new TypeError('output IR array initialization must use the next index'); } const current = (parent as Record)[key]; const node = resolution?.node; if (write.kind === 'append') { - if (node?.kind !== 'string' || node.append !== true) throw new TypeError(`IR append requires an append-target path: ${irPathKey(path)}`); - if (typeof current !== 'string') throw new TypeError('IR append requires a string target'); - if (typeof write.value !== 'string') throw new TypeError('IR append requires a string value'); + if (node?.kind !== 'string' || node.append !== true) throw new TypeError(`output IR append requires an append-target path: ${irPathKey(path)}`); + if (typeof current !== 'string') throw new TypeError('output IR append requires a string target'); + if (typeof write.value !== 'string') throw new TypeError('output IR append requires a string value'); if (write.value === '') return false; } else { - if (node === undefined) throw new TypeError(`IR path is not part of the IR schema: ${irPathKey(path)}`); + if (node === undefined) throw new TypeError(`output IR path is not part of the output IR schema: ${irPathKey(path)}`); if (write.kind === 'initialize') { walkValue(path, node, write.value, (childPath, childNode, child) => { - if (childNode.kind === 'string' && childNode.append === true && child !== '') throw new TypeError(`IR append target must initialize to an empty string: ${irPathKey(childPath)}`); + if (childNode.kind === 'string' && childNode.append === true && child !== '') throw new TypeError(`output IR append target must initialize to an empty string: ${irPathKey(childPath)}`); }, { allowAbsent: true }); } else if (write.value === IRAbsent) { if (node.kind !== 'string' || node.absent !== true) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); if (present) throw new TypeError('IRAbsent may only be assigned to an absent target'); } else { walkValue(path, node, write.value, (childPath, childNode) => { - if (childNode.kind === 'string' && childNode.append === true) throw new TypeError(`IR append target cannot be assigned: ${irPathKey(childPath)}`); + if (childNode.kind === 'string' && childNode.append === true) throw new TypeError(`output IR append target cannot be assigned: ${irPathKey(childPath)}`); }, { allowAbsent: false }); if (present && Object.is(current, write.value)) return false; } @@ -84,7 +84,7 @@ export const createIRCollector = () => { const ended = irEndedUnit(event); if (ended !== undefined) { const resolution = resolveIRPath(ended.path, state); - if (resolution === undefined || valueAt(state, ended.path) === undefined) throw new TypeError(`IR ${ended.kind} does not exist: ${irPathKey(ended.path)}`); + if (resolution === undefined || valueAt(state, ended.path) === undefined) throw new TypeError(`output IR ${ended.kind} does not exist: ${irPathKey(ended.path)}`); lifecycle.closeUnit(resolution.units); assertComplete(ended.path, resolution.node, valueAt(state, ended.path)); } else if (event.type === 'finish') lifecycle.assertFinishable(); diff --git a/packages/translate/src/ir/output/lifecycle.ts b/packages/translate/src/ir/output/lifecycle.ts index 25754c73b4..194fecbf72 100644 --- a/packages/translate/src/ir/output/lifecycle.ts +++ b/packages/translate/src/ir/output/lifecycle.ts @@ -1,4 +1,5 @@ -import { irPathKey, type IRUnit } from './operation-paths.ts'; +import { irPathKey } from './operation-paths.ts'; +import type { IRUnit } from './operation-paths.ts'; import type { IRPath } from './records.ts'; const createCounter = () => { @@ -8,7 +9,7 @@ const createCounter = () => { increment: (key: K): void => { counts.set(key, (counts.get(key) ?? 0) + 1); }, decrement: (key: K): void => { const count = counts.get(key); - if (count === undefined) throw new TypeError(`IR lifecycle counter underflow: ${String(key)}`); + if (count === undefined) throw new TypeError(`output IR lifecycle counter underflow: ${String(key)}`); if (count === 1) counts.delete(key); else counts.set(key, count - 1); }, @@ -22,7 +23,7 @@ export const createLifecycle = () => { const openChildren = createCounter(); const assertWritable = (units: readonly IRUnit[], path: IRPath): void => { - for (const unit of units) if (closed.has(irPathKey(unit.path))) throw new TypeError(`IR write targets a closed ${unit.kind}: ${irPathKey(path)}`); + for (const unit of units) if (closed.has(irPathKey(unit.path))) throw new TypeError(`output IR write targets a closed ${unit.kind}: ${irPathKey(path)}`); }; // `units` ends with the unit that was initialized. @@ -34,14 +35,14 @@ export const createLifecycle = () => { const closeUnit = (units: readonly IRUnit[]): void => { const unit = units[units.length - 1]; const key = irPathKey(unit.path); - if (!open.delete(key)) throw new TypeError(`IR ${unit.kind} is not open: ${key}`); - if (openChildren.get(key) !== 0) throw new TypeError(`IR ${unit.kind} ended with an open unit inside it: ${key}`); + if (!open.delete(key)) throw new TypeError(`output IR ${unit.kind} is not open: ${key}`); + if (openChildren.get(key) !== 0) throw new TypeError(`output IR ${unit.kind} ended with an open unit inside it: ${key}`); closed.add(key); if (units.length > 1) openChildren.decrement(irPathKey(units[units.length - 2].path)); }; const assertFinishable = (): void => { - if (open.size !== 0) throw new TypeError('IR finish arrived with open units'); + if (open.size !== 0) throw new TypeError('output IR finish arrived with open units'); }; return { assertWritable, openUnit, closeUnit, assertFinishable, isOpen: (path: IRPath): boolean => open.has(irPathKey(path)), isClosed: (path: IRPath): boolean => closed.has(irPathKey(path)) }; diff --git a/packages/translate/src/ir/output/operation-paths.ts b/packages/translate/src/ir/output/operation-paths.ts index 07be9869b2..c7460b8f3d 100644 --- a/packages/translate/src/ir/output/operation-paths.ts +++ b/packages/translate/src/ir/output/operation-paths.ts @@ -3,7 +3,7 @@ import { irSchema, type IRObjectNode, type IRSchemaNode, type IRUnionNode, type // Everything here is derived from the schema in schema.ts. A path is resolved against the // schema, and the answers come from the traits of the node it lands on. Where the node -// depends on which variant of a union the IR holds there, the variant is read from `root` +// depends on which variant of a union the output IR holds there, the variant is read from `root` // (the accumulated IR), so `root` must already contain the structure the path goes through. export const irPathKey = (path: IRPath): string => JSON.stringify(path); @@ -120,12 +120,12 @@ export const irUnitPath = (kind: IRUnitKind, ...indices: readonly number[]): IRP let template = unitTemplates.get(kind); if (template === undefined) { template = findUnitTemplate(irSchema, kind, []); - if (template === undefined) throw new TypeError(`The IR schema has no ${kind} unit`); + if (template === undefined) throw new TypeError(`The output IR schema has no ${kind} unit`); unitTemplates.set(kind, template); } let next = 0; const path = template.map(segment => segment === WILDCARD ? indices[next++] : segment); - if (next !== indices.length) throw new TypeError(`IR ${kind} paths take ${next} indices, got ${indices.length}`); + if (next !== indices.length) throw new TypeError(`output IR ${kind} paths take ${next} indices, got ${indices.length}`); return path; }; diff --git a/packages/translate/src/ir/output/schema.ts b/packages/translate/src/ir/output/schema.ts index 034e3fd3ea..b5377c5294 100644 --- a/packages/translate/src/ir/output/schema.ts +++ b/packages/translate/src/ir/output/schema.ts @@ -1,10 +1,10 @@ import type { IRAbsent } from './absent.ts'; import type { IROutput, IROutputSourceCitation, IROutputTokenLogprob } from './types.ts'; -// The structure of the IR, declared once. Every question about a path (is it an append +// The structure of the IROutput, declared once. Every question about a path (is it an append // target, may it hold `IRAbsent`, which lifecycle unit does it belong to) is answered by // walking this schema, never by matching path shapes. The value type inferred from the -// schema is checked against the IR types at the bottom of this file. +// schema is checked against the output IR types at the bottom of this file. // Items, content parts and choices have their own start and end in a stream. export type IRUnitKind = 'choice' | 'item' | 'part'; @@ -34,7 +34,7 @@ export interface IRUnionNode { readonly kind: 'opaque'; readonly phantom?: V } export type IRSchemaNode = IRStringNode | IRNumberNode | IRNullableNode | IRArrayNode | IRObjectNode | IRUnionNode | IROpaqueNode; export interface IRSchemaFields { readonly [key: string]: IRSchemaNode } @@ -118,7 +118,7 @@ type ValueOf = type Mutual = [A] extends [B] ? ([B] extends [A] ? true : false) : false; -// Compile-time proof that the schema and the IR types describe the same values: a field the -// schema declares optional, nullable, appendable or `IRAbsent`-capable differs from the IR type +// Compile-time proof that the schema and the output IR types describe the same values: a field the +// schema declares optional, nullable, appendable or `IRAbsent`-capable differs from the output IR type // exactly when this stops being `true`. export const irSchemaTypeEquality: Mutual, IROutput> = true; diff --git a/packages/translate/src/ir/output/stream.ts b/packages/translate/src/ir/output/stream.ts index e5dce93ac3..5110a47a42 100644 --- a/packages/translate/src/ir/output/stream.ts +++ b/packages/translate/src/ir/output/stream.ts @@ -9,15 +9,15 @@ export const consumeIRRecords = async function* (records: AsyncIterable { let next = 0; const open = (path: IRPath): number => { const key = irPathKey(path); - if (indices.has(key)) throw new TypeError(`IR unit is already open: ${key}`); + if (indices.has(key)) throw new TypeError(`output IR unit is already open: ${key}`); const index = next++; indices.set(key, index); return index; @@ -16,7 +16,7 @@ export const createIRUnitIndexAllocator = () => { const get = (path: IRPath): number => { const key = irPathKey(path); const index = indices.get(key); - if (index === undefined) throw new TypeError(`Unknown open IR unit: ${key}`); + if (index === undefined) throw new TypeError(`Unknown open output IR unit: ${key}`); return index; }; const close = (path: IRPath): void => { diff --git a/packages/translate/src/ir/output/unit-registry.ts b/packages/translate/src/ir/output/unit-registry.ts index 228e553441..32629eaea4 100644 --- a/packages/translate/src/ir/output/unit-registry.ts +++ b/packages/translate/src/ir/output/unit-registry.ts @@ -2,7 +2,7 @@ import { irPathKey, irRecordOwnerPaths } from './operation-paths.ts'; import type { IRPath, IRRecord } from './records.ts'; // Finds the unit that a record belongs to. A writer registers each unit at the path -// where its IR structure was initialized. +// where its output IR structure was initialized. export const createIRUnitRegistry = () => { const units = new Map(); return { diff --git a/packages/translate/src/ir/output/value-validation.ts b/packages/translate/src/ir/output/value-validation.ts index 5cb73c903f..75549562c4 100644 --- a/packages/translate/src/ir/output/value-validation.ts +++ b/packages/translate/src/ir/output/value-validation.ts @@ -2,15 +2,15 @@ import { IRAbsent } from './absent.ts'; import { irPathKey, unionVariantNode } from './operation-paths.ts'; import type { IRPath } from './records.ts'; import type { IRSchemaNode } from './schema.ts'; -import type { IROutputValue } from './types.ts'; +import { type IROutputValue } from './types.ts'; -type IROutputValueVisitor = (path: IRPath, node: IRSchemaNode, value: IROutputValue) => void; +type IRValueVisitor = (path: IRPath, node: IRSchemaNode, value: IROutputValue) => void; const isPlainObject = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); -// Opaque values are replaced as a whole, so only what no IR value may contain is rejected. +// Opaque values are replaced as a whole, so only what no output IR value may contain is rejected. const assertPlainValue = (path: IRPath, value: unknown): void => { - if (value === undefined) throw new TypeError(`IR values cannot contain undefined: ${irPathKey(path)}`); + if (value === undefined) throw new TypeError(`output IR values cannot contain undefined: ${irPathKey(path)}`); if (value === IRAbsent) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); if (typeof value !== 'object' || value === null) return; for (const [key, child] of Object.entries(value)) assertPlainValue([...path, Array.isArray(value) ? Number(key) : key], child); @@ -18,8 +18,8 @@ const assertPlainValue = (path: IRPath, value: unknown): void => { // Walks a value that is about to be written together with the schema node describing it. // `IRAbsent` is accepted where the schema allows it unless `allowAbsent` is off. -export const walkValue = (path: IRPath, node: IRSchemaNode, value: IROutputValue, visitor: IROutputValueVisitor, { allowAbsent }: { allowAbsent: boolean }): void => { - if (value === undefined) throw new TypeError(`IR values cannot contain undefined: ${irPathKey(path)}`); +export const walkValue = (path: IRPath, node: IRSchemaNode, value: IROutputValue, visitor: IRValueVisitor, { allowAbsent }: { readonly allowAbsent: boolean }): void => { + if (value === undefined) throw new TypeError(`output IR values cannot contain undefined: ${irPathKey(path)}`); if (node.kind === 'nullable') { if (value === null) return visitor(path, node, value); node = node.of; @@ -30,23 +30,23 @@ export const walkValue = (path: IRPath, node: IRSchemaNode, value: IROutputValue if (value === IRAbsent) { if (!allowAbsent) throw new TypeError('IRAbsent must be assigned directly to call_id'); if (node.absent !== true) throw new TypeError(`IRAbsent is not allowed at ${irPathKey(path)}`); - } else if (typeof value !== 'string') throw new TypeError(`IR value must be a string: ${irPathKey(path)}`); + } else if (typeof value !== 'string') throw new TypeError(`output IR value must be a string: ${irPathKey(path)}`); return; case 'number': - if (typeof value !== 'number') throw new TypeError(`IR value must be a number: ${irPathKey(path)}`); + if (typeof value !== 'number') throw new TypeError(`output IR value must be a number: ${irPathKey(path)}`); return; case 'array': - if (!Array.isArray(value)) throw new TypeError(`IR value must be an array: ${irPathKey(path)}`); + if (!Array.isArray(value)) throw new TypeError(`output IR value must be an array: ${irPathKey(path)}`); value.forEach((child, index) => walkValue([...path, index], node.of, child, visitor, { allowAbsent })); return; case 'object': case 'union': { - if (!isPlainObject(value)) throw new TypeError(`IR value must be an object: ${irPathKey(path)}`); + if (!isPlainObject(value)) throw new TypeError(`output IR value must be an object: ${irPathKey(path)}`); const variant = unionVariantNode(node, value); - if (variant?.kind !== 'object') throw new TypeError(`IR value has an unknown variant: ${irPathKey(path)}`); + if (variant?.kind !== 'object') throw new TypeError(`output IR value has an unknown variant: ${irPathKey(path)}`); for (const [key, child] of Object.entries(value)) { const field = node.kind === 'union' && key === node.discriminant ? { kind: 'string' } as const : Object.hasOwn(variant.fields, key) ? variant.fields[key] : undefined; - if (field === undefined) throw new TypeError(`IR value has no field ${key}: ${irPathKey(path)}`); + if (field === undefined) throw new TypeError(`output IR value has no field ${key}: ${irPathKey(path)}`); walkValue([...path, key], field, child, visitor, { allowAbsent }); } return; @@ -66,11 +66,11 @@ export const assertComplete = (path: IRPath, node: IRSchemaNode, value: unknown) else if (actual.kind === 'object') { const present = value as Record; if (actual.requiresAnyOf !== undefined && !actual.requiresAnyOf.some(key => present[key] !== undefined)) { - throw new TypeError(`IR value requires one of ${actual.requiresAnyOf.join(', ')}: ${irPathKey(path)}`); + throw new TypeError(`output IR value requires one of ${actual.requiresAnyOf.join(', ')}: ${irPathKey(path)}`); } for (const [key, field] of Object.entries(actual.fields)) { if (!Object.hasOwn(present, key)) { - if (!actual.optional.includes(key)) throw new TypeError(`IR value requires field ${key}: ${irPathKey(path)}`); + if (!actual.optional.includes(key)) throw new TypeError(`output IR value requires field ${key}: ${irPathKey(path)}`); continue; } assertComplete([...path, key], field, present[key]); diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/events.ts b/packages/translate/src/protocols/anthropic-messages/as-source/events.ts index 7ed73dcdc3..ad3a41b51e 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/events.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/events.ts @@ -15,7 +15,8 @@ import { appendContent, createContentState, type ContentState, type ContentState import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; -import { parseJSONObject, parseJSONObjectAtCompletion, requireJSONObject } from '../../../shared/json/object.ts'; +import { parseJSONObjectUnlessTruncated } from '../../../shared/json/completion.ts'; +import { parseJSONObject, requireJSONObject, isJSONRecord } from '../../../shared/json/object.ts'; import type { AnthropicMessagesAssistantState, AnthropicMessagesInputAssistantTurn } from '../as-target/assistant-turn.ts'; import { generateAnthropicId, @@ -53,11 +54,8 @@ interface MessagesUnit { pendingText?: Map; } -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - const unitKind = (value: unknown): MessagesUnit['kind'] | 'custom_tool_call' | undefined => { - if (!isRecord(value)) return undefined; + if (!isJSONRecord(value)) return undefined; return value.type === 'text' || value.type === 'refusal' || value.type === 'audio' @@ -257,7 +255,7 @@ export const writeAnthropicMessagesEvents = async function* ( : record.finish_reason === 'tool_calls' ? 'tool_use' : record.finish_reason === 'content_filter' ? 'refusal' : 'end_turn'; for (const item of state.choices[0].items) if (item.type === 'function_call') { - parseJSONObjectAtCompletion(item.arguments, record.finish_reason === 'length' ? 'truncated' : 'complete', options.parseToolArguments); + parseJSONObjectUnlessTruncated(item.arguments, record.finish_reason === 'length' ? 'truncated' : 'complete', options.parseToolArguments); } continue; } @@ -316,7 +314,7 @@ export const writeAnthropicMessagesEvents = async function* ( serializedInput = serialized; if (serialized !== '') yield blockDelta(index, { type: 'input_json_delta', partial_json: serialized }); } else { - const parsed = parseJSONObjectAtCompletion(item.arguments, record.type === 'item_end' && record.status === 'incomplete' ? 'truncated' : 'complete'); + const parsed = parseJSONObjectUnlessTruncated(item.arguments, record.type === 'item_end' && record.status === 'incomplete' ? 'truncated' : 'complete'); if (parsed.completion === 'complete') serializedInput = JSON.stringify(parsed.value); } } diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts index d72c8ed9c7..ef1c3a912a 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts @@ -14,7 +14,8 @@ import { appendContent, createContentState, type ContentStates, type ContentStat import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import { isContextExceededError } from '../../../shared/context-window-error.ts'; import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; -import { isCompleteJSONObject, parseJSONObjectAtCompletion } from '../../../shared/json/object.ts'; +import { parseJSONObjectUnlessTruncated } from '../../../shared/json/completion.ts'; +import { isCompleteJSONObject } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import type { GeminiGenerateContentAssistantState, GeminiGenerateContentInputAssistantTurn } from '../as-target/assistant-turn.ts'; import { eventFrame, FAST_SERVICE_TIER, type EventFrame } from '@floway-dev/protocols/common'; @@ -271,7 +272,7 @@ export const writeGeminiGenerateContentEvents = async function* ( if (completion === 'truncated') part = undefined; else if (!isCompleteJSONObject(item.arguments) && choiceEnding === undefined && record.type !== 'finish') wait = true; else { - const parsed = parseJSONObjectAtCompletion(item.arguments, completion, options.parseToolArguments); + const parsed = parseJSONObjectUnlessTruncated(item.arguments, completion, options.parseToolArguments); if (parsed.completion === 'complete') { const partIndex = occupyPart(choice, 'other'); part = { diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts index 2941fd9b60..a18f4bdd4d 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts @@ -10,6 +10,7 @@ import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; import { appendContent, createContentState, type ContentStates, type ContentState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; +import { isJSONRecord } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIChatCompletionsInputAssistantTurn, type OpenAIChatCompletionsOutputAssistantState } from '../as-target/assistant-turn.ts'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; @@ -40,11 +41,8 @@ const tierFields = (tier?: string): { service_tier?: string } => tier === undefi type IRToolCallItem = Extract; -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - const isIRItem = (value: unknown): value is IROutputItem => { - if (!isRecord(value)) return false; + if (!isJSONRecord(value)) return false; switch (value.type) { case 'message': return Array.isArray(value.content); case 'reasoning': return true; @@ -55,7 +53,7 @@ const isIRItem = (value: unknown): value is IROutputItem => { }; const partType = (value: unknown): IROutputMessageItem['content'][number]['type'] | undefined => { - if (!isRecord(value)) return undefined; + if (!isJSONRecord(value)) return undefined; return value.type === 'text' || value.type === 'refusal' || value.type === 'image' || value.type === 'audio' ? value.type : undefined; }; @@ -66,7 +64,7 @@ type LogprobMetadata = { readonly scope: 'choice' } | { }; const logprobMetadata = (value: unknown): LogprobMetadata | undefined => { - if (!isRecord(value)) return undefined; + if (!isJSONRecord(value)) return undefined; if (value.scope === 'choice') return { scope: 'choice' }; if (value.scope === 'text_part' && typeof value.item_index === 'number' && typeof value.content_index === 'number') { return { scope: 'text_part', item_index: value.item_index, content_index: value.content_index }; @@ -75,7 +73,7 @@ const logprobMetadata = (value: unknown): LogprobMetadata | undefined => { }; const isIRTokenLogprob = (value: unknown): value is IROutputTokenLogprob => - isRecord(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; + isJSONRecord(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; // Emitted deltas carry the assistant extension fields (reasoning, annotations) beside the standard ones. type ChatChoiceChunk = Omit & { delta: OpenAIChatCompletionsAssistantDeltaEx }; diff --git a/packages/translate/src/protocols/openai-responses/as-source/events.ts b/packages/translate/src/protocols/openai-responses/as-source/events.ts index 64fc6b19df..a9910775f3 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/events.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/events.ts @@ -14,7 +14,8 @@ import { createIRUnitRegistry } from '../../../ir/output/unit-registry.ts'; import { appendContent, createContentState, type ContentState, type ContentStates } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; -import { isCompleteJSONObject, parseJSONObjectAtCompletion } from '../../../shared/json/object.ts'; +import { parseJSONObjectUnlessTruncated } from '../../../shared/json/completion.ts'; +import { isCompleteJSONObject, isJSONRecord } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIResponsesInputAssistantTurn, type OpenAIResponsesItemState, type OpenAIResponsesOutputAssistantState } from '../as-target/assistant-turn.ts'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; @@ -68,11 +69,8 @@ type MessagePart = IROutputMessageItem['content'][number]; type ResponsesMessageState = OpenAIResponsesItemState, ContentStates>; type ResponsesReasoningState = OpenAIResponsesItemState, ContentStates>; -const isRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - const initializedType = (value: unknown): IROutputItem['type'] | MessagePart['type'] | undefined => { - if (!isRecord(value)) return undefined; + if (!isJSONRecord(value)) return undefined; return value.type === 'message' || value.type === 'reasoning' || value.type === 'function_call' @@ -88,14 +86,14 @@ const initializedType = (value: unknown): IROutputItem['type'] | MessagePart['ty type TextPartLogprobMetadata = { readonly item_index: number; readonly content_index: number }; const textPartLogprobMetadata = (value: unknown): TextPartLogprobMetadata | undefined => { - if (!isRecord(value) || value.scope !== 'text_part') return undefined; + if (!isJSONRecord(value) || value.scope !== 'text_part') return undefined; return typeof value.item_index === 'number' && typeof value.content_index === 'number' ? { item_index: value.item_index, content_index: value.content_index } : undefined; }; const isIRTokenLogprob = (value: unknown): value is IROutputTokenLogprob => - isRecord(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; + isJSONRecord(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; const reasoningPartAdded = (field: ReasoningField, itemId: string, outputIndex: number, index: number): OpenAIResponsesStreamEventEx => field === 'summary' @@ -647,7 +645,7 @@ export const writeOpenAIResponsesEvents = async function* ( const item = state.choices[0].items[current.path[3] as number]; if (item.type !== 'function_call') throw new TypeError('Responses pending function unit requires a function item'); const outputIndex = requireOutputIndex(current); - parseJSONObjectAtCompletion(item.arguments, finishReason === 'length' ? 'truncated' : 'complete'); + parseJSONObjectUnlessTruncated(item.arguments, finishReason === 'length' ? 'truncated' : 'complete'); yield emit({ type: 'response.function_call_arguments.done', item_id: requireId(current), diff --git a/packages/translate/src/shared/json/completion.ts b/packages/translate/src/shared/json/completion.ts new file mode 100644 index 0000000000..bb15600b62 --- /dev/null +++ b/packages/translate/src/shared/json/completion.ts @@ -0,0 +1,8 @@ +import { parseJSONObject } from './object.ts'; + +export const parseJSONObjectUnlessTruncated = ( + text: string, + completion: 'complete' | 'truncated', + parser?: (text: string) => unknown, +): { readonly completion: 'truncated' } | { readonly completion: 'complete'; readonly value: ReturnType } => + completion === 'truncated' ? { completion } : { completion, value: parseJSONObject(text, parser) }; diff --git a/packages/translate/src/shared/json/object.ts b/packages/translate/src/shared/json/object.ts index 76f05749c7..499d7132da 100644 --- a/packages/translate/src/shared/json/object.ts +++ b/packages/translate/src/shared/json/object.ts @@ -2,6 +2,10 @@ import { rawJSON } from './raw-json.ts'; import type { IRJSONObject } from '../../ir/common/json.ts'; import { isJsonObject, parseJSONWithRawNumbers } from '@floway-dev/protocols/common'; +/** Structural record check that also permits raw JSON token objects; isPlainJSONObject excludes those tokens. */ +export const isJSONRecord = (value: unknown): value is Readonly> => + typeof value === 'object' && value !== null && !Array.isArray(value); + export const isPlainJSONObject = (value: unknown): value is IRJSONObject => isJsonObject(value) && !rawJSON.isRawJSON(value); export const requireJSONObject = (value: unknown): IRJSONObject => { @@ -23,10 +27,3 @@ export const isCompleteJSONObject = (text: string): boolean => { throw error; } }; - -export const parseJSONObjectAtCompletion = ( - text: string, - completion: 'complete' | 'truncated', - parser?: (text: string) => unknown, -): { readonly completion: 'truncated' } | { readonly completion: 'complete'; readonly value: ReturnType } => - completion === 'truncated' ? { completion } : { completion, value: parseJSONObject(text, parser) }; diff --git a/packages/translate/src/shared/text-coordinate-index.ts b/packages/translate/src/shared/text-coordinate-index.ts index 74cc96296c..dd8bb9d4e9 100644 --- a/packages/translate/src/shared/text-coordinate-index.ts +++ b/packages/translate/src/shared/text-coordinate-index.ts @@ -141,11 +141,11 @@ export class TextCoordinateIndex { #utf16Range(range: TextRange): readonly [number, number] { if (!Number.isInteger(range.start) || !Number.isInteger(range.end_exclusive) || range.start < 0 || range.end_exclusive < range.start || range.end_exclusive > this.#utf16Length) { - throw new RangeError('IR range is outside text or splits a surrogate pair'); + throw new RangeError('output IR range is outside text or splits a surrogate pair'); } const start = this.#boundaries.get(range.start); const end = this.#boundaries.get(range.end_exclusive); - if (start === INVALID_BOUNDARY || end === INVALID_BOUNDARY) throw new RangeError('IR range is outside text or splits a surrogate pair'); + if (start === INVALID_BOUNDARY || end === INVALID_BOUNDARY) throw new RangeError('output IR range is outside text or splits a surrogate pair'); return [start, end]; } } From dd936c1482b6604464b9f6704490e322832f863d Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 08:19:50 +0800 Subject: [PATCH 4/5] test(translate): group output error ownership checks Move the existing three immutable error identity cases together under their protocol ownership file, retaining every test body and assertion. --- .../__tests__/protocols/copy-ownership_test.ts | 16 ++++++++++++++++ .../__tests__/protocols/ownership_test.ts | 13 +------------ 2 files changed, 17 insertions(+), 12 deletions(-) create mode 100644 packages/translate/__tests__/protocols/copy-ownership_test.ts diff --git a/packages/translate/__tests__/protocols/copy-ownership_test.ts b/packages/translate/__tests__/protocols/copy-ownership_test.ts new file mode 100644 index 0000000000..c9f0ae3b40 --- /dev/null +++ b/packages/translate/__tests__/protocols/copy-ownership_test.ts @@ -0,0 +1,16 @@ +import { expect, test } from 'vitest'; + +import { readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents } from './helpers.ts'; +import { collect, iterate } from '../shared/iterables.ts'; +import { eventFrame } from '@floway-dev/protocols/common'; + +test.each([ + ['ChatCompletions', readOpenAIChatCompletionsEvents, (error: object) => ({ id: 'chat', model: 'm', choices: [], error })], + ['Responses', readOpenAIResponsesEvents, (error: object) => ({ type: 'error', error })], + ['Messages', readAnthropicMessagesEvents, (error: object) => ({ type: 'error', error })], +] as const)('%s reader transfers immutable error objects without copying', async (_name, reader, event) => { + const error = Object.freeze({ type: 'api_error', message: 'original', details: Object.freeze([Object.freeze({ text: 'original' })]) }); + const frames = await collect(reader(iterate([eventFrame(event(error) as any)]))); + const record = frames.find(record => record.type === 'error'); + expect(record?.error).toBe(error); +}); diff --git a/packages/translate/__tests__/protocols/ownership_test.ts b/packages/translate/__tests__/protocols/ownership_test.ts index 05d7a849e8..705ccd72e1 100644 --- a/packages/translate/__tests__/protocols/ownership_test.ts +++ b/packages/translate/__tests__/protocols/ownership_test.ts @@ -1,7 +1,7 @@ import { expect, test, vi } from 'vitest'; import { nativeResult } from './fixtures.ts'; -import { writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; +import { writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; import { collectIR } from '../ir/output/helpers.ts'; import { collect, iterate } from '../shared/iterables.ts'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; @@ -109,14 +109,3 @@ test('range-less Messages citations do not expand to whole-part ChatCompletions const generateContentFrames = await collect(writeGeminiGenerateContentEvents(readAnthropicMessagesEvents(iterate(messagesCitationFrames('source quote'))))); expect(generateContentFrames.some(frame => frame.type === 'event' && 'candidates' in frame.event && frame.event.candidates?.some(candidate => candidate.groundingMetadata !== undefined))).toBe(false); }); - -test.each([ - ['ChatCompletions', readOpenAIChatCompletionsEvents, (error: object) => ({ id: 'chat', model: 'm', choices: [], error })], - ['Responses', readOpenAIResponsesEvents, (error: object) => ({ type: 'error', error })], - ['Messages', readAnthropicMessagesEvents, (error: object) => ({ type: 'error', error })], -] as const)('%s reader transfers immutable error objects without copying', async (_name, reader, event) => { - const error = Object.freeze({ type: 'api_error', message: 'original', details: Object.freeze([Object.freeze({ text: 'original' })]) }); - const frames = await collect(reader(iterate([eventFrame(event(error) as any)]))); - const record = frames.find(record => record.type === 'error'); - expect(record?.error).toBe(error); -}); From 13703d23211fa7ee0c20cbda91898c6d93426b90 Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 13:40:51 +0800 Subject: [PATCH 5/5] refactor(translate): reuse the protocol JSON object guard Remove the duplicate structural guard and import isJsonObject from protocols/common. Keep tool-argument and IR-specific object validation at the translation boundary. --- .../protocols/anthropic-messages/as-source/events.ts | 5 +++-- .../openai-chat-completions/as-source/events.ts | 10 +++++----- .../src/protocols/openai-responses/as-source/events.ts | 9 +++++---- packages/translate/src/shared/json/object.ts | 4 ---- 4 files changed, 13 insertions(+), 15 deletions(-) diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/events.ts b/packages/translate/src/protocols/anthropic-messages/as-source/events.ts index ad3a41b51e..26c7630bfd 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-source/events.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-source/events.ts @@ -16,7 +16,7 @@ import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan import { isContextExceededError } from '../../../shared/context-window-error.ts'; import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; import { parseJSONObjectUnlessTruncated } from '../../../shared/json/completion.ts'; -import { parseJSONObject, requireJSONObject, isJSONRecord } from '../../../shared/json/object.ts'; +import { parseJSONObject, requireJSONObject } from '../../../shared/json/object.ts'; import type { AnthropicMessagesAssistantState, AnthropicMessagesInputAssistantTurn } from '../as-target/assistant-turn.ts'; import { generateAnthropicId, @@ -29,6 +29,7 @@ import { type AnthropicMessagesStreamEventEx, type AnthropicMessagesUsage, } from '@floway-dev/protocols/anthropic-messages'; +import { isJsonObject } from '@floway-dev/protocols/common'; import { eventFrame, type EventFrame } from '@floway-dev/protocols/common'; const MESSAGES_ERROR_TYPES = [ @@ -55,7 +56,7 @@ interface MessagesUnit { } const unitKind = (value: unknown): MessagesUnit['kind'] | 'custom_tool_call' | undefined => { - if (!isJSONRecord(value)) return undefined; + if (!isJsonObject(value)) return undefined; return value.type === 'text' || value.type === 'refusal' || value.type === 'audio' diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts index a18f4bdd4d..dbebad7b07 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/events.ts @@ -10,9 +10,9 @@ import { createIRUnitIndexAllocator } from '../../../ir/output/unit-index.ts'; import { appendContent, createContentState, type ContentStates, type ContentState } from '../../../round-trip/reference/reference-builder.ts'; import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; -import { isJSONRecord } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIChatCompletionsInputAssistantTurn, type OpenAIChatCompletionsOutputAssistantState } from '../as-target/assistant-turn.ts'; +import { isJsonObject } from '@floway-dev/protocols/common'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { type OpenAIChatCompletionsAnnotation, @@ -42,7 +42,7 @@ const tierFields = (tier?: string): { service_tier?: string } => tier === undefi type IRToolCallItem = Extract; const isIRItem = (value: unknown): value is IROutputItem => { - if (!isJSONRecord(value)) return false; + if (!isJsonObject(value)) return false; switch (value.type) { case 'message': return Array.isArray(value.content); case 'reasoning': return true; @@ -53,7 +53,7 @@ const isIRItem = (value: unknown): value is IROutputItem => { }; const partType = (value: unknown): IROutputMessageItem['content'][number]['type'] | undefined => { - if (!isJSONRecord(value)) return undefined; + if (!isJsonObject(value)) return undefined; return value.type === 'text' || value.type === 'refusal' || value.type === 'image' || value.type === 'audio' ? value.type : undefined; }; @@ -64,7 +64,7 @@ type LogprobMetadata = { readonly scope: 'choice' } | { }; const logprobMetadata = (value: unknown): LogprobMetadata | undefined => { - if (!isJSONRecord(value)) return undefined; + if (!isJsonObject(value)) return undefined; if (value.scope === 'choice') return { scope: 'choice' }; if (value.scope === 'text_part' && typeof value.item_index === 'number' && typeof value.content_index === 'number') { return { scope: 'text_part', item_index: value.item_index, content_index: value.content_index }; @@ -73,7 +73,7 @@ const logprobMetadata = (value: unknown): LogprobMetadata | undefined => { }; const isIRTokenLogprob = (value: unknown): value is IROutputTokenLogprob => - isJSONRecord(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; + isJsonObject(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; // Emitted deltas carry the assistant extension fields (reasoning, annotations) beside the standard ones. type ChatChoiceChunk = Omit & { delta: OpenAIChatCompletionsAssistantDeltaEx }; diff --git a/packages/translate/src/protocols/openai-responses/as-source/events.ts b/packages/translate/src/protocols/openai-responses/as-source/events.ts index a9910775f3..85cc651b33 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/events.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/events.ts @@ -15,9 +15,10 @@ import { appendContent, createContentState, type ContentState, type ContentState import { provenanceSpanForAppend, replayTextProvenance, type TextProvenanceSpan } from '../../../round-trip/reference/text-provenance.ts'; import type { SourceEventWriterOptions } from '../../../shared/event-writer-options.ts'; import { parseJSONObjectUnlessTruncated } from '../../../shared/json/completion.ts'; -import { isCompleteJSONObject, isJSONRecord } from '../../../shared/json/object.ts'; +import { isCompleteJSONObject } from '../../../shared/json/object.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; import { toOpenAIResponsesInputAssistantTurn, type OpenAIResponsesItemState, type OpenAIResponsesOutputAssistantState } from '../as-target/assistant-turn.ts'; +import { isJsonObject } from '@floway-dev/protocols/common'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { createRandomOpenAIResponsesItemId, @@ -70,7 +71,7 @@ type ResponsesMessageState = OpenAIResponsesItemState, ContentStates>; const initializedType = (value: unknown): IROutputItem['type'] | MessagePart['type'] | undefined => { - if (!isJSONRecord(value)) return undefined; + if (!isJsonObject(value)) return undefined; return value.type === 'message' || value.type === 'reasoning' || value.type === 'function_call' @@ -86,14 +87,14 @@ const initializedType = (value: unknown): IROutputItem['type'] | MessagePart['ty type TextPartLogprobMetadata = { readonly item_index: number; readonly content_index: number }; const textPartLogprobMetadata = (value: unknown): TextPartLogprobMetadata | undefined => { - if (!isJSONRecord(value) || value.scope !== 'text_part') return undefined; + if (!isJsonObject(value) || value.scope !== 'text_part') return undefined; return typeof value.item_index === 'number' && typeof value.content_index === 'number' ? { item_index: value.item_index, content_index: value.content_index } : undefined; }; const isIRTokenLogprob = (value: unknown): value is IROutputTokenLogprob => - isJSONRecord(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; + isJsonObject(value) && typeof value.token === 'string' && typeof value.logprob === 'number'; const reasoningPartAdded = (field: ReasoningField, itemId: string, outputIndex: number, index: number): OpenAIResponsesStreamEventEx => field === 'summary' diff --git a/packages/translate/src/shared/json/object.ts b/packages/translate/src/shared/json/object.ts index 499d7132da..7edcfe3426 100644 --- a/packages/translate/src/shared/json/object.ts +++ b/packages/translate/src/shared/json/object.ts @@ -2,10 +2,6 @@ import { rawJSON } from './raw-json.ts'; import type { IRJSONObject } from '../../ir/common/json.ts'; import { isJsonObject, parseJSONWithRawNumbers } from '@floway-dev/protocols/common'; -/** Structural record check that also permits raw JSON token objects; isPlainJSONObject excludes those tokens. */ -export const isJSONRecord = (value: unknown): value is Readonly> => - typeof value === 'object' && value !== null && !Array.isArray(value); - export const isPlainJSONObject = (value: unknown): value is IRJSONObject => isJsonObject(value) && !rawJSON.isRawJSON(value); export const requireJSONObject = (value: unknown): IRJSONObject => {