diff --git a/packages/gateway/__tests__/data-plane/chat/anthropic-messages/attempt_test.ts b/packages/gateway/__tests__/data-plane/chat/anthropic-messages/attempt_test.ts index 95f1854bee..916089d1c1 100644 --- a/packages/gateway/__tests__/data-plane/chat/anthropic-messages/attempt_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/anthropic-messages/attempt_test.ts @@ -288,7 +288,7 @@ test('generate lets the target system-to-developer rewrite take precedence over assertEquals(input[1], { type: 'message', role: 'developer', content: 'inline instructions' }); }); -test('generate translate-to-responses branch rewrites a multi-block system prefix to developer', async () => { +test('generate translate-to-responses keeps top-level instructions apart from rewritten inline system messages', async () => { installRepo(); const observedBodies: Omit[] = []; const callOpenAIResponses = vi.fn(async (_model, body): Promise => { @@ -340,13 +340,9 @@ test('generate translate-to-responses branch rewrites a multi-block system prefi if (!observedBody) throw new Error('expected observed OpenAI Responses body'); const input = observedBody.input; if (!Array.isArray(input)) throw new Error('expected OpenAI Responses input array'); - assertEquals(input[0], { - type: 'message', - role: 'developer', - content: [{ type: 'input_text', text: 'base A' }, { type: 'input_text', text: 'base B' }], - }); - assertEquals(input[1], { type: 'message', role: 'user', content: 'hello' }); - assertEquals(input[2], { type: 'message', role: 'developer', content: 'inline instructions' }); + assertEquals(observedBody.instructions, 'base Abase B'); + assertEquals(input[0], { type: 'message', role: 'user', content: 'hello' }); + assertEquals(input[1], { type: 'message', role: 'developer', content: 'inline instructions' }); }); test('countTokens proxies the upstream response as a plain result', async () => { diff --git a/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/round-trip-call-identity_test.ts b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/round-trip-call-identity_test.ts new file mode 100644 index 0000000000..d78c4d2ddf --- /dev/null +++ b/packages/gateway/__tests__/data-plane/chat/gemini-generate-content/round-trip-call-identity_test.ts @@ -0,0 +1,45 @@ +import { expect, test } from 'vitest'; + +import { createSidecarCodecFactory, SidecarDomain } from '../../../../src/data-plane/chat/shared/sidecar-codec.ts'; +import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; +import { collectGeminiGenerateContentProtocolEventsToResult } from '@floway-dev/protocols/gemini-generate-content'; +import type { GeminiGenerateContentContent } from '@floway-dev/protocols/gemini-generate-content'; +import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; +import { routeGeminiGenerateContentViaOpenAIChatCompletions } from '@floway-dev/translate'; + +test.each([{ modernIds: ['legacy_call_1'] }, { modernIds: ['legacy_call_1', 'legacy_call_2'] }])('signed legacy Chat replay preserves later modern call IDs: $modernIds', async ({ modernIds }) => { + const codec = () => createSidecarCodecFactory('native-legacy-call-identity').codecFor(SidecarDomain.TranslatedAssistantTurn); + const question: GeminiGenerateContentContent = { role: 'user', parts: [{ text: 'question' }] }; + const first = await routeGeminiGenerateContentViaOpenAIChatCompletions({ model: 'model', contents: [question] }, { sidecarCodec: codec() }); + if (first.type === 'input-error') throw new Error(first.message); + const upstream = async function* (): AsyncGenerator> { + yield eventFrame({ + id: 'legacy', object: 'chat.completion.chunk', created: 1, model: 'model', + choices: [{ index: 0, delta: { role: 'assistant', function_call: { name: 'lookup', arguments: '{}' } }, finish_reason: 'function_call' }], + }); + yield doneFrame(); + }; + const produced = await collectGeminiGenerateContentProtocolEventsToResult(first.events(upstream())); + const native = produced.candidates?.[0]?.content; + if (native === undefined) throw new Error('Expected the generated Gemini assistant turn'); + expect(native.parts?.some(part => typeof part.thoughtSignature === 'string')).toBe(true); + + const replay = await routeGeminiGenerateContentViaOpenAIChatCompletions({ + model: 'model', + contents: [ + question, + native, + { role: 'user', parts: [{ functionResponse: { name: 'lookup', response: { label: 'LEGACY' } } }] }, + ...modernIds.flatMap((id, index): GeminiGenerateContentContent[] => [ + { role: 'model', parts: [{ functionCall: { id, name: `modern_${index}`, args: {} } }] }, + { role: 'user', parts: [{ functionResponse: { id, name: `modern_${index}`, response: { label: `MODERN_${index}` } } }] }, + ]), + ], + }, { sidecarCodec: codec() }); + if (replay.type === 'input-error') throw new Error(replay.message); + expect(replay.target.messages[1]).toMatchObject({ role: 'assistant', function_call: { name: 'lookup', arguments: '{}' } }); + expect(replay.target.messages[2]).toEqual({ role: 'function', name: 'lookup', content: '{"label":"LEGACY"}' }); + expect(replay.target.messages.slice(3).filter(message => message.role !== 'assistant')).toEqual(modernIds.map((id, index) => ({ + role: 'tool', tool_call_id: id, content: `{"label":"MODERN_${index}"}`, + }))); +}); diff --git a/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/attempt_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/attempt_test.ts index 6bb6f33a3d..5ffc87a4c9 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/attempt_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-chat-completions/attempt_test.ts @@ -186,11 +186,10 @@ test('generate translates through the Anthropic Messages target when only that e assertEquals(anthropicBeta, []); }); -test('generate injects the platform external-image loader into OpenAI-Chat-Completions-to-Anthropic-Messages translation', async () => { +test('generate states a remote image URL to Anthropic Messages without fetching it', async () => { installRepo(); - initExternalResourceFetcher(url => { - assertEquals(url.href, 'https://example.com/image.png'); - return Promise.resolve(new Response(Uint8Array.of(1, 2, 3), { headers: { 'content-type': 'image/png' } })); + initExternalResourceFetcher(() => { + throw new Error('translation must not fetch images'); }); let observedBody: Omit | undefined; const callAnthropicMessages = vi.fn(async (_model, body): Promise> => { @@ -216,7 +215,7 @@ test('generate injects the platform external-image loader into OpenAI-Chat-Compl assert(message?.role === 'user' && Array.isArray(message.content)); const image = message.content.find(block => block.type === 'image'); assert(image?.type === 'image'); - assertEquals(image.source, { type: 'base64', media_type: 'image/png', data: 'AQID' }); + assertEquals(image.source, { type: 'url', url: 'https://example.com/image.png' }); }); test('generate translates through the OpenAI Responses target when only that endpoint is exposed', async () => { @@ -288,11 +287,12 @@ test('generate preserves translated instructions before rewriting inline system await collectEvents(result.events); const observedBody = observedBodies[0]; if (!observedBody) throw new Error('expected observed OpenAI Responses body'); - assertEquals(observedBody.instructions, 'base instructions'); + assertEquals(observedBody.instructions, undefined); const input = observedBody.input; if (!Array.isArray(input)) throw new Error('expected OpenAI Responses input array'); - assertEquals(input[0], { type: 'message', role: 'user', content: 'hello' }); - assertEquals(input[1], { type: 'message', role: 'developer', content: 'inline instructions' }); + assertEquals(input[0], { type: 'message', role: 'developer', content: 'base instructions' }); + assertEquals(input[1], { type: 'message', role: 'user', content: 'hello' }); + assertEquals(input[2], { type: 'message', role: 'developer', content: 'inline instructions' }); }); test('generate propagates upstream response headers onto the EventResult so respond can forward them', async () => { diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/attempt_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/attempt_test.ts index dad47c2ee6..7ce6137d20 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/attempt_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/attempt_test.ts @@ -427,11 +427,10 @@ test('compact returns the clean upstream result for source-edge affinity and sto assertEquals(result.result.id, compactionResult.id); }); -test('generate strips disallowed headers and injects external image loading across translation to Anthropic Messages', async () => { +test('generate strips disallowed headers and states a remote image URL to Anthropic Messages', async () => { installRepo(); - initExternalResourceFetcher(url => { - assertEquals(url.href, 'https://example.com/image.png'); - return Promise.resolve(new Response(Uint8Array.of(1, 2, 3), { headers: { 'content-type': 'image/png' } })); + initExternalResourceFetcher(() => { + throw new Error('translation must not fetch images'); }); let observedHeaders: Headers | undefined; let observedAnthropicBeta: readonly string[] | undefined; @@ -493,7 +492,7 @@ test('generate strips disallowed headers and injects external image loading acro assert(message?.role === 'user' && Array.isArray(message.content)); const image = message.content.find(block => block.type === 'image'); assert(image?.type === 'image'); - assertEquals(image.source, { type: 'base64', media_type: 'image/png', data: 'AQID' }); + assertEquals(image.source, { type: 'url', url: 'https://example.com/image.png' }); }); test('generate preserves order and downgrades a disallowed system turn across translation to Anthropic Messages', async () => { diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/server-tool-shim_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/server-tool-shim_test.ts index 8f29a5827c..7c65f71743 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/server-tool-shim_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/interceptors/server-tool-shim_test.ts @@ -1,5 +1,6 @@ import { beforeEach, expect, test, vi } from 'vitest'; +import { inputErrorResult } from '../../../../../src/data-plane/chat/openai-responses/errors.ts'; import { withOpenAIResponsesServerToolShim } from '../../../../../src/data-plane/chat/openai-responses/interceptors/server-tool-shim.ts'; import { consumeTurnStreaming, @@ -36,7 +37,6 @@ import type { import { getRepo, initRepo } from '../../../../../src/repo/index.ts'; import { InMemoryRepo } from '../../../../repo/memory.ts'; import { mockChatGatewayCtx } from '../../../../test-utils/gateway-ctx.ts'; -import { requireTrip } from '../../shared/require-trip.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { decodeCanonicalBase64, encodeBase64, eventFrame } from '@floway-dev/protocols/common'; import type { BillableUsage, ProtocolFrame } from '@floway-dev/protocols/common'; @@ -777,6 +777,7 @@ const makeInvocation = (overrides: InvocationOverrides = {}): OpenAIResponsesInv targetApi: overrides.targetApi ?? 'openaiChatCompletions', payload: { model: 'claude-x', + stream: true, input: [{ type: 'message', role: 'user', content: 'hi' }], tools: [{ type: 'web_search' }], // Opt the wire item into `results` so most tests can inspect them @@ -7192,7 +7193,8 @@ for (const targetApi of ['openaiChatCompletions', 'anthropicMessages', 'openaiRe ]]).run(); } if (targetApi === 'openaiChatCompletions') { - const trip = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(inv.payload, { model: 'm', sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) })); + const trip = await routeOpenAIResponsesViaOpenAIChatCompletions(inv.payload, { sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }); + if (trip.type === 'input-error') return inputErrorResult(trip); const client = trip.target.tools?.[1]; assert(client?.type === 'function'); const events = (async function* (): AsyncGenerator> { @@ -7201,7 +7203,8 @@ for (const targetApi of ['openaiChatCompletions', 'anthropicMessages', 'openaiRe })(); return eventResult(trip.events(events), testTelemetryModelIdentity); } - const trip = await requireTrip(routeOpenAIResponsesViaAnthropicMessages(inv.payload, { model: 'm', sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), loadRemoteImage: async () => { throw new Error('Unexpected image'); } })); + const trip = await routeOpenAIResponsesViaAnthropicMessages(inv.payload, { sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }); + if (trip.type === 'input-error') return inputErrorResult(trip); const tool = trip.target.tools?.[1]; const name = tool !== undefined && 'name' in tool ? tool.name : undefined; assert(typeof name === 'string'); @@ -7239,7 +7242,7 @@ test('native helper injection allocates past namespace children while retaining for (const target of ['openaiChatCompletions', 'anthropicMessages'] as const) { for (const mode of ['forced', 'auto', 'required'] as const) { - test(`${target} ${mode} helper choice follows a namespace-induced alias and restores its original echo`, async () => { + test(`${target} ${mode} helper choice follows a namespace-induced alias and preserves translation constraints`, async () => { const { backend } = makeStubDeps(); const selector = { type: 'function' as const, name: SHIM_TOOL_NAME }; const choice: OpenAIResponsesToolChoice = mode === 'forced' ? selector : { type: 'allowed_tools', mode, tools: [selector] }; @@ -7254,25 +7257,45 @@ for (const target of ['openaiChatCompletions', 'anthropicMessages'] as const) { const alias = `${SHIM_TOOL_NAME}_2`; const script = scriptedRun([fcTurn(0, 'hosted', alias, '{"search_query":[{"q":"hosted"}]}'), messageTurn('done')]); const choices: unknown[] = []; - const { frames } = await runShimAndDrain(withOpenAIResponsesWebSearchShim, inv, makeGatewayCtx(), async () => { + const run = async () => { choices.push(structuredClone(inv.payload.tool_choice)); const before = structuredClone(inv.payload); const trip = target === 'openaiChatCompletions' - ? await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(inv.payload, { model: 'm', sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) })) - : await requireTrip(routeOpenAIResponsesViaAnthropicMessages(inv.payload, { model: 'm', sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), loadRemoteImage: async () => { throw new Error('Unexpected image'); } })); + ? await routeOpenAIResponsesViaOpenAIChatCompletions(inv.payload, { sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }) + : await routeOpenAIResponsesViaAnthropicMessages(inv.payload, { sidecarCodec: mockChatGatewayCtx().sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }); + if (trip.type === 'input-error') return inputErrorResult(trip); const wire = JSON.parse(JSON.stringify(trip.target)) as { tools: Array<{ name?: string; function?: { name: string } }> }; - assert(wire.tools.some(tool => (tool.function?.name ?? tool.name) === alias), 'the translated subset must declare the helper alias'); + assert(wire.tools.some(tool => (tool.function?.name ?? tool.name) === alias), 'the complete declaration list must retain the helper alias'); assertEquals(inv.payload, before, 'repeated translation must preserve the outer loop payload'); return await script.run(); - }); + }; + if (target === 'anthropicMessages' && mode === 'auto') { + const { result } = await runShimAndDrain(withOpenAIResponsesWebSearchShim, inv, makeGatewayCtx(), run); + assert(result.type === 'api-error'); + assertEquals(result.status, 400); + assert(new TextDecoder().decode(result.body).includes('allowed_tools auto subset')); + assertEquals(script.callCount(), 0); + assertEquals(backend.calls.length, 0); + assertEquals(choices, [{ type: 'allowed_tools', mode: 'auto', tools: [{ type: 'function', name: alias }] }]); + return; + } + const { frames } = await runShimAndDrain(withOpenAIResponsesWebSearchShim, inv, makeGatewayCtx(), run); const renamed = { type: 'function', name: alias }; assertEquals(choices, mode === 'forced' ? [renamed, 'auto'] : [ { type: 'allowed_tools', mode, tools: [renamed] }, { type: 'allowed_tools', mode: 'auto', tools: [renamed] }, ]); assertEquals(choice, original); - assertEquals(script.callCount(), 2); assertEquals(backend.calls.length, 1); + if (target === 'anthropicMessages' && mode === 'required') { + assertEquals(script.callCount(), 1); + const error = frames.find(frame => frame.type === 'event' && frame.event.type === 'error'); + expect(error).toMatchObject({ event: { type: 'error', error: { message: expect.stringContaining('allowed_tools auto subset'), code: null } } }); + const failed = frames.find(frame => frame.type === 'event' && frame.event.type === 'response.failed'); + expect(failed).toMatchObject({ event: { response: { status: 'failed', error: null } } }); + return; + } + assertEquals(script.callCount(), 2); assertEquals(findResponseCompleted(frames).response.tool_choice, original); }); } diff --git a/packages/gateway/__tests__/data-plane/chat/openai-responses/namespace-descriptions-http_test.ts b/packages/gateway/__tests__/data-plane/chat/openai-responses/namespace-descriptions-http_test.ts index 73c21c2347..f2fe39248a 100644 --- a/packages/gateway/__tests__/data-plane/chat/openai-responses/namespace-descriptions-http_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/openai-responses/namespace-descriptions-http_test.ts @@ -55,6 +55,7 @@ for (const target of ['openaiChatCompletions', 'anthropicMessages'] as const) { const choice = { type: 'allowed_tools', mode, tools: [selector] }; const input = [{ type: 'message', role: 'user', content: 'Read my account.' }]; const payload = { model: 'model', stream: false, store: false, tool_choice: choice, ...(representation !== 'Standard' ? { input: [{ type: 'additional_tools', role: 'developer', tools: [namespace] }, ...input] } : { input, tools: [namespace] }) }; + const unrepresentable = target === 'anthropicMessages' && mode === 'auto' && selection === 'callable'; const wire: WireRequest[] = []; await withMockedFetch(async request => { if (new URL(request.url).pathname === '/v1/models') return Response.json({ data: [{ id: 'model' }] }); @@ -64,6 +65,13 @@ for (const target of ['openaiChatCompletions', 'anthropicMessages'] as const) { return toolCallResponse(target, 'payments_read'); }, async () => { const response = await requestApp('/v1/responses', { method: 'POST', headers: { authorization: `Bearer ${apiKey.key}`, 'content-type': 'application/json' }, body: JSON.stringify(payload) }); + if (unrepresentable) { + assertEquals(response.status, 400); + const failure = await response.json() as { error: { message: string } }; + assert(failure.error.message.includes('cannot express an allowed_tools auto subset')); + await flushBackground(); + return; + } const resource = await response.json() as OpenAIResponsesResultEx; assertEquals(response.status, 200); assertEquals(resource.status, 'completed'); @@ -73,11 +81,17 @@ for (const target of ['openaiChatCompletions', 'anthropicMessages'] as const) { assertEquals([call.name, call.namespace], ['read', 'payments']); await flushBackground(); }); + if (unrepresentable) { + assertEquals(wire.length, 0, 'an unrepresentable selector must fail before contacting the provider'); + return; + } assertEquals(wire.length, 1, 'the final provider serializer must actually run once'); const selected = selection === 'namespace' ? namespace.tools : namespace.tools.slice(0, 1); - assertEquals(wire[0].tools?.map(tool => tool.function?.name ?? tool.name), selected.map(tool => `payments_${tool.name}`)); - assertEquals(wire[0].tools?.map(tool => tool.function?.description ?? tool.description), selected.map(tool => `${namespace.description}\n\n${tool.description}`)); - assertEquals(wire[0].tool_choice, target === 'openaiChatCompletions' ? mode : { type: mode === 'required' ? 'any' : 'auto' }); + assertEquals(wire[0].tools?.map(tool => tool.function?.name ?? tool.name), namespace.tools.map(tool => `payments_${tool.name}`)); + assertEquals(wire[0].tools?.map(tool => tool.function?.description ?? tool.description), namespace.tools.map(tool => `${namespace.description}\n\n${tool.description}`)); + assertEquals(wire[0].tool_choice, target === 'openaiChatCompletions' + ? { type: 'allowed_tools', allowed_tools: { mode, tools: selected.map(tool => ({ type: 'function', function: { name: `payments_${tool.name}` } })) } } + : selection === 'callable' ? { type: 'tool', name: 'payments_read' } : { type: mode === 'required' ? 'any' : 'auto' }); }); } } diff --git a/packages/gateway/__tests__/data-plane/chat/shared/external-image-loader_test.ts b/packages/gateway/__tests__/data-plane/chat/shared/external-image-fetcher_test.ts similarity index 80% rename from packages/gateway/__tests__/data-plane/chat/shared/external-image-loader_test.ts rename to packages/gateway/__tests__/data-plane/chat/shared/external-image-fetcher_test.ts index 5b4686257b..4e396cae7e 100644 --- a/packages/gateway/__tests__/data-plane/chat/shared/external-image-loader_test.ts +++ b/packages/gateway/__tests__/data-plane/chat/shared/external-image-fetcher_test.ts @@ -1,10 +1,10 @@ import { test } from 'vitest'; -import { createExternalImageFetcher, createExternalImageLoader } from '../../../../src/data-plane/chat/shared/external-image-loader.ts'; +import { createExternalImageFetcher } from '../../../../src/data-plane/chat/shared/external-image-fetcher.ts'; import { initExternalResourceFetcher } from '@floway-dev/platform'; import { assert, assertEquals, assertRejects } from '@floway-dev/test-utils'; -test('external image loader follows relative redirects and memoizes one request chain', async () => { +test('external image fetcher follows relative redirects and memoizes one request chain', async () => { const requested: string[] = []; initExternalResourceFetcher((url, signal) => { assertEquals(signal.aborted, false); @@ -30,19 +30,17 @@ test('external image loader follows relative redirects and memoizes one request assertEquals(requested, ['https://example.com/start', 'https://example.com/image.png']); }); -test('external image loader rejects unsafe URL syntax before egress', async () => { +test('external image fetcher rejects unsafe URL syntax before egress', async () => { let requests = 0; initExternalResourceFetcher(() => { requests += 1; return Promise.resolve(new Response()); }); - const load = createExternalImageLoader(); const fetchImage = createExternalImageFetcher(); assertEquals(await fetchImage('file:///etc/passwd'), { type: 'invalid-url' }); - assertEquals(await load('file:///etc/passwd'), null); - assertEquals(await load('https://user:secret@example.com/image.png'), null); - assertEquals(await load('not a URL'), null); + assertEquals(await fetchImage('https://user:secret@example.com/image.png'), { type: 'invalid-url' }); + assertEquals(await fetchImage('not a URL'), { type: 'invalid-url' }); assertEquals(requests, 0); }); @@ -56,7 +54,7 @@ test('external image fetcher reports non-success and oversized responses', async assertEquals(await fetchImage('https://example.com/oversized'), { type: 'too-large', limitBytes: 50 * 1024 * 1024 }); }); -test('external image loader bounds redirect chains', async () => { +test('external image fetcher bounds redirect chains', async () => { let requests = 0; initExternalResourceFetcher(_url => { requests += 1; @@ -74,7 +72,7 @@ test('external image loader bounds redirect chains', async () => { assertEquals(requests, 6); }); -test('external image loader cancels a streamed body beyond the byte limit', async () => { +test('external image fetcher cancels a streamed body beyond the byte limit', async () => { const chunk = new Uint8Array(1024 * 1024); let cancelled = false; initExternalResourceFetcher(() => Promise.resolve(new Response(new ReadableStream({ @@ -104,13 +102,13 @@ test('external image fetcher preserves transport failure detail', async () => { assertEquals(result.error, expected); }); -test('external image loader propagates downstream cancellation', async () => { +test('external image fetcher propagates downstream cancellation', async () => { const controller = new AbortController(); controller.abort(new Error('downstream cancelled')); initExternalResourceFetcher(() => Promise.reject(new Error('should not fetch'))); await assertRejects( - () => createExternalImageLoader(controller.signal)('https://example.com/image.png'), + () => createExternalImageFetcher(controller.signal)('https://example.com/image.png'), Error, 'downstream cancelled', ); diff --git a/packages/gateway/src/data-plane/chat/anthropic-messages/attempt.ts b/packages/gateway/src/data-plane/chat/anthropic-messages/attempt.ts index 838fbd4f72..2c60152a70 100644 --- a/packages/gateway/src/data-plane/chat/anthropic-messages/attempt.ts +++ b/packages/gateway/src/data-plane/chat/anthropic-messages/attempt.ts @@ -115,7 +115,7 @@ const dispatchAnthropicMessages = async ( if (targetApi === 'openaiResponses') { return await executeTranslation( invocation.payload, - p => routeAnthropicMessagesViaOpenAIResponses(p, { model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), + p => routeAnthropicMessagesViaOpenAIResponses(p, { sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), translated => openaiResponsesAttempt.generate({ payload: translated, ctx, candidate, headers: invocation.headers, }), @@ -125,7 +125,7 @@ const dispatchAnthropicMessages = async ( if (targetApi === 'openaiChatCompletions') { return await executeTranslation( invocation.payload, - p => routeAnthropicMessagesViaOpenAIChatCompletions(p, { model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), + p => routeAnthropicMessagesViaOpenAIChatCompletions(p, { sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), translated => openaiChatCompletionsAttempt.generate({ payload: translated, ctx, candidate, headers: invocation.headers, }), diff --git a/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts b/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts index 409e4b4d48..88c23626b5 100644 --- a/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts +++ b/packages/gateway/src/data-plane/chat/gemini-generate-content/attempt.ts @@ -64,7 +64,6 @@ export const geminiGenerateContentAttempt = { // payload-mutators are applied inline here before translation; the // attempt-owned payload clone keeps the caller's source intact. const transCtx = { - model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), fallbackMaxOutputTokens: candidate.model.limits.max_output_tokens, }; @@ -72,7 +71,9 @@ export const geminiGenerateContentAttempt = { stripUnsupportedPartFieldsFromPayload(cleaned); stripUnsupportedToolsFromPayload(cleaned); delete cleaned.safetySettings; - const trip = await routeGeminiGenerateContentViaAnthropicMessages(cleaned, transCtx); + // The model travels in the URL rather than the body, so the request this + // trip reads is the cleaned payload with the resolved model folded in. + const trip = await routeGeminiGenerateContentViaAnthropicMessages({ ...cleaned, model: candidate.model.id }, transCtx); if (trip.type === 'input-error') return inputErrorResult(trip, ctx.attempt.telemetry); const { stream: _stream, ...target } = trip.target; const anthropicMessagesResult = await anthropicMessagesAttempt.countTokens({ @@ -134,18 +135,15 @@ const dispatchGeminiGenerateContent = async ( ctx: ChatGatewayCtx, ): Promise> | TranslateInputError> => { const { candidate, targetApi } = invocation; - // Gemini generateContent has no native upstream target today — every targetApi we - // pick is reached via translation. The dispatch threads each branch - // through `executeTranslation` so each inner attempt owns its own - // interceptor chain and rewrite. + // The model comes from the URL and is folded into the translation request. + const request = { ...invocation.payload, model: candidate.model.id }; const transCtx = { - model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), fallbackMaxOutputTokens: candidate.model.limits.max_output_tokens, }; if (targetApi === 'anthropicMessages') { return await executeTranslation( - invocation.payload, + request, p => routeGeminiGenerateContentViaAnthropicMessages(p, transCtx), translated => anthropicMessagesAttempt.generate({ payload: translated, ctx, candidate, headers: invocation.headers, anthropicBeta: [], @@ -155,7 +153,7 @@ const dispatchGeminiGenerateContent = async ( } if (targetApi === 'openaiResponses') { return await executeTranslation( - invocation.payload, + request, p => routeGeminiGenerateContentViaOpenAIResponses(p, transCtx), translated => openaiResponsesAttempt.generate({ payload: translated, ctx, candidate, headers: invocation.headers, @@ -165,7 +163,7 @@ const dispatchGeminiGenerateContent = async ( } if (targetApi === 'openaiChatCompletions') { return await executeTranslation( - invocation.payload, + request, p => routeGeminiGenerateContentViaOpenAIChatCompletions(p, transCtx), translated => openaiChatCompletionsAttempt.generate({ payload: translated, ctx, candidate, headers: invocation.headers, diff --git a/packages/gateway/src/data-plane/chat/openai-chat-completions/attempt.ts b/packages/gateway/src/data-plane/chat/openai-chat-completions/attempt.ts index 3c642d6313..a48c3b36e4 100644 --- a/packages/gateway/src/data-plane/chat/openai-chat-completions/attempt.ts +++ b/packages/gateway/src/data-plane/chat/openai-chat-completions/attempt.ts @@ -7,7 +7,6 @@ import { anthropicMessagesAttempt } from '../anthropic-messages/attempt.ts'; import { openaiResponsesAttempt } from '../openai-responses/attempt.ts'; import { applyRulesToUpstreamOpenAIChatCompletions } from '../shared/alias-rules.ts'; import { captureFromDump, executeTranslation } from '../shared/execute-translation.ts'; -import { createExternalImageLoader } from '../shared/external-image-loader.ts'; import type { ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import { providerStreamResultToExecuteResult } from '../shared/provider-stream-result.ts'; import { SidecarDomain } from '../shared/sidecar-codec.ts'; @@ -68,10 +67,8 @@ const dispatchOpenAIChatCompletions = async ( return await executeTranslation( invocation.payload, p => routeOpenAIChatCompletionsViaAnthropicMessages(p, { - model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), fallbackMaxOutputTokens: candidate.model.limits.max_output_tokens, - loadRemoteImage: createExternalImageLoader(ctx.abortSignal), }), translated => anthropicMessagesAttempt.generate({ payload: translated, ctx, candidate, headers: invocation.headers, anthropicBeta: [], @@ -82,7 +79,7 @@ const dispatchOpenAIChatCompletions = async ( if (targetApi === 'openaiResponses') { return await executeTranslation( invocation.payload, - p => routeOpenAIChatCompletionsViaOpenAIResponses(p, { model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), + p => routeOpenAIChatCompletionsViaOpenAIResponses(p, { sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), translated => openaiResponsesAttempt.generate({ payload: translated, ctx, candidate, headers: invocation.headers, }), diff --git a/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts b/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts index 1b36bc596c..4c04b62003 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/attempt.ts @@ -13,7 +13,6 @@ import { anthropicMessagesAttempt } from '../anthropic-messages/attempt.ts'; import { openaiChatCompletionsAttempt } from '../openai-chat-completions/attempt.ts'; import { applyRulesToUpstreamOpenAIResponses } from '../shared/alias-rules.ts'; import { captureFromDump, executeTranslation } from '../shared/execute-translation.ts'; -import { createExternalImageLoader } from '../shared/external-image-loader.ts'; import type { ChatGatewayCtx } from '../shared/gateway-ctx.ts'; import { providerStreamResultToExecuteResult } from '../shared/provider-stream-result.ts'; import { SidecarDomain } from '../shared/sidecar-codec.ts'; @@ -148,10 +147,8 @@ const dispatchOpenAIResponses = async ( return await executeTranslation( invocation.payload, p => routeOpenAIResponsesViaAnthropicMessages(p, { - model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn), fallbackMaxOutputTokens: candidate.model.limits.max_output_tokens, - loadRemoteImage: createExternalImageLoader(ctx.abortSignal), }), translated => anthropicMessagesAttempt.generate({ payload: translated, ctx, candidate, headers: invocation.headers, anthropicBeta: [], @@ -164,7 +161,7 @@ const dispatchOpenAIResponses = async ( } return await executeTranslation( invocation.payload, - p => routeOpenAIResponsesViaOpenAIChatCompletions(p, { model: candidate.model.id, sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), + p => routeOpenAIResponsesViaOpenAIChatCompletions(p, { sidecarCodec: ctx.sidecarCodecFactory.codecFor(SidecarDomain.TranslatedAssistantTurn) }), translated => openaiChatCompletionsAttempt.generate({ payload: translated, ctx, candidate, headers: invocation.headers, }), diff --git a/packages/gateway/src/data-plane/chat/openai-responses/interceptors/server-tools/image-generation.ts b/packages/gateway/src/data-plane/chat/openai-responses/interceptors/server-tools/image-generation.ts index b0671b87f7..2c9caeff41 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/interceptors/server-tools/image-generation.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/interceptors/server-tools/image-generation.ts @@ -8,7 +8,7 @@ import { appendFailedUpstreams } from '../../../../shared/failed-upstreams.ts'; import type { AttemptState } from '../../../../shared/gateway-ctx.ts'; import { recordPerformance, type PerformanceTelemetryContext } from '../../../../shared/telemetry/performance.ts'; import { recordTokenUsage, tokenUsageFromOpenAIImagesBody } from '../../../../shared/telemetry/usage.ts'; -import { createExternalImageFetcher, type ExternalImageFetchResult } from '../../../shared/external-image-loader.ts'; +import { createExternalImageFetcher, type ExternalImageFetchResult } from '../../../shared/external-image-fetcher.ts'; import type { ServerToolHistoryExpansion, ServerToolLifecycleEvent, ServerToolOutputItem, ServerToolRegistration, ServerToolSlotResult } from '../server-tool-shim.ts'; import { dimensionsFromBytes, getImageProcessor, type BackgroundScheduler } from '@floway-dev/platform'; import { decodeForgivingBase64, encodeHex, isImageMediaType, isJsonObject, mediaTypeEssence, parseSSEStream } from '@floway-dev/protocols/common'; diff --git a/packages/gateway/src/data-plane/chat/shared/external-image-loader.ts b/packages/gateway/src/data-plane/chat/shared/external-image-fetcher.ts similarity index 92% rename from packages/gateway/src/data-plane/chat/shared/external-image-loader.ts rename to packages/gateway/src/data-plane/chat/shared/external-image-fetcher.ts index 1ae6afabc0..89e26711a3 100644 --- a/packages/gateway/src/data-plane/chat/shared/external-image-loader.ts +++ b/packages/gateway/src/data-plane/chat/shared/external-image-fetcher.ts @@ -1,5 +1,4 @@ import { getExternalResourceFetcher } from '@floway-dev/platform'; -import type { RemoteImageLoader } from '@floway-dev/translate'; const MAX_REDIRECTS = 5; const MAX_IMAGE_BYTES = 50 * 1024 * 1024; @@ -132,11 +131,3 @@ export const createExternalImageFetcher = (downstreamSignal?: AbortSignal): Exte return request; }; }; - -export const createExternalImageLoader = (downstreamSignal?: AbortSignal): RemoteImageLoader => { - const fetchImage = createExternalImageFetcher(downstreamSignal); - return async url => { - const result = await fetchImage(url); - return result.type === 'success' ? { mediaType: result.mediaType, data: result.data } : null; - }; -}; diff --git a/packages/protocols/SOURCES.md b/packages/protocols/SOURCES.md index 1ab8d03531..c45f484a48 100644 --- a/packages/protocols/SOURCES.md +++ b/packages/protocols/SOURCES.md @@ -22,7 +22,7 @@ Provider additions and broader wire views use `Ex`. Chat assistant and tool-call Messages collectors follow the pinned SDK's cumulative usage counters, citation events, complete signature replacement, and compaction final-value assignment, including null. Standard usage deltas exclude fixed metadata. Incoming gateway TTL deltas and Floway's late fixed metadata use `AnthropicMessagesUsageDeltaEx`, following the complete-usage convention used by [new-api](https://github.com/QuantumNous/new-api/blob/6370b29424168039e94d40d610191e7d2e65dbf4/relaykit/relayconvert/internal/oai_chat/to_claude_messages_resp.go#L215-L235). This preserves realtime streaming without a new carrier field. Floway's collector and accounting reader understand the extension; the official SDK's standard accumulator does not interpret those extra fields. Accounting snapshots remain separate from wire types. -Recognizing an official block does not add a translation implementation. Unimplemented Messages-to-Chat blocks and compaction stops fail explicitly; context-window exhaustion maps to Chat `length`. Display-only alias rules update existing enabled/adaptive thinking and preserve other modes. System/developer text part boundaries and empty history turns remain represented. Native Chat custom tools, calls, and choices map to Responses; function `allowed_tools` subsets map to Messages. Unrepresentable inputs fail instead of receiving invented defaults. +Recognizing an official block does not add a translation implementation. Unimplemented Messages-to-Chat blocks and compaction stops fail explicitly; context-window exhaustion maps to Chat `length`. Display-only alias rules update existing enabled/adaptive thinking and preserve other modes. System/developer text part boundaries and empty history turns remain represented. Native Chat custom tools, calls, and choices map to Responses. Messages preserves tool declarations and accepts only `allowed_tools` selections its native choice modes can express. Unrepresentable request semantics fail instead of receiving invented defaults; citation and cache controls can degrade when no target equivalent exists. Gemini Schema conversion preserves constraints, reorders properties before removing `propertyOrdering`, and emits int64 constraints as exact JSON numeric tokens. The `klona/json` and streaming JSON serializer patches retain immutable raw JSON values; ordered schema properties retain their enumeration order through request cloning and serialization, including integer-named keys. Boolean JSON schemas use exact object equivalents; destination schema restrictions remain enforced by the destination boundary or upstream. Mutually exclusive schema fields fail explicitly. Omitted Chat schema is not replaced with an unconstrained schema. diff --git a/packages/protocols/src/anthropic-messages/index.ts b/packages/protocols/src/anthropic-messages/index.ts index a7b5d5448a..136789b600 100644 --- a/packages/protocols/src/anthropic-messages/index.ts +++ b/packages/protocols/src/anthropic-messages/index.ts @@ -73,13 +73,34 @@ export type AnthropicMessagesImageSource = | { type: 'file'; file_id: string }; export interface AnthropicMessagesImageBlock { type: 'image'; source: AnthropicMessagesImageSource; cache_control?: AnthropicMessagesCacheControl | null; transformations?: { oversized_image?: 'downsize' | 'error' } | null } +// The official snapshot pins a binary document's `media_type` to +// `'application/pdf'`. Floway keeps this extension open so translation can +// forward caller-provided MIME values for upstream validation without +// relabelling non-PDF bytes as PDF. +export type AnthropicMessagesDocumentBlockEx = { + type: 'document'; + source: Omit, 'media_type'> & { media_type: string }; + cache_control?: AnthropicMessagesCacheControl | null; + citations?: Native.CitationsConfigParam | null; + context?: string | null; + title?: string | null; +}; + export type AnthropicMessagesSearchResultBlock = Native.SearchResultBlockParam | Beta.BetaSearchResultBlockParam; export type AnthropicMessagesWebSearchResultBlock = Native.WebSearchResultBlock | Beta.BetaWebSearchResultBlock; -export type AnthropicMessagesToolResultContentBlock = Exclude, string>[number]; +type AnthropicMessagesNativeToolResultContentBlock = + | Exclude, string>[number] + | Exclude, string>[number]; + +export type AnthropicMessagesToolResultContentBlock = AnthropicMessagesNativeToolResultContentBlock | AnthropicMessagesDocumentBlockEx; + +export type AnthropicMessagesToolResultBlockEx = Omit & { + content?: string | AnthropicMessagesToolResultContentBlock[]; +}; -export type AnthropicMessagesToolResultBlock = Native.ToolResultBlockParam | Beta.BetaToolResultBlockParam; +export type AnthropicMessagesToolResultBlock = Native.ToolResultBlockParam | Beta.BetaToolResultBlockParam | AnthropicMessagesToolResultBlockEx; export type AnthropicMessagesToolUseBlock = Native.ToolUseBlock; export type AnthropicMessagesToolUseBlockParam = Native.ToolUseBlockParam | Beta.BetaToolUseBlockParam; @@ -132,7 +153,7 @@ export type AnthropicMessagesUserContentBlock = AnthropicMessagesInputContentBlo export type AnthropicMessagesAssistantContentBlock = Native.ContentBlock | Beta.BetaContentBlock; export type AnthropicMessagesAssistantInputContentBlock = Native.ContentBlockParam | Beta.BetaContentBlockParam; -export type AnthropicMessagesInputContentBlock = AnthropicMessagesAssistantInputContentBlock; +export type AnthropicMessagesInputContentBlock = AnthropicMessagesAssistantInputContentBlock | AnthropicMessagesDocumentBlockEx | AnthropicMessagesToolResultBlockEx; export interface AnthropicMessagesUserMessage extends Omit { role: 'user'; diff --git a/packages/translate/AGENTS.md b/packages/translate/AGENTS.md index c90a2ddc43..16f60b6342 100644 --- a/packages/translate/AGENTS.md +++ b/packages/translate/AGENTS.md @@ -2,60 +2,63 @@ `src/protocols//` owns translation entrypoints and protocol-specific code. `__tests__/protocols/` mirrors production ownership. Package callers -import the package root and call -`routeVia(input, options)` to create an independent trip. +import the pair's named export from the package root and call it with a source +payload and `TranslateOptions`, receiving a `TranslateTrip` or a `TranslateInputError`. Protocol-native request normalization and Part data classification belong to `@floway-dev/protocols`; translation maps their failures at its own boundary. ## Categories -1. **Single-pair helper** — keep it in - `protocols//via-/`, beside its request and event translators. +1. **Composition** — a route pairs a source and a target and states nothing else. + Anything a pair alone needs belongs to one of its sides; if it truly needs + both, it belongs in `composition/index.ts`. 2. **Source-only helper** — keep it in `protocols//as-source/`. Only routes whose source is that protocol may import it. 3. **Target-only helper** — keep it in `protocols//as-target/`. Only routes whose target is that protocol may import it. -4. **Both directions** — keep it in `protocols//common/`. - Routes using that protocol as source or target may import it. Keep another - protocol's wire mapping in the matching `via-*` route. Retained files use - functional names without another protocol name. -5. **Output IR** — keep private types, its path schema, stream operations, +4. **IR** — keep private types, its path schema, stream operations, validation, accumulation, operation paths, and native unit index allocation under - `ir/output/`. Native SSE adapters + `ir/output/`, and the input model under `ir/input/`. A reading holds the + source's native assistant turns beside the IR it would produce, so it belongs + to composition rather than to the IR. Protocol event adapters belong to their protocol: `as-target/events.ts` reads upstream protocol events and `as-source/events.ts` writes client protocol events with its sidecar. The return path is - upstream events → IR records → client events with sidecar. IR stays internal to translate. -6. **Assistant-turn round trips** — keep owner-specific sidecar validation, replay + upstream protocol events → output IR records → client protocol events with sidecar. IR stays internal to translate. +5. **Assistant-turn round trips** — keep owner-specific sidecar validation, replay validation, and native stream coordination under `round-trip/`. Source replay inspection and carriers belong in `as-source/round-trip.ts`; native assistant-turn reference shapes and state alignment belong in `as-target/assistant-turn.ts`. Native output-to-input payload normalization belongs in `@floway-dev/protocols`. Transport encoding is injected through `TranslateOptions.sidecarCodec`. -7. **Protocol-independent utility** — keep it in `shared/`, with tests under +6. **Protocol-independent utility** — keep it in `shared/`, with tests under `__tests__/shared/`. Protocol behavior belongs in its owning protocol directory. ## Current Production Subdirectories -- `ir/output/` — output model, its path schema, and protocol-independent streaming state. +- `ir/common/` — the JSON value shape shared by both directions. +- `ir/input/` — the request conversation model, its types, and the target-side + selection helpers. +- `composition/` — what a trip needs before the IR is written: a + source conversation read into turn slots and resolved through exclusive native or IR paths. + `composition/request/` prepares those resolved turns for target writers by + allocating callable names and call IDs, then segmenting around restored turns. +- `ir/output/` — the returned-stream model, its path schema, builder, record consumer, and protocol-independent streaming state. - `round-trip/` — authenticated assistant-turn sidecars, replay validation, and native stream coordination. - `protocols/anthropic-messages/as-source/` — interprets Messages requests and emits Messages SSE. - `protocols/anthropic-messages/as-target/` — maps requests to Messages and consumes Messages SSE. - `protocols/gemini-generate-content/as-source/` — interprets GenerateContent parts and schemas and emits GenerateContent SSE. -- `protocols/openai-chat-completions/as-source/` — emits ChatCompletions SSE. -- `protocols/openai-chat-completions/as-target/` — consumes ChatCompletions SSE. -- `protocols/openai-chat-completions/common/` — reads scalar reasoning aliases and summary content. -- `protocols/openai-responses/as-source/` — interprets Responses tools, restores event identities, and emits Responses SSE. -- `protocols/openai-responses/as-target/` — consumes Responses SSE. -- `protocols/openai-responses/common/` — owns wrapper formats used in both translation directions. -- `protocols//via-/` — owns request translation and composes native SSE adapters for one protocol pair. -- `shared/` — cross-route coordinate conversion, error classification, JSON handling, and token validation. +- `protocols/openai-chat-completions/as-source/` — interprets ChatCompletions requests and emits ChatCompletions SSE. +- `protocols/openai-chat-completions/as-target/` — maps requests to ChatCompletions and consumes ChatCompletions SSE. +- `protocols/openai-responses/as-source/` — interprets Responses requests, restores event identities, and emits Responses SSE. +- `protocols/openai-responses/as-target/` — maps requests to Responses and consumes Responses SSE. +- `composition/index.ts` — pairs one source protocol with one target protocol into a trip; the generic composition boundary. +- `protocols//as-source.ts` — states that protocol as the side a request comes from. +- `protocols//as-target.ts` — states that protocol as the side a request goes to. +- `shared/` — cross-route coordinates, error classification, JSON handling, token validation, and callable wrapper formats. ## Rules -- Inspect helpers by content and consumers. Keep most translation logic in the - owning `via-*` route; extract only cohesive logic shared by multiple routes. - - Shallow wrappers that only rename or stringify must be inlined at every call site, not extracted. A helper that defines a format two or more routes must agree on is not such a wrapper. diff --git a/packages/translate/__tests__/composition/helpers.ts b/packages/translate/__tests__/composition/helpers.ts new file mode 100644 index 0000000000..af9408948e --- /dev/null +++ b/packages/translate/__tests__/composition/helpers.ts @@ -0,0 +1,93 @@ +import type { SourceProtocol, TargetProtocol } from '../../src/composition/index.ts'; +import { resolveReading } from '../../src/composition/reading.ts'; +import type { SourceReading, RestoredAssistantTurn } from '../../src/composition/reading.ts'; +import type { IRInputTurn } from '../../src/ir/input/types.ts'; +import { anthropicMessagesSourceProtocol } from '../../src/protocols/anthropic-messages/as-source.ts'; +import { anthropicMessagesTargetProtocol } from '../../src/protocols/anthropic-messages/as-target.ts'; +import { geminiGenerateContentSourceProtocol } from '../../src/protocols/gemini-generate-content/as-source.ts'; +import { openaiChatCompletionsSourceProtocol } from '../../src/protocols/openai-chat-completions/as-source.ts'; +import { openaiChatCompletionsTargetProtocol } from '../../src/protocols/openai-chat-completions/as-target.ts'; +import { openaiResponsesSourceProtocol } from '../../src/protocols/openai-responses/as-source.ts'; +import { openaiResponsesTargetProtocol } from '../../src/protocols/openai-responses/as-target.ts'; +import { createRoundTripCoordinator } from '../../src/round-trip/coordinator.ts'; +import type { AssistantTurnProtocol, InputAssistantTurnFor } from '../../src/round-trip/input-assistant-turn.ts'; +import { roundTripAssistantTurn, type SidecarRecognition } from '../../src/round-trip/resolve.ts'; +import type { SourceAssistantTurnFor } from '../../src/round-trip/source-assistant-turn.ts'; +import type { TranslateOptions } from '../../src/types.ts'; +import type { AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; +import type { ProtocolFrame } from '@floway-dev/protocols/common'; +import type { GeminiGenerateContentRequest } from '@floway-dev/protocols/gemini-generate-content'; +import type { OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; +import type { OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; + +export const pairs = { + messagesToChat: [anthropicMessagesSourceProtocol, openaiChatCompletionsTargetProtocol], + messagesToResponses: [anthropicMessagesSourceProtocol, openaiResponsesTargetProtocol], + chatToMessages: [openaiChatCompletionsSourceProtocol, anthropicMessagesTargetProtocol], + chatToResponses: [openaiChatCompletionsSourceProtocol, openaiResponsesTargetProtocol], + responsesToMessages: [openaiResponsesSourceProtocol, anthropicMessagesTargetProtocol], + responsesToChat: [openaiResponsesSourceProtocol, openaiChatCompletionsTargetProtocol], + geminiToMessages: [geminiGenerateContentSourceProtocol, anthropicMessagesTargetProtocol], + geminiToChat: [geminiGenerateContentSourceProtocol, openaiChatCompletionsTargetProtocol], + geminiToResponses: [geminiGenerateContentSourceProtocol, openaiResponsesTargetProtocol], +} as const; + +export const emptyPayload = { + messages: { model: 'm', max_tokens: 16, stream: true, messages: [] } as AnthropicMessagesPayload, + chat: { model: 'm', stream: true, messages: [] } as OpenAIChatCompletionsPayloadEx, + responses: { model: 'm', stream: true, input: [] } as OpenAIResponsesRequestPayloadEx, + gemini: { model: 'model', contents: [] } as GeminiGenerateContentRequest, +}; + +/** + * Settles every assistant slot that carries a sidecar this target can restore. + * + * A slot whose sidecar was recognised is written natively by the target; one + * that carries no sidecar for this target falls back to the IR its own thunk + * builds. The two paths are exclusive, which is why the thunk is only called on + * the second. + */ +const resolveReadingTurns = async < + SrcName extends AssistantTurnProtocol, + TgtName extends AssistantTurnProtocol, +>( + source: { readonly name: SrcName; readonly sidecarRecognition: SidecarRecognition> }, + target: { readonly name: TgtName }, + reading: SourceReading>, + options: TranslateOptions, +): Promise<(IRInputTurn | RestoredAssistantTurn>)[]> => + await resolveReading(reading, turn => roundTripAssistantTurn( + source.name, target.name, turn.assistantTurn, options.sidecarCodec, source.sidecarRecognition, + )); + +export const buildTargetPayloadFor = async < + SrcName extends AssistantTurnProtocol, SrcPayload, SrcEvent, + TgtName extends AssistantTurnProtocol, TgtPayload, TgtEvent, Context, +>( + source: SourceProtocol, + target: TargetProtocol, + payload: SrcPayload, + options: TranslateOptions, + reading: SourceReading> = source.read(payload), +): Promise => + target.write(reading.request, await resolveReadingTurns(source, target, reading, options), options).payload; + +export const buildSourceEventsFor = < + SrcName extends AssistantTurnProtocol, SrcPayload, SrcEvent, + TgtName extends AssistantTurnProtocol, TgtPayload, TgtEvent, Context, +>( + source: SourceProtocol, + target: TargetProtocol, + payload: SrcPayload, + options: TranslateOptions, + frames: AsyncIterable>, +): AsyncIterable> => { + const reading = source.read(payload); + const coordinator = createRoundTripCoordinator(source.name, target.name, options.sidecarCodec); + // Stating the answer needs what writing the request recorded, so this walks + // the same writing step the composed trip does. + return (async function* () { + const { context } = target.write(reading.request, await resolveReadingTurns(source, target, reading, options), options); + yield* source.writeEvents(payload, target.readEvents(frames, context, coordinator.reader), coordinator.writer); + })(); +}; diff --git a/packages/translate/__tests__/composition/reading_test.ts b/packages/translate/__tests__/composition/reading_test.ts new file mode 100644 index 0000000000..c951940d25 --- /dev/null +++ b/packages/translate/__tests__/composition/reading_test.ts @@ -0,0 +1,49 @@ +import { expect, test } from 'vitest'; + +import { resolveReading } from '../../src/composition/reading.ts'; +import { readAnthropicMessagesRequest } from '../../src/protocols/anthropic-messages/as-source/input-to.ts'; + +test('joins parallel server results and the next user message into one client turn', async () => { + const reading = readAnthropicMessagesRequest({ + model: 'model', max_tokens: 32, messages: [ + { role: 'user', content: 'Q' }, + { + role: 'assistant', content: [ + { type: 'server_tool_use', id: 's1', name: 'web_search', input: { query: 'one' } }, + { type: 'server_tool_use', id: 's2', name: 'web_search', input: { query: 'two' } }, + { type: 'web_search_tool_result', tool_use_id: 's1', content: [] }, + { type: 'web_search_tool_result', tool_use_id: 's2', content: [] }, + ], + }, + { role: 'user', content: 'Next' }, + ], + }); + expect(await resolveReading(reading, async () => undefined)).toEqual([ + { kind: 'client', items: [{ type: 'message', role: 'user', content: [{ type: 'text', text: 'Q' }] }] }, + { + kind: 'assistant', items: [ + { type: 'function_call', call_id: 's1', name: 'web_search', arguments: '{"query":"one"}' }, + { type: 'function_call', call_id: 's2', name: 'web_search', arguments: '{"query":"two"}' }, + ], + }, + { + kind: 'client', items: [ + { type: 'tool_result', call_id: 's1', output: '[]' }, + { type: 'tool_result', call_id: 's2', output: '[]' }, + { type: 'message', role: 'user', content: [{ type: 'text', text: 'Next' }] }, + ], + }, + ]); +}); + +test('keeps instruction and restored assistant boundaries while joining client pieces', async () => { + const reading = readAnthropicMessagesRequest({ + model: 'model', max_tokens: 32, system: 'Rules', messages: [ + { role: 'user', content: 'Before' }, + { role: 'assistant', content: 'Native' }, + { role: 'user', content: 'After' }, + ], + }); + expect((await resolveReading(reading, async () => 'restored')).map(turn => turn.kind)) + .toEqual(['client', 'client', 'restored', 'client']); +}); diff --git a/packages/translate/__tests__/composition/request/call-ids_test.ts b/packages/translate/__tests__/composition/request/call-ids_test.ts new file mode 100644 index 0000000000..cb693fd783 --- /dev/null +++ b/packages/translate/__tests__/composition/request/call-ids_test.ts @@ -0,0 +1,58 @@ +import { test } from 'vitest'; + +import type { RestoredAssistantTurn } from '../../../src/composition/reading.ts'; +import { assignCallIds, type PendingCall } from '../../../src/composition/request/call-ids.ts'; +import type { IRInputTurn } from '../../../src/ir/input/types.ts'; +import { TranslateInputException } from '../../../src/shared/input-error.ts'; +import { assertEquals, assertThrows } from '@floway-dev/test-utils'; + +const call = (name: string, callId?: string): IRInputTurn => ({ + kind: 'assistant', + items: [{ type: 'function_call', name, arguments: '{}', ...(callId === undefined ? {} : { call_id: callId }) }], +}); +const result = (name?: string, callId?: string): IRInputTurn => ({ + kind: 'client', + items: [{ type: 'tool_result', ...(name === undefined ? {} : { name }), ...(callId === undefined ? {} : { call_id: callId }), output: '' }], +}); +const restored = (calls: readonly PendingCall[]): RestoredAssistantTurn => ({ kind: 'restored', turn: calls }); +const restoredCalls = (turn: readonly PendingCall[]): readonly PendingCall[] => turn; + +const lastResult = (turns: ReturnType) => { + const piece = turns.at(-1); + if (piece?.kind !== 'client') throw new Error('expected a client turn'); + const item = piece.items.at(-1); + if (item?.type !== 'tool_result') throw new Error('expected a tool result'); + return item; +}; + +test('an id-less result answers the oldest call of its name', () => { + assertEquals(lastResult(assignCallIds([call('read', 'a'), call('read', 'b'), result('read')], () => [])).call_id, 'a'); +}); + +test('an id-less result with no name answers the oldest pending call', () => { + assertEquals(lastResult(assignCallIds([call('read', 'a'), call('write', 'b'), result()], () => [])).call_id, 'a'); +}); + +test('an id-less result answers a call a sidecar restored', () => { + assertEquals(lastResult(assignCallIds([restored([{ name: 'read', callId: 'native-1' }]), result()], restoredCalls)).call_id, 'native-1'); +}); + +test('a result naming a call nothing awaits is refused rather than answered with an invented id', () => { + // The call it names is a turn this side never saw — a trimmed history, say. + // Answering an oldest pending call instead would pair it with the wrong one, + // and inventing an id would state a call no assistant turn ever made. + assertThrows(() => assignCallIds([call('read', 'a'), result('write')], () => []), TranslateInputException); + assertThrows(() => assignCallIds([call('read', 'a'), result(undefined, 'invented')], () => []), TranslateInputException); +}); + +test('a stated result releases its call so a later id-less result cannot take it', () => { + assertEquals(lastResult(assignCallIds([call('read', 'a'), call('read', 'b'), result(undefined, 'a'), result('read')], () => [])).call_id, 'b'); +}); + +test('an invented id does not collide with one the conversation states', () => { + // A source may state any string as a real id, including one this step would + // otherwise invent, and two calls sharing an id cannot be told apart. + const ids = assignCallIds([call('read'), call('read', 'read_call_1')], () => []) + .flatMap(turn => turn.kind === 'assistant' ? turn.items.flatMap(item => item.type === 'function_call' ? [item.call_id] : []) : []); + assertEquals(new Set(ids).size, ids.length); +}); diff --git a/packages/translate/__tests__/composition/request/tool-names_test.ts b/packages/translate/__tests__/composition/request/tool-names_test.ts new file mode 100644 index 0000000000..2ce4538ba7 --- /dev/null +++ b/packages/translate/__tests__/composition/request/tool-names_test.ts @@ -0,0 +1,111 @@ +import { test } from 'vitest'; + +import type { IRInputTurnWithCallIds } from '../../../src/composition/request/call-ids.ts'; +import { allocateToolNames } from '../../../src/composition/request/tool-names.ts'; +import type { IRInputRequestHeader, IRInputTool } from '../../../src/ir/input/types.ts'; +import { TranslateInputException } from '../../../src/shared/input-error.ts'; +import { assertThrows, assertEquals } from '@floway-dev/test-utils'; + +const request = (tools: readonly IRInputTool[]): IRInputRequestHeader => ({ settings: { model: 'm', stream: true }, tools }); + +test('a grouped declaration keeps its pair when its namespace contains the flatten separator', () => { + // Keep the full callable identity beside its allocated wire name; never reconstruct it by parsing that spelling. + const flattened = allocateToolNames(request([{ type: 'function', namespace: 'a.b', name: 'read', parameters: { type: 'object' } }]), []); + + const flatName = flattened.context.originalToolNames.keys().next().value; + assertEquals(typeof flatName, 'string'); + assertEquals(flattened.context.originalToolNames.get(flatName!), { namespace: 'a.b', name: 'read' }); +}); + +test('a grouped declaration keeps its pair when its name contains the flatten separator', () => { + const flattened = allocateToolNames(request([{ type: 'function', namespace: 'files', name: 'read.one', parameters: { type: 'object' } }]), []); + + const flatName = flattened.context.originalToolNames.keys().next().value; + assertEquals(typeof flatName, 'string'); + assertEquals(flattened.context.originalToolNames.get(flatName!), { namespace: 'files', name: 'read.one' }); +}); + +test('custom declarations are reported under the flat names this wire writes', () => { + const flattened = allocateToolNames(request([{ type: 'custom', namespace: 'files', name: 'edit' }]), []); + + // The reader that recognises these calls back sees the wire name, not the + // client's, so the set has to be stated after flattening. + assertEquals([...flattened.context.customToolNames], [...flattened.context.originalToolNames.keys()]); + assertEquals(flattened.request.tools?.[0]?.namespace, undefined); +}); + +test('a call naming an undeclared group still gets a flat spelling and its pair', () => { + const turns: IRInputTurnWithCallIds[] = [{ + kind: 'assistant', + items: [{ type: 'function_call', call_id: 'c1', namespace: 'ghost', name: 'run', arguments: '{}' }], + }]; + const flattened = allocateToolNames(request([]), turns); + + const assistantTurn = flattened.turns[0]; + assertEquals(assistantTurn.kind, 'assistant'); + const item = assistantTurn.kind === 'assistant' ? assistantTurn.items[0] : undefined; + assertEquals(item?.type, 'function_call'); + const flatName = item?.type === 'function_call' ? item.name : undefined; + assertEquals(typeof flatName, 'string'); + assertEquals(flattened.context.originalToolNames.get(flatName!), { namespace: 'ghost', name: 'run' }); +}); + +test('a selector naming a group the request never declared is a caller error', () => { + assertThrows( + () => allocateToolNames({ + settings: { model: 'm', stream: true }, + tools: [{ type: 'function', namespace: 'files', name: 'read', parameters: { type: 'object' } }], + tool_choice: { type: 'function', namespace: 'ghost', name: 'run' }, + }, []), + TranslateInputException, + 'undeclared grouped tool', + ); +}); + +test('distinct structured identities with the same dotted spelling get distinct wire names', () => { + const flattened = allocateToolNames(request([ + { type: 'function', namespace: 'a', name: 'b.c', parameters: { type: 'object' } }, + { type: 'function', namespace: 'a.b', name: 'c', parameters: { type: 'object' } }, + ]), []); + assertEquals(flattened.request.tools?.map(tool => tool.name), ['a_b_c', 'a_b_c_2']); + assertEquals([...flattened.context.originalToolNames.values()], [{ namespace: 'a', name: 'b.c' }, { namespace: 'a.b', name: 'c' }]); +}); + +test('a selector naming a group this request does not declare is refused', () => { + assertThrows( + () => allocateToolNames({ settings: { model: 'm', stream: true }, tools: [{ type: 'function', name: 'web.run', parameters: { type: 'object' } }], tool_choice: { type: 'function', name: 'run', namespace: 'web' } }, []), + TranslateInputException, + ); +}); + +test('a plain selector passes through untouched', () => { + const flattened = allocateToolNames({ settings: { model: 'm', stream: true }, tools: [{ type: 'function', name: 'plain', parameters: { type: 'object' } }], tool_choice: { type: 'function', name: 'plain' } }, []); + assertEquals(flattened.request.tool_choice, { type: 'function', name: 'plain' }); +}); + +test.each([undefined, 'files'])('allocates distinct names per callable kind in namespace %s independently of selection', namespace => { + const tools: IRInputTool[] = [ + { type: 'function', namespace, name: 'edit', parameters: { type: 'object' } }, + { type: 'custom', namespace, name: 'edit' }, + { type: 'function', name: 'edit_2', parameters: { type: 'object' } }, + ]; + const turns: IRInputTurnWithCallIds[] = [{ + kind: 'assistant', items: [ + { type: 'function_call', namespace, name: 'edit', call_id: 'f', arguments: '{}' }, + { type: 'custom_tool_call', namespace, name: 'edit', call_id: 'c', input: 'raw' }, + { type: 'custom_tool_call', namespace, name: 'edit', call_id: 'c2', input: 'again' }, + ], + }]; + const baseline = allocateToolNames({ ...request(tools), tool_choice: 'auto' }, turns); + const selected = allocateToolNames({ ...request(tools), tool_choice: { type: 'allowed_tools', mode: 'required', tools: [{ type: 'custom', namespace, name: 'edit' }] } }, turns); + const first = namespace === undefined ? 'edit' : 'files_edit'; + const second = namespace === undefined ? 'edit_3' : 'files_edit_2'; + assertEquals(baseline.request.tools?.map(tool => tool.name), [first, second, 'edit_2']); + assertEquals(selected.request.tools, baseline.request.tools); + assertEquals(selected.turns, baseline.turns); + assertEquals(selected.request.tool_choice, { type: 'allowed_tools', mode: 'required', tools: [{ type: 'custom', name: second }] }); + const assistant = selected.turns[0]; + assertEquals(assistant.kind === 'assistant' ? assistant.items.map(item => 'name' in item ? item.name : undefined) : [], [first, second, second]); + assertEquals([...selected.context.customToolNames], [second]); + assertEquals(selected.context.originalToolNames.get(second), { name: 'edit', ...(namespace === undefined ? {} : { namespace }) }); +}); diff --git a/packages/translate/__tests__/input-error_test.ts b/packages/translate/__tests__/input-error_test.ts new file mode 100644 index 0000000000..d90fe61a27 --- /dev/null +++ b/packages/translate/__tests__/input-error_test.ts @@ -0,0 +1,81 @@ +import { expect, test } from 'vitest'; + +import { createTestSidecarCodec } from './round-trip/sidecar-codec.ts'; +import { createOpenAIChatCompletionsSidecarDetails } from '../src/protocols/openai-chat-completions/as-source/round-trip.ts'; +import type { OpenAIChatCompletionsAssistantMessageEx } from '@floway-dev/protocols/openai-chat-completions'; +import * as translate from '@floway-dev/translate'; + +const options = { sidecarCodec: createTestSidecarCodec() }; +const messages = { + model: 'm', stream: true, max_tokens: 32, + messages: [{ + role: 'user' as const, + content: [{ + type: 'document' as const, + source: { + type: 'content' as const, + content: [{ type: 'unknown' } as never], + }, + }], + }], +}; +const chat = { + model: 'm', stream: true, + messages: [{ role: 'user' as const, content: [{ type: 'input_audio' as const, input_audio: { format: 'wav' as const, data: 'AAAA' } }] }], +}; +const responses = { model: 'm', stream: true, input: [{ type: 'item_reference' as const, id: 'opaque' }] }; +const gemini = { + model: 'm', contents: [{ role: 'user' as const, parts: [{ fileData: { mimeType: 'application/pdf', fileUri: 'https://example.com/a.pdf' } }] }], +}; + +const routes = [ + () => translate.routeAnthropicMessagesViaOpenAIChatCompletions(messages, options), + () => translate.routeAnthropicMessagesViaOpenAIResponses(messages, options), + () => translate.routeOpenAIChatCompletionsViaAnthropicMessages(chat, options), + () => translate.routeOpenAIChatCompletionsViaOpenAIResponses(chat, options), + () => translate.routeOpenAIResponsesViaAnthropicMessages(responses, options), + () => translate.routeOpenAIResponsesViaOpenAIChatCompletions(responses, options), + () => translate.routeGeminiGenerateContentViaAnthropicMessages(gemini, options), + () => translate.routeGeminiGenerateContentViaOpenAIChatCompletions(gemini, options), + () => translate.routeGeminiGenerateContentViaOpenAIResponses(gemini, options), +]; + +test.each(routes.map((run, index) => ({ index, run })))('route $index returns an ordinary input error object', async ({ run }) => { + const result = await run(); + expect(result.type).toBe('input-error'); + expect(result).not.toBeInstanceOf(Error); + expect(Object.getPrototypeOf(result)).toBe(Object.prototype); + if (result.type !== 'input-error') throw new Error('Expected an input failure'); + expect(result.message.length).toBeGreaterThan(0); +}); + +test('an unexpected codec failure retains its original error and cause', async () => { + const cause = new Error('codec cause'); + const failure = new Error('codec failure', { cause }); + const codec = { ...createTestSidecarCodec(), decode: async (): Promise => { throw failure; } }; + const turn: OpenAIChatCompletionsAssistantMessageEx = { + role: 'assistant', content: 'history', reasoning_details: createOpenAIChatCompletionsSidecarDetails('sidecar'), + }; + await expect(translate.routeOpenAIChatCompletionsViaAnthropicMessages({ model: 'm', stream: true, messages: [turn] }, { sidecarCodec: codec })).rejects.toBe(failure); +}); + +test.each([ + translate.routeAnthropicMessagesViaOpenAIChatCompletions, + translate.routeAnthropicMessagesViaOpenAIResponses, +])('a target that cannot express document image transformations returns an input error', async route => { + const result = await route({ + model: 'm', max_tokens: 32, stream: true, messages: [{ + role: 'user', content: [{ + type: 'document', + source: { + type: 'content', content: [{ + type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' }, + transformations: { oversized_image: 'downsize' }, + }], + }, + }], + }], + }, options); + expect(result).toMatchObject({ type: 'input-error', message: 'Cannot translate document image transformations to this protocol.' }); + expect(result).not.toBeInstanceOf(Error); +}); diff --git a/packages/translate/__tests__/ir/input/types_test.ts b/packages/translate/__tests__/ir/input/types_test.ts new file mode 100644 index 0000000000..dfede359fd --- /dev/null +++ b/packages/translate/__tests__/ir/input/types_test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from 'vitest'; + +import type { IRInputAssistantItem, IRInputAssistantTurn, IRInputToolResultContentPart, IRInputToolResultItem, IRInputUserContentPart, IRInputClientItem, IRInputClientTurn } from '../../../src/ir/input/types.ts'; + +describe('input IR shape', () => { + const userParts: IRInputUserContentPart[] = [ + { type: 'text', text: 'hi' }, + { type: 'image', image: { source_kind: 'url', url: 'https://example.com/a.png' }, detail: 'high' }, + { type: 'image', image: { source_kind: 'base64', media_type: 'image/png', data: 'AAAA' } }, + { type: 'file', file: { source_kind: 'file', file_id: 'file_1' }, filename: 'a.pdf' }, + { type: 'file', file: { source_kind: 'base64', data: 'AAAA' } }, + { type: 'document', document: { source: { source_kind: 'media', media: { source_kind: 'file', file_id: 'file_2' } } } }, + { + type: 'document', + document: { + source: { + source_kind: 'content', + content: [ + { type: 'text', text: 'caption' }, + { type: 'image', image: { source_kind: 'base64', media_type: 'image/png', data: 'AAAA' } }, + ], + }, + }, + }, + { type: 'audio', audio: { media_type: 'audio/wav', data: 'AAAA' } }, + ]; + + test('a conversation carries user and assistant turns in order', () => { + const clientItems: IRInputClientItem[] = [ + { type: 'instruction', content: [{ type: 'text', text: 'top-level instruction' }] }, + { type: 'message', role: 'system', content: [{ type: 'text', text: 'be brief' }] }, + { type: 'message', role: 'developer', content: [{ type: 'text', text: 'tools are strict' }] }, + { type: 'message', role: 'user', content: userParts }, + { type: 'tool_result', call_id: 'call_1', output: 'ok' }, + { type: 'tool_result', call_id: 'call_2', output: [{ type: 'text', text: 'see' }, { type: 'image', image: { source_kind: 'url', url: 'https://example.com/b.png' } }], is_error: true }, + ]; + const assistantItems: IRInputAssistantItem[] = [ + { type: 'reasoning', summary: ['thought'], content: ['detail'] }, + { type: 'message', content: [{ type: 'text', text: 'answer' }, { type: 'refusal', refusal: 'no' }] }, + { type: 'function_call', call_id: 'call_1', name: 'lookup', arguments: '{"q":1}' }, + { type: 'custom_tool_call', call_id: 'call_2', name: 'edit', input: 'raw' }, + ]; + const user: IRInputClientTurn = { kind: 'client', items: clientItems }; + const assistant: IRInputAssistantTurn = { kind: 'assistant', items: assistantItems }; + const turns = [user, assistant]; + + expect(turns.map(turn => turn.kind)).toEqual(['client', 'assistant']); + expect(user.items.map(item => item.type)).toEqual(['instruction', 'message', 'message', 'message', 'tool_result', 'tool_result']); + expect(assistant.items.map(item => item.type)).toEqual(['reasoning', 'message', 'function_call', 'custom_tool_call']); + }); + + test('a malformed tool call keeps its arguments as text rather than a parsed wrapper', () => { + const call: IRInputAssistantItem = { type: 'function_call', call_id: 'call_1', name: 'f', arguments: 'not json' }; + expect(call.type === 'function_call' && call.arguments).toBe('not json'); + }); + + test('a structured tool result keeps its parts instead of stringifying them', () => { + const parts: IRInputToolResultContentPart[] = [ + { type: 'text', text: 'caption' }, + { type: 'file', file: { source_kind: 'base64', media_type: 'application/pdf', data: 'AAAA' }, filename: 'r.pdf' }, + { type: 'document', document: { source: { source_kind: 'text', media_type: 'text/plain', text: 'quoted' } } }, + ]; + const result: IRInputToolResultItem = { type: 'tool_result', call_id: 'call_1', output: parts }; + expect(Array.isArray(result.output) && result.output.map(part => part.type)).toEqual(['text', 'file', 'document']); + }); + + test('a file may omit media type, while audio must state one', () => { + const typedFile: IRInputUserContentPart = { type: 'file', file: { source_kind: 'base64', media_type: 'application/pdf', data: 'AAAA' } }; + const untypedFile: IRInputUserContentPart = { type: 'file', file: { source_kind: 'base64', data: 'AAAA' } }; + const audio: IRInputUserContentPart = { type: 'audio', audio: { media_type: 'audio/mp3', data: 'AAAA' } }; + // @ts-expect-error Chat Completions names a format enum and GenerateContent a + // MIME type; neither admits an untyped audio payload. + const untypedAudio: IRInputUserContentPart = { type: 'audio', audio: { data: 'AAAA' } }; + expect([typedFile.type, untypedFile.type, audio.type, untypedAudio.type]).toEqual(['file', 'file', 'audio', 'audio']); + }); +}); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-source/input-to_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/input-to_test.ts new file mode 100644 index 0000000000..7f10a75fea --- /dev/null +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-source/input-to_test.ts @@ -0,0 +1,61 @@ +import { expect, test } from 'vitest'; + +import { readAnthropicMessagesRequest } from '../../../../src/protocols/anthropic-messages/as-source/input-to.ts'; + +test('reads document metadata and nested part attributes into the IR', () => { + const citation = { + type: 'char_location' as const, cited_text: 'caption', document_index: 0, document_title: null, + start_char_index: 0, end_char_index: 7, + }; + const reading = readAnthropicMessagesRequest({ + model: 'm', max_tokens: 32, messages: [{ + role: 'user', + content: [{ + type: 'document', title: 'Report', context: 'USD', citations: { enabled: true }, + cache_control: { type: 'ephemeral', ttl: '1h' }, + source: { + type: 'content', content: [ + { type: 'text', text: 'caption', citations: [citation], cache_control: null }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' }, transformations: { oversized_image: 'downsize' }, cache_control: { type: 'ephemeral' } }, + ], + }, + }], + }], + }); + + expect(reading.turns[0].toIRInputTurns()).toStrictEqual([{ + kind: 'client', items: [{ + type: 'message', role: 'user', content: [{ + type: 'document', document: { + title: 'Report', context: 'USD', citations: { enabled: true }, cache_control: { type: 'ephemeral', ttl: '1h' }, + source: { + source_kind: 'content', content: [ + { type: 'text', text: 'caption', citations: [citation], cache_control: null }, + { type: 'image', image: { source_kind: 'base64', media_type: 'image/png', data: 'AAAA' }, transformations: { oversized_image: 'downsize' }, cache_control: { type: 'ephemeral' } }, + ], + }, + }, + }], + }], + }]); +}); + +test('distinguishes null document metadata from absent metadata', () => { + const document = { type: 'document' as const, source: { type: 'text' as const, media_type: 'text/plain' as const, data: 'body' } }; + const reading = readAnthropicMessagesRequest({ + model: 'm', max_tokens: 32, messages: [{ + role: 'user', content: [ + document, + { ...document, title: null, context: null, citations: null, cache_control: null }, + { ...document, citations: {} }, + ], + }], + }); + const [turn] = reading.turns[0].toIRInputTurns(); + if (turn.kind !== 'client' || turn.items[0].type !== 'message') throw new Error('Expected a user message'); + expect(turn.items[0].content).toStrictEqual([ + { type: 'document', document: { source: { source_kind: 'text', media_type: 'text/plain', text: 'body' } } }, + { type: 'document', document: { source: { source_kind: 'text', media_type: 'text/plain', text: 'body' }, title: null, context: null, citations: null, cache_control: null } }, + { type: 'document', document: { source: { source_kind: 'text', media_type: 'text/plain', text: 'body' }, citations: {} } }, + ]); +}); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-source/native-reassembly_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-source/native-reassembly_test.ts index 9eda683e9a..94f3b1b9e1 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-source/native-reassembly_test.ts +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-source/native-reassembly_test.ts @@ -2,7 +2,7 @@ import { expect, test, vi } from 'vitest'; import { computeAnthropicMessagesTurnDigest } from '../../../../src/protocols/anthropic-messages/as-source/round-trip.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; -import { requireTrip } from '../../require-trip.ts'; +import { expectTrip } from '../../../trip.ts'; import { createAnthropicMessagesEventAccumulator, reassembleAnthropicMessagesEvents } from '@floway-dev/protocols/anthropic-messages'; import { openaiResponsesResultToEvents } from '@floway-dev/protocols/openai-responses'; import { routeAnthropicMessagesViaOpenAIResponses } from '@floway-dev/translate'; @@ -22,9 +22,9 @@ test.each(['complete', 'truncated'] as const)('reads only the current native blo vi.mocked(createAnthropicMessagesEventAccumulator).mockClear(); const count = 32; const codec = createTestSidecarCodec(); - const trip = await requireTrip(routeAnthropicMessagesViaOpenAIResponses({ - model: 'source', max_tokens: 4096, messages: [{ role: 'user', content: 'question' }], - }, { model: 'target', sidecarCodec: codec })); + const trip = expectTrip(await routeAnthropicMessagesViaOpenAIResponses({ + model: 'source', max_tokens: 4096, stream: true, messages: [{ role: 'user', content: 'question' }], + }, { sidecarCodec: codec })); const frames = async function* () { yield* openaiResponsesResultToEvents({ id: 'response', object: 'response', model: 'target', diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-target/data-url_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-target/data-url_test.ts new file mode 100644 index 0000000000..34017691df --- /dev/null +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-target/data-url_test.ts @@ -0,0 +1,26 @@ +import { test } from 'vitest'; + +import { parseDataURL } from '../../../../src/protocols/anthropic-messages/as-target/data-url.ts'; +import { assertEquals } from '@floway-dev/test-utils'; + +test('data URL parsing retains media-type parameters', () => { + assertEquals(parseDataURL('data:text/csv;charset=utf-8;base64,YQ=='), { + media_type: 'text/csv;charset=utf-8', data: 'YQ==', + }); + assertEquals(parseDataURL('data:;base64,AAAA'), { data: 'AAAA' }); + assertEquals(parseDataURL('data:;charset=utf-8,hi'), { + media_type: 'text/plain;charset=utf-8', data: 'aGk=', + }); +}); + +test('data URL parsing decodes percent-escaped bytes', () => { + assertEquals(parseDataURL('data:image/svg+xml,%3Csvg%2F%3E'), { + media_type: 'image/svg+xml', data: 'PHN2Zy8+', + }); + assertEquals(parseDataURL('data:text/plain;charset=iso-8859-1,%E9'), { + media_type: 'text/plain;charset=iso-8859-1', data: '6Q==', + }); + assertEquals(parseDataURL('data:text/plain;base64,SGk%3D'), { + media_type: 'text/plain', data: 'SGk=', + }); +}); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-target/input-from_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-target/input-from_test.ts new file mode 100644 index 0000000000..00ab11966f --- /dev/null +++ b/packages/translate/__tests__/protocols/anthropic-messages/as-target/input-from_test.ts @@ -0,0 +1,92 @@ +import { test } from 'vitest'; + +import type { IRInputTurnWithCallIds } from '../../../../src/composition/request/call-ids.ts'; +import { anthropicMessagesConversationFromIR } from '../../../../src/protocols/anthropic-messages/as-target/input-from.ts'; +import { assertEquals } from '@floway-dev/test-utils'; + +test('writes a content-backed IR document as a native Messages document', () => { + const citation = { + type: 'char_location' as const, cited_text: 'caption', document_index: 0, document_title: null, + start_char_index: 0, end_char_index: 7, + }; + const turns: IRInputTurnWithCallIds[] = [{ + kind: 'client', + items: [{ + type: 'message', role: 'user', content: [ + { + type: 'document', + document: { + title: 'Report', + context: 'USD', + citations: { enabled: true }, + cache_control: { type: 'ephemeral', ttl: '1h' }, + source: { + source_kind: 'content', + content: [ + { type: 'text', text: 'caption', citations: [citation], cache_control: null }, + { type: 'image', image: { source_kind: 'base64', media_type: 'image/png', data: 'AAAA' }, transformations: { oversized_image: 'downsize' }, cache_control: { type: 'ephemeral' } }, + ], + }, + }, + }, + ], + }], + }]; + + assertEquals(anthropicMessagesConversationFromIR(turns, true), { + system: [], + messages: [{ + role: 'user', + content: [ + { + type: 'document', + title: 'Report', + context: 'USD', + citations: { enabled: true }, + cache_control: { type: 'ephemeral', ttl: '1h' }, + source: { + type: 'content', + content: [ + { type: 'text', text: 'caption', citations: [citation], cache_control: null }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' }, transformations: { oversized_image: 'downsize' }, cache_control: { type: 'ephemeral' } }, + ], + }, + }, + ], + }], + }); +}); + +test('writes null document metadata and leaves absent metadata absent', () => { + const turns: IRInputTurnWithCallIds[] = [{ + kind: 'client', items: [{ + type: 'message', role: 'user', content: [ + { type: 'document', document: { source: { source_kind: 'text', media_type: 'text/plain', text: 'a' } } }, + { type: 'document', document: { source: { source_kind: 'text', media_type: 'text/plain', text: 'b' }, title: null, context: null, citations: null, cache_control: null } }, + ], + }], + }]; + assertEquals(anthropicMessagesConversationFromIR(turns, true).messages, [{ + role: 'user', content: [ + { type: 'document', source: { type: 'text', media_type: 'text/plain', data: 'a' } }, + { type: 'document', source: { type: 'text', media_type: 'text/plain', data: 'b' }, title: null, context: null, citations: null, cache_control: null }, + ], + }]); +}); + +test('writes metadata for both text and media document sources', () => { + const turns: IRInputTurnWithCallIds[] = [{ + kind: 'client', items: [{ + type: 'message', role: 'user', content: [ + { type: 'document', document: { source: { source_kind: 'text', media_type: 'text/plain', text: 'body' }, title: 'Text', citations: {} } }, + { type: 'document', document: { source: { source_kind: 'media', media: { source_kind: 'file', file_id: 'file_1' } }, title: 'File', cache_control: { type: 'ephemeral', ttl: '5m' } } }, + ], + }], + }]; + assertEquals(anthropicMessagesConversationFromIR(turns, true).messages, [{ + role: 'user', content: [ + { type: 'document', source: { type: 'text', media_type: 'text/plain', data: 'body' }, title: 'Text', citations: {} }, + { type: 'document', source: { type: 'file', file_id: 'file_1' }, title: 'File', cache_control: { type: 'ephemeral', ttl: '5m' } }, + ], + }]); +}); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/as-target/reasoning-effort_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/as-target/reasoning-effort_test.ts deleted file mode 100644 index 57ea9da719..0000000000 --- a/packages/translate/__tests__/protocols/anthropic-messages/as-target/reasoning-effort_test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { test } from 'vitest'; - -import { anthropicMessagesReasoningFieldsFromEffort } from '../../../../src/protocols/anthropic-messages/as-target/reasoning-effort.ts'; -import { assertEquals } from '@floway-dev/test-utils'; - -test('effort none becomes the Anthropic Messages native disable shape rather than an output_config level', () => { - assertEquals(anthropicMessagesReasoningFieldsFromEffort('none'), { thinking: { type: 'disabled' } }); -}); - -test('every other effort passes through to output_config verbatim', () => { - for (const effort of ['', 'minimal', 'low', 'medium', 'high', 'max', 'vendor-specific-level']) { - assertEquals(anthropicMessagesReasoningFieldsFromEffort(effort), { effort }); - } -}); - -test('an absent effort selects neither slot', () => { - assertEquals(anthropicMessagesReasoningFieldsFromEffort(undefined), {}); - assertEquals(anthropicMessagesReasoningFieldsFromEffort(null), {}); -}); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events_test.ts deleted file mode 100644 index a03fca1f03..0000000000 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events_test.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { translateToSourceEvents } from '../../../../src/protocols/anthropic-messages/via-openai-chat-completions/events.ts'; -import { testOutputTranslation } from '../../fixtures.ts'; - -testOutputTranslation('anthropic-messages', 'openai-chat-completions', (frames, codec) => translateToSourceEvents(frames, codec)); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/request_test.ts b/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/request_test.ts deleted file mode 100644 index 9ee67180c3..0000000000 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/request_test.ts +++ /dev/null @@ -1,560 +0,0 @@ -import { test } from 'vitest'; - -import { buildTargetRequest as buildTargetRequestWithSidecar } from '../../../../src/protocols/anthropic-messages/via-openai-responses/request.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; -import type { AnthropicMessagesPayload, AnthropicMessagesAssistantContentBlock, AnthropicMessagesUserContentBlock } from '@floway-dev/protocols/anthropic-messages'; -import type { OpenAIResponsesFunctionTool, OpenAIResponsesInputReasoning } from '@floway-dev/protocols/openai-responses'; -import { assertEquals, assertFalse, assertRejects } from '@floway-dev/test-utils'; - -const buildWithTestSidecar = (payload: Parameters[0]) => - buildTargetRequestWithSidecar(payload, createTestSidecarCodec()); - -test('buildTargetRequest omits tool_choice when Anthropic Messages omits it', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - messages: [{ role: 'user', content: 'hello' }], - tools: [{ name: 'lookup', input_schema: { type: 'object' } }], - }); - - assertFalse('tool_choice' in result); -}); - -test('buildTargetRequest omits tool_choice when Anthropic Messages carries no client tools to apply it to', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - messages: [{ role: 'user', content: 'hello' }], - tool_choice: { type: 'any' }, - }); - - assertFalse('tool_choice' in result); -}); - -test.each([ - { tools: undefined, name: 'lookup' }, - { tools: [{ name: 'other', input_schema: { type: 'object' as const } }], name: 'lookup' }, -])('buildTargetRequest rejects named tool_choice for an undeclared tool', async ({ tools, name }) => { - await assertRejects( - () => buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - messages: [{ role: 'user', content: 'hello' }], - tools, - tool_choice: { type: 'tool', name }, - }), - Error, - `Anthropic Messages tool_choice names an undeclared tool: ${name}`, - ); -}); - -test('buildTargetRequest drops filtered-native tool_choice and rewrites assistant native web-search history as function-call history', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - tool_choice: { type: 'any' }, - tools: [{ type: 'web_search_20260209', name: 'NativeSearch' }], - messages: [ - { - role: 'assistant', - content: [ - { - caller: { type: 'direct' }, - type: 'server_tool_use', - id: 'st_1', - name: 'web_search', - input: { query: 'React docs' }, - }, - { - type: 'web_search_tool_result', - tool_use_id: 'st_1', - content: [ - { - type: 'web_search_result', - url: 'https://react.dev', - title: 'React', - encrypted_content: 'opaque-payload', - }, - ], - }, - ], - }, - ], - }); - - assertEquals(result.tools, []); - assertEquals(result.tool_choice, undefined); - assertEquals(result.input, [ - { - type: 'function_call', - call_id: 'st_1', - name: 'web_search', - arguments: '{"query":"React docs"}', - status: 'completed', - }, - { - type: 'function_call_output', - call_id: 'st_1', - output: '[{"type":"web_search_result","url":"https://react.dev","title":"React","encrypted_content":"opaque-payload"}]', - status: 'completed', - }, - ]); -}); - -test('buildTargetRequest maps output_config.effort directly to reasoning.effort', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - output_config: { effort: 'xhigh' }, - messages: [{ role: 'user', content: 'hi' }], - }); - - assertEquals(result.reasoning, { effort: 'xhigh' }); - assertFalse('include' in result); -}); - -test('buildTargetRequest forwards empty output_config.effort verbatim', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - output_config: { effort: '' }, - thinking: { type: 'disabled' }, - messages: [{ role: 'user', content: 'hi' }], - }); - - assertEquals(result.reasoning, { effort: '' }); -}); - -test('buildTargetRequest prefers output_config.effort over thinking.disabled', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - output_config: { effort: 'high' }, - thinking: { type: 'disabled' }, - messages: [{ role: 'user', content: 'hi' }], - }); - - assertEquals(result.reasoning, { effort: 'high' }); -}); - -test('buildTargetRequest preserves output_config.effort max at the translation boundary', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - output_config: { effort: 'max' }, - messages: [{ role: 'user', content: 'hi' }], - }); - - assertEquals(result.reasoning, { effort: 'max' }); -}); - -test('buildTargetRequest maps thinking.enabled to reasoning.effort medium regardless of budget_tokens', async () => { - for (const budget of [1024, 16384]) { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 4096, - thinking: { type: 'enabled', budget_tokens: budget }, - messages: [{ role: 'user', content: 'hi' }], - }); - - assertEquals(result.reasoning, { effort: 'medium' }); - } -}); - -test('buildTargetRequest maps thinking.adaptive to reasoning.effort medium', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 4096, - thinking: { type: 'adaptive' }, - messages: [{ role: 'user', content: 'hi' }], - }); - - assertEquals(result.reasoning, { effort: 'medium' }); -}); - -test('buildTargetRequest never invents reasoning.context from a source thinking block', async () => { - // The Anthropic Messages thinking shape carries no reasoning-context mode, so the - // target reasoning object must expose effort only — never a synthesized - // `all_turns` (or any other) context value. - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 4096, - thinking: { type: 'enabled', budget_tokens: 8192 }, - messages: [{ role: 'user', content: 'hi' }], - }); - - assertEquals(result.reasoning, { effort: 'medium' }); - assertEquals(result.reasoning?.context, undefined); - assertFalse('context' in (result.reasoning ?? {})); -}); - -test('buildTargetRequest preserves max_tokens at the translation boundary', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - messages: [{ role: 'user', content: 'hi' }], - }); - - assertEquals(result.max_output_tokens, 256); -}); - -test('buildTargetRequest maps thinking.disabled to reasoning.effort none', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - thinking: { type: 'disabled' }, - messages: [{ role: 'user', content: 'hi' }], - }); - - assertEquals(result.reasoning, { effort: 'none' }); - assertFalse('include' in result); -}); - -test('buildTargetRequest preserves explicit temperature and omits translated-path defaults', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - temperature: 0.2, - messages: [{ role: 'user', content: 'hi' }], - }); - - assertEquals(result.temperature, 0.2); - assertFalse('store' in result); - assertFalse('parallel_tool_calls' in result); -}); - -test('buildTargetRequest omits temperature when the source omitted it', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - messages: [{ role: 'user', content: 'hi' }], - }); - - assertFalse('temperature' in result); -}); - -test('buildTargetRequest prepends multi-block top-level system as a leading input system message preserving block boundaries', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - system: [ - { type: 'text', text: 'Alpha' }, - { type: 'text', text: 'Beta' }, - ], - messages: [{ role: 'user', content: 'hi' }], - }); - - assertFalse('instructions' in result); - const input = result.input as Array<{ type: string; role?: string; content?: unknown }>; - assertEquals(input[0], { - type: 'message', - role: 'system', - content: [ - { type: 'input_text', text: 'Alpha' }, - { type: 'input_text', text: 'Beta' }, - ], - }); -}); - -test('buildTargetRequest keeps a single-block top-level system in canonical `instructions` slot', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - system: [{ type: 'text', text: 'You are helpful.' }], - messages: [{ role: 'user', content: 'hi' }], - }); - - assertEquals(result.instructions, 'You are helpful.'); - const input = result.input as Array<{ type: string; role?: string }>; - assertEquals(input[0].role, 'user'); -}); - -test('buildTargetRequest preserves text-only thinking input', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - messages: [ - { - role: 'assistant', - content: [{ signature: '', type: 'thinking', thinking: 'trace' }], - }, - ], - }); - - if (!Array.isArray(result.input)) throw new Error('expected input array'); - const reasoning = result.input[0] as OpenAIResponsesInputReasoning; - assertEquals(reasoning, { - type: 'reasoning', - summary: [{ type: 'summary_text', text: 'trace' }], - }); -}); - -// OpenAI strict-mode JSON Schema validators reject {type: 'object'} without a -// `properties` field. Anthropic accepts that shape, so the input_schema must -// be normalized before forwarding to OpenAI Responses. Ref: -// https://github.com/caozhiyuan/copilot-api/commit/ad57069826843c5d17d7b0e5ef2f75050128893c -test('buildTargetRequest defaults missing input_schema.properties to {} for object tools', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - tools: [{ name: 'no_args', input_schema: { type: 'object' } }], - messages: [{ role: 'user', content: 'hi' }], - }); - - assertEquals(result.tools, [ - { - type: 'function', - name: 'no_args', - parameters: { type: 'object', properties: {} }, - strict: false, - }, - ]); -}); - -test('buildTargetRequest preserves declared input_schema.properties verbatim', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - tools: [ - { - name: 'with_args', - input_schema: { - type: 'object', - properties: { q: { type: 'string' } }, - required: ['q'], - }, - }, - ], - messages: [{ role: 'user', content: 'hi' }], - }); - - const tool = result.tools?.[0] as OpenAIResponsesFunctionTool; - assertEquals(tool.parameters, { - type: 'object', - properties: { q: { type: 'string' } }, - required: ['q'], - }); -}); - -test('buildTargetRequest does not inject properties for non-object input_schema', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - tools: [{ name: 'scalar', input_schema: { type: 'string' } }] as unknown as AnthropicMessagesPayload['tools'], - messages: [{ role: 'user', content: 'hi' }], - }); - - const tool = result.tools?.[0] as OpenAIResponsesFunctionTool; - assertEquals(tool.parameters, { type: 'string' }); -}); - -test('buildTargetRequest wraps output_config.format json_schema as text.format with synthesised name and strict', async () => { - const schema = { - type: 'object', - properties: { test: { type: 'string' } }, - required: ['test'], - additionalProperties: false, - }; - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - messages: [{ role: 'user', content: 'Hi' }], - output_config: { format: { type: 'json_schema', schema } }, - }); - - assertEquals(result.text, { - format: { type: 'json_schema', name: 'messages_response', strict: true, schema }, - }); -}); - -test('buildTargetRequest omits text when output_config has no format', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - messages: [{ role: 'user', content: 'Hi' }], - output_config: { effort: 'high' }, - }); - - assertFalse('text' in result); -}); - -test('buildTargetRequest rejects an unknown assistant content block type', async () => { - await assertRejects( - () => - buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - messages: [{ role: 'assistant', content: [{ type: 'audio' } as unknown as AnthropicMessagesAssistantContentBlock] }], - }), - Error, - "messages.0.content.0.type: 'audio' assistant content blocks are not supported", - ); -}); - -test('buildTargetRequest rejects an unknown user content block type', async () => { - await assertRejects( - () => - buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - messages: [{ role: 'user', content: [{ type: 'audio' } as unknown as AnthropicMessagesUserContentBlock] }], - }), - Error, - "messages.0.content.0.type: 'audio' user content blocks are not supported", - ); -}); - -test('buildTargetRequest emits in-array role:"system" inline as an OpenAI Responses message input item', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - messages: [ - { role: 'user', content: 'hi' }, - { role: 'system', content: 'be terse' }, - { role: 'user', content: 'who are you' }, - ], - }); - - const input = result.input as Array<{ type: string; role?: string; content?: unknown }>; - assertEquals(input.length, 3); - assertEquals(input[0].role, 'user'); - assertEquals(input[1], { type: 'message', role: 'system', content: 'be terse' }); - assertEquals(input[2].role, 'user'); -}); - -test('buildTargetRequest preserves in-array system text blocks as separate input_text parts', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - messages: [ - { - role: 'system', - content: [ - { type: 'text', text: 'Para A' }, - { type: 'text', text: 'Para B' }, - ], - }, - { role: 'user', content: 'hi' }, - ], - }); - - const input = result.input as Array<{ type: string; role?: string; content?: unknown }>; - assertEquals(input[0], { - type: 'message', - role: 'system', - content: [ - { type: 'input_text', text: 'Para A' }, - { type: 'input_text', text: 'Para B' }, - ], - }); -}); - -test('buildTargetRequest preserves chronology of multiple in-array system messages', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - messages: [ - { role: 'system', content: 'mid-array A' }, - { role: 'user', content: 'q1' }, - { role: 'assistant', content: 'a1' }, - { role: 'system', content: 'mid-array B' }, - { role: 'user', content: 'q2' }, - ], - }); - - const input = result.input as Array<{ type: string; role?: string }>; - assertEquals(input.length, 5); - assertEquals(input[0].role, 'system'); - assertEquals(input[1].role, 'user'); - assertEquals(input[2].role, 'assistant'); - assertEquals(input[3].role, 'system'); - assertEquals(input[4].role, 'user'); -}); - -test('buildTargetRequest rejects an unknown message role', async () => { - await assertRejects( - () => - buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - messages: [{ role: 'tool', content: 'oops' } as unknown as { role: 'user'; content: string }], - }), - Error, - "messages.0.role: role 'tool' is not supported", - ); -}); - -test('buildTargetRequest collapses Anthropic thinking mode onto reasoning.effort only', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - messages: [{ role: 'user', content: 'hi' }], - thinking: { type: 'enabled', budget_tokens: 4096 }, - }); - - // `thinking.type === 'enabled'` resolves to the OpenAI-canonical `medium` - // effort; the `budget_tokens` scalar has no OpenAI Responses slot and drops. - assertEquals(result.reasoning, { effort: 'medium' }); -}); - -// ── speed ↔ service_tier bridge ── - -test('buildTargetRequest maps speed:fast to service_tier:fast on the outbound OpenAI Responses payload', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - speed: 'fast', - messages: [{ role: 'user', content: 'hi' }], - }); - - assertEquals(result.service_tier, 'priority'); -}); - -test('buildTargetRequest omits service_tier when speed is absent', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - messages: [{ role: 'user', content: 'hi' }], - }); - - assertFalse('service_tier' in result); -}); - -test('buildTargetRequest drops speed values other than fast without emitting service_tier', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - speed: 'standard', - messages: [{ role: 'user', content: 'hi' }], - }); - - assertFalse('service_tier' in result); -}); - -test('buildTargetRequest forwards Anthropic service_tier to OpenAI Responses when speed is absent', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - service_tier: 'auto', - messages: [{ role: 'user', content: 'hi' }], - }); - - assertEquals(result.service_tier, 'auto'); -}); - -test('buildTargetRequest forwards service_tier:standard_only to OpenAI Responses when speed is absent', async () => { - const result = await buildWithTestSidecar({ - model: 'gpt-test', - max_tokens: 256, - service_tier: 'standard_only', - messages: [{ role: 'user', content: 'hi' }], - }); - - assertEquals(result.service_tier, 'standard_only'); -}); - -test('preserves expressible Messages metadata extensions and rejects non-string values', async () => { - const payload = { model: 'm', max_tokens: 16, messages: [], metadata: { user_id: 'u', tenant: 't' } }; - assertEquals((await buildWithTestSidecar(payload)).metadata, { user_id: 'u', tenant: 't' }); - await assertRejects(() => buildWithTestSidecar({ ...payload, metadata: { user_id: 'u', tenant: { nested: 't' } } } as AnthropicMessagesPayload), undefined, "metadata field 'tenant'"); -}); diff --git a/packages/translate/__tests__/protocols/call-association_test.ts b/packages/translate/__tests__/protocols/call-association_test.ts new file mode 100644 index 0000000000..7334812ea3 --- /dev/null +++ b/packages/translate/__tests__/protocols/call-association_test.ts @@ -0,0 +1,298 @@ +import { expect, test, vi } from 'vitest'; + +import { openaiResponsesSourceProtocol } from '../../src/protocols/openai-responses/as-source.ts'; +import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { iterate } from '../shared/iterables.ts'; +import { expectTrip } from '../trip.ts'; +import { createAnthropicMessagesUsage, toAnthropicMessagesUsageDelta, type AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; +import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; +import { collectGeminiGenerateContentProtocolEventsToResult } from '@floway-dev/protocols/gemini-generate-content'; +import type { GeminiGenerateContentRequest } from '@floway-dev/protocols/gemini-generate-content'; +import { collectOpenAIChatCompletionsProtocolEventsToResult, type OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; +import { toOpenAIResponsesInputItems, collectOpenAIResponsesProtocolEventsToResult } from '@floway-dev/protocols/openai-responses'; +import type { OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx, OpenAIResponsesTool } from '@floway-dev/protocols/openai-responses'; +import { routeGeminiGenerateContentViaAnthropicMessages, routeGeminiGenerateContentViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIResponses, routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses, routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; + +const chatFrames = (name: string, arguments_: string, legacy = false): ProtocolFrame[] => [ + eventFrame({ + id: 'chat', object: 'chat.completion.chunk', created: 1, model: 'm', + choices: [{ + index: 0, delta: { + role: 'assistant', reasoning_content: 'native reasoning', + ...(legacy ? { function_call: { name, arguments: arguments_ } } : { tool_calls: [{ index: 0, type: 'function', id: 'native', function: { name, arguments: arguments_ } }] }), + }, finish_reason: legacy ? 'function_call' : 'tool_calls', + }], + }), + doneFrame(), +]; + +const messagesFrames = (name: string, arguments_: string): ProtocolFrame[] => [ + eventFrame({ type: 'message_start', message: { id: 'message', type: 'message', role: 'assistant', model: 'm', content: [], stop_reason: null, stop_sequence: null, stop_details: null, container: null, usage: createAnthropicMessagesUsage(1, 0) } }), + eventFrame({ type: 'content_block_start', index: 0, content_block: { type: 'thinking', thinking: 'native reasoning', signature: 'native signature' } }), + eventFrame({ type: 'content_block_stop', index: 0 }), + eventFrame({ type: 'content_block_start', index: 1, content_block: { type: 'redacted_thinking', data: 'native encrypted blob' } }), + eventFrame({ type: 'content_block_stop', index: 1 }), + eventFrame({ type: 'content_block_start', index: 2, content_block: { type: 'tool_use', id: 'native', name, input: {} } }), + eventFrame({ type: 'content_block_delta', index: 2, delta: { type: 'input_json_delta', partial_json: arguments_ } }), + eventFrame({ type: 'content_block_stop', index: 2 }), + eventFrame({ type: 'message_delta', delta: { stop_reason: 'tool_use', stop_sequence: null, container: null, stop_details: null }, usage: toAnthropicMessagesUsageDelta(createAnthropicMessagesUsage(1, 1)) }), + eventFrame({ type: 'message_stop' }), +]; + +const responsesFrames = (): ProtocolFrame[] => { + const item = { type: 'function_call' as const, id: 'item', call_id: 'native', name: 'same', arguments: '{"n":1}', status: 'completed' as const }; + return [ + eventFrame({ type: 'response.created', sequence_number: 0, response: { id: 'r', object: 'response', model: 'm', status: 'in_progress', incomplete_details: null, error: null, output: [] } }), + eventFrame({ type: 'response.output_item.added', sequence_number: 1, output_index: 0, item: { ...item, arguments: '', status: 'in_progress' } }), + eventFrame({ type: 'response.function_call_arguments.delta', sequence_number: 2, item_id: 'item', output_index: 0, delta: item.arguments }), + eventFrame({ type: 'response.function_call_arguments.done', sequence_number: 3, item_id: 'item', output_index: 0, arguments: item.arguments }), + eventFrame({ type: 'response.output_item.done', sequence_number: 4, output_index: 0, item }), + eventFrame({ type: 'response.completed', sequence_number: 5, response: { id: 'r', object: 'response', model: 'm', status: 'completed', incomplete_details: null, error: null, output: [item] } }), + ]; +}; + +const group = (kind: 'function' | 'custom'): OpenAIResponsesTool => ({ + type: 'namespace', name: 'web', description: 'Web tools', + tools: [kind === 'function' ? { type: 'function', name: 'run', parameters: { type: 'object', properties: {} } } : { type: 'custom', name: 'run', format: { type: 'text' } }], +}); + +test.each([ + { initial: 'web_run', final: 'plain', name: 'plain', namespace: undefined }, + { initial: 'plain', final: 'web_run', name: 'run', namespace: 'web' }, + { initial: 'web_run', final: 'files_run', name: 'run', namespace: 'files' }, + { initial: 'plain', final: 'other', name: 'other', namespace: undefined }, + { initial: 'web_run', final: 'plain', name: 'plain', namespace: undefined, delayedId: true }, + { initial: 'plain', final: 'web_run', name: 'run', namespace: 'web', delayedId: true }, + { initial: 'web_run', final: 'files_run', name: 'run', namespace: 'files', delayedId: true }, +])('keeps callable identity when a Chat tool name changes from $initial to $final', async ({ initial, final, name, namespace, delayedId }) => { + const source: OpenAIResponsesRequestPayloadEx = { + model: 'm', stream: true, input: 'start', + tools: [ + group('function'), + { type: 'namespace', name: 'files', description: '', tools: [{ type: 'function', name: 'run', parameters: { type: 'object', properties: {} } }] }, + { type: 'function', name: 'plain', parameters: { type: 'object', properties: {} } }, + { type: 'function', name: 'other', parameters: { type: 'object', properties: {} } }, + ], + }; + const trip = expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions(source, { sidecarCodec: createTestSidecarCodec() })); + const frames: ProtocolFrame[] = [ + eventFrame({ + id: 'chat', object: 'chat.completion.chunk', created: 1, model: 'm', + choices: [{ index: 0, delta: { tool_calls: [{ index: 0, ...(delayedId ? {} : { id: 'c' }), type: 'function', function: { name: initial, arguments: '' } }] }, finish_reason: null }], + }), + eventFrame({ + id: 'chat', object: 'chat.completion.chunk', created: 1, model: 'm', + choices: [{ index: 0, delta: { tool_calls: [{ index: 0, ...(delayedId ? { id: 'c' } : {}), function: { name: final, arguments: '{}' } }] }, finish_reason: 'tool_calls' }], + }), + doneFrame(), + ]; + const events: OpenAIResponsesStreamEventEx[] = []; + for await (const frame of trip.events(iterate(frames))) if (frame.type === 'event') events.push(frame.event); + const done = events.find(event => event.type === 'response.output_item.done' && event.item.type === 'function_call'); + const completed = events.find(event => event.type === 'response.completed'); + if (done?.type !== 'response.output_item.done' || completed?.type !== 'response.completed') throw new Error('Expected completed tool call'); + if (delayedId) { + const added = events.find(event => event.type === 'response.output_item.added' && event.item.type === 'function_call'); + expect(added?.type === 'response.output_item.added' ? added.item : undefined).toMatchObject({ type: 'function_call', name }); + if (namespace === undefined) expect(added?.type === 'response.output_item.added' ? added.item : undefined).not.toHaveProperty('namespace'); + else expect(added?.type === 'response.output_item.added' ? added.item : undefined).toHaveProperty('namespace', namespace); + } + const finalCall = completed.response.output.find(item => item.type === 'function_call'); + for (const call of [done.item, finalCall]) { + expect(call).toMatchObject({ type: 'function_call', name, arguments: '{}' }); + if (namespace === undefined) expect(call).not.toHaveProperty('namespace'); + else expect(call).toHaveProperty('namespace', namespace); + } +}); + +test('clears a grouped custom-tool namespace when its Chat name becomes ungrouped', async () => { + const source: OpenAIResponsesRequestPayloadEx = { + model: 'm', stream: true, input: 'start', + tools: [group('custom'), { type: 'custom', name: 'plain', format: { type: 'text' } }], + }; + const trip = expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions(source, { sidecarCodec: createTestSidecarCodec() })); + const frames: ProtocolFrame[] = [ + eventFrame({ + id: 'chat', object: 'chat.completion.chunk', created: 1, model: 'm', + choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'c', type: 'function', function: { name: 'web_run', arguments: '' } }] }, finish_reason: null }], + }), + eventFrame({ + id: 'chat', object: 'chat.completion.chunk', created: 1, model: 'm', + choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { name: 'plain', arguments: '{"input":"raw"}' } }] }, finish_reason: 'tool_calls' }], + }), + doneFrame(), + ]; + const events: OpenAIResponsesStreamEventEx[] = []; + for await (const frame of trip.events(iterate(frames))) if (frame.type === 'event') events.push(frame.event); + const done = events.find(event => event.type === 'response.output_item.done' && event.item.type === 'custom_tool_call'); + const completed = events.find(event => event.type === 'response.completed'); + if (done?.type !== 'response.output_item.done' || completed?.type !== 'response.completed') throw new Error('Expected completed custom tool call'); + const finalCall = completed.response.output.find(item => item.type === 'custom_tool_call'); + for (const call of [done.item, finalCall]) { + expect(call).toMatchObject({ type: 'custom_tool_call', name: 'plain', input: 'raw' }); + expect(call).not.toHaveProperty('namespace'); + } +}); + +test.each([['chat', 'function'], ['chat', 'custom'], ['messages', 'function'], ['messages', 'custom']] as const)('native %s %s calls share logical aliases with declarations, selectors and ordinary history', async (target, kind) => { + const options = { sidecarCodec: createTestSidecarCodec() }; + const source: OpenAIResponsesRequestPayloadEx = { model: 'm', stream: true, input: 'start', tools: [group(kind)] }; + const args = kind === 'function' ? '{}' : '{"input":"raw"}'; + const result = target === 'chat' + ? await collectOpenAIResponsesProtocolEventsToResult(expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions(source, options)).events(iterate(chatFrames('web_run', args)))) + : await collectOpenAIResponsesProtocolEventsToResult(expectTrip(await routeOpenAIResponsesViaAnthropicMessages(source, options)).events(iterate(messagesFrames('web_run', args)))); + const call = result.output.find(item => item.type === 'function_call' || item.type === 'custom_tool_call'); + if (call === undefined) throw new Error('Expected the public callable and its replay sidecar.'); + expect(call).toMatchObject({ type: kind === 'function' ? 'function_call' : 'custom_tool_call', name: 'run', namespace: 'web' }); + const request: OpenAIResponsesRequestPayloadEx = { + model: 'm', stream: true, + tools: [group(kind), { type: 'function', name: 'web_run', parameters: { type: 'object', properties: {} } }], + tool_choice: { type: 'allowed_tools', mode: 'required', tools: [{ type: kind, name: 'run', namespace: 'web' }] }, + input: [ + ...toOpenAIResponsesInputItems(result.output), + kind === 'function' ? { type: 'function_call_output', call_id: call.call_id, output: 'ok' } : { type: 'custom_tool_call_output', call_id: call.call_id, output: 'ok' }, + kind === 'function' ? { type: 'function_call', call_id: 'ordinary', name: 'run', namespace: 'web', arguments: '{}' } : { type: 'custom_tool_call', call_id: 'ordinary', name: 'run', namespace: 'web', input: 'again' }, + ], + }; + const original = structuredClone(request); + const read = openaiResponsesSourceProtocol.read; + const toIR = vi.fn(() => { throw new Error('Native replay must not build IR.'); }); + const spy = vi.spyOn(openaiResponsesSourceProtocol, 'read').mockImplementation(payload => { + const reading = read(payload); + return { ...reading, turns: reading.turns.map(turn => turn.kind === 'assistant' && turn.assistantTurn.some(item => item.type === 'reasoning' && typeof item.encrypted_content === 'string') ? { ...turn, toIRInputTurns: toIR } : turn) }; + }); + try { + if (target === 'chat') { + const trip = expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions(request, options)); + expect(trip.target.tools?.map(tool => tool.type === 'function' ? tool.function.name : tool.custom.name)).toEqual(['web_run_2', 'web_run']); + expect(trip.target.tool_choice).toEqual({ type: 'allowed_tools', allowed_tools: { mode: 'required', tools: [{ type: 'function', function: { name: 'web_run_2' } }] } }); + const calls = trip.target.messages.flatMap(message => message.role === 'assistant' ? message.tool_calls ?? [] : []); + expect(calls.map(item => item.type === 'function' ? item.function.name : item.custom.name)).toEqual(['web_run', 'web_run_2']); + expect(trip.target.messages[0]).toMatchObject({ reasoning_content: 'native reasoning' }); + const returned = await collectOpenAIResponsesProtocolEventsToResult(trip.events(iterate(chatFrames('web_run_2', args)))); + expect(returned.output.find(item => item.type === 'function_call' || item.type === 'custom_tool_call')).toMatchObject({ type: kind === 'function' ? 'function_call' : 'custom_tool_call', name: 'run', namespace: 'web' }); + } else { + const trip = expectTrip(await routeOpenAIResponsesViaAnthropicMessages(request, options)); + expect(trip.target.tools?.map(tool => 'name' in tool ? tool.name : undefined)).toEqual(['web_run_2', 'web_run']); + expect(trip.target.tool_choice).toEqual({ type: 'tool', name: 'web_run_2' }); + const calls = trip.target.messages.flatMap(message => message.role === 'assistant' && Array.isArray(message.content) ? message.content.flatMap(block => block.type === 'tool_use' ? [block] : []) : []); + expect(calls.map(item => item.name)).toEqual(['web_run', 'web_run_2']); + expect(trip.target.messages[0].content).toContainEqual({ type: 'thinking', thinking: 'native reasoning', signature: 'native signature' }); + expect(trip.target.messages[0].content).toContainEqual({ type: 'redacted_thinking', data: 'native encrypted blob' }); + const returned = await collectOpenAIResponsesProtocolEventsToResult(trip.events(iterate(messagesFrames('web_run_2', args)))); + expect(returned.output.find(item => item.type === 'function_call' || item.type === 'custom_tool_call')).toMatchObject({ type: kind === 'function' ? 'function_call' : 'custom_tool_call', name: 'run', namespace: 'web' }); + } + expect(toIR).not.toHaveBeenCalled(); + expect(request).toEqual(original); + } finally { + spy.mockRestore(); + } +}); + +test('a native legacy Chat call consumes the unique id-less Gemini result of the same name', async () => { + const options = { sidecarCodec: createTestSidecarCodec() }; + const first = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions({ model: 'm', contents: [{ role: 'user', parts: [{ text: 'start' }] }] }, options)); + const result = await collectGeminiGenerateContentProtocolEventsToResult(first.events(iterate(chatFrames('legacy', '{}', true)))); + const native = result.candidates?.[0]?.content; + if (native === undefined) throw new Error('Expected the public legacy call.'); + const replay = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions({ + model: 'm', contents: [native, { role: 'user', parts: [{ functionResponse: { name: 'legacy', response: { ok: true } } }] }], + }, options)); + expect(replay.target.messages[0]).toMatchObject({ role: 'assistant', function_call: { name: 'legacy', arguments: '{}' }, reasoning_content: 'native reasoning' }); + expect(replay.target.messages[0]).not.toHaveProperty('tool_calls'); + expect(replay.target.messages[1]).toEqual({ role: 'function', name: 'legacy', content: '{"ok":true}' }); +}); + +test.each(['chat', 'messages', 'responses'] as const)('Gemini id-less results answer the complete native and ordinary %s history in FIFO order', async target => { + const options = { sidecarCodec: createTestSidecarCodec() }; + const source: GeminiGenerateContentRequest = { model: 'm', contents: [{ role: 'user', parts: [{ text: 'start' }] }] }; + const first = target === 'chat' + ? await collectGeminiGenerateContentProtocolEventsToResult(expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions(source, options)).events(iterate(chatFrames('same', '{"n":1}')))) + : target === 'messages' + ? await collectGeminiGenerateContentProtocolEventsToResult(expectTrip(await routeGeminiGenerateContentViaAnthropicMessages(source, options)).events(iterate(messagesFrames('same', '{"n":1}')))) + : await collectGeminiGenerateContentProtocolEventsToResult(expectTrip(await routeGeminiGenerateContentViaOpenAIResponses(source, options)).events(iterate(responsesFrames()))); + const native = first.candidates?.[0]?.content; + if (native === undefined) throw new Error('Expected a public Gemini assistant turn.'); + const request: GeminiGenerateContentRequest = { + model: 'm', contents: [ + ...source.contents ?? [], native, + { role: 'user', parts: [{ text: 'also call it again' }] }, + { role: 'model', parts: [{ functionCall: { name: 'same', args: { n: 2 } } }] }, + { role: 'user', parts: [{ functionResponse: { name: 'same', response: { answer: 'FIRST' } } }, { functionResponse: { name: 'same', response: { answer: 'SECOND' } } }] }, + ], + }; + if (target === 'chat') { + const trip = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions(request, options)); + const answers = trip.target.messages.filter(message => message.role === 'tool'); + expect(answers.map(message => message.tool_call_id)).toEqual(['native', 'same_call_1']); + expect(answers.map(message => message.content)).toEqual(['{"answer":"FIRST"}', '{"answer":"SECOND"}']); + expect(trip.target.messages[1]).toMatchObject({ reasoning_content: 'native reasoning' }); + } else if (target === 'messages') { + const trip = expectTrip(await routeGeminiGenerateContentViaAnthropicMessages(request, options)); + const answers = trip.target.messages.flatMap(message => message.role === 'user' && Array.isArray(message.content) ? message.content.flatMap(block => block.type === 'tool_result' ? [block] : []) : []); + expect(answers.map(block => block.tool_use_id)).toEqual(['native', 'same_call_1']); + expect(answers.map(block => block.content)).toEqual(['{"answer":"FIRST"}', '{"answer":"SECOND"}']); + } else { + const trip = expectTrip(await routeGeminiGenerateContentViaOpenAIResponses(request, options)); + const answers = trip.target.input.filter(item => item.type === 'function_call_output'); + expect(answers.map(item => item.call_id)).toEqual(['native', 'same_call_1']); + expect(answers.map(item => item.output)).toEqual(['{"answer":"FIRST"}', '{"answer":"SECOND"}']); + } +}); + +test('Gemini target allocation reserves explicit IDs against every missing-ID call', async () => { + const options = { sidecarCodec: createTestSidecarCodec() }; + const source: GeminiGenerateContentRequest = { + model: 'm', contents: [ + { role: 'model', parts: [{ functionCall: { name: 'first', args: {} } }, { functionCall: { id: 'first_call_1', name: 'second', args: {} } }, { functionCall: { id: 'gemini_call_0_0', name: 'third', args: {} } }] }, + { role: 'user', parts: [{ functionResponse: { id: 'first_call_1', name: 'second', response: { answer: 'SECOND' } } }, { functionResponse: { name: 'first', response: { answer: 'FIRST' } } }, { functionResponse: { id: 'gemini_call_0_0', name: 'third', response: { answer: 'THIRD' } } }] }, + ], + }; + const trip = expectTrip(await routeGeminiGenerateContentViaOpenAIResponses(source, options)); + const calls = trip.target.input.filter(item => item.type === 'function_call'); + const answers = trip.target.input.filter(item => item.type === 'function_call_output'); + expect(new Set(calls.map(call => call.call_id)).size).toBe(3); + expect(answers.map(item => item.call_id)).toEqual(['first_call_1', calls[0].call_id, 'gemini_call_0_0']); +}); + +test.each(['messages', 'responses'] as const)('Chat legacy IDs stay distinct from modern explicit IDs through %s translation', async target => { + const options = { sidecarCodec: createTestSidecarCodec() }; + const source = { + model: 'm', stream: true, messages: [ + { role: 'assistant' as const, content: null, function_call: { name: 'first', arguments: '{}' } }, + { role: 'user' as const, content: 'next' }, + { role: 'assistant' as const, content: null, tool_calls: [{ id: 'legacy__first__0', type: 'function' as const, function: { name: 'second', arguments: '{}' } }] }, + { role: 'tool' as const, tool_call_id: 'legacy__first__0', content: 'SECOND' }, + { role: 'function' as const, name: 'first', content: 'FIRST' }, + ], + }; + if (target === 'responses') { + const trip = expectTrip(await routeOpenAIChatCompletionsViaOpenAIResponses(source, options)); + const calls = trip.target.input.filter(item => item.type === 'function_call'); + expect(new Set(calls.map(call => call.call_id)).size).toBe(2); + expect(trip.target.input.filter(item => item.type === 'function_call_output').map(item => item.call_id)).toEqual(['legacy__first__0', calls[0].call_id]); + } else { + const trip = expectTrip(await routeOpenAIChatCompletionsViaAnthropicMessages(source, options)); + const calls = trip.target.messages.flatMap(message => message.role === 'assistant' && Array.isArray(message.content) ? message.content.flatMap(block => block.type === 'tool_use' ? [block] : []) : []); + expect(new Set(calls.map(call => call.id)).size).toBe(2); + expect(trip.target.messages.flatMap(message => message.role === 'user' && Array.isArray(message.content) ? message.content.flatMap(block => block.type === 'tool_result' ? [block.tool_use_id] : []) : [])).toEqual(['legacy__first__0', calls[0].id]); + } +}); + +test('Chat source custom identity renames native Messages calls independently from same-name functions', async () => { + const options = { sidecarCodec: createTestSidecarCodec() }; + const first = expectTrip(await routeOpenAIChatCompletionsViaAnthropicMessages({ + model: 'm', stream: true, messages: [{ role: 'user', content: 'start' }], + tools: [{ type: 'custom', custom: { name: 'edit' } }], + }, options)); + const result = await collectOpenAIChatCompletionsProtocolEventsToResult(first.events(iterate(messagesFrames('edit', '{"input":"raw"}')))); + const replay = expectTrip(await routeOpenAIChatCompletionsViaAnthropicMessages({ + model: 'm', stream: true, + tools: [{ type: 'function', function: { name: 'edit', parameters: { type: 'object', properties: {} } } }, { type: 'custom', custom: { name: 'edit' } }], + messages: [result.choices[0].message, { role: 'tool', tool_call_id: 'native', content: 'ok' }, { role: 'assistant', content: null, tool_calls: [{ type: 'custom', id: 'later', custom: { name: 'edit', input: 'later' } }] }], + }, options)); + const calls = replay.target.messages.flatMap(message => message.role === 'assistant' && Array.isArray(message.content) ? message.content.flatMap(block => block.type === 'tool_use' ? [block] : []) : []); + expect(calls.map(call => call.name)).toEqual(['edit', 'edit_2']); + expect(calls.map(call => call.input)).toEqual([{ input: 'raw' }, { input: 'later' }]); + expect(replay.target.messages[0].content).toContainEqual({ type: 'thinking', thinking: 'native reasoning', signature: 'native signature' }); +}); diff --git a/packages/translate/__tests__/protocols/callable-names_test.ts b/packages/translate/__tests__/protocols/callable-names_test.ts new file mode 100644 index 0000000000..51132ebfd7 --- /dev/null +++ b/packages/translate/__tests__/protocols/callable-names_test.ts @@ -0,0 +1,84 @@ +import { expect, test } from 'vitest'; + +import { fixtureFrames } from './fixtures.ts'; +import type { IRInputRequestHeader } from '../../src/ir/input/types.ts'; +import { type IRRecord } from '../../src/ir/output/records.ts'; +import { consumeIRRecords } from '../../src/ir/output/stream.ts'; +import { anthropicMessagesTargetProtocol } from '../../src/protocols/anthropic-messages/as-target.ts'; +import { openaiChatCompletionsTargetProtocol } from '../../src/protocols/openai-chat-completions/as-target.ts'; +import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { createRoundTripReaderCapture } from '../round-trip/test-helpers.ts'; +import { iterate } from '../shared/iterables.ts'; +import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; +import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; +import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; + +const request = (namespace: string | undefined): IRInputRequestHeader => ({ + settings: { model: 'm', stream: true }, + tools: [{ type: 'function', namespace, name: 'edit', parameters: { type: 'object', properties: {} } }, { type: 'custom', namespace, name: 'edit' }], +}); + +const expectOriginalCalls = async (records: AsyncIterable, namespace: string | undefined): Promise => { + let completed = false; + for await (const { record, collector } of consumeIRRecords(records)) { + if (record.type !== 'finish') continue; + completed = true; + const items = collector.state.choices[0].items; + expect(items).toMatchObject([ + { type: 'function_call', name: 'edit', arguments: '{}' }, + { type: 'custom_tool_call', name: 'edit', input: 'raw' }, + ]); + for (const item of items) { + if (namespace === undefined) expect(item).not.toHaveProperty('namespace'); + else expect(item).toHaveProperty('namespace', namespace); + } + } + expect(completed).toBe(true); +}; + +test.each([undefined, 'files'])('Chat restores callable names and kinds in namespace %s while retaining native aliases', async namespace => { + const written = openaiChatCompletionsTargetProtocol.write(request(namespace), [], { sidecarCodec: createTestSidecarCodec() }); + const names = written.payload.tools?.flatMap(tool => tool.type === 'function' ? [tool.function.name] : []); + expect(names).toHaveLength(2); + const [functionName, customName] = names!; + expect(functionName).not.toBe(customName); + const frames: ProtocolFrame[] = [eventFrame({ + id: 'chat', object: 'chat.completion.chunk', created: 1, model: 'm', choices: [{ + index: 0, delta: { + role: 'assistant', tool_calls: [ + { index: 0, id: 'f', type: 'function', function: { name: functionName, arguments: '{}' } }, + { index: 1, id: 'c', type: 'function', function: { name: customName, arguments: '{"input":"raw"}' } }, + ], + }, finish_reason: 'tool_calls', + }], + }), doneFrame()]; + const capture = createRoundTripReaderCapture<'openaiChatCompletions'>(); + await expectOriginalCalls(openaiChatCompletionsTargetProtocol.readEvents(iterate(frames), written.context, capture.roundTrip), namespace); + expect(capture.turns.get(0)?.turn.tool_calls).toMatchObject([ + { function: { name: functionName } }, { function: { name: customName } }, + ]); +}); + +test.each([undefined, 'files'])('Messages restores callable names and kinds in namespace %s while retaining native aliases', async namespace => { + const written = anthropicMessagesTargetProtocol.write(request(namespace), [], { sidecarCodec: createTestSidecarCodec() }); + const names = written.payload.tools?.flatMap(tool => 'name' in tool ? [tool.name] : []); + expect(names).toHaveLength(2); + const [functionName, customName] = names!; + expect(functionName).not.toBe(customName); + const ordinary = fixtureFrames('anthropic-messages', {}); + const frames: ProtocolFrame[] = [ + ...ordinary.filter(frame => frame.type === 'event' && frame.event.type === 'message_start'), + eventFrame({ type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'f', name: functionName, input: {} } }), + eventFrame({ type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '{}' } }), + eventFrame({ type: 'content_block_stop', index: 0 }), + eventFrame({ type: 'content_block_start', index: 1, content_block: { type: 'tool_use', id: 'c', name: customName, input: {} } }), + eventFrame({ type: 'content_block_delta', index: 1, delta: { type: 'input_json_delta', partial_json: '{"input":"raw"}' } }), + eventFrame({ type: 'content_block_stop', index: 1 }), + ...ordinary.filter(frame => frame.type === 'event' && (frame.event.type === 'message_delta' || frame.event.type === 'message_stop')), + ]; + const capture = createRoundTripReaderCapture<'anthropicMessages'>(); + await expectOriginalCalls(anthropicMessagesTargetProtocol.readEvents(iterate(frames), written.context, capture.roundTrip), namespace); + expect(capture.turns.get(0)?.turn.content).toMatchObject([ + { type: 'tool_use', name: functionName }, { type: 'tool_use', name: customName }, + ]); +}); diff --git a/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts b/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts index 267fbef566..50ea3d42dc 100644 --- a/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts +++ b/packages/translate/__tests__/protocols/chat-legacy-round-trip_test.ts @@ -1,15 +1,13 @@ import { expect, test } from 'vitest'; -import { requireTrip } from './require-trip.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { iterate } from '../shared/iterables.ts'; +import { expectTrip } from '../trip.ts'; import { collectAnthropicMessagesProtocolEventsToResult, type AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; -import { collectGeminiGenerateContentProtocolEventsToResult, type GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; +import { collectGeminiGenerateContentProtocolEventsToResult, type GeminiGenerateContentRequest } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIChatCompletionsAssistantDeltaEx, OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; -import { toOpenAIResponsesInputItems } from '@floway-dev/protocols/openai-responses'; -import { collectOpenAIResponsesProtocolEventsToResult, type OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; -import { routeAnthropicMessagesViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIChatCompletions, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; +import { routeAnthropicMessagesViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIChatCompletions } from '@floway-dev/translate'; const nativeFrames = (mode: 'legacy' | 'modern' | 'mixed', name: string, arguments_ = '{"x":1}'): ProtocolFrame[] => { const chunk = (delta: OpenAIChatCompletionsAssistantDeltaEx, finish_reason: 'function_call' | 'tool_calls' | null = null) => eventFrame({ @@ -27,12 +25,25 @@ const nativeFrames = (mode: 'legacy' | 'modern' | 'mixed', name: string, argumen ]; }; -const assertNativeHistory = (target: OpenAIChatCompletionsPayload, name: string, contents: string[]): void => { - expect(target.messages.filter(message => message.role === 'function' || message.role === 'tool')).toEqual([ - { role: 'function', name, content: contents[0] }, - { role: 'tool', tool_call_id: 'native-modern', content: contents[1] }, - { role: 'function', name, content: contents[2] }, - ]); +/** + * What this wire states for three alternating legacy/modern calls. + * + * A result answers the shape its assistant turn stated: a call the turn named by + * id gets a `tool` message, and a legacy `function_call` — which states no id — + * gets the `function` message this wire pairs with it by name. That pairing is + * read off the restored turn, not off the source protocol, so every source + * reaches it the same way. + */ +const assertNativeHistory = ( + target: OpenAIChatCompletionsPayload, + name: string, + contents: string[], +): void => { + const results = target.messages.filter(message => message.role === 'tool' || message.role === 'function'); + expect(results.map(message => message.content)).toEqual(contents); + expect(results[0]).toEqual({ role: 'function', name, content: contents[0] }); + expect(results[1]).toEqual({ role: 'tool', tool_call_id: 'native-modern', content: contents[1] }); + expect(results[2]).toEqual({ role: 'function', name, content: contents[2] }); const assistants = target.messages.filter(message => message.role === 'assistant'); expect(assistants).toHaveLength(3); expect(assistants[0].function_call).toEqual({ name, arguments: '{"x":1}' }); @@ -43,57 +54,15 @@ const assertNativeHistory = (target: OpenAIChatCompletionsPayload, name: string, expect(assistants[2].tool_calls).toBeUndefined(); }; -test('Messages returns matching legacy function results while modern calls remain tool messages across turns', async () => { - const context = { model: 'native-model', sidecarCodec: createTestSidecarCodec() }; - const payload: AnthropicMessagesPayload = { - model: 'public-model', max_tokens: 64, - messages: [{ role: 'user', content: 'Call lookup' }], - tools: [{ name: 'lookup', input_schema: { type: 'object', properties: { x: { type: 'integer' } } } }], - }; - for (const [index, mode] of (['legacy', 'modern', 'legacy'] as const).entries()) { - const trip = await requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)); - const result = await collectAnthropicMessagesProtocolEventsToResult(trip.events(iterate(nativeFrames(mode, 'lookup')))); - const call = result.content.find(block => block.type === 'tool_use'); - if (call === undefined) throw new Error('Expected a public Messages tool invocation'); - payload.messages.push({ role: 'assistant', content: result.content }, { role: 'user', content: [{ type: 'tool_result', tool_use_id: call.id, content: `result-${index}` }] }); - } - const replay = await requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)); - assertNativeHistory(replay.target, 'lookup', ['result-0', 'result-1', 'result-2']); -}); - -test('Responses binds public namespaced call IDs to the native legacy function name', async () => { - const context = { model: 'native-model', sidecarCodec: createTestSidecarCodec() }; - const payload: OpenAIResponsesRequestPayloadEx = { - model: 'public-model', input: [{ type: 'message', role: 'user', content: 'Call lookup' }], - tools: [{ type: 'namespace', name: 'public', description: 'Public lookup tools', tools: [{ type: 'function', name: 'lookup', parameters: { type: 'object', properties: { x: { type: 'integer' } } } }] }], - }; - let nativeName = ''; - for (const [index, mode] of (['legacy', 'modern', 'legacy'] as const).entries()) { - const trip = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(payload, context)); - const declaration = trip.target.tools?.find(tool => tool.type === 'function'); - if (declaration?.type !== 'function') throw new Error('Expected a native function declaration'); - nativeName = declaration.function.name; - expect(nativeName).not.toBe('lookup'); - const result = await collectOpenAIResponsesProtocolEventsToResult(trip.events(iterate(nativeFrames(mode, nativeName)))); - const call = result.output.find(item => item.type === 'function_call'); - if (call === undefined) throw new Error('Expected a public Responses function invocation'); - expect(call.name).toBe('lookup'); - expect(call.namespace).toBe('public'); - if (!Array.isArray(payload.input)) throw new Error('Expected item-array history'); - payload.input.push(...toOpenAIResponsesInputItems(result.output), { type: 'function_call_output', call_id: call.call_id, output: `result-${index}` }); - } - const replay = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(payload, context)); - assertNativeHistory(replay.target, nativeName, ['result-0', 'result-1', 'result-2']); -}); - test('Gemini matches ID-less legacy responses by public name and explicit modern responses by ID', async () => { - const context = { model: 'native-model', sidecarCodec: createTestSidecarCodec() }; - const payload: GeminiGenerateContentPayload = { + const context = { sidecarCodec: createTestSidecarCodec() }; + const payload: GeminiGenerateContentRequest = { + model: 'model', contents: [{ role: 'user', parts: [{ text: 'Call lookup' }] }], tools: [{ functionDeclarations: [{ name: 'lookup', parameters: { type: 'OBJECT', properties: { x: { type: 'INTEGER' } } } }] }], }; for (const [index, mode] of (['legacy', 'modern', 'legacy'] as const).entries()) { - const trip = await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(payload, context)); + const trip = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions(payload, context)); const result = await collectGeminiGenerateContentProtocolEventsToResult(trip.events(iterate(nativeFrames(mode, 'lookup')))); const content = result.candidates?.[0]?.content; const call = content?.parts?.find(part => part.functionCall !== undefined)?.functionCall; @@ -101,20 +70,20 @@ test('Gemini matches ID-less legacy responses by public name and explicit modern expect(call.id).toBe(mode === 'modern' ? 'native-modern' : undefined); payload.contents.push(content, { role: 'user', parts: [{ functionResponse: { name: call.name, ...(call.id === undefined ? {} : { id: call.id }), response: { result: index } } }] }); } - const replay = await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(payload, context)); + const replay = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions(payload, context)); assertNativeHistory(replay.target, 'lookup', ['{"result":0}', '{"result":1}', '{"result":2}']); }); - test('Gemini keeps modern IDs separate when a vendor stream includes both call formats', async () => { - const context = { model: 'native-model', sidecarCodec: createTestSidecarCodec() }; - const source: GeminiGenerateContentPayload = { contents: [{ role: 'user', parts: [{ text: 'Call lookup' }] }] }; - const trip = await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(source, context)); + const context = { sidecarCodec: createTestSidecarCodec() }; + const source: GeminiGenerateContentRequest = { model: 'model', contents: [{ role: 'user', parts: [{ text: 'Call lookup' }] }] }; + const trip = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions(source, context)); const result = await collectGeminiGenerateContentProtocolEventsToResult(trip.events(iterate(nativeFrames('mixed', 'lookup')))); const content = result.candidates?.[0]?.content; if (content === undefined) throw new Error('Expected a public Gemini assistant content'); const calls = content.parts?.flatMap(part => part.functionCall === undefined ? [] : [part.functionCall]); expect(calls).toHaveLength(2); - const replay = await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions({ + const replay = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions({ + model: 'model', contents: [ ...source.contents ?? [], content, { @@ -128,21 +97,31 @@ test('Gemini keeps modern IDs separate when a vendor stream includes both call f const native = replay.target.messages.find(message => message.role === 'assistant'); expect(native?.function_call).toEqual({ name: 'lookup', arguments: '{"x":1}' }); expect(native?.tool_calls).toEqual([{ id: 'native-modern', type: 'function', function: { name: 'lookup', arguments: '{"x":1}' } }]); - expect(replay.target.messages.slice(-2)).toEqual([ - { role: 'tool', tool_call_id: 'native-modern', content: '{"modern":true}' }, - { role: 'function', name: 'lookup', content: '{"legacy":true}' }, - ]); + // The id-less legacy response answers the call still awaiting a result. That + // call is stated as a legacy `function_call`, which carries no id, so the + // answer is written as the matching `function` message and named by the call. + const results = replay.target.messages.slice(-2); + expect(results.map(message => message.content)).toEqual(['{"modern":true}', '{"legacy":true}']); + expect(results[0]).toMatchObject({ role: 'tool', tool_call_id: 'native-modern' }); + expect(results[1]).toMatchObject({ role: 'function', name: 'lookup' }); }); - -test('restored legacy results retain the ordinary Responses input_file rejection', async () => { - const context = { model: 'native-model', sidecarCodec: createTestSidecarCodec() }; - const trip = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'public-model', input: 'Call lookup' }, context)); - const result = await collectOpenAIResponsesProtocolEventsToResult(trip.events(iterate(nativeFrames('legacy', 'lookup')))); - const call = result.output.find(item => item.type === 'function_call'); - if (call === undefined) throw new Error('Expected a public Responses invocation'); - const output = { type: 'function_call_output' as const, call_id: call.call_id, output: [{ type: 'input_file' as const, file_id: 'unsupported-file' }] }; - const input = toOpenAIResponsesInputItems(result.output); - for (const history of [input, input.filter(item => item.type !== 'reasoning')]) { - await expect(requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'public-model', input: [...history, output] }, context))).rejects.toThrow('Cannot translate input_file tool output to OpenAI Chat Completions.'); - } +test('a source-ID-only result cannot answer a restored legacy call', async () => { + // The restored native call states no id, and source-side ids are not consulted. + const context = { sidecarCodec: createTestSidecarCodec() }; + const payload: AnthropicMessagesPayload = { + model: 'public-model', max_tokens: 64, stream: true, + messages: [{ role: 'user', content: 'Call lookup' }], + tools: [{ name: 'lookup', input_schema: { type: 'object', properties: { x: { type: 'integer' } } } }], + }; + const trip = expectTrip(await routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)); + const result = await collectAnthropicMessagesProtocolEventsToResult(trip.events(iterate(nativeFrames('legacy', 'lookup')))); + const call = result.content.find(block => block.type === 'tool_use'); + if (call === undefined) throw new Error('Expected a public Messages tool invocation'); + payload.messages.push( + { role: 'assistant', content: result.content }, + { role: 'user', content: 'still thinking' }, + { role: 'assistant', content: 'meanwhile' }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: call.id, content: 'late' }] }, + ); + await expect(routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)).resolves.toMatchObject({ type: 'input-error', message: expect.stringContaining('no unique id-less result') }); }); diff --git a/packages/translate/__tests__/protocols/chat-reasoning-round-trip_test.ts b/packages/translate/__tests__/protocols/chat-reasoning-round-trip_test.ts index aa6194444b..633f26d865 100644 --- a/packages/translate/__tests__/protocols/chat-reasoning-round-trip_test.ts +++ b/packages/translate/__tests__/protocols/chat-reasoning-round-trip_test.ts @@ -1,7 +1,7 @@ import { expect, test } from 'vitest'; -import { requireTrip } from './require-trip.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { expectTrip } from '../trip.ts'; import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsAssistantDeltaEx, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; import { toOpenAIResponsesInputItems, reassembleOpenAIResponsesEvents } from '@floway-dev/protocols/openai-responses'; @@ -12,8 +12,8 @@ const chunk = (delta: OpenAIChatCompletionsAssistantDeltaEx, finish_reason: 'sto }); test('round-trips native Chat reasoning fragments through Responses without corrupting detail identity', async () => { - const context = { model: 'target', sidecarCodec: createTestSidecarCodec() }; - const trip = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'source', input: [{ role: 'user', content: 'question' }] }, context)); + const context = { sidecarCodec: createTestSidecarCodec() }; + const trip = expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'source', stream: true, input: [{ role: 'user', content: 'question' }] }, context)); const upstream = async function* () { yield chunk({ role: 'assistant', reasoning_details: [{ type: 'reasoning.summary', summary: ' reading', id: 'summary', index: 0, format: 'openai-responses-v1' }] }); yield chunk({ reasoning_details: [{ type: 'reasoning.summary', summary: ' it.', id: 'summary', index: 0, format: 'openai-responses-v1' }] }); @@ -25,7 +25,7 @@ test('round-trips native Chat reasoning fragments through Responses without corr for await (const frame of trip.events(upstream())) if (frame.type === 'event') yield frame.event; }; const result = await reassembleOpenAIResponsesEvents(responseEvents()); - const restored = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'source', input: toOpenAIResponsesInputItems(result.output) }, context)); + const restored = expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'source', stream: true, input: toOpenAIResponsesInputItems(result.output) }, context)); expect(restored.target.messages).toEqual([{ role: 'assistant', content: 'answer', refusal: null, provider_specific_fields: { trace: 'native' }, reasoning_details: [ diff --git a/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts b/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts index bcc53febfc..b6d6cc0c63 100644 --- a/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts +++ b/packages/translate/__tests__/protocols/cross-protocol-errors_test.ts @@ -24,11 +24,11 @@ import { type TranslateTrip, } from '@floway-dev/translate'; -const options = () => ({ model: 'm', sidecarCodec: createTestSidecarCodec('error-routes') }); -const chat = { model: 'm', messages: [{ role: 'user' as const, content: 'hello' }] }; -const responses = { model: 'm', input: 'hello' }; -const messages = { model: 'm', max_tokens: 16, messages: [{ role: 'user' as const, content: 'hello' }] }; -const gemini = { contents: [{ role: 'user' as const, parts: [{ text: 'hello' }] }] }; +const options = () => ({ sidecarCodec: createTestSidecarCodec('error-routes') }); +const chat = { stream: true, model: 'm', messages: [{ role: 'user' as const, content: 'hello' }] }; +const responses = { stream: true, model: 'm', input: 'hello' }; +const messages = { stream: true, model: 'm', max_tokens: 16, messages: [{ role: 'user' as const, content: 'hello' }] }; +const gemini = { model: 'm', contents: [{ role: 'user' as const, parts: [{ text: 'hello' }] }] }; const failure = { message: 'bad field', type: 'invalid_request_error', code: 'invalid_prompt', param: 'temperature', @@ -98,9 +98,9 @@ const verifyPhases = async ( const cases = [ ['Chat via Responses', () => verifyPhases(routeOpenAIChatCompletionsViaOpenAIResponses(chat, options()), nestedEvent(failure), expectedChat)], - ['Chat via Messages', () => verifyPhases(routeOpenAIChatCompletionsViaAnthropicMessages(chat, { ...options(), loadRemoteImage: async () => null, fallbackMaxOutputTokens: 16 }), nestedEvent(failure), expectedChat)], + ['Chat via Messages', () => verifyPhases(routeOpenAIChatCompletionsViaAnthropicMessages(chat, { ...options(), fallbackMaxOutputTokens: 16 }), nestedEvent(failure), expectedChat)], ['Responses via Chat', () => verifyPhases(routeOpenAIResponsesViaOpenAIChatCompletions(responses, options()), chatEvent(failure), expectedResponses)], - ['Responses via Messages', () => verifyPhases(routeOpenAIResponsesViaAnthropicMessages(responses, { ...options(), loadRemoteImage: async () => null, fallbackMaxOutputTokens: 16 }), nestedEvent(failure), expectedResponses)], + ['Responses via Messages', () => verifyPhases(routeOpenAIResponsesViaAnthropicMessages(responses, { ...options(), fallbackMaxOutputTokens: 16 }), nestedEvent(failure), expectedResponses)], ['Messages via Chat', () => verifyPhases(routeAnthropicMessagesViaOpenAIChatCompletions(messages, options()), chatEvent(failure), expectedMessages)], ['Messages via Responses', () => verifyPhases(routeAnthropicMessagesViaOpenAIResponses(messages, options()), nestedEvent(failure), expectedMessages)], ['Gemini via Chat', () => verifyPhases(routeGeminiGenerateContentViaOpenAIChatCompletions(gemini, options()), chatEvent(failure), expectedGemini)], diff --git a/packages/translate/__tests__/protocols/edge-cases_test.ts b/packages/translate/__tests__/protocols/edge-cases_test.ts index 35caec5395..77259a91bf 100644 --- a/packages/translate/__tests__/protocols/edge-cases_test.ts +++ b/packages/translate/__tests__/protocols/edge-cases_test.ts @@ -3,20 +3,34 @@ import { expect, test, vi } from 'vitest'; import { fixtureFrames, nativeResult } from './fixtures.ts'; import { writeAnthropicMessagesEvents, writeGeminiGenerateContentEvents, readAnthropicMessagesEvents, readOpenAIChatCompletionsEvents, readOpenAIResponsesEvents, writeOpenAIChatCompletionsEvents, writeOpenAIResponsesEvents } from './helpers.ts'; import { createIRBuilder } from '../../src/ir/output/builder.ts'; -import { translateToSourceEvents as messagesViaChat } from '../../src/protocols/anthropic-messages/via-openai-chat-completions/events.ts'; -import { buildTargetRequest as messagesRequestViaChat } from '../../src/protocols/anthropic-messages/via-openai-chat-completions/request.ts'; -import { translateToSourceEvents as messagesViaResponses } from '../../src/protocols/anthropic-messages/via-openai-responses/events.ts'; -import { translateToSourceEvents as generateContentViaChat } from '../../src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts'; -import { translateToSourceEvents as generateContentViaResponses } from '../../src/protocols/gemini-generate-content/via-openai-responses/events.ts'; -import { readOpenAIChatCompletionsEvents as readOpenAIChatCompletionsEventsProduction } from '../../src/protocols/openai-chat-completions/as-target/events.ts'; -import { translateToSourceEvents as responsesViaMessages } from '../../src/protocols/openai-responses/via-anthropic-messages/events.ts'; -import { translateToSourceEvents as responsesViaChat } from '../../src/protocols/openai-responses/via-openai-chat-completions/events.ts'; +import { readOpenAIChatCompletionsEvents as irFromOpenAIChatCompletionsProduction } from '../../src/protocols/openai-chat-completions/as-target/events.ts'; +import { buildSourceEventsFor, buildTargetPayloadFor, emptyPayload, pairs } from '../composition/helpers.ts'; import { collectIR, completeIR } from '../ir/output/helpers.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; -import { createRoundTripReaderCapture, emptyNamespaceToolNames } from '../round-trip/test-helpers.ts'; +import { createRoundTripReaderCapture } from '../round-trip/test-helpers.ts'; import { collect, iterate } from '../shared/iterables.ts'; import { eventFrame } from '@floway-dev/protocols/common'; +const events = (pair: readonly [any, any], payload: any, codec: any) => + (frames: AsyncIterable) => buildSourceEventsFor(pair[0], pair[1], payload, { sidecarCodec: codec }, frames); +const messagesViaChat = (frames: AsyncIterable, codec: any) => events(pairs.messagesToChat, emptyPayload.messages, codec)(frames); +const messagesViaResponses = (frames: AsyncIterable, codec: any) => events(pairs.messagesToResponses, emptyPayload.messages, codec)(frames); +const generateContentViaChat = (frames: AsyncIterable, codec: any) => events(pairs.geminiToChat, emptyPayload.gemini, codec)(frames); +const generateContentViaResponses = (frames: AsyncIterable, codec: any) => events(pairs.geminiToResponses, emptyPayload.gemini, codec)(frames); +// The freeform tool names these cases restore come from the request's own +// declarations, so a case that expects one states it there. +const responsesViaMessages = (frames: AsyncIterable, names: ReadonlySet, codec: any) => + buildSourceEventsFor(pairs.responsesToMessages[0], pairs.responsesToMessages[1], responsesPayload(names), { sidecarCodec: codec }, frames); +const responsesViaChat = (frames: AsyncIterable, names: ReadonlySet, codec: any) => + buildSourceEventsFor(pairs.responsesToChat[0], pairs.responsesToChat[1], responsesPayload(names), { sidecarCodec: codec }, frames); +const responsesPayload = (names: ReadonlySet) => ({ + ...emptyPayload.responses, + tools: [...names].map(name => ({ type: 'custom' as const, name, description: '', format: { type: 'text' as const } })), + tool_choice: 'auto' as const, +}); +const messagesRequestViaChat = (payload: any, codec: any) => + buildTargetPayloadFor(pairs.messagesToChat[0], pairs.messagesToChat[1], payload, { sidecarCodec: codec }); + for (const [code, category] of [['bio_policy', 'bio'], ['cyber_policy', 'cyber']] as const) { test(`${code} becomes a structured Messages classifier refusal without invented body text`, async () => { const source = [eventFrame({ type: 'response.failed', response: { id: 'resp_policy', model: 'served', output: [], status: 'failed', error: { code, message: 'Classifier declined.' } } })]; @@ -65,7 +79,7 @@ test('ChatCompletions rejects generated content before any non-empty model', asy ])))).rejects.toThrow('before a non-empty model'); }); -test('ChatCompletions role-only choices still require finish_reason without creating IR records', async () => { +test('ChatCompletions role-only choices still require finish_reason without creating output IR records', async () => { const role = eventFrame({ id: 'chat_role', model: 'served', choices: [{ index: 0, delta: { role: 'assistant' }, finish_reason: null }] } as any); await expect(collectIR(readOpenAIChatCompletionsEvents(iterate([role])))).rejects.toThrow('ended without finish_reason'); @@ -199,7 +213,7 @@ for (const source of ['openai-chat-completions', 'anthropic-messages'] as const) const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { const frames = fixtureFrames(source, { tools: [{ name: 'run_code', id: 'call_custom', args: wrapped }] }); - const output = source === 'openai-chat-completions' ? responsesViaChat(iterate(frames), new Set(['run_code']), createTestSidecarCodec(), emptyNamespaceToolNames()) : responsesViaMessages(iterate(frames), 'resp_custom', new Set(['run_code']), createTestSidecarCodec(), emptyNamespaceToolNames()); + const output = source === 'openai-chat-completions' ? responsesViaChat(iterate(frames), new Set(['run_code']), createTestSidecarCodec()) : responsesViaMessages(iterate(frames), new Set(['run_code']), createTestSidecarCodec()); const result = await nativeResult('openai-responses', await collect(output)); expect(result.output.slice(0, -1)).toEqual([expect.objectContaining({ type: 'custom_tool_call', call_id: 'call_custom', name: 'run_code', input: 'input' in wrapped ? wrapped.input : JSON.stringify(wrapped) })]); expect(warning).toHaveBeenCalledTimes('input' in wrapped ? 0 : 1); @@ -210,8 +224,8 @@ for (const source of ['openai-chat-completions', 'anthropic-messages'] as const) test('ChatCompletions EOF succeeds after explicit choice completion and fails before it', async () => { const frames = fixtureFrames('openai-chat-completions', { text: ['answer'] }); - expect((await collectIR(readOpenAIChatCompletionsEventsProduction(iterate(frames.slice(0, -1)), { roundTrip: createRoundTripReaderCapture().roundTrip }))).choices[0].items).toMatchObject([{ content: [{ text: 'answer' }] }]); - await expect(collectIR(readOpenAIChatCompletionsEventsProduction(iterate(frames.slice(0, -2)), { roundTrip: createRoundTripReaderCapture().roundTrip }))).rejects.toThrow('finish_reason'); + expect((await collectIR(irFromOpenAIChatCompletionsProduction(iterate(frames.slice(0, -1)), { roundTrip: createRoundTripReaderCapture().roundTrip }))).choices[0].items).toMatchObject([{ content: [{ text: 'answer' }] }]); + await expect(collectIR(irFromOpenAIChatCompletionsProduction(iterate(frames.slice(0, -2)), { roundTrip: createRoundTripReaderCapture().roundTrip }))).rejects.toThrow('finish_reason'); }); test.each([messagesViaChat, generateContentViaChat])('completed empty function JSON is rejected by object-requiring destinations', async translate => { @@ -229,7 +243,7 @@ test.each([ const result = await nativeResult('anthropic-messages', await collect(messagesViaChat(iterate(frames), codec))); expect(result.content[0]).toMatchObject({ type: 'tool_use', input }); expect(result.stop_reason).toBe(stop === 'length' ? 'max_tokens' : 'tool_use'); - const request = await messagesRequestViaChat({ model: 'model', max_tokens: 100, messages: [{ role: 'assistant', content: result.content }] }, codec); + const request = await messagesRequestViaChat({ model: 'model', max_tokens: 100, stream: true, messages: [{ role: 'assistant', content: result.content }] }, codec); expect(request.messages).toMatchObject([{ role: 'assistant', tool_calls: [{ id: 'call_partial', function: { name: 'lookup', arguments: arguments_ } }] }]); }); @@ -244,9 +258,9 @@ test('ignores vendor object arrays and retains scalar reasoning items', async () const frames = fixtureFrames('openai-chat-completions', { thinking: ['ab'], text: ['answer'] }); const records = [{ type: 'vendor_record', id: 'record_a', segments: ['a'] }, { type: 'vendor_record', id: 'record_b', segments: ['b'] }]; frames.splice(-1, 0, eventFrame({ id: 'chatcmpl_test', model: 'served-model', created: 100, choices: [{ index: 0, delta: { vendor_records: records }, finish_reason: null }] })); - const ir = await collectIR(readOpenAIChatCompletionsEventsProduction(iterate(frames), { roundTrip: createRoundTripReaderCapture().roundTrip })); + const ir = await collectIR(irFromOpenAIChatCompletionsProduction(iterate(frames), { roundTrip: createRoundTripReaderCapture().roundTrip })); expect(ir.choices[0].items.filter(item => item.type === 'reasoning')).toEqual([{ type: 'reasoning', summary: ['ab'] }]); - const result = await nativeResult('openai-responses', await collect(responsesViaChat(iterate(frames), new Set(), createTestSidecarCodec(), emptyNamespaceToolNames()))); + const result = await nativeResult('openai-responses', await collect(responsesViaChat(iterate(frames), new Set(), createTestSidecarCodec()))); expect(result.output.filter((item: any) => item.type === 'reasoning' && item.encrypted_content === undefined)).toMatchObject([{ summary: [{ text: 'ab' }] }]); expect(result.output.some((item: any) => ['record_a', 'record_b'].includes(item.id))).toBe(false); expect(result.output.filter((item: any) => item.type === 'message')[0].content[0].text).toBe('answer'); @@ -255,7 +269,7 @@ test('ignores vendor object arrays and retains scalar reasoning items', async () test('late vendor extensions preserve messages on either side of reasoning', async () => { const chunk = (delta: any, finish_reason: string | null = null) => eventFrame({ id: 'chatcmpl_order', model: 'served-model', created: 100, choices: [{ index: 0, delta, finish_reason }] }); const frames = [chunk({ content: 'before' }), chunk({ reasoning_text: 'think' }), chunk({ content: 'after' }), chunk({}, 'stop'), chunk({ vendor_records: [{ type: 'vendor_record', id: 'record_order', segments: ['think'] }] }), { type: 'done' as const }]; - const ir = await collectIR(readOpenAIChatCompletionsEventsProduction(iterate(frames as any), { roundTrip: createRoundTripReaderCapture().roundTrip })); + const ir = await collectIR(irFromOpenAIChatCompletionsProduction(iterate(frames as any), { roundTrip: createRoundTripReaderCapture().roundTrip })); expect(ir.choices[0].items).toEqual([{ type: 'message', content: [{ type: 'text', text: 'before' }] }, { type: 'reasoning', summary: ['think'] }, { type: 'message', content: [{ type: 'text', text: 'after' }] }]); }); @@ -263,7 +277,7 @@ for (const category of ['bio', 'cyber']) { test.each([null, '', ' \t\n', 'Upstream explanation.', ' Original spacing. '])(`Responses via Messages preserves nonblank ${category} explanations and supplies a native error message when absent: %j`, async explanation => { const frames = fixtureFrames('anthropic-messages', { stop: 'content_filter' }); for (const frame of frames) if (frame.type === 'event' && frame.event.type === 'message_delta') frame.event.delta.stop_details = { type: 'refusal', category, explanation }; - const output = await collect(responsesViaMessages(iterate(frames), 'resp_policy', new Set(), createTestSidecarCodec(), emptyNamespaceToolNames())); + const output = await collect(responsesViaMessages(iterate(frames), new Set(), createTestSidecarCodec())); expect(output.at(-1)).toMatchObject({ event: { type: 'response.failed', diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/reasoning_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/input-to_test.ts similarity index 51% rename from packages/translate/__tests__/protocols/gemini-generate-content/as-source/reasoning_test.ts rename to packages/translate/__tests__/protocols/gemini-generate-content/as-source/input-to_test.ts index eaedc91cbc..e2fe3221bd 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/reasoning_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/input-to_test.ts @@ -1,6 +1,6 @@ import { test } from 'vitest'; -import { geminiGenerateContentReasoningEffort, geminiGenerateContentThinkingLevelEffort } from '../../../../src/protocols/gemini-generate-content/as-source/reasoning.ts'; +import { readGeminiGenerateContentRequest } from '../../../../src/protocols/gemini-generate-content/as-source/input-to.ts'; import { assertEquals } from '@floway-dev/test-utils'; test.each([ @@ -13,7 +13,5 @@ test.each([ ['VENDOR_Turbo', 'VENDOR_Turbo'], ['Low', 'Low'], ] as const)('thinkingLevel %s maps to effort %s', (thinkingLevel, effort) => { - const config = { thinkingLevel }; - assertEquals(geminiGenerateContentThinkingLevelEffort(config), effort); - assertEquals(geminiGenerateContentReasoningEffort(config), effort ?? null); + assertEquals(readGeminiGenerateContentRequest({ model: 'm', contents: [], generationConfig: { thinkingConfig: { thinkingLevel } } }).request.settings.reasoning_effort, effort); }); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/native-reassembly_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/as-source/native-reassembly_test.ts deleted file mode 100644 index 8dbd1ed9df..0000000000 --- a/packages/translate/__tests__/protocols/gemini-generate-content/as-source/native-reassembly_test.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { expect, test, vi } from 'vitest'; - -import { computeGeminiGenerateContentTurnDigest } from '../../../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; -import { requireTrip } from '../../require-trip.ts'; -import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; -import { createGeminiGenerateContentEventAccumulator, reassembleGeminiGenerateContentEvents } from '@floway-dev/protocols/gemini-generate-content'; -import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; -import { routeGeminiGenerateContentViaOpenAIChatCompletions } from '@floway-dev/translate'; - -vi.mock('@floway-dev/protocols/gemini-generate-content', async importOriginal => { - const actual = await importOriginal(); - return { - ...actual, - createGeminiGenerateContentEventAccumulator: vi.fn(() => { - const accumulator = actual.createGeminiGenerateContentEventAccumulator(); - return { ...accumulator, candidate: vi.fn(accumulator.candidate), result: vi.fn(accumulator.result) }; - }), - }; -}); - -const chatFrames = async function* (count: number): AsyncGenerator> { - for (let index = 0; index < count; index++) yield eventFrame({ - id: 'chat', object: 'chat.completion.chunk', created: 1, model: 'target', - choices: [{ index, delta: { role: 'assistant', content: `answer-${index}` }, finish_reason: null }], - }); - for (let index = count - 1; index >= 0; index--) yield eventFrame({ - id: 'chat', object: 'chat.completion.chunk', created: 1, model: 'target', - choices: [{ index, delta: {}, finish_reason: 'stop' }], - }); - yield doneFrame(); -}; - -test.each([2, 32])('reads only the ending candidate for %i interleaved choices', async count => { - vi.mocked(createGeminiGenerateContentEventAccumulator).mockClear(); - const codec = createTestSidecarCodec(); - const trip = await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions({ - contents: [{ role: 'user', parts: [{ text: 'question' }] }], generationConfig: { candidateCount: count }, - }, { model: 'target', sidecarCodec: codec })); - const events = async function* () { - for await (const frame of trip.events(chatFrames(count))) if (frame.type === 'event') yield frame.event; - }; - const result = await reassembleGeminiGenerateContentEvents(events()); - expect(result.candidates).toHaveLength(count); - for (const [index, candidate] of (result.candidates ?? []).entries()) { - expect(candidate.index).toBe(index); - expect(candidate.finishReason).toBe('STOP'); - expect(candidate.content?.parts?.filter(part => part.text !== undefined)).toEqual([{ text: `answer-${index}` }]); - const carrier = candidate.content?.parts?.find(part => part.thoughtSignature !== undefined)?.thoughtSignature; - if (carrier === undefined) throw new Error('Expected a round-trip sidecar'); - expect(await codec.decode(carrier)).toMatchObject({ - sourceTurnDigest: await computeGeminiGenerateContentTurnDigest([{ role: 'model', parts: [{ text: `answer-${index}` }] }]), - }); - } - expect(createGeminiGenerateContentEventAccumulator).toHaveBeenCalledTimes(1); - const creation = vi.mocked(createGeminiGenerateContentEventAccumulator).mock.results[0]; - if (creation.type !== 'return') throw new Error('Expected the native accumulator'); - expect(creation.value.result).not.toHaveBeenCalled(); - expect(creation.value.candidate).toHaveBeenCalledTimes(count); -}); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/round-trip-request_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/round-trip-request_test.ts index 25708c0be3..620f22c0e3 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/round-trip-request_test.ts +++ b/packages/translate/__tests__/protocols/gemini-generate-content/round-trip-request_test.ts @@ -3,7 +3,7 @@ import { expect, test } from 'vitest'; import { computeGeminiGenerateContentTurnDigest } from '../../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; import { encodeRoundTripSidecar } from '../../../src/round-trip/sidecar.ts'; import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; -import { requireTrip } from '../require-trip.ts'; +import { expectTrip } from '../../trip.ts'; import type { GeminiGenerateContentContent } from '@floway-dev/protocols/gemini-generate-content'; import { routeGeminiGenerateContentViaAnthropicMessages, routeGeminiGenerateContentViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIResponses } from '@floway-dev/translate'; @@ -15,16 +15,16 @@ const replayedContents = (name: string, carrier: string): GeminiGenerateContentC { role: 'model', parts: [{ functionCall: { name: 'other', args: {} } }] }, ]; -test.each(['lookup', '__proto__'])('Gemini via Chat uses restored call IDs for id-less responses and original indices for later bare calls for %s', async name => { +test.each(['lookup', '__proto__'])('Gemini via Chat uses restored call IDs for id-less responses and target-allocated ids for later calls for %s', async name => { const codec = createTestSidecarCodec(); const native = { role: 'assistant' as const, tool_calls: [{ id: 'native-chat-call', type: 'function' as const, function: { name, arguments: '{"query":"docs"}' } }] }; const carrier = await encodeRoundTripSidecar(codec, { v: 1, source: 'geminiGenerateContent', target: 'openaiChatCompletions', targetTurn: native, sourceTurnDigest: await computeGeminiGenerateContentTurnDigest(targetTurn(name)) }); - const { target: request } = await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions({ contents: replayedContents(name, carrier) }, { model: 'test', sidecarCodec: createTestSidecarCodec('test-codec-scope') })); + const { target: request } = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions({ model: 'model', contents: replayedContents(name, carrier) }, { sidecarCodec: createTestSidecarCodec('test-codec-scope') })); expect(request.messages).toEqual([ { role: 'user', content: 'question' }, native, { role: 'tool', tool_call_id: 'native-chat-call', content: '{"answer":42}' }, - { role: 'assistant', content: null, tool_calls: [{ id: 'gemini_call_3_0', type: 'function', function: { name: 'other', arguments: '{}' } }] }, + { role: 'assistant', content: null, tool_calls: [{ id: 'other_call_1', type: 'function', function: { name: 'other', arguments: '{}' } }] }, ]); }); @@ -32,21 +32,21 @@ test.each(['lookup', '__proto__'])('Gemini via Messages uses restored call IDs w const codec = createTestSidecarCodec(); const native = { role: 'assistant' as const, content: [{ type: 'tool_use' as const, id: 'native-messages-call', name, input: { query: 'docs' } }] }; const carrier = await encodeRoundTripSidecar(codec, { v: 1, source: 'geminiGenerateContent', target: 'anthropicMessages', targetTurn: native, sourceTurnDigest: await computeGeminiGenerateContentTurnDigest(targetTurn(name)) }); - const { target: request } = await requireTrip(routeGeminiGenerateContentViaAnthropicMessages({ contents: replayedContents(name, carrier) }, { model: 'test', sidecarCodec: createTestSidecarCodec('test-codec-scope'), fallbackMaxOutputTokens: undefined })); + const { target: request } = expectTrip(await routeGeminiGenerateContentViaAnthropicMessages({ model: 'model', contents: replayedContents(name, carrier) }, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), fallbackMaxOutputTokens: undefined })); expect(request.messages[1]).toEqual(native); expect(request.messages[2]).toEqual({ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'native-messages-call', content: '{"answer":42}' }] }); - expect(request.messages[3]).toEqual({ role: 'assistant', content: [{ type: 'tool_use', id: 'gemini_call_3_0', name: 'other', input: {}, cache_control: { type: 'ephemeral' } }] }); + expect(request.messages[3]).toEqual({ role: 'assistant', content: [{ type: 'tool_use', id: 'other_call_1', name: 'other', input: {}, cache_control: { type: 'ephemeral' } }] }); }); -test.each(['lookup', '__proto__'])('Gemini via Responses uses restored call IDs and preserves later bare call positions for %s', async name => { +test.each(['lookup', '__proto__'])('Gemini via Responses uses restored call IDs and allocates later bare call ids for %s', async name => { const codec = createTestSidecarCodec(); const native = [{ type: 'function_call' as const, call_id: 'native-responses-call', name, arguments: '{"query":"docs"}', status: 'completed' as const }]; const carrier = await encodeRoundTripSidecar(codec, { v: 1, source: 'geminiGenerateContent', target: 'openaiResponses', targetTurn: native, sourceTurnDigest: await computeGeminiGenerateContentTurnDigest(targetTurn(name)) }); - const { target: request } = await requireTrip(routeGeminiGenerateContentViaOpenAIResponses({ contents: replayedContents(name, carrier) }, { model: 'test', sidecarCodec: createTestSidecarCodec('test-codec-scope') })); + const { target: request } = expectTrip(await routeGeminiGenerateContentViaOpenAIResponses({ model: 'model', contents: replayedContents(name, carrier) }, { sidecarCodec: createTestSidecarCodec('test-codec-scope') })); expect(request.input).toEqual([ - { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'question' }] }, + { type: 'message', role: 'user', content: 'question' }, ...native, { type: 'function_call_output', call_id: 'native-responses-call', output: '{"answer":42}', status: 'completed' }, - { type: 'function_call', call_id: 'gemini_call_3_0', name: 'other', arguments: '{}', status: 'completed' }, + { type: 'function_call', call_id: 'other_call_1', name: 'other', arguments: '{}', status: 'completed' }, ]); }); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/events_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/events_test.ts deleted file mode 100644 index 9576b13585..0000000000 --- a/packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/events_test.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { translateToSourceEvents } from '../../../../src/protocols/gemini-generate-content/via-anthropic-messages/events.ts'; -import { testOutputTranslation } from '../../fixtures.ts'; - -testOutputTranslation('gemini-generate-content', 'anthropic-messages', (frames, codec) => translateToSourceEvents(frames, codec)); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/events_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/events_test.ts deleted file mode 100644 index 803a949a56..0000000000 --- a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/events_test.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { translateToSourceEvents } from '../../../../src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts'; -import { testOutputTranslation } from '../../fixtures.ts'; - -testOutputTranslation('gemini-generate-content', 'openai-chat-completions', (frames, codec) => translateToSourceEvents(frames, codec)); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/events_test.ts b/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/events_test.ts deleted file mode 100644 index da065b0d58..0000000000 --- a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/events_test.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { translateToSourceEvents } from '../../../../src/protocols/gemini-generate-content/via-openai-responses/events.ts'; -import { testOutputTranslation } from '../../fixtures.ts'; - -testOutputTranslation('gemini-generate-content', 'openai-responses', (frames, codec) => translateToSourceEvents(frames, codec)); diff --git a/packages/translate/__tests__/protocols/gemini-thinking-round-trip_test.ts b/packages/translate/__tests__/protocols/gemini-thinking-round-trip_test.ts new file mode 100644 index 0000000000..9b09e245be --- /dev/null +++ b/packages/translate/__tests__/protocols/gemini-thinking-round-trip_test.ts @@ -0,0 +1,46 @@ +import { expect, test } from 'vitest'; + +import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { iterate } from '../shared/iterables.ts'; +import { expectTrip } from '../trip.ts'; +import { doneFrame, eventFrame } from '@floway-dev/protocols/common'; +import { collectGeminiGenerateContentProtocolEventsToResult } from '@floway-dev/protocols/gemini-generate-content'; +import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; +import { routeGeminiGenerateContentViaOpenAIChatCompletions } from '@floway-dev/translate'; + +const upstream = async function* (): AsyncGenerator> | ReturnType> { + yield eventFrame({ + id: 'chat', model: 'm', object: 'chat.completion.chunk', created: 1, + choices: [{ index: 0, delta: { role: 'assistant', reasoning_content: 'secret' }, finish_reason: null }], + }); + yield eventFrame({ + id: 'chat', model: 'm', object: 'chat.completion.chunk', created: 1, + choices: [{ index: 0, delta: { content: 'answer' }, finish_reason: 'stop' }], + }); + yield doneFrame(); +}; + +test('includeThoughts controls only visible plaintext', async () => { + const options = { sidecarCodec: createTestSidecarCodec() }; + const runs = await Promise.all([false, true].map(async includeThoughts => { + const trip = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions({ + model: 'm', contents: [{ role: 'user', parts: [{ text: 'question' }] }], + generationConfig: { thinkingConfig: { includeThoughts } }, + }, options)); + const frames = []; + for await (const frame of trip.events(upstream())) frames.push(frame); + const result = await collectGeminiGenerateContentProtocolEventsToResult(iterate(frames)); + const content = result.candidates?.[0]?.content; + if (content === undefined) throw new Error('Expected translated Gemini content.'); + return { includeThoughts, target: trip.target, frames, content }; + })); + + expect(runs[0].target).toEqual(runs[1].target); + expect(JSON.stringify(runs[0].frames)).not.toContain('secret'); + expect(JSON.stringify(runs[1].frames)).toContain('secret'); + for (const run of runs) { + expect(run.content.parts?.some(part => part.thoughtSignature !== undefined)).toBe(true); + const replay = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions({ model: 'm', contents: [run.content] }, options)); + expect(replay.target.messages).toContainEqual(expect.objectContaining({ role: 'assistant', content: 'answer', reasoning_content: 'secret' })); + } +}); diff --git a/packages/translate/__tests__/protocols/helpers.ts b/packages/translate/__tests__/protocols/helpers.ts index 8d27268b6d..9afa333f28 100644 --- a/packages/translate/__tests__/protocols/helpers.ts +++ b/packages/translate/__tests__/protocols/helpers.ts @@ -5,7 +5,6 @@ import { writeGeminiGenerateContentEvents as writeGeminiGenerateContentEventsPro import { writeOpenAIChatCompletionsEvents as writeOpenAIChatCompletionsEventsProduction } from '../../src/protocols/openai-chat-completions/as-source/events.ts'; import { readOpenAIChatCompletionsEvents as readOpenAIChatCompletionsEventsProduction } from '../../src/protocols/openai-chat-completions/as-target/events.ts'; import { writeOpenAIResponsesEvents as writeOpenAIResponsesEventsProduction } from '../../src/protocols/openai-responses/as-source/events.ts'; -import { flattenNamespaceTools } from '../../src/protocols/openai-responses/as-source/namespace-tools.ts'; import { readOpenAIResponsesEvents as readOpenAIResponsesEventsProduction } from '../../src/protocols/openai-responses/as-target/events.ts'; import type { RoundTripReader } from '../../src/round-trip/reader.ts'; import { createReferenceBuilder } from '../../src/round-trip/reference/reference-builder.ts'; @@ -19,7 +18,6 @@ const writer = 'test-sidecar', }); -const namespaceToolNames = flattenNamespaceTools({ model: 'test', input: [], tools: [] }).names; // These ordinary IR fixtures use independent mock participants. Provenance is // exercised by the native-reader and public-route tests sharing one coordinator. @@ -44,7 +42,7 @@ export const writeAnthropicMessagesEvents = (records: Parameters() }); 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 writeOpenAIResponsesEvents = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => + writeOpenAIResponsesEventsProduction(records, { ...options, roundTrip: writer<'openaiResponses'>() }); export const writeGeminiGenerateContentEvents = (records: Parameters[0], options: Omit[1], 'roundTrip'> = {}) => writeGeminiGenerateContentEventsProduction(records, { ...options, roundTrip: writer<'geminiGenerateContent'>() }); diff --git a/packages/translate/__tests__/protocols/initial-envelope_test.ts b/packages/translate/__tests__/protocols/initial-envelope_test.ts index 43b3fd14ca..11071d26d4 100644 --- a/packages/translate/__tests__/protocols/initial-envelope_test.ts +++ b/packages/translate/__tests__/protocols/initial-envelope_test.ts @@ -1,14 +1,13 @@ import { expect, test } from 'vitest'; -import { translateToSourceEvents as messagesViaResponses } from '../../src/protocols/anthropic-messages/via-openai-responses/events.ts'; -import { translateToSourceEvents as chatViaResponses } from '../../src/protocols/openai-chat-completions/via-openai-responses/events.ts'; +import { buildSourceEventsFor, emptyPayload, pairs } from '../composition/helpers.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; test.each([ - ['ChatCompletions', (frames: AsyncIterable>) => chatViaResponses(frames, {}, createTestSidecarCodec())], - ['Messages', (frames: AsyncIterable>) => messagesViaResponses(frames, createTestSidecarCodec())], + ['ChatCompletions', (frames: AsyncIterable>) => buildSourceEventsFor(pairs.chatToResponses[0], pairs.chatToResponses[1], emptyPayload.chat, { sidecarCodec: createTestSidecarCodec() }, frames)], + ['Messages', (frames: AsyncIterable>) => buildSourceEventsFor(pairs.messagesToResponses[0], pairs.messagesToResponses[1], emptyPayload.messages, { sidecarCodec: createTestSidecarCodec() }, frames)], ] as const)('%s returns its initial envelope before pulling beyond Responses creation', async (protocol, convert) => { const response = { id: 'resp_initial', object: 'response', model: 'served', created_at: 7, status: 'in_progress', output: [], usage: null, service_tier: 'fast', error: null, incomplete_details: null }; let reached!: () => void; diff --git a/packages/translate/__tests__/protocols/lifecycle_test.ts b/packages/translate/__tests__/protocols/lifecycle_test.ts index 5a26097814..10f5db2932 100644 --- a/packages/translate/__tests__/protocols/lifecycle_test.ts +++ b/packages/translate/__tests__/protocols/lifecycle_test.ts @@ -205,7 +205,7 @@ test('Messages closes an earlier concurrent block immediately and the frontier b ]); }); -test.each([['ChatCompletions', writeOpenAIChatCompletionsEvents], ['Messages', writeAnthropicMessagesEvents], ['GenerateContent', writeGeminiGenerateContentEvents]] as const)('%s flattens reasoning content and summary in arrival order', async (name, writer) => { +test.each([['ChatCompletions', writeOpenAIChatCompletionsEvents], ['Messages', writeAnthropicMessagesEvents], ['GenerateContent', (records: AsyncIterable) => writeGeminiGenerateContentEvents(records, { includeThoughts: true })]] as const)('%s flattens reasoning content and summary in arrival order', async (name, writer) => { const item = { type: 'reasoning', id: 'r', summary: [] }; const source = [responseStart, add(0, item), eventFrame({ type: 'response.content_part.added', output_index: 0, content_index: 0, part: { type: 'reasoning_text', text: '' } } as any), eventFrame({ type: 'response.reasoning_text.delta', output_index: 0, content_index: 0, delta: 'C' } as any), eventFrame({ type: 'response.reasoning_summary_part.added', output_index: 0, summary_index: 0, part: { type: 'summary_text', text: '' } } as any), eventFrame({ type: 'response.reasoning_summary_text.delta', output_index: 0, summary_index: 0, delta: 'S' } as any), eventFrame({ type: 'response.completed', response: response([{ ...item, content: [{ type: 'reasoning_text', text: 'C' }], summary: [{ type: 'summary_text', text: 'S' }] }]) } as any)]; const frames: any[] = await collect(writer(readOpenAIResponsesEvents(iterate(source)))); @@ -263,7 +263,7 @@ test('Responses closes only the addressed text part while a refusal remains open test('GenerateContent retains reasoning arrival order while it waits for a prior tool', async () => { const reasoning = { type: 'reasoning', id: 'r', content: [{ type: 'reasoning_text', text: 'C' }], summary: [] }; const source = [responseStart, add(0, call(0)), args(0, '{"x":'), add(1, reasoning), eventFrame({ type: 'response.reasoning_summary_part.added', output_index: 1, summary_index: 0, part: { type: 'summary_text', text: 'S' } } as any), eventFrame({ type: 'response.output_item.done', output_index: 1, item: { ...reasoning, summary: [{ type: 'summary_text', text: 'S' }] } } as any), args(0, '1}'), endCall(0, '{"x":1}'), eventFrame({ type: 'response.completed', response: response([call(0, '{"x":1}', 'completed'), { ...reasoning, summary: [{ type: 'summary_text', text: 'S' }] }]) } as any)]; - const frames = await collect(writeGeminiGenerateContentEvents(readOpenAIResponsesEvents(iterate(source)))); + const frames = await collect(writeGeminiGenerateContentEvents(readOpenAIResponsesEvents(iterate(source)), { includeThoughts: true })); const parts = frames.flatMap(frame => (frame.event as any).candidates?.flatMap((candidate: any) => candidate.content?.parts ?? []) ?? []); expect(parts[0].functionCall.id).toBe('call_0'); expect(parts.filter((part: any) => part.thought).map((part: any) => part.text).join('')).toBe('CS'); @@ -300,3 +300,29 @@ test('Messages publishes cumulative usage progress before pulling the rest of th const final = output.findLast(frame => frame.event?.type === 'message_delta'); expect(final).toMatchObject({ event: { delta: { stop_reason: 'end_turn' }, usage: { input_tokens: 10, output_tokens: 2 } } }); }); + +test('Responses states a grouped call its group on the announcement and the snapshot alike', async () => { + + const records: IRRecord[] = [ + { type: 'write', kind: 'initialize', path: ['choices', 0], value: { items: [] } }, + { type: 'write', kind: 'assign', path: ['model'], value: 'served' }, + { type: 'start', id: 'source' }, + { + type: 'write', + kind: 'initialize', + path: ['choices', 0, 'items', 0], + value: { type: 'function_call', call_id: 'call_1', name: 'lookup', namespace: 'public', arguments: '' }, + }, + { type: 'write', kind: 'append', path: ['choices', 0, 'items', 0, 'arguments'], value: '{}' }, + { type: 'item_end', choice: 0, item: 0 }, + { type: 'choice_end', choice: 0, finish_reason: 'tool_calls' }, + { type: 'finish', status: 'completed' }, + ]; + const frames = await collect(writeOpenAIResponsesEvents(iterate(records))); + + const callItems = frames.flatMap(frame => frame.type === 'event' ? [frame.event] : []) + .flatMap(event => 'item' in event && event.item != null && typeof event.item === 'object' ? [event.item] : []) + .filter(item => 'type' in item && (item.type === 'function_call' || item.type === 'custom_tool_call')); + expect(callItems.map(item => ('name' in item ? item.name : undefined))).toEqual(['lookup', 'lookup']); + expect(callItems.map(item => ('namespace' in item ? item.namespace : undefined))).toEqual(['public', 'public']); +}); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/continuous-usage_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/continuous-usage_test.ts index bbdfc8de89..a54be4f6d4 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/continuous-usage_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/continuous-usage_test.ts @@ -2,8 +2,8 @@ import { expect, test } from 'vitest'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; import { collect, iterate } from '../../../shared/iterables.ts'; +import { expectTrip } from '../../../trip.ts'; import { fixtureFrames } from '../../fixtures.ts'; -import { requireTrip } from '../../require-trip.ts'; import type { OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; import { routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses } from '@floway-dev/translate'; @@ -14,10 +14,8 @@ test.each(protocols)('ChatCompletions via %s captures continuous usage preferenc for (const include_usage of flags) for (const continuous_usage_stats of flags) { const src: OpenAIChatCompletionsPayloadEx = { model: 'model', messages: [], stream: true, stream_options: { include_usage, continuous_usage_stats } }; const trip = protocol === 'anthropic-messages' - ? await requireTrip(routeOpenAIChatCompletionsViaAnthropicMessages(src, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'model', fallbackMaxOutputTokens: 100, loadRemoteImage: async () => { throw new Error('Unexpected image'); } })) - : await requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses(src, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'model' })); - src.stream_options!.include_usage = include_usage !== true; - src.stream_options!.continuous_usage_stats = continuous_usage_stats !== true; + ? expectTrip(await routeOpenAIChatCompletionsViaAnthropicMessages(src, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), fallbackMaxOutputTokens: 100 })) + : expectTrip(await routeOpenAIChatCompletionsViaOpenAIResponses(src, { sidecarCodec: createTestSidecarCodec('test-codec-scope') })); const usage = protocol === 'anthropic-messages' ? { input_tokens: 10, output_tokens: 4 } : { input_tokens: 10, output_tokens: 4, total_tokens: 14 }; const frames = await collect(trip.events(iterate(fixtureFrames(protocol, { text: ['answer'], thinking: ['reason'], tools: [{ id: 'call', name: 'tool', args: { value: 1 } }], usage })))); const chunks = frames.flatMap(frame => frame.type === 'event' && !('error' in frame.event) ? [frame.event] : []); @@ -32,7 +30,7 @@ test.each(protocols)('ChatCompletions via %s captures continuous usage preferenc }); test('ChatCompletions continuous usage reflects reported Responses counters without extra progress chunks', async () => { - const trip = await requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses({ model: 'model', messages: [], stream_options: { include_usage: true, continuous_usage_stats: true } }, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'model' })); + const trip = expectTrip(await routeOpenAIChatCompletionsViaOpenAIResponses({ model: 'model', messages: [], stream: true, stream_options: { include_usage: true, continuous_usage_stats: true } }, { sidecarCodec: createTestSidecarCodec('test-codec-scope') })); const response = { id: 'resp', model: 'model', created_at: 1, status: 'in_progress', output: [], usage: null }; const frames = [ { type: 'response.created', response }, diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-source/tool-kind_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/tool-kind_test.ts new file mode 100644 index 0000000000..b4f4da03e9 --- /dev/null +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-source/tool-kind_test.ts @@ -0,0 +1,43 @@ +import { expect, test } from 'vitest'; + +import { routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses } from '../../../../src/index.ts'; +import type { IRInputTurn } from '../../../../src/ir/input/types.ts'; +import { readOpenAIChatCompletionsRequest } from '../../../../src/protocols/openai-chat-completions/as-source/input-to.ts'; +import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { assertEquals } from '@floway-dev/test-utils'; + +const itemsOf = (turns: readonly IRInputTurn[]) => turns.flatMap(turn => [...turn.items]); + +test('a tool result keeps the call kind even when a sidecar states the turn that made the call', () => { + const reading = readOpenAIChatCompletionsRequest({ + model: 'gpt-test', + messages: [ + { role: 'user', content: 'go' }, + { role: 'assistant', tool_calls: [{ id: 'call_patch', type: 'custom', custom: { name: 'apply_patch', input: 'x' } }] }, + { role: 'tool', tool_call_id: 'call_patch', content: 'ok' }, + ], + }); + + // The target writes the assistant slot natively, so its `toIRInputTurns` never + // runs. The kind of its call is still needed by the result that answers it, + // which is why opening names every declared call before reading. + const turns = reading.turns.flatMap(turn => (turn.kind === 'assistant' ? [] : turn.toIRInputTurns())); + + const result = itemsOf(turns).find(item => item.type === 'tool_result'); + assertEquals(result?.type === 'tool_result' ? result.tool_kind : undefined, 'custom'); +}); + +test('preserves unrecognized Chat custom format until target writing', async () => { + const format = { type: 'future_format', definition: 'future syntax' }; + const payload = { + model: 'm', stream: true, messages: [], + tools: [{ type: 'custom' as const, custom: { name: 'edit', format: format as never } }], + }; + const tool = readOpenAIChatCompletionsRequest(payload).request.tools?.[0]; + expect(tool).toEqual({ type: 'custom', name: 'edit', format: { type: 'unrecognized', value: format } }); + for (const route of [routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses]) { + const result = await route(payload, { sidecarCodec: createTestSidecarCodec() }); + expect(result).toMatchObject({ type: 'input-error', message: 'Cannot translate an unrecognized custom tool format.' }); + expect(result).not.toBeInstanceOf(Error); + } +}); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts index cb31812722..5b6852b759 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/events_test.ts @@ -70,7 +70,7 @@ test('collects every native Chat choice when a sibling omits delta', async () => ]); }); -const traceTools = async (frames: ReturnType[], options: { prefix?: string; customToolNames?: ReadonlySet } = {}) => { +const traceTools = async (frames: ReturnType[], options: { prefix?: string; customToolNames?: ReadonlySet; originalToolNames?: ReadonlyMap } = {}) => { const builder = createReferenceBuilder(); const content = createEmittedFieldState(builder, 'text'); let text = options.prefix ?? ''; @@ -82,7 +82,7 @@ const traceTools = async (frames: ReturnType[], options: { prefix? builder, onAssistantTurn: (...args) => { captured = args; }, }; - for await (const record of readOpenAIChatCompletionsEvents((async function* () { yield* frames; yield doneFrame(); })(), { roundTrip, customToolNames: options.customToolNames })) { + for await (const record of readOpenAIChatCompletionsEvents((async function* () { yield* frames; yield doneFrame(); })(), { roundTrip, customToolNames: options.customToolNames, originalToolNames: options.originalToolNames })) { if (record.type === 'write' && record.kind === 'assign') assigns.push(record); if (record.type !== 'write' || record.kind !== 'append') continue; const field = record.path.at(-1); @@ -99,7 +99,7 @@ const traceTools = async (frames: ReturnType[], options: { prefix? return { turn, fieldState, appends, assigns, finalized }; }; -test('keeps merged pre-name arguments in one IR append with every original atom ID', async () => { +test('keeps merged pre-name arguments in one output IR append with every original atom ID', async () => { const result = await traceTools([ chunk([{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call', type: 'function', function: { arguments: '{"x":' } }] }, finish_reason: null }]), chunk([{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: '1}' } }] }, finish_reason: null }]), @@ -167,3 +167,23 @@ test('keeps the name of an open tool call when later fragments repeat an empty n expect(result.assigns.filter(record => record.path.at(-1) === 'name').map(record => record.value)).not.toContain(''); expect(result.finalized.targetTurn).toMatchObject({ tool_calls: [{ function: { name: 'bash', arguments: '{"command":"ls"}' } }] }); }); + +test('states the pair again when a grouped call renames after it opened', async () => { + // The request sent `web__run` for the pair `web`/`run`; a later fragment + // renames the call. Both writes have to state the pair, or the item ends with + // a flat name beside the group's namespace. + const originalToolNames = new Map([ + ['web__run', { namespace: 'web', name: 'run' }], + ['web__fetch', { namespace: 'web', name: 'fetch' }], + ]); + const result = await traceTools([ + chunk([{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call_1', type: 'function', function: { name: 'web__run', arguments: '{"u":' } }] }, finish_reason: null }]), + chunk([{ index: 0, delta: { tool_calls: [{ index: 0, function: { name: 'web__fetch', arguments: '"x"}' } }] }, finish_reason: 'tool_calls' }]), + ], { originalToolNames }); + + const nameAssigns = result.assigns.filter(record => record.path.at(-1) === 'name').map(record => record.value); + expect(nameAssigns).toEqual(['fetch']); + expect(result.finalized.targetTurn).toMatchObject({ + tool_calls: [{ function: { name: 'web__fetch', arguments: '{"u":"x"}' } }], + }); +}); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts new file mode 100644 index 0000000000..8d5ddff91d --- /dev/null +++ b/packages/translate/__tests__/protocols/openai-chat-completions/as-target/legacy-calls_test.ts @@ -0,0 +1,98 @@ +import { test } from 'vitest'; + +import type { RestoredAssistantTurn } from '../../../../src/composition/reading.ts'; +import { assignCallIds } from '../../../../src/composition/request/call-ids.ts'; +import type { IRInputTurn } from '../../../../src/ir/input/types.ts'; +import { chatLegacyCallsOf } from '../../../../src/protocols/openai-chat-completions/as-target/legacy-calls.ts'; +import type { InputAssistantTurnFor } from '../../../../src/round-trip/input-assistant-turn.ts'; +import { TranslateInputException } from '../../../../src/shared/input-error.ts'; +import { assertEquals, assertThrows } from '@floway-dev/test-utils'; + +type LegacyTurn = InputAssistantTurnFor<'openaiChatCompletions'>; +const legacy = (name: string): RestoredAssistantTurn => ({ kind: 'restored', turn: { role: 'assistant', function_call: { name, arguments: '{}' } } }); +const modern = (callId: string): RestoredAssistantTurn => ({ kind: 'restored', turn: { role: 'assistant', tool_calls: [{ id: callId, type: 'function', function: { name: 'lookup', arguments: '{}' } }] } }); +const mixed = (): RestoredAssistantTurn => ({ + kind: 'restored', + turn: { + role: 'assistant', + function_call: { name: 'legacy', arguments: '{}' }, + tool_calls: [{ id: 'modern-id', type: 'function', function: { name: 'modern', arguments: '{}' } }], + }, +}); +const message = (text: string): IRInputTurn => ({ kind: 'client', items: [{ type: 'message', role: 'user', content: [{ type: 'text', text }] }] }); +const result = (callId: string): IRInputTurn => ({ kind: 'client', items: [{ type: 'tool_result', call_id: callId, output: '' }] }); +const namedResult = (name: string): IRInputTurn => ({ kind: 'client', items: [{ type: 'tool_result', name, output: '' }] }); + +test('the unique id-less result naming the legacy function answers it', () => { + const { callIds, names } = chatLegacyCallsOf([legacy('lookup'), namedResult('lookup')]); + assertEquals([...names.values()], ['lookup']); + assertEquals(callIds.size, 1); +}); + +test('an answer naming a call the conversation states is left alone', () => { + assertEquals(chatLegacyCallsOf([modern('native-1'), result('native-1')]).names.size, 0); +}); + +test('two id-less answers naming the legacy function are refused as ambiguous', () => { + const twoAnswers: IRInputTurn = { kind: 'client', items: [{ type: 'tool_result', name: 'lookup', output: '' }, { type: 'tool_result', name: 'lookup', output: '' }] }; + assertThrows(() => chatLegacyCallsOf([legacy('lookup'), twoAnswers]), TranslateInputException); +}); + +test('a non-tail legacy call without a matching result is refused', () => { + assertThrows(() => chatLegacyCallsOf([legacy('lookup'), message('later')]), TranslateInputException); +}); + +test('a later uniquely named id-less result answers the legacy call', () => { + const turns = [legacy('lookup'), message('chatter'), namedResult('lookup')]; + assertEquals([...chatLegacyCallsOf(turns).names.values()], ['lookup']); +}); + +test('a legacy call still awaiting its answer at the end is accepted', () => { + // The conversation has not moved past the call, so nothing is missing yet. + assertEquals(chatLegacyCallsOf([legacy('lookup')]).names.size, 0); +}); + +test('legacy matching consumes its uniquely named id-less result before modern matching', () => { + const native = mixed(); + const results: IRInputTurn = { + kind: 'client', + items: [ + { type: 'tool_result', name: 'modern', output: 'MODERN' }, + { type: 'tool_result', name: 'legacy', output: 'LEGACY' }, + ], + }; + const legacy = chatLegacyCallsOf([native, results]); + const assigned = assignCallIds([native, results], turn => [ + { callId: legacy.callIds.get(turn)!, name: 'legacy' }, + { callId: 'modern-id', name: 'modern' }, + ], { resultCallIds: legacy.results }); + const client = assigned[1]; + assertEquals(client.kind === 'client' ? client.items.map(item => item.type === 'tool_result' ? item.call_id : undefined) : [], ['modern-id', legacy.callIds.get(native.turn)]); +}); + +test('legacy matching wins over an earlier pending modern call of the same name', () => { + const legacyTurn = legacy('lookup'); + const answer = namedResult('lookup'); + const turns = [modern('modern-id'), legacyTurn, answer]; + const legacyCalls = chatLegacyCallsOf(turns); + const assigned = assignCallIds(turns, turn => [ + ...(turn.tool_calls ?? []).map(call => ({ callId: call.id, name: call.type === 'function' ? call.function.name : call.custom.name })), + ...(turn.function_call == null ? [] : [{ callId: legacyCalls.callIds.get(turn)!, name: turn.function_call.name }]), + ], { resultCallIds: legacyCalls.results }); + const client = assigned[2]; + assertEquals(client.kind === 'client' && client.items[0].type === 'tool_result' ? client.items[0].call_id : undefined, legacyCalls.callIds.get(legacyTurn.turn)); +}); + +test('a different legacy function does not end the result search', () => { + const first = legacy('first'); + const second = legacy('second'); + const answers: IRInputTurn = { + kind: 'client', + items: [ + { type: 'tool_result', name: 'first', output: 'FIRST' }, + { type: 'tool_result', name: 'second', output: 'SECOND' }, + ], + }; + const legacyCalls = chatLegacyCallsOf([first, message('between'), second, answers]); + assertEquals([...legacyCalls.names.values()], ['first', 'second']); +}); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events_test.ts b/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events_test.ts deleted file mode 100644 index 3fdcfbc082..0000000000 --- a/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events_test.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { translateToSourceEvents } from '../../../../src/protocols/openai-chat-completions/via-anthropic-messages/events.ts'; -import { testOutputTranslation } from '../../fixtures.ts'; - -testOutputTranslation('openai-chat-completions', 'anthropic-messages', (frames, codec) => translateToSourceEvents(frames, {}, codec)); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/allowed-tools_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/allowed-tools_test.ts index 58a9a2d950..390c27f49d 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/allowed-tools_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/allowed-tools_test.ts @@ -1,14 +1,22 @@ import { test } from 'vitest'; -import { buildTargetRequest as buildMessages } from '../../../../src/protocols/openai-responses/via-anthropic-messages/request.ts'; -import { buildTargetRequest as buildChat } from '../../../../src/protocols/openai-responses/via-openai-chat-completions/request.ts'; +import { requireCallableSelector } from '../../../../src/protocols/openai-responses/as-source/allowed-tools.ts'; +import type { SidecarCodec } from '../../../../src/round-trip/codec.ts'; import { TranslateInputException } from '../../../../src/shared/input-error.ts'; +import { buildTargetPayloadFor, pairs } from '../../../composition/helpers.ts'; import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; import type { CanonicalOpenAIResponsesInputItem, OpenAIResponsesRequestPayloadEx, OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; -import { assertEquals, assertRejects } from '@floway-dev/test-utils'; +import { assertEquals, assertRejects, assertThrows } from '@floway-dev/test-utils'; + +const buildChat = async (payload: OpenAIResponsesRequestPayloadEx, codec: SidecarCodec) => { + return { target: await buildTargetPayloadFor(pairs.responsesToChat[0], pairs.responsesToChat[1], payload, { sidecarCodec: codec }) }; +}; +const buildMessages = async (payload: OpenAIResponsesRequestPayloadEx, codec: SidecarCodec, options: { fallbackMaxOutputTokens?: number } = {}) => { + return { target: await buildTargetPayloadFor(pairs.responsesToMessages[0], pairs.responsesToMessages[1], payload, { sidecarCodec: codec, ...options }) }; +}; const source = (tool_choice: OpenAIResponsesToolChoice): OpenAIResponsesRequestPayloadEx => ({ - model: 'model', input: 'Read only.', + model: 'model', stream: true, input: 'Read only.', tools: [ { type: 'function', name: 'read', parameters: { type: 'object', properties: {} } }, { type: 'function', name: 'charge', parameters: { type: 'object', properties: {} } }, @@ -22,17 +30,23 @@ for (const target of ['chat', 'messages'] as const) { ? await buildChat(payload, createTestSidecarCodec()) : await buildMessages(payload, createTestSidecarCodec(), { fallbackMaxOutputTokens: undefined, - loadRemoteImage: async () => null, }); for (const mode of ['auto', 'required'] as const) { - test(`${target} request serializes only the allowed function/custom subset and ${mode} mode`, async () => { + test(`${target} preserves declarations or rejects an unrepresentable ${mode} subset`, async () => { const payload = source({ type: 'allowed_tools', mode, tools: [{ type: 'custom', name: 'edit' }, { type: 'function', name: 'read' }] }); const original = structuredClone(payload); + if (target === 'messages') { + await assertRejects(() => build(payload), TranslateInputException, 'cannot express an allowed_tools'); + assertEquals(payload, original); + return; + } const result = await build(payload); + const baseline = await build({ ...payload, tool_choice: 'auto' }); + assertEquals(result.target.tools, baseline.target.tools); const wire = JSON.parse(JSON.stringify(result.target)) as { tools: Array<{ name?: string; function?: { name: string } }>; tool_choice: unknown }; - assertEquals(wire.tools.map(tool => tool.function?.name ?? tool.name), ['read', 'edit']); - assertEquals(wire.tool_choice, target === 'chat' ? mode : { type: mode === 'required' ? 'any' : 'auto' }); - assertEquals(result.customToolNames, new Set(['edit'])); + assertEquals(wire.tools.map(tool => tool.function?.name ?? tool.name), ['read', 'charge', 'edit']); + assertEquals(wire.tool_choice, { type: 'allowed_tools', allowed_tools: { mode, tools: [{ type: 'function', function: { name: 'edit' } }, { type: 'function', function: { name: 'read' } }] } }); + assertEquals(payload, original); }); } @@ -41,29 +55,34 @@ for (const target of ['chat', 'messages'] as const) { const payload = source({ type: 'allowed_tools', mode: 'required', tools: [{ type: 'custom', name: 'read' }] }); payload.tools!.push({ type: 'custom', name: 'read' }); const result = await build(payload); - assertEquals(result.target.tools?.length, 1); - assertEquals(result.customToolNames, new Set(['read'])); + assertEquals(result.target.tools?.length, 4); + }); for (const history of ['additional_tools', 'tool_search_output'] as const) { - test(`${target} request rejects flat function/custom collisions from ${history} before converting calls`, async () => { + test(`${target} request allocates distinct names for function/custom declarations from ${history}`, async () => { const custom: OpenAIResponsesTool = { type: 'custom', name: 'web_search' }; const item: CanonicalOpenAIResponsesInputItem = history === 'additional_tools' ? { type: 'additional_tools', role: 'developer', tools: [custom] } : { type: 'tool_search_output', execution: 'client', tools: [custom] }; const payload: OpenAIResponsesRequestPayloadEx = { - model: 'model', + model: 'model', stream: true, tools: [{ type: 'function', name: 'web_search', parameters: { type: 'object' } }], input: [item], }; - await assertRejects(() => build(payload), TranslateInputException, "distinct callable kinds sharing 'web_search'"); + const result = await build(payload); + const wire = JSON.parse(JSON.stringify(result.target)) as { tools: Array<{ name?: string; function?: { name: string } }> }; + assertEquals(wire.tools.map(tool => tool.function?.name ?? tool.name), ['web_search', 'web_search_2']); }); } - test(`${target} request represents an empty auto subset as no tools`, async () => { + test(`${target} request disables calling without removing declarations for an empty auto subset`, async () => { const result = await build(source({ type: 'allowed_tools', mode: 'auto', tools: [] })); - assertEquals(result.target.tools, undefined); - assertEquals(result.target.tool_choice, target === 'chat' ? 'none' : { type: 'none' }); + const baseline = await build(source('auto')); + assertEquals(result.target.tools, baseline.target.tools); + assertEquals(result.target.tool_choice, target === 'chat' + ? { type: 'allowed_tools', allowed_tools: { mode: 'auto', tools: [] } } + : { type: 'none' }); }); test(`${target} request rejects a flat selector that would erase a declaration namespace`, async () => { @@ -82,7 +101,6 @@ for (const target of ['chat', 'messages'] as const) { ['unknown mode', { type: 'allowed_tools', mode: 'future', tools: [{ type: 'function', name: 'read' }] }], ['malformed tools array', { type: 'allowed_tools', mode: 'auto', tools: null }], ['malformed selector', { type: 'allowed_tools', mode: 'auto', tools: [null] }], - ['colliding callable kinds', { type: 'allowed_tools', mode: 'auto', tools: [{ type: 'function', name: 'read' }, { type: 'custom', name: 'read' }] }], ] as const) { test(`${target} request typed-rejects ${label} instead of widening allowed_tools`, async () => { const payload = source(choice as unknown as OpenAIResponsesToolChoice); @@ -91,3 +109,96 @@ for (const target of ['chat', 'messages'] as const) { }); } } + +test('requireCallableSelector rejects a namespace that is not a string', () => { + + assertEquals(requireCallableSelector({ type: 'function', name: 'read', namespace: 'files' }).namespace, 'files'); + assertThrows(() => requireCallableSelector({ type: 'function', name: 'read', namespace: 42 }), TranslateInputException); +}); + +test.each(['chat', 'messages'] as const)('rejects namespace selector extensions for %s', async target => { + const payload = { + model: 'm', stream: true, input: 'hi', + tools: [{ type: 'namespace' as const, name: 'files', description: 'files', tools: [{ type: 'function' as const, name: 'read' }] }], + tool_choice: { type: 'allowed_tools' as const, mode: 'required' as const, tools: [{ type: 'namespace', name: 'files', extension: true }] }, + }; + const build = target === 'chat' ? buildChat : buildMessages; + await assertRejects(() => build(payload as never, createTestSidecarCodec()), TranslateInputException, 'namespace selector extensions'); +}); + +test.each(['auto', 'required'] as const)('Messages expresses a complete %s callable set without changing its declarations', async mode => { + const payload = source({ + type: 'allowed_tools', mode, tools: [ + { type: 'function', name: 'read' }, { type: 'function', name: 'charge' }, { type: 'custom', name: 'edit' }, + ], + }); + const result = await buildMessages(payload, createTestSidecarCodec()); + const baseline = await buildMessages(source('auto'), createTestSidecarCodec()); + assertEquals(result.target.tools, baseline.target.tools); + assertEquals(result.target.tool_choice, { type: mode === 'auto' ? 'auto' : 'any' }); +}); + +test('Messages rejects an optional single-tool subset rather than forcing that call', async () => { + await assertRejects( + () => buildMessages(source({ type: 'allowed_tools', mode: 'auto', tools: [{ type: 'function', name: 'read' }] }), createTestSidecarCodec()), + TranslateInputException, + 'cannot express an allowed_tools auto subset', + ); +}); + +test('a dotted selector is only read as a pair when that group is declared', async () => { + // A dot in a flat declaration belongs to its name; treating it as a namespace separator selects a different callable. + const flat = await buildChat(source({ type: 'function', name: 'a.b' }), createTestSidecarCodec()); + assertEquals(flat.target.tool_choice, { type: 'function', function: { name: 'a.b' } }); +}); + +test.each(['chat', 'messages'] as const)('preserves declarations and selects only the namespaced custom callable on %s', async target => { + const payload: OpenAIResponsesRequestPayloadEx = { + model: 'm', stream: true, input: 'hi', tools: [{ + type: 'namespace', name: 'files', description: '', tools: [ + { type: 'function', name: 'edit', parameters: { type: 'object', properties: {} } }, + { type: 'custom', name: 'edit' }, + ], + }], + tool_choice: { type: 'allowed_tools', mode: 'required', tools: [{ type: 'custom', namespace: 'files', name: 'edit' }] }, + }; + const codec = createTestSidecarCodec(); + if (target === 'chat') { + const result = await buildChat(payload, codec); + const baseline = await buildChat({ ...payload, tool_choice: 'auto' }, codec); + assertEquals(result.target.tools, baseline.target.tools); + assertEquals(result.target.tools?.map(tool => tool.type === 'function' ? tool.function.name : tool.custom.name), ['files_edit', 'files_edit_2']); + assertEquals(result.target.tool_choice, { type: 'allowed_tools', allowed_tools: { mode: 'required', tools: [{ type: 'function', function: { name: 'files_edit_2' } }] } }); + } else { + const result = await buildMessages(payload, codec); + const baseline = await buildMessages({ ...payload, tool_choice: 'auto' }, codec); + assertEquals(result.target.tools, baseline.target.tools); + assertEquals(result.target.tools?.flatMap(tool => 'name' in tool ? [tool.name] : []), ['files_edit', 'files_edit_2']); + assertEquals(result.target.tool_choice, { type: 'tool', name: 'files_edit_2' }); + } +}); + +for (const allowed of [false, true]) { + test.each([ + ['exact flat declaration', 'web.run', 'web.run', 'web', 'run', true], + ['dotted namespace', 'a.b.run', 'a_b_run', 'a.b', 'run', false], + ['double underscore alias', 'web__run', 'web_run', 'web', 'run', false], + ] as const)(`resolves %s from full callable identities (allowed_tools=${allowed})`, async (_label, selected, expected, namespace, name, flat) => { + const payload: OpenAIResponsesRequestPayloadEx = { + model: 'm', stream: true, input: 'hi', + tools: [ + ...(flat ? [{ type: 'function' as const, name: selected, parameters: { type: 'object', properties: {} } }] : []), + { type: 'namespace', name: namespace, description: '', tools: [{ type: 'function', name, parameters: { type: 'object', properties: {} } }] }, + ], + tool_choice: allowed + ? { type: 'allowed_tools', mode: 'required', tools: [{ type: 'function', name: selected }] } + : { type: 'function', name: selected }, + }; + const result = await buildChat(payload, createTestSidecarCodec()); + const baseline = await buildChat({ ...payload, tool_choice: 'auto' }, createTestSidecarCodec()); + assertEquals(result.target.tools, baseline.target.tools); + assertEquals(result.target.tool_choice, allowed + ? { type: 'allowed_tools', allowed_tools: { mode: 'required', tools: [{ type: 'function', function: { name: expected } }] } } + : { type: 'function', function: { name: expected } }); + }); +} diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/conversation-turns_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/conversation-turns_test.ts index 178555219a..4666a0beae 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/conversation-turns_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/conversation-turns_test.ts @@ -12,10 +12,10 @@ test.each(['additional_tools', 'tool_search_output'] as const)('%s belongs to th : { type, call_id: 'load', execution: 'client', tools: [] }; expect(partitionOpenAIResponsesTurns([inventory, first, output, inventory, second, inventory])).toEqual([ - { role: 'bare', items: [inventory] }, + { role: 'client', items: [inventory] }, { role: 'assistant', items: [first] }, - { role: 'bare', items: [output, inventory] }, + { role: 'client', items: [output, inventory] }, { role: 'assistant', items: [second] }, - { role: 'bare', items: [inventory] }, + { role: 'client', items: [inventory] }, ]); }); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts index 05f9282e53..003e74661c 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/events_test.ts @@ -2,20 +2,10 @@ import { expect, test } from 'vitest'; import { createIRBuilder } from '../../../../src/ir/output/builder.ts'; import { writeOpenAIResponsesEvents } from '../../../../src/protocols/openai-responses/as-source/events.ts'; -import { flattenNamespaceTools } from '../../../../src/protocols/openai-responses/as-source/namespace-tools.ts'; import { appendReadField, collectEmittedFieldPlacements, createReferenceBuilder, createReadFieldState, createFieldReferencer } from '../../../../src/round-trip/reference/reference-builder.ts'; import type { RoundTripWriter } from '../../../../src/round-trip/writer.ts'; test('collects sparse namespace-restored Responses state from emitted IR atoms', async () => { - const namespace = flattenNamespaceTools({ - model: 'model', - input: [], - tools: [{ type: 'namespace', name: 'tools', description: '', tools: [{ type: 'custom', name: 'script' }, { type: 'function', name: 'lookup', parameters: { type: 'object' } }] }], - }); - const flatScript = namespace.names.sourceToTarget.get('tools.script'); - const flatLookup = namespace.names.sourceToTarget.get('tools.lookup'); - if (flatScript === undefined || flatLookup === undefined) throw new Error('Namespace tools were not flattened'); - const references = createReferenceBuilder(); const field = (parts: readonly string[]) => { const state = createReadFieldState('text'); @@ -42,9 +32,9 @@ test('collects sparse namespace-restored Responses state from emitted IR atoms', builder.assign(['model'], 'model'); builder.event({ type: 'start', id: 'response' }); - builder.initialize(['choices', 0, 'items', 0], { type: 'function_call', name: flatScript, arguments: '' }); - builder.append(['choices', 0, 'items', 0, 'arguments'], '{"code":', [functionArguments.textFragmentIds[0]]); - builder.append(['choices', 0, 'items', 0, 'arguments'], '1}', [functionArguments.textFragmentIds[1]]); + builder.initialize(['choices', 0, 'items', 0], { type: 'custom_tool_call', namespace: 'tools', name: 'script', input: '' }); + builder.append(['choices', 0, 'items', 0, 'input'], '{"code":', [functionArguments.textFragmentIds[0]]); + builder.append(['choices', 0, 'items', 0, 'input'], '1}', [functionArguments.textFragmentIds[1]]); builder.initialize(['choices', 0, 'items', 1], { type: 'message', content: [] }); builder.initialize(['choices', 0, 'items', 1, 'content', 0], { type: 'text', text: '' }); @@ -59,9 +49,9 @@ test('collects sparse namespace-restored Responses state from emitted IR atoms', builder.append(['choices', 0, 'items', 2, 'summary', 0], 'C', [reasoningSummary.textFragmentIds[1]]); builder.append(['choices', 0, 'items', 2, 'content', 0], 'D', [reasoningContent.textFragmentIds[0]]); - builder.initialize(['choices', 0, 'items', 3], { type: 'custom_tool_call', call_id: 'lookup', name: flatLookup, input: '' }); - builder.append(['choices', 0, 'items', 3, 'input'], '{"q":', [customInput.textFragmentIds[0]]); - builder.append(['choices', 0, 'items', 3, 'input'], '1}', [customInput.textFragmentIds[1]]); + builder.initialize(['choices', 0, 'items', 3], { type: 'function_call', namespace: 'tools', call_id: 'lookup', name: 'lookup', arguments: '' }); + builder.append(['choices', 0, 'items', 3, 'arguments'], '{"q":', [customInput.textFragmentIds[0]]); + builder.append(['choices', 0, 'items', 3, 'arguments'], '1}', [customInput.textFragmentIds[1]]); builder.assign(['choices', 0, 'items', 0, 'call_id'], 'script'); builder.event({ type: 'part_end', choice: 0, item: 1, part: 0 }); @@ -79,7 +69,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 writeOpenAIResponsesEvents((async function* () { yield* builder.drain(); })(), { roundTrip, namespaceToolNames: namespace.names })) {} + for await (const _frame of writeOpenAIResponsesEvents((async function* () { yield* builder.drain(); })(), { roundTrip })) {} if (prepared === undefined) throw new Error('Responses sidecar was not prepared'); const [, , turn, state] = prepared; diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/input-to_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/input-to_test.ts new file mode 100644 index 0000000000..fddaf3b18c --- /dev/null +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/input-to_test.ts @@ -0,0 +1,95 @@ +import { expect, test } from 'vitest'; + +import { routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions, routeOpenAIChatCompletionsViaOpenAIResponses } from '../../../../src/index.ts'; +import { readOpenAIResponsesRequest } from '../../../../src/protocols/openai-responses/as-source/input-to.ts'; +import { openaiResponsesConversationFromIR } from '../../../../src/protocols/openai-responses/as-target/input-from.ts'; +import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import type { OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; + +const source: OpenAIResponsesRequestPayloadEx = { + model: 'm', stream: true, input: [{ + type: 'reasoning', summary: [{ type: 'summary_text', text: 'S' }], + content: [{ type: 'reasoning_text', text: 'C' }], encrypted_content: 'opaque-vendor-data', + }], +}; + +test('readable reasoning stays separate in IR beside an opaque encrypted field', async () => { + const slot = readOpenAIResponsesRequest(source).turns[0]; + expect(slot.toIRInputTurns()).toEqual([{ + kind: 'assistant', items: [{ type: 'reasoning', summary: ['S'], content: ['C'] }], + }]); + const trip = await routeOpenAIResponsesViaOpenAIChatCompletions(source, { sidecarCodec: createTestSidecarCodec() }); + if (trip.type !== 'trip') throw new Error(trip.message); + expect(trip.target.messages).toEqual([{ role: 'assistant', content: null, reasoning_text: 'SC' }]); + expect(JSON.stringify(trip.target)).not.toContain('opaque-vendor-data'); +}); + +test('entirely opaque reasoning preserves an empty assistant turn', () => { + const slot = readOpenAIResponsesRequest({ + model: 'm', stream: true, input: [{ type: 'reasoning', summary: [], encrypted_content: 'opaque-vendor-data' }], + }).turns[0]; + expect(slot.toIRInputTurns()).toEqual([{ kind: 'assistant', items: [] }]); +}); + +test('an incomplete custom result reaches Messages with its failure marker', async () => { + const trip = await routeOpenAIResponsesViaAnthropicMessages({ + model: 'm', stream: true, input: [ + { type: 'custom_tool_call', call_id: 'c', name: 'edit', input: 'run' }, + { type: 'custom_tool_call_output', call_id: 'c', output: 'failed', status: 'incomplete' }, + ], + }, { sidecarCodec: createTestSidecarCodec() }); + if (trip.type !== 'trip') throw new Error(trip.message); + expect(trip.target.messages[1]).toMatchObject({ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'c', content: 'failed', is_error: true }] }); +}); + +test('custom result status reaches the Responses wire from the IR failure marker', () => { + const output = openaiResponsesConversationFromIR([{ + kind: 'client', items: [{ type: 'tool_result', tool_kind: 'custom', call_id: 'c', output: 'failed', is_error: true }], + }], true); + expect(output.input).toEqual([{ type: 'custom_tool_call_output', call_id: 'c', output: 'failed', status: 'incomplete' }]); +}); + +test('native Chat custom results preserve their result shape on Responses', async () => { + const trip = await routeOpenAIChatCompletionsViaOpenAIResponses({ + model: 'm', stream: true, messages: [ + { role: 'assistant', tool_calls: [{ type: 'custom', id: 'c', custom: { name: 'edit', input: 'run' } }] }, + { role: 'tool', tool_call_id: 'c', content: 'done' }, + ], + }, { sidecarCodec: createTestSidecarCodec() }); + if (trip.type !== 'trip') throw new Error(trip.message); + expect(trip.target.input.at(-1)).toEqual({ type: 'custom_tool_call_output', call_id: 'c', output: 'done' }); +}); + +test.each([ + { type: 'future_format', syntax: 'lark', definition: 'start: "ok"' }, + { type: 'grammar', syntax: 'lark' }, +])('preserves unrecognized custom format in IR and rejects it at target writing: %j', async format => { + const payload: OpenAIResponsesRequestPayloadEx = { + model: 'm', stream: true, input: [], + tools: [{ type: 'custom', name: 'edit', format }], + }; + const tool = readOpenAIResponsesRequest(payload).request.tools?.[0]; + expect(tool).toEqual({ type: 'custom', name: 'edit', format: { type: 'unrecognized', value: format } }); + + for (const route of [routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions]) { + const result = await route(payload, { sidecarCodec: createTestSidecarCodec() }); + expect(result).toMatchObject({ type: 'input-error', message: 'Cannot translate an unrecognized custom tool format.' }); + expect(result).not.toBeInstanceOf(Error); + } +}); + +test('an allowed-tools subset does not hide an unrecognized tool declaration', async () => { + const payload: OpenAIResponsesRequestPayloadEx = { + model: 'm', stream: true, input: [], + tools: [ + { type: 'function', name: 'read', parameters: { type: 'object', properties: {} }, strict: false }, + { type: 'custom', name: 'edit', format: { type: 'future_format' } }, + ], + tool_choice: { type: 'allowed_tools', mode: 'required', tools: [{ type: 'function', name: 'read' }] }, + }; + expect(readOpenAIResponsesRequest(payload).request.tools).toHaveLength(2); + for (const route of [routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions]) { + const result = await route(payload, { sidecarCodec: createTestSidecarCodec() }); + expect(result).toMatchObject({ type: 'input-error', message: 'Cannot translate an unrecognized custom tool format.' }); + } +}); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-allocation_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-allocation_test.ts deleted file mode 100644 index d1017fc7b1..0000000000 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-allocation_test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { test, vi } from 'vitest'; - -import { flattenNamespaceTools } from '../../../../src/protocols/openai-responses/as-source/namespace-tools.ts'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesTool } from '@floway-dev/protocols/openai-responses'; -import { assert, assertEquals } from '@floway-dev/test-utils'; - -const functionTool = (name: string): Extract => ({ type: 'function', name, parameters: { type: 'object' } }); - -for (const [namespace, reservePreferred] of [['n'.repeat(64), false], ['n'.repeat(59), true]] as const) { - test(`callable projection bounds collision lookups for ${namespace.length}-character namespaces`, () => { - const count = 1000; - const children = Array.from({ length: count }, (_, index) => functionTool(String(index).padStart(4, '0'))); - const reserved = reservePreferred ? children.map(child => functionTool(`${namespace}_${'name' in child ? child.name : ''}`)) : []; - const request: CanonicalOpenAIResponsesPayload = { model: 'm', input: [], tools: [...reserved, { type: 'namespace', name: namespace, description: '', tools: children }] }; - let call: ReturnType; - const has = vi.spyOn(Set.prototype, 'has'); - let checks = 0; - try { - call = flattenNamespaceTools(request); - checks = has.mock.calls.length; - } finally { has.mockRestore(); } - const names = call.payload.tools!.slice(reserved.length).map(tool => 'name' in tool ? tool.name : ''); - assertEquals(names.length, count); - assertEquals(new Set(names).size, count); - assert(names.every(name => typeof name === 'string' && name.length <= 64)); - assert(checks >= count, 'instrument must observe collision membership checks'); - assert(checks <= 2 * count + reserved.length, `expected bounded lookup count, observed ${checks}`); - }); -} - -test('callable projection preserves suffix ordering across digit widths, invalid characters and duplicates', () => { - const namespace = 'n'.repeat(59); - const prefix = `${namespace}_`; - const children = ['aaaa', 'aaab', 'aaac', 'a', 'aa', 'aaaa']; - const reserved = [...children.slice(0, -1).map(name => `${prefix}${name}`), ...Array.from({ length: 8 }, (_, i) => `${prefix}aa_${i + 2}`), `${prefix}a_10`, `${prefix}a_12`]; - const call = flattenNamespaceTools({ model: 'm', input: [], tools: [...reserved.map(functionTool), { type: 'namespace', name: namespace, description: '', tools: children.map(functionTool) }, { type: 'namespace', name: 'bad.ns', description: '', tools: [functionTool('bad/name')] }] }); - assertEquals(call.payload.tools?.slice(reserved.length).map(tool => 'name' in tool ? tool.name : null), [`${prefix}a_11`, `${prefix}a_13`, `${prefix}a_14`, `${prefix}a_2`, `${prefix}a_15`, `${prefix}a_11`, 'bad_ns_bad_name']); -}); - -test('callable projection bounds sanitized flat-name prefixes for a shared long namespace', () => { - const namespace = 'n'.repeat(4096); - const count = 32; - const request: CanonicalOpenAIResponsesPayload = { model: 'm', input: [], tools: [{ type: 'namespace', name: namespace, description: '', tools: Array.from({ length: count }, (_, index) => functionTool(`tool${index}`)) }] }; - let call: ReturnType; - const replacements = vi.spyOn(String.prototype, 'replaceAll'); - let lengths: number[] = []; - try { - call = flattenNamespaceTools(request); - lengths = replacements.mock.contexts.map(context => String(context).length); - } finally { - replacements.mockRestore(); - } - assert(lengths.length > 0, 'instrument must observe the allocator sanitizing names'); - assert(Math.max(...lengths) <= 64, `allocator scanned an unbounded prefix: ${Math.max(...lengths)}`); - assertEquals(new Set(call.payload.tools!.map(tool => 'name' in tool ? tool.name : null)).size, count); -}); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-descriptions_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-descriptions_test.ts deleted file mode 100644 index 1dc8179b1f..0000000000 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-descriptions_test.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { test } from 'vitest'; - -import { flattenNamespaceTools } from '../../../../src/protocols/openai-responses/as-source/namespace-tools.ts'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesTool } from '@floway-dev/protocols/openai-responses'; -import { assertEquals } from '@floway-dev/test-utils'; - -const functionTool = (name: string): Extract => ({ type: 'function', name, parameters: { type: 'object' } }); - -test('callable projection retains parent and child descriptions for translated targets', () => { - const request: CanonicalOpenAIResponsesPayload = { - model: 'm', input: [], tools: [ - { type: 'namespace', name: 'files', description: 'Read-only access. Never modify files.', tools: [{ ...functionTool('read'), description: 'Read a file.' }, { type: 'custom', name: 'inspect' }] }, - { type: 'namespace', name: 'empty', description: '', tools: [{ ...functionTool('read'), description: 'Child-only description.' }] }, - ], - }; - const original = structuredClone(request); - const call = flattenNamespaceTools(request); - assertEquals(call.payload.tools?.map(tool => 'description' in tool ? tool.description : undefined), ['Read-only access. Never modify files.\n\nRead a file.', 'Read-only access. Never modify files.', 'Child-only description.']); - assertEquals(request, original); -}); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-events_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-events_test.ts deleted file mode 100644 index a037a4c4bd..0000000000 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-events_test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { expect, test, vi } from 'vitest'; - -import { flattenNamespaceTools, restoreNamespaceEvents } from '../../../../src/protocols/openai-responses/as-source/namespace-tools.ts'; -import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesResultEx, OpenAIResponsesStreamEventEx, OpenAIResponsesTool } from '@floway-dev/protocols/openai-responses'; -import { assert, assertEquals } from '@floway-dev/test-utils'; - -const functionTool = (name: string): Extract => ({ type: 'function', name, parameters: { type: 'object' } }); -const emptyResult = (): OpenAIResponsesResultEx => ({ id: 'resp', object: 'response', model: 'model', status: 'completed', output: [], error: null, incomplete_details: null }); -const framesOf = async function* (events: OpenAIResponsesStreamEventEx[]): AsyncGenerator> { - for (const event of events) yield eventFrame(event); - yield doneFrame(); -}; - -test('callable projection restores item lifecycle, function/custom types, and resource echoes before outer readers', async () => { - const request: CanonicalOpenAIResponsesPayload = { model: 'm', input: [], tools: [{ type: 'namespace', name: 'files', description: '', tools: [{ type: 'custom', name: 'edit' }, functionTool('read')] }], tool_choice: { type: 'custom', name: 'edit', namespace: 'files' } as CanonicalOpenAIResponsesPayload['tool_choice'] }; - const call = flattenNamespaceTools(request); - const output = [ - { type: 'function_call' as const, name: 'files_edit', id: 'fc1', call_id: 'a', arguments: 'patch', status: 'completed' as const }, - { type: 'custom_tool_call' as const, name: 'files_read', id: 'fc2', call_id: 'b', input: '{}' }, - ]; - const upstream = { ...emptyResult(), output, tools: [functionTool('files_edit')], tool_choice: { type: 'function' as const, name: 'files_edit' }, extension: 'kept' }; - const response = restoreNamespaceEvents(framesOf([ - { type: 'response.output_item.added', output_index: 0, item: output[0]! }, - { type: 'response.output_item.done', output_index: 0, item: output[0]! }, - { type: 'response.completed', response: upstream }, - ]), call.names); - const frames: ProtocolFrame[] = []; - for await (const frame of response) frames.push(frame); - const expected = [ - { type: 'custom_tool_call', name: 'edit', namespace: 'files', id: 'fc1', call_id: 'a', input: 'patch', status: 'completed' }, - { type: 'function_call', name: 'read', namespace: 'files', id: 'fc2', call_id: 'b', arguments: '{}', status: 'completed' }, - ]; - assertEquals(frames[0], eventFrame({ type: 'response.output_item.added', output_index: 0, item: expected[0] } as OpenAIResponsesStreamEventEx)); - assertEquals(frames[1], eventFrame({ type: 'response.output_item.done', output_index: 0, item: expected[0] } as OpenAIResponsesStreamEventEx)); - assertEquals(frames[2], eventFrame({ type: 'response.completed', response: { ...upstream, output: expected, tools: request.tools, tool_choice: request.tool_choice } } as OpenAIResponsesStreamEventEx)); - assertEquals(frames[3], doneFrame()); - assertEquals(upstream.output, output); -}); - -for (const type of ['function', 'custom'] as const) { - test(`callable projection restores ${type} argument event types along with callable items`, async () => { - const call = flattenNamespaceTools({ model: 'm', input: [], tools: [{ type: 'namespace', name: 'files', description: '', tools: [{ type, name: 'read' }] }] }); - const sourceIsFunction = type === 'function'; - const item = sourceIsFunction - ? { type: 'custom_tool_call' as const, id: 'item', name: 'files_read', call_id: 'call', input: '{}' } - : { type: 'function_call' as const, id: 'item', name: 'files_read', call_id: 'call', arguments: '{}', status: 'completed' as const }; - const unknown = { type: 'future.event', opaque: { retained: true } } as unknown as OpenAIResponsesStreamEventEx; - const response = restoreNamespaceEvents(framesOf([ - { type: 'response.output_item.added', output_index: 0, item }, - { type: sourceIsFunction ? 'response.custom_tool_call_input.delta' : 'response.function_call_arguments.delta', item_id: 'item', output_index: 0, delta: '{}' }, - sourceIsFunction - ? { type: 'response.custom_tool_call_input.done', item_id: 'item', output_index: 0, input: '{}' } - : { type: 'response.function_call_arguments.done', item_id: 'item', output_index: 0, arguments: '{}', name: 'files_read' } as OpenAIResponsesStreamEventEx, - unknown, - ]), call.names); - const events: OpenAIResponsesStreamEventEx[] = []; - for await (const frame of response) if (frame.type === 'event') events.push(frame.event); - assertEquals(events[1], { type: sourceIsFunction ? 'response.function_call_arguments.delta' : 'response.custom_tool_call_input.delta', item_id: 'item', output_index: 0, delta: '{}' }); - assertEquals(events[2], { type: sourceIsFunction ? 'response.function_call_arguments.done' : 'response.custom_tool_call_input.done', item_id: 'item', output_index: 0, [sourceIsFunction ? 'arguments' : 'input']: '{}', ...(sourceIsFunction ? { name: 'read' } : {}) }); - assert(events[3] === unknown); - }); -} - -test('callable projection restores function arguments.done names without adding a namespace field', async () => { - const call = flattenNamespaceTools({ model: 'm', input: [], tools: [{ type: 'namespace', name: 'files', description: '', tools: [functionTool('read')] }] }); - const response = restoreNamespaceEvents(framesOf([ - { type: 'response.output_item.added', output_index: 0, item: { type: 'function_call', id: 'item', call_id: 'call', name: 'files_read', arguments: '', status: 'in_progress' } }, - { type: 'response.function_call_arguments.done', item_id: 'item', output_index: 0, name: 'files_read', arguments: '{}' } as OpenAIResponsesStreamEventEx, - ]), call.names); - const events: OpenAIResponsesStreamEventEx[] = []; - for await (const frame of response) if (frame.type === 'event') events.push(frame.event); - assertEquals(events[1], { type: 'response.function_call_arguments.done', item_id: 'item', output_index: 0, name: 'read', arguments: '{}' }); -}); - -test('callable projection restores lifecycle-appropriate function status from custom items', async () => { - const call = flattenNamespaceTools({ model: 'm', input: [], tools: [{ type: 'namespace', name: 'files', description: '', tools: [functionTool('read')] }] }); - const item = { type: 'custom_tool_call' as const, id: 'item', call_id: 'call', name: 'files_read', input: '{}' }; - const response = restoreNamespaceEvents(framesOf([ - { type: 'response.output_item.added', output_index: 0, item }, - { type: 'response.created', response: { ...emptyResult(), status: 'in_progress', output: [item] } }, - { type: 'response.output_item.done', output_index: 0, item }, - { type: 'response.completed', response: { ...emptyResult(), output: [item] } }, - ]), call.names); - const statuses: unknown[] = []; - for await (const frame of response) { - if (frame.type !== 'event') continue; - if ('item' in frame.event) statuses.push((frame.event.item as { status?: string }).status); - else if ('response' in frame.event) statuses.push((frame.event.response.output[0] as { status?: string }).status); - } - assertEquals(statuses, ['in_progress', 'in_progress', 'completed', 'completed']); -}); - -test('callable projection projects Standard carriers before namespace allocation without mutating history', async () => { - const developer = { type: 'message' as const, role: 'developer' as const, content: 'Keep this ordinary developer instruction.' }; - const delayed = { type: 'tool_search_output' as const, tools: [functionTool('delayed')] }; - const request: CanonicalOpenAIResponsesPayload = { - model: 'm', tools: [functionTool('files_edit')], - input: [ - developer, - { type: 'additional_tools', role: 'developer', tools: [{ type: 'namespace', name: 'files', description: '', tools: [{ type: 'custom', name: 'edit', description: 'Edit a file.' }] }] }, - { type: 'additional_tools', role: 'developer', tools: [functionTool('read')] }, - { type: 'custom_tool_call', namespace: 'files', name: 'edit', call_id: 'past', input: 'patch' }, - delayed, - ], - tool_choice: { type: 'allowed_tools', mode: 'required', tools: [{ type: 'custom', namespace: 'files', name: 'edit' }] }, - }; - const original = structuredClone(request); - const call = flattenNamespaceTools(request); - const response = restoreNamespaceEvents(framesOf([ - { type: 'response.completed', response: { ...emptyResult(), tools: call.payload.tools ?? undefined, tool_choice: call.payload.tool_choice, output: [{ type: 'function_call', name: 'files_edit_2', call_id: 'current', arguments: 'new patch', status: 'completed' }] } }, - ]), call.names); - assertEquals(call.payload.tools?.map(tool => 'name' in tool ? tool.name : undefined), ['files_edit', 'files_edit_2', 'read', 'delayed']); - assertEquals(call.payload.tools?.[1], { type: 'custom', name: 'files_edit_2', description: 'Edit a file.' }); - const input = call.flattenInput(request.input); - assertEquals(input, [developer, { type: 'custom_tool_call', name: 'files_edit_2', call_id: 'past', input: 'patch' }]); - assert(input[0] === developer); - assertEquals(call.payload.tool_choice, { type: 'allowed_tools', mode: 'required', tools: [{ type: 'custom', name: 'files_edit_2' }] }); - assertEquals(request, original); - const frames: ProtocolFrame[] = []; - for await (const frame of response) frames.push(frame); - const frame = frames[0]; - assert(frame.type === 'event' && frame.event.type === 'response.completed'); - assertEquals(frame.event.response.tools, request.tools); - assertEquals(frame.event.response.tool_choice, request.tool_choice); - assertEquals(frame.event.response.output, [{ type: 'custom_tool_call', name: 'edit', namespace: 'files', call_id: 'current', input: 'new patch', status: 'completed' }]); -}); - -for (const tools of [undefined, [], [functionTool('existing')]]) { - for (const echo of [false, true]) { - test(`carrier projection restores original tool echoes (${tools === undefined ? 'omitted' : tools.length} declarations, upstream echo ${echo})`, async () => { - const request: CanonicalOpenAIResponsesPayload = { model: 'm', input: [{ type: 'additional_tools', role: 'developer', tools: [functionTool('read')] }], ...(tools === undefined ? {} : { tools }) }; - const call = flattenNamespaceTools(request); - const response = restoreNamespaceEvents(framesOf([ - { type: 'response.completed', response: { ...emptyResult(), ...(echo ? { tools: call.payload.tools ?? undefined } : {}) } }, - ]), call.names); - assertEquals(call.flattenInput(request.input), []); - assertEquals(call.payload.tools, [...(tools ?? []), functionTool('read')]); - let completed = 0; - for await (const frame of response) { - if (frame.type !== 'event' || frame.event.type !== 'response.completed') continue; - completed++; - assertEquals(frame.event.response.tools, echo ? tools : undefined); - assertEquals(Object.hasOwn(frame.event.response, 'tools'), echo && tools !== undefined); - } - assertEquals(completed, 1); - }); - } -} - -test('callable restoration retains only echo fields, not the source request or input', () => { - const request: CanonicalOpenAIResponsesPayload = { model: 'm', input: [{ type: 'message', role: 'user', content: 'Long conversation' }], tools: [{ type: 'namespace', name: 'files', description: '', tools: [functionTool('read')] }] }; - const { names } = flattenNamespaceTools(request); - const reachable = new Set(); - const visit = (value: unknown) => { - if (typeof value !== 'object' || value === null || reachable.has(value)) return; - reachable.add(value); - for (const child of value instanceof Map ? value.values() : Object.values(value)) visit(child); - }; - visit(names); - expect(reachable.has(request.tools)).toBe(true); - expect(reachable.has(request)).toBe(false); - expect(reachable.has(request.input)).toBe(false); -}); - -test.each(['forced', 'allowed_tools'] as const)('unchanged flat %s choices retain no echo sources and preserve response frames', async mode => { - const selector = { type: 'function' as const, name: 'read' }; - const request: CanonicalOpenAIResponsesPayload = { model: 'm', input: [], tools: [functionTool('read'), functionTool('write')], tool_choice: mode === 'forced' ? selector : { type: 'allowed_tools', mode: 'auto', tools: [selector] } }; - const { payload, names } = flattenNamespaceTools(request); - expect(payload.tool_choice).toBe(request.tool_choice); - expect(names.toolsChanged).toBe(false); - expect(names.toolChoiceChanged).toBe(false); - expect(names.sourceTools).toBeUndefined(); - expect(names.sourceToolChoice).toBeUndefined(); - const item = { type: 'function_call' as const, id: 'item', call_id: 'call', name: 'read', arguments: '{}', status: 'completed' as const }; - const source: ProtocolFrame[] = [ - eventFrame({ type: 'response.output_item.added', output_index: 0, item }), - eventFrame({ type: 'response.function_call_arguments.done', item_id: 'item', output_index: 0, name: 'read', arguments: '{}' } as OpenAIResponsesStreamEventEx), - eventFrame({ type: 'response.output_item.done', output_index: 0, item }), - eventFrame({ type: 'response.completed', response: { ...emptyResult(), output: [item], tools: request.tools ?? undefined, tool_choice: request.tool_choice } }), - doneFrame(), - ]; - const restored = []; - for await (const frame of restoreNamespaceEvents((async function* () { yield* source; })(), names)) restored.push(frame); - expect(restored).toHaveLength(source.length); - restored.forEach((frame, index) => expect(frame).toBe(source[index])); -}); - -test('callable projection does not rescan unchanged flattened tool references', () => { - const tools = Array.from({ length: 2000 }, (_, index) => functionTool(`tool_${index}`)); - const scans: Array<{ array: unknown[]; visits: number }> = []; - const originalSome = Array.prototype.some; - const spy = vi.spyOn(Array.prototype, 'some').mockImplementation(function (this: unknown[], predicate, thisArg) { - const scan = { array: this, visits: 0 }; - scans.push(scan); - return originalSome.call(this, (value, index, array) => { - scan.visits++; - return predicate.call(thisArg, value, index, array); - }); - }); - const prepared = (() => { - try { - return flattenNamespaceTools({ model: 'm', input: [], tools }); - } finally { - spy.mockRestore(); - } - })(); - expect(prepared.payload.tools).toEqual(tools); - expect(prepared.payload.tools?.every((tool, index) => tool === tools[index])).toBe(true); - expect(scans.some(scan => scan.array === tools && scan.visits === tools.length)).toBe(true); - expect(scans.filter(scan => scan.array === prepared.payload.tools).reduce((total, scan) => total + scan.visits, 0)).toBe(0); -}); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-tools_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-tools_test.ts deleted file mode 100644 index fdf426e86b..0000000000 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/namespace-tools_test.ts +++ /dev/null @@ -1,209 +0,0 @@ -import { expect, test } from 'vitest'; - -import { flattenNamespaceTools, restoreNamespaceEvents, restoreNamespaceOutputItem } from '../../../../src/protocols/openai-responses/as-source/namespace-tools.ts'; -import { buildTargetRequest as buildMessagesRequest } from '../../../../src/protocols/openai-responses/via-anthropic-messages/request.ts'; -import { buildTargetRequest as buildChatRequest } from '../../../../src/protocols/openai-responses/via-openai-chat-completions/request.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; -import { requireTrip } from '../../require-trip.ts'; -import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import { doneFrame, eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; -import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; -import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesOutputItemEx, OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; -import { routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; - -const payload = (): OpenAIResponsesRequestPayloadEx => ({ - model: 'm', - tools: [ - { type: 'function', name: 'agents_spawn' }, - { - type: 'namespace', name: 'agents', description: '', tools: [ - { type: 'function', name: 'spawn', parameters: { type: 'object' } }, - { type: 'function', name: 'wait', parameters: { type: 'object' } }, - { type: 'custom', name: 'audit' }, - ], - }, - ], - input: [{ type: 'function_call', namespace: 'agents', name: 'wait', call_id: 'old', arguments: '{}', status: 'completed' }], - tool_choice: { type: 'allowed_tools', mode: 'required', tools: [{ type: 'function', namespace: 'agents', name: 'spawn' }] }, -}); - -const messagesRequestOptions = { fallbackMaxOutputTokens: undefined, loadRemoteImage: async () => null }; - -test.each(['additional_tools', 'tool_search_output'] as const)('keeps %s as a user-side boundary between ordinary assistant turns', async type => { - const source: CanonicalOpenAIResponsesPayload = { - model: 'm', - input: [ - { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'A' }] }, - type === 'additional_tools' ? { type, role: 'developer', tools: [] } : { type, tools: [] }, - { type: 'function_call', call_id: 'c', name: 'lookup', arguments: '{}' }, - ], - }; - expect((await buildChatRequest(source, createTestSidecarCodec())).target.messages).toEqual([ - { role: 'assistant', content: 'A' }, - { role: 'assistant', content: null, tool_calls: [{ type: 'function', id: 'c', function: { name: 'lookup', arguments: '{}' } }] }, - ]); - expect((await buildMessagesRequest(source, createTestSidecarCodec(), messagesRequestOptions)).target.messages).toEqual([ - { role: 'assistant', content: [{ type: 'text', text: 'A' }] }, - { role: 'assistant', content: [{ type: 'tool_use', id: 'c', name: 'lookup', input: {}, cache_control: { type: 'ephemeral' } }] }, - ]); -}); - -const canonicalPayload = () => { - const canonical = canonicalizeOpenAIResponsesPayload(payload()); - if (!('input' in canonical)) throw new Error(canonical.error.message); - return canonical; -}; - -test('namespace setup preserves the original input and allocates historical names only when explicitly flattened', () => { - const request: CanonicalOpenAIResponsesPayload = { - model: 'm', - input: [ - { type: 'function_call', namespace: 'x.y', name: 'f', call_id: 'first', arguments: '{}' }, - { type: 'function_call', namespace: 'x', name: 'y.f', call_id: 'second', arguments: '{}' }, - ], - }; - const prepared = flattenNamespaceTools(request); - - expect(prepared.payload.input).toBe(request.input); - expect(prepared.names.sourceToTarget.size).toBe(0); - expect(prepared.names.targetToSource.size).toBe(0); - expect(prepared.flattenInput([request.input[0]])).toEqual([{ type: 'function_call', name: 'x_y_f', call_id: 'first', arguments: '{}' }]); - expect(() => prepared.flattenInput([request.input[1]])).toThrow('ambiguous'); -}); - -test('both targets preserve namespace subsets and excluded replay identities', async () => { - const source = payload(); - const chat = await buildChatRequest(source, createTestSidecarCodec()); - const messages = await buildMessagesRequest(source, createTestSidecarCodec(), messagesRequestOptions); - expect(chat.target.tools?.map(tool => tool.type === 'function' ? tool.function.name : '')).toEqual(['agents_spawn_2']); - expect(messages.target.tools?.map(tool => 'name' in tool ? tool.name : undefined)).toEqual(['agents_spawn_2']); - expect((chat.target.messages[0] as OpenAIChatCompletionsAssistantMessageEx).tool_calls?.filter(call => call.type === 'function')[0].function.name).toBe('agents_wait'); - expect(messages.target.messages[0].content).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'tool_use', name: 'agents_wait' })])); - expect(chat.target.tool_choice).toBe('required'); - expect(messages.target.tool_choice).toEqual({ type: 'any' }); -}); - -test('expands namespace selectors and includes deferred declarations on both targets', async () => { - const source = payload(); - source.tool_choice = { type: 'allowed_tools', mode: 'auto', tools: [{ type: 'namespace', name: 'agents' }] }; - source.input = [{ type: 'additional_tools', role: 'developer', tools: source.tools! }]; - source.tools = undefined; - expect((await buildChatRequest(source, createTestSidecarCodec())).target.tools).toHaveLength(3); - expect((await buildMessagesRequest(source, createTestSidecarCodec(), messagesRequestOptions)).target.tools).toHaveLength(3); -}); - -test('history-only namespace calls reserve names without inventing declarations', async () => { - const source = payload(); - source.tools = undefined; - source.tool_choice = undefined; - const chat = await buildChatRequest(source, createTestSidecarCodec()); - const messages = await buildMessagesRequest(source, createTestSidecarCodec(), messagesRequestOptions); - expect(chat.target.tools).toBeUndefined(); - expect(messages.target.tools).toBeUndefined(); - expect((chat.target.messages[0] as OpenAIChatCompletionsAssistantMessageEx).tool_calls?.filter(call => call.type === 'function')[0].function.name).toBe('agents_wait'); - expect(chat.namespaceToolNames.targetToSource.get('agents_wait')).toEqual({ namespace: 'agents', name: 'wait', type: 'function_call' }); -}); - -test('forced namespace choices use the declaration mapping', async () => { - const source = payload(); - source.tool_choice = { type: 'function', namespace: 'agents', name: 'spawn' }; - expect((await buildChatRequest(source, createTestSidecarCodec())).target.tool_choice).toEqual({ type: 'function', function: { name: 'agents_spawn_2' } }); - expect((await buildMessagesRequest(source, createTestSidecarCodec(), messagesRequestOptions)).target.tool_choice).toEqual({ type: 'tool', name: 'agents_spawn_2' }); -}); - -test('restores function and custom calls across item events and terminal snapshots', async () => { - const prepared = flattenNamespaceTools(canonicalPayload()); - const output = [ - { type: 'function_call' as const, name: 'agents_spawn_2', call_id: 'spawn', arguments: '{"name":"agents_spawn_2"}', status: 'completed' }, - { type: 'custom_tool_call' as const, name: 'agents_audit', call_id: 'audit', input: 'agents_audit' }, - ]; - const frames = (async function* (): AsyncGenerator> { - yield eventFrame({ type: 'response.output_item.done', output_index: 0, item: output[0] }); - yield eventFrame({ type: 'response.completed', response: { id: 'r', object: 'response', model: 'm', status: 'completed', error: null, incomplete_details: null, output } }); - })(); - const events: OpenAIResponsesStreamEventEx[] = []; - for await (const frame of restoreNamespaceEvents(frames, prepared.names)) if (frame.type === 'event') events.push(frame.event); - expect(events[0]).toMatchObject({ item: { namespace: 'agents', name: 'spawn', arguments: '{"name":"agents_spawn_2"}' } }); - expect(events[1]).toMatchObject({ - response: { - output: [ - { namespace: 'agents', name: 'spawn' }, { namespace: 'agents', name: 'audit', input: 'agents_audit' }, - ], - }, - }); -}); - -test('restores all function/custom output item type combinations without retaining the replaced payload field', () => { - const names = flattenNamespaceTools(canonicalPayload()).names; - const functionName = names.sourceToTarget.get('agents.spawn'); - const customName = names.sourceToTarget.get('agents.audit'); - if (functionName === undefined || customName === undefined) throw new Error('Expected flattened namespace tool names'); - - const sameFunction = { - type: 'function_call', name: functionName, call_id: 'same-function', arguments: '{}', status: 'completed', trace: 'kept', - } satisfies OpenAIResponsesOutputItemEx & { trace: string }; - const sameCustom = { - type: 'custom_tool_call', name: customName, call_id: 'same-custom', input: 'input', trace: 'kept', - } satisfies OpenAIResponsesOutputItemEx & { trace: string }; - const customAsFunction = { - type: 'custom_tool_call', name: functionName, call_id: 'custom-as-function', input: '{"q":1}', trace: 'kept', - } satisfies OpenAIResponsesOutputItemEx & { trace: string }; - const functionAsCustom = { - type: 'function_call', name: customName, call_id: 'function-as-custom', arguments: 'plain', status: 'completed', trace: 'kept', - } satisfies OpenAIResponsesOutputItemEx & { trace: string }; - - expect(restoreNamespaceOutputItem(sameFunction, names, 'in_progress')).toEqual({ ...sameFunction, name: 'spawn', namespace: 'agents' }); - expect(restoreNamespaceOutputItem(sameCustom, names, 'in_progress')).toEqual({ ...sameCustom, name: 'audit', namespace: 'agents' }); - expect(restoreNamespaceOutputItem(customAsFunction, names, 'in_progress')).toEqual({ - type: 'function_call', name: 'spawn', namespace: 'agents', call_id: 'custom-as-function', arguments: '{"q":1}', status: 'in_progress', trace: 'kept', - }); - expect(restoreNamespaceOutputItem(functionAsCustom, names, 'in_progress')).toEqual({ - type: 'custom_tool_call', name: 'audit', namespace: 'agents', call_id: 'function-as-custom', input: 'plain', status: 'completed', trace: 'kept', - }); -}); - -test.each(['.', '__'])('lowers qualified forced selectors with %s', async separator => { - const source = payload(); - source.tool_choice = { type: 'function', name: `agents${separator}spawn` }; - expect((await buildChatRequest(source, createTestSidecarCodec())).target.tool_choice).toEqual({ type: 'function', function: { name: 'agents_spawn_2' } }); - expect((await buildMessagesRequest(source, createTestSidecarCodec(), messagesRequestOptions)).target.tool_choice).toEqual({ type: 'tool', name: 'agents_spawn_2' }); -}); - -test('rejects function/custom ambiguity and distinct tuples with the same qualified spelling', async () => { - for (const tools of [ - [{ type: 'namespace' as const, name: 'x', description: '', tools: [{ type: 'function' as const, name: 'f' }, { type: 'custom' as const, name: 'f' }] }], - [{ type: 'namespace' as const, name: 'x.y', description: '', tools: [{ type: 'function' as const, name: 'f' }] }, { type: 'namespace' as const, name: 'x', description: '', tools: [{ type: 'function' as const, name: 'y.f' }] }], - ]) { - await expect(buildChatRequest({ model: 'm', input: [], tools }, createTestSidecarCodec())).rejects.toThrow('ambiguous'); - } -}); - -test('the complete Chat Completions trip restores the namespace after target tool calls', async () => { - const trip = await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(payload(), { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' })); - const frames = (async function* (): AsyncGenerator> { - yield eventFrame({ id: 'chat1', object: 'chat.completion.chunk', model: 'm', created: 0, choices: [{ index: 0, delta: { tool_calls: [{ index: 0, id: 'call1', type: 'function', function: { name: 'agents_spawn_2', arguments: '{}' } }] }, finish_reason: null }] }); - yield eventFrame({ id: 'chat1', object: 'chat.completion.chunk', model: 'm', created: 0, choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] }); - yield doneFrame(); - })(); - const events: OpenAIResponsesStreamEventEx[] = []; - for await (const frame of trip.events(frames)) if (frame.type === 'event') events.push(frame.event); - expect(events).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'response.output_item.done', item: expect.objectContaining({ namespace: 'agents', name: 'spawn', arguments: '{}' }) })])); - expect(events.at(-1)).toMatchObject({ type: 'response.completed', response: { output: [expect.objectContaining({ namespace: 'agents', name: 'spawn' }), expect.objectContaining({ type: 'reasoning', summary: [], encrypted_content: expect.any(String) })] } }); -}); - -test('the complete Anthropic Messages trip restores the namespace after target tool calls', async () => { - const trip = await requireTrip(routeOpenAIResponsesViaAnthropicMessages(payload(), { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm', loadRemoteImage: async () => { throw new Error('Unexpected remote image'); } })); - const frames = (async function* (): AsyncGenerator> { - yield eventFrame({ type: 'message_start', message: { container: null, diagnostics: null, stop_details: null, id: 'msg1', type: 'message', model: 'm', role: 'assistant', content: [], stop_reason: null, stop_sequence: null, usage: { cache_creation: null, cache_creation_input_tokens: null, cache_read_input_tokens: null, inference_geo: null, output_tokens_details: null, server_tool_use: null, service_tier: null, input_tokens: 1, output_tokens: 0 } } }); - yield eventFrame({ type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: 'call1', name: 'agents_spawn_2', input: {} } }); - yield eventFrame({ type: 'content_block_delta', index: 0, delta: { type: 'input_json_delta', partial_json: '{}' } }); - yield eventFrame({ type: 'content_block_stop', index: 0 }); - yield eventFrame({ type: 'message_delta', delta: { container: null, stop_details: null, stop_reason: 'tool_use', stop_sequence: null }, usage: { input_tokens: null, cache_creation_input_tokens: null, cache_read_input_tokens: null, output_tokens_details: null, server_tool_use: null, output_tokens: 1 } }); - yield eventFrame({ type: 'message_stop' }); - })(); - const events: OpenAIResponsesStreamEventEx[] = []; - for await (const frame of trip.events(frames)) if (frame.type === 'event') events.push(frame.event); - expect(events).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'response.output_item.done', item: expect.objectContaining({ namespace: 'agents', name: 'spawn', arguments: '{}' }) })])); - expect(events.at(-1)).toMatchObject({ type: 'response.completed', response: { output: [expect.objectContaining({ namespace: 'agents', name: 'spawn' }), expect.objectContaining({ type: 'reasoning', summary: [], encrypted_content: expect.any(String) })] } }); -}); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-source/programmatic-tooling_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-source/programmatic-tooling_test.ts index 72fbd4dedb..d901653565 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-source/programmatic-tooling_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-source/programmatic-tooling_test.ts @@ -56,13 +56,20 @@ const payloadCases: Array<{ }, message: 'Programmatic', }, + { + name: 'deferred inventory tool', + payload: { input: [{ type: 'additional_tools', role: 'developer', tools: [{ type: 'function', name: 'lookup', parameters: {}, defer_loading: true }] }] }, + message: 'Deferred', + }, + { + name: 'programmatic inventory caller', + payload: { input: [{ type: 'tool_search_output', call_id: 'search', execution: 'client', tools: [{ type: 'function', name: 'lookup', parameters: {}, allowed_callers: ['programmatic'] }] }] }, + message: 'Programmatic', + }, ]; -const targetPayloadCases = ['OpenAI Chat Completions', 'Anthropic Messages'].flatMap(target => - payloadCases.map(testCase => ({ target, ...testCase }))); - -test.each(targetPayloadCases)('rejectProgrammaticOpenAIResponsesPayload rejects $name for $target', ({ target, payload, message }) => { - expect(() => rejectProgrammaticOpenAIResponsesPayload({ model: 'gpt-test', input: [], ...payload }, target)).toThrow(message); +test.each(payloadCases)('rejectProgrammaticOpenAIResponsesPayload rejects $name', ({ payload, message }) => { + expect(() => rejectProgrammaticOpenAIResponsesPayload({ model: 'gpt-test', input: [], ...payload })).toThrow(message); }); test('rejectProgramCaller accepts ordinary callers', () => { @@ -74,5 +81,5 @@ test('rejectProgrammaticOpenAIResponsesPayload accepts ordinary function tooling model: 'gpt-test', input: [], tools: [{ type: 'function', name: 'lookup', parameters: {}, strict: true }], - }, 'Anthropic Messages')).not.toThrow(); + })).not.toThrow(); }); diff --git a/packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts b/packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts index acb5790ce9..c6473541d7 100644 --- a/packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/as-target/events_test.ts @@ -227,3 +227,65 @@ test.each(toolCases)('invalidates native $type provenance when its done snapshot expect(finalized.targetTurn).toEqual(captured.turn); expect(finalized).not.toHaveProperty('referencedContentHashes'); }); + +for (const { type, field } of toolCases) { + test(`accepts an unchanged ${type} identity across snapshots`, async () => { + const added = { ...toolItem(type, '', 'in_progress'), namespace: 'files' }; + const done = { ...toolItem(type, '{}', 'completed'), namespace: 'files' }; + const captured = await collectProvenance([ + { type: 'response.created', response: response([], 'in_progress') }, + { type: 'response.output_item.added', output_index: 0, item: added }, + { type: 'response.output_item.done', output_index: 0, item: done }, + { type: 'response.completed', response: response([done], 'completed') }, + ], field); + expect(captured.turn[0]).toMatchObject({ type, name: 'lookup', namespace: 'files', call_id: 'call' }); + }); + + test.each([ + ['files', 'web'], + [undefined, 'files'], + ['files', undefined], + ])(`rejects ${type} namespace changing from %s to %s`, async (initial, final) => { + const added = { ...toolItem(type, '', 'in_progress'), ...(initial === undefined ? {} : { namespace: initial }) }; + const done = { ...toolItem(type, '{}', 'completed'), ...(final === undefined ? {} : { namespace: final }) }; + await expect(collectProvenance([ + { type: 'response.created', response: response([], 'in_progress') }, + { type: 'response.output_item.added', output_index: 0, item: added }, + { type: 'response.output_item.done', output_index: 0, item: done }, + { type: 'response.completed', response: response([done], 'completed') }, + ], field)).rejects.toThrow(`namespace changed from ${String(initial)} to ${String(final)}`); + }); + + test(`rejects a ${type} name change in the done snapshot`, async () => { + const added = toolItem(type, '', 'in_progress'); + const done = { ...toolItem(type, '{}', 'completed'), name: 'other' }; + await expect(collectProvenance([ + { type: 'response.created', response: response([], 'in_progress') }, + { type: 'response.output_item.added', output_index: 0, item: added }, + { type: 'response.output_item.done', output_index: 0, item: done }, + { type: 'response.completed', response: response([done], 'completed') }, + ], field)).rejects.toThrow('Responses snapshot conflicts with streamed value at ["choices",0,"items",0,"name"]'); + }); + + test(`rejects a ${type} namespace change after the done snapshot`, async () => { + const added = { ...toolItem(type, '', 'in_progress'), namespace: 'files' }; + const done = { ...toolItem(type, '{}', 'completed'), namespace: 'files' }; + await expect(collectProvenance([ + { type: 'response.created', response: response([], 'in_progress') }, + { type: 'response.output_item.added', output_index: 0, item: added }, + { type: 'response.output_item.done', output_index: 0, item: done }, + { type: 'response.completed', response: response([{ ...done, namespace: 'web' }], 'completed') }, + ], field)).rejects.toThrow('namespace changed from files to web'); + }); + + test(`rejects a ${type} name change after the done snapshot`, async () => { + const added = toolItem(type, '', 'in_progress'); + const done = toolItem(type, '{}', 'completed'); + await expect(collectProvenance([ + { type: 'response.created', response: response([], 'in_progress') }, + { type: 'response.output_item.added', output_index: 0, item: added }, + { type: 'response.output_item.done', output_index: 0, item: done }, + { type: 'response.completed', response: response([{ ...done, name: 'other' }], 'completed') }, + ], field)).rejects.toThrow('Responses terminal snapshot conflicts with closed output item 0'); + }); +} diff --git a/packages/translate/__tests__/protocols/openai-responses/round-trip-request_test.ts b/packages/translate/__tests__/protocols/openai-responses/round-trip-request_test.ts index 7c55acfbc3..db4e68723f 100644 --- a/packages/translate/__tests__/protocols/openai-responses/round-trip-request_test.ts +++ b/packages/translate/__tests__/protocols/openai-responses/round-trip-request_test.ts @@ -1,12 +1,20 @@ import { expect, it } from 'vitest'; +import { readOpenAIResponsesRequest } from '../../../src/protocols/openai-responses/as-source/input-to.ts'; import { computeOpenAIResponsesTurnDigest, createOpenAIResponsesSidecarItem } from '../../../src/protocols/openai-responses/as-source/round-trip.ts'; import type { OpenAIResponsesSourceAssistantTurn } from '../../../src/protocols/openai-responses/as-source/round-trip.ts'; -import { buildTargetRequest as buildMessagesRequest } from '../../../src/protocols/openai-responses/via-anthropic-messages/request.ts'; -import { buildTargetRequest as buildChatRequest } from '../../../src/protocols/openai-responses/via-openai-chat-completions/request.ts'; +import type { SidecarCodec } from '../../../src/round-trip/codec.ts'; import { encodeRoundTripSidecar } from '../../../src/round-trip/sidecar.ts'; +import { buildTargetPayloadFor, pairs } from '../../composition/helpers.ts'; import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; -import type { CanonicalOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; +import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; + +const buildChatRequest = async (payload: OpenAIResponsesRequestPayloadEx, codec: SidecarCodec) => ({ + target: await buildTargetPayloadFor(pairs.responsesToChat[0], pairs.responsesToChat[1], { ...payload, stream: true }, { sidecarCodec: codec }), +}); +const buildMessagesRequest = async (payload: OpenAIResponsesRequestPayloadEx, codec: SidecarCodec, options: { fallbackMaxOutputTokens?: number } = {}) => ({ + target: await buildTargetPayloadFor(pairs.responsesToMessages[0], pairs.responsesToMessages[1], { ...payload, stream: true }, { sidecarCodec: codec, ...options }), +}); const replayedTurn: OpenAIResponsesSourceAssistantTurn = [ { type: 'function_call', call_id: 'native', name: 'run', namespace: 'web', arguments: '{}', status: 'completed' }, @@ -63,13 +71,13 @@ it('keeps restored Messages history native while hoisting system and translating sourceTurnDigest: await computeOpenAIResponsesTurnDigest(replayedTurn), }); const result = await buildMessagesRequest(request(sidecar), codec, { - loadRemoteImage: async () => null, fallbackMaxOutputTokens: undefined, + fallbackMaxOutputTokens: undefined, }); expect(result.target.system).toEqual([{ type: 'text', text: 'instructions', cache_control: { type: 'ephemeral' } }]); expect(result.target.messages).toEqual([ targetTurn, - { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'native', content: 'native result', is_error: undefined }] }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'native', content: 'native result' }] }, { role: 'assistant', content: [{ type: 'tool_use', id: 'bare', name: 'web_run_2', input: {}, cache_control: { type: 'ephemeral' } }] }, ]); }); @@ -91,19 +99,26 @@ it.each(['openaiChatCompletions', 'anthropicMessages'] as const)('bypasses ordin }); const source: CanonicalOpenAIResponsesPayload = { model: 'test', + stream: true, input: [ ...calls, createOpenAIResponsesSidecarItem('carrier', sidecar), { type: 'message', role: 'user', content: 'next' }, { type: 'function_call', namespace: 'x', name: 'y.f', call_id: 'bare', arguments: '{}', status: 'completed' }, ], }; - const result = target === 'openaiChatCompletions' - ? await buildChatRequest(source, codec) - : await buildMessagesRequest(source, codec, { loadRemoteImage: async () => null, fallbackMaxOutputTokens: undefined }); - - expect(result.target.messages[0]).toEqual(native); - expect([...result.namespaceToolNames.sourceToTarget]).toEqual([['x.y.f', 'x_y_f']]); - expect([...result.namespaceToolNames.targetToSource]).toEqual([['x_y_f', { namespace: 'x', name: 'y.f', type: 'function_call' }]]); + const ctx = { sidecarCodec: codec, fallbackMaxOutputTokens: undefined }; + const reading = readOpenAIResponsesRequest(source); + const irCall = (r: typeof reading, callId: string): { namespace?: string; name: string } | undefined => r.turns + .flatMap(turn => turn.toIRInputTurns()) + .flatMap(turn => turn.items as readonly { type: string; call_id?: string; name: string; namespace?: string }[]) + .find(item => (item.type === 'function_call' || item.type === 'custom_tool_call') && item.call_id === callId); + const target_ = target === 'openaiChatCompletions' + ? await buildTargetPayloadFor(pairs.responsesToChat[0], pairs.responsesToChat[1], source, ctx, reading) + : await buildTargetPayloadFor(pairs.responsesToMessages[0], pairs.responsesToMessages[1], source, ctx, reading); + // The reading keeps a grouped call's logical identity; flattening it into the + // single name this target's wire uses is the target's own step. + expect(irCall(reading, 'bare')).toMatchObject({ namespace: 'x', name: 'y.f' }); + expect(target_.messages[0]).toEqual(native); expect(source.input[0]).toEqual(calls[0]); expect(source.input[1]).toEqual(calls[1]); }); @@ -118,6 +133,7 @@ it.each(['openaiChatCompletions', 'anthropicMessages'] as const)('allocates bare }); const source: CanonicalOpenAIResponsesPayload = { model: 'test', + stream: true, input: [ { type: 'function_call', namespace: 'a.b', name: 'run', call_id: 'first', arguments: '{}' }, createOpenAIResponsesSidecarItem('carrier', sidecar), @@ -125,12 +141,21 @@ it.each(['openaiChatCompletions', 'anthropicMessages'] as const)('allocates bare { type: 'function_call', namespace: 'a_b', name: 'run', call_id: 'second', arguments: '{}' }, ], }; - const result = target === 'openaiChatCompletions' - ? await buildChatRequest(source, codec) - : await buildMessagesRequest(source, codec, { loadRemoteImage: async () => null, fallbackMaxOutputTokens: undefined }); - - expect([...result.namespaceToolNames.sourceToTarget]).toEqual([['a.b.run', 'a_b_run'], ['a_b.run', 'a_b_run_2']]); - expect(result.target.messages).toHaveLength(3); + const ctx = { sidecarCodec: codec, fallbackMaxOutputTokens: undefined }; + const reading = readOpenAIResponsesRequest(source); + const target_ = target === 'openaiChatCompletions' + ? await buildTargetPayloadFor(pairs.responsesToChat[0], pairs.responsesToChat[1], source, ctx, reading) + : await buildTargetPayloadFor(pairs.responsesToMessages[0], pairs.responsesToMessages[1], source, ctx, reading); + // Two groups spelling the same flat name must not collide on the wire, and + // which one takes the suffix follows the order the conversation states them. + const callNames = target === 'openaiChatCompletions' + ? (target_.messages as Array<{ tool_calls?: Array<{ function: { name: string } }> }>) + .flatMap(message => message.tool_calls?.map(call => call.function.name) ?? []) + : (target_.messages as Array<{ content: unknown }>).flatMap(message => Array.isArray(message.content) + ? message.content.flatMap(block => (block as { type: string; name?: string }).type === 'tool_use' ? [(block as { name: string }).name] : []) + : []); + expect(callNames).toEqual(['a_b_run', 'a_b_run_2']); + expect(target_.messages).toHaveLength(3); }); it.each(['additional_tools', 'tool_search_output'] as const)('restores assistant turns independently across a user-side %s boundary', async type => { @@ -151,12 +176,12 @@ it.each(['additional_tools', 'tool_search_output'] as const)('restores assistant ? { type, role: 'developer' as const, tools: [{ type: 'function' as const, name: 'lookup' }] } : { type, call_id: 'load', execution: 'client' as const, tools: [{ type: 'function' as const, name: 'lookup' }] }; const source: CanonicalOpenAIResponsesPayload = { - model: 'test', input: [...turns[0], carriers[0], inventory, ...turns[1], carriers[1]], + model: 'test', stream: true, input: [...turns[0], carriers[0], inventory, ...turns[1], carriers[1]], }; const original = structuredClone(source); const result = target === 'openaiChatCompletions' - ? await buildChatRequest(source, codec) - : await buildMessagesRequest(source, codec, { loadRemoteImage: async () => null, fallbackMaxOutputTokens: undefined }); + ? { target: await buildTargetPayloadFor(pairs.responsesToChat[0], pairs.responsesToChat[1], source, { sidecarCodec: codec }) } + : await buildMessagesRequest(source, codec, { fallbackMaxOutputTokens: undefined }); expect(result.target.messages).toEqual(nativeTurns); expect(result.target.tools).toHaveLength(1); diff --git a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events_test.ts b/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events_test.ts deleted file mode 100644 index d8873d7dfa..0000000000 --- a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events_test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { translateToSourceEvents } from '../../../../src/protocols/openai-responses/via-anthropic-messages/events.ts'; -import { emptyNamespaceToolNames } from '../../../round-trip/test-helpers.ts'; -import { testOutputTranslation } from '../../fixtures.ts'; - -testOutputTranslation('openai-responses', 'anthropic-messages', (frames, codec) => translateToSourceEvents(frames, 'resp_test', new Set(), codec, emptyNamespaceToolNames())); diff --git a/packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/events_test.ts b/packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/events_test.ts deleted file mode 100644 index c1ffc06400..0000000000 --- a/packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/events_test.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { translateToSourceEvents } from '../../../../src/protocols/openai-responses/via-openai-chat-completions/events.ts'; -import { emptyNamespaceToolNames } from '../../../round-trip/test-helpers.ts'; -import { testOutputTranslation } from '../../fixtures.ts'; - -testOutputTranslation('openai-responses', 'openai-chat-completions', (frames, codec) => translateToSourceEvents(frames, new Set(), codec, emptyNamespaceToolNames())); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events-protocol_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-events-protocol_test.ts similarity index 61% rename from packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events-protocol_test.ts rename to packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-events-protocol_test.ts index c9bb209a3a..b8344b8338 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/events-protocol_test.ts +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-events-protocol_test.ts @@ -1,10 +1,16 @@ import { test } from 'vitest'; -import { translateToSourceEvents } from '../../../../src/protocols/anthropic-messages/via-openai-chat-completions/events.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; +import type { ProtocolFrame } from '@floway-dev/protocols/common'; import { eventFrame } from '@floway-dev/protocols/common'; import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; +const codec = createTestSidecarCodec(); +const translate = (frames: AsyncIterable>) => buildSourceEventsFor( + pairs.messagesToChat[0], pairs.messagesToChat[1], emptyPayload.messages, { sidecarCodec: codec }, frames, +); + const drain = async (frames: AsyncIterable): Promise => { for await (const _frame of frames) { // Exhaust the stream so async translator errors surface to the caller. @@ -28,5 +34,5 @@ test('translateToSourceEvents accepts OpenAI Chat Completions streams without DO } satisfies OpenAIChatCompletionsStreamEvent); } - await drain(translateToSourceEvents(stream(), createTestSidecarCodec())); + await drain(translate(stream())); }); diff --git a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-events_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-events_test.ts new file mode 100644 index 0000000000..fb9ce47c51 --- /dev/null +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-events_test.ts @@ -0,0 +1,7 @@ +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { testOutputTranslation } from '../fixtures.ts'; + +testOutputTranslation('anthropic-messages', 'openai-chat-completions', (frames, codec) => { + const [source, target] = pairs.messagesToChat; + return buildSourceEventsFor(source, target, emptyPayload.messages, { sidecarCodec: codec }, frames); +}); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/request_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts similarity index 59% rename from packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/request_test.ts rename to packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts index 9b3dd88a60..55cf5ba326 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-chat-completions/request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-chat-completions-request_test.ts @@ -1,15 +1,84 @@ import { test } from 'vitest'; -import { buildTargetRequest as buildTargetRequestWithSidecar } from '../../../../src/protocols/anthropic-messages/via-openai-chat-completions/request.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { TranslateInputException } from '../../../src/shared/input-error.ts'; +import { buildTargetPayloadFor, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; import type { AnthropicMessagesPayload, AnthropicMessagesAssistantContentBlock, AnthropicMessagesUserContentBlock } from '@floway-dev/protocols/anthropic-messages'; import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsFunctionTool } from '@floway-dev/protocols/openai-chat-completions'; import { assertEquals, assertFalse, assertRejects } from '@floway-dev/test-utils'; -const buildWithTestSidecar = (payload: Parameters[0]) => - buildTargetRequestWithSidecar(payload, createTestSidecarCodec()); +const buildWithTestSidecar = (payload: AnthropicMessagesPayload) => + buildTargetPayloadFor(pairs.messagesToChat[0], pairs.messagesToChat[1], { ...payload, stream: true }, { sidecarCodec: createTestSidecarCodec() }); -test('buildTargetRequest maps thinking.disabled to reasoning_effort none', async () => { +test('unmodeled structured tool-result blocks remain individually opaque', async () => { + const content = [{ type: 'browser_state', tabs: [] }]; + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [ + { role: 'assistant', content: [{ type: 'tool_use', id: 'c', name: 'browse', input: {} }] }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'c', content: content as never }] }, + ], + }); + assertEquals(result.messages.at(-1), { role: 'tool', tool_call_id: 'c', content: JSON.stringify(content[0]) }); +}); + +test('content-backed document tool output is lifted with its document tags', async () => { + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [ + { role: 'assistant', content: [{ type: 'tool_use', id: 'c', name: 'read', input: {} }] }, + { + role: 'user', + content: [{ + type: 'tool_result', tool_use_id: 'c', content: [{ + type: 'document', + source: { + type: 'content', + content: [ + { type: 'text', text: 'caption' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } }, + ], + }, + }], + }], + }, + ], + }); + assertEquals(result.messages.at(-2), { role: 'tool', tool_call_id: 'c', content: 'Document output is attached in the following user message.' }); + assertEquals(result.messages.at(-1)?.content, [ + { type: 'text', text: 'Document output from tool call c:' }, + { type: 'text', text: '' }, + { type: 'text', text: 'caption' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } }, + { type: 'text', text: '' }, + ]); +}); + +test('lifts a tool-result document with metadata while dropping unsupported cache and citation controls', async () => { + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [ + { role: 'assistant', content: [{ type: 'tool_use', id: 'c', name: 'read', input: {} }] }, + { + role: 'user', content: [{ + type: 'tool_result', tool_use_id: 'c', content: [{ + type: 'document', title: 'Report <1>', context: 'A & B', + citations: { enabled: true }, cache_control: { type: 'ephemeral', ttl: '1h' }, + source: { type: 'base64', media_type: 'application/pdf', data: 'AAAA' }, + }], + }], + }, + ], + }); + assertEquals(result.messages.at(-2), { role: 'tool', tool_call_id: 'c', content: 'Document output is attached in the following user message.' }); + assertEquals(result.messages.at(-1), { + role: 'user', content: [ + { type: 'text', text: 'Document output from tool call c:' }, + { type: 'text', text: '\nReport <1>\nA & B' }, + { type: 'file', file: { file_data: 'data:application/pdf;base64,AAAA' } }, + { type: 'text', text: '' }, + ], + }); +}); + +test('buildTargetPayload maps thinking.disabled to reasoning_effort none', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -20,7 +89,7 @@ test('buildTargetRequest maps thinking.disabled to reasoning_effort none', async assertEquals(result.reasoning_effort, 'none'); }); -test('buildTargetRequest prefers output_config.effort over thinking.disabled', async () => { +test('buildTargetPayload prefers output_config.effort over thinking.disabled', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -32,7 +101,7 @@ test('buildTargetRequest prefers output_config.effort over thinking.disabled', a assertEquals(result.reasoning_effort, 'high'); }); -test('buildTargetRequest forwards empty output_config.effort verbatim', async () => { +test('buildTargetPayload forwards empty output_config.effort verbatim', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -44,8 +113,8 @@ test('buildTargetRequest forwards empty output_config.effort verbatim', async () assertEquals(result.reasoning_effort, ''); }); -test('buildTargetRequest maps thinking.enabled to reasoning_effort medium regardless of budget_tokens', async () => { - for (const budget of [1024, 16384]) { +test('buildTargetPayload projects thinking.enabled budgets onto reasoning effort', async () => { + for (const [budget, effort] of [[1024, 'low'], [16384, 'high']] as const) { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 4096, @@ -53,11 +122,11 @@ test('buildTargetRequest maps thinking.enabled to reasoning_effort medium regard messages: [{ role: 'user', content: 'hi' }], }); - assertEquals(result.reasoning_effort, 'medium'); + assertEquals(result.reasoning_effort, effort); } }); -test('buildTargetRequest maps thinking.adaptive to reasoning_effort medium', async () => { +test('buildTargetPayload maps thinking.adaptive to reasoning_effort medium', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 4096, @@ -68,7 +137,7 @@ test('buildTargetRequest maps thinking.adaptive to reasoning_effort medium', asy assertEquals(result.reasoning_effort, 'medium'); }); -test('buildTargetRequest prefers output_config.effort over thinking.enabled', async () => { +test('buildTargetPayload prefers output_config.effort over thinking.enabled', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 4096, @@ -80,11 +149,12 @@ test('buildTargetRequest prefers output_config.effort over thinking.enabled', as assertEquals(result.reasoning_effort, 'high'); }); -test('buildTargetRequest keeps tool_result and user text as separate chat messages', async () => { +test('buildTargetPayload keeps tool_result and user text as separate chat messages', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, messages: [ + { role: 'assistant', content: [{ type: 'tool_use', id: 'toolu_1', name: 'lookup', input: {} }] }, { role: 'user', content: [ @@ -96,6 +166,7 @@ test('buildTargetRequest keeps tool_result and user text as separate chat messag }); assertEquals(result.messages, [ + { role: 'assistant', content: null, tool_calls: [{ id: 'toolu_1', type: 'function', function: { name: 'lookup', arguments: '{}' } }] }, { role: 'tool', tool_call_id: 'toolu_1', content: 'result' }, { role: 'user', content: 'Please continue.' }, ]); @@ -104,7 +175,7 @@ test('buildTargetRequest keeps tool_result and user text as separate chat messag test.each([ { tools: undefined, name: 'lookup' }, { tools: [{ name: 'other', input_schema: { type: 'object' as const } }], name: 'lookup' }, -])('buildTargetRequest rejects named tool_choice for an undeclared tool', async ({ tools, name }) => { +])('buildTargetPayload rejects named tool_choice for an undeclared tool', async ({ tools, name }) => { await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', @@ -118,7 +189,7 @@ test.each([ ); }); -test('buildTargetRequest drops filtered-native tool_choice and rewrites assistant native web-search history as tool-call history', async () => { +test('buildTargetPayload drops filtered-native tool_choice and rewrites assistant native web-search history as tool-call history', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -177,11 +248,51 @@ test('buildTargetRequest drops filtered-native tool_choice and rewrites assistan ]); }); -test('buildTargetRequest flattens text-block tool_result content but serializes search-result arrays', async () => { +test('buildTargetPayload folds a server tool result into the client turn that follows it', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, messages: [ + { role: 'user', content: 'search please' }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'looking' }, + { type: 'server_tool_use', id: 'st_1', name: 'web_search', input: { query: 'q' } }, + { type: 'web_search_tool_result', tool_use_id: 'st_1', content: [] }, + ], + }, + { role: 'user', content: 'thanks' }, + ], + }); + + // The result the API wrote into the assistant message and the caller's next + // message are one client turn in the IR, so they stay one run of messages on + // the wire instead of being split by an invented boundary. + assertEquals(result.messages, [ + { role: 'user', content: 'search please' }, + { + role: 'assistant', + content: 'looking', + tool_calls: [{ id: 'st_1', type: 'function', function: { name: 'web_search', arguments: '{"query":"q"}' } }], + }, + { role: 'tool', tool_call_id: 'st_1', content: '[]' }, + { role: 'user', content: 'thanks' }, + ]); +}); + +test('buildTargetPayload flattens text-block tool_result content but serializes search-result arrays', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [ + { + role: 'assistant', + content: [ + { type: 'tool_use', id: 'toolu_text', name: 'read_text', input: {} }, + { type: 'tool_use', id: 'toolu_search', name: 'search_docs', input: {} }, + ], + }, { role: 'user', content: [ @@ -208,6 +319,13 @@ test('buildTargetRequest flattens text-block tool_result content but serializes }); assertEquals(result.messages, [ + { + role: 'assistant', content: null, + tool_calls: [ + { id: 'toolu_text', type: 'function', function: { name: 'read_text', arguments: '{}' } }, + { id: 'toolu_search', type: 'function', function: { name: 'search_docs', arguments: '{}' } }, + ], + }, { role: 'tool', tool_call_id: 'toolu_text', content: 'hello' }, { role: 'tool', @@ -217,11 +335,18 @@ test('buildTargetRequest flattens text-block tool_result content but serializes ]); }); -test('buildTargetRequest preserves mixed user/tool_result chronology', async () => { +test('buildTargetPayload preserves mixed user/tool_result chronology', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, messages: [ + { + role: 'assistant', + content: [ + { type: 'tool_use', id: 'toolu_1', name: 'first_lookup', input: {} }, + { type: 'tool_use', id: 'toolu_2', name: 'second_lookup', input: {} }, + ], + }, { role: 'user', content: [ @@ -235,6 +360,13 @@ test('buildTargetRequest preserves mixed user/tool_result chronology', async () }); assertEquals(result.messages, [ + { + role: 'assistant', content: null, + tool_calls: [ + { id: 'toolu_1', type: 'function', function: { name: 'first_lookup', arguments: '{}' } }, + { id: 'toolu_2', type: 'function', function: { name: 'second_lookup', arguments: '{}' } }, + ], + }, { role: 'user', content: 'First question.' }, { role: 'tool', tool_call_id: 'toolu_1', content: 'first' }, { role: 'user', content: 'Follow-up.' }, @@ -242,7 +374,7 @@ test('buildTargetRequest preserves mixed user/tool_result chronology', async () ]); }); -test('buildTargetRequest concatenates readable thinking groups and drops signatures', async () => { +test('buildTargetPayload concatenates readable thinking groups and drops signatures', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -265,7 +397,7 @@ test('buildTargetRequest concatenates readable thinking groups and drops signatu } as OpenAIChatCompletionsAssistantMessageEx); }); -test('buildTargetRequest drops redacted opaque blocks while keeping readable thinking', async () => { +test('buildTargetPayload drops redacted opaque blocks while keeping readable thinking', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -287,13 +419,9 @@ test('buildTargetRequest drops redacted opaque blocks while keeping readable thi } as OpenAIChatCompletionsAssistantMessageEx); }); -// OpenAI strict-mode JSON Schema validators reject {type: 'object'} without a -// `properties` field. Anthropic accepts that shape, so the input_schema must -// be normalized before forwarding. The reverse direction at -// packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/request.ts already -// defaults `parameters` to {type: 'object', properties: {}}. Ref: +// OpenAI strict-mode validators require object schemas to state properties. The target writer supplies missing properties. // https://github.com/caozhiyuan/copilot-api/commit/ad57069826843c5d17d7b0e5ef2f75050128893c -test('buildTargetRequest defaults missing input_schema.properties to {} for object tools', async () => { +test('buildTargetPayload defaults missing input_schema.properties to {} for object tools', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -313,7 +441,7 @@ test('buildTargetRequest defaults missing input_schema.properties to {} for obje ]); }); -test('buildTargetRequest preserves declared input_schema.properties verbatim', async () => { +test('buildTargetPayload preserves declared input_schema.properties verbatim', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -337,7 +465,7 @@ test('buildTargetRequest preserves declared input_schema.properties verbatim', a }); }); -test('buildTargetRequest does not inject properties for non-object input_schema', async () => { +test('buildTargetPayload does not inject properties for non-object input_schema', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -348,7 +476,7 @@ test('buildTargetRequest does not inject properties for non-object input_schema' assertEquals((result.tools?.[0] as OpenAIChatCompletionsFunctionTool).function.parameters, { type: 'string' }); }); -test('buildTargetRequest wraps output_config.format json_schema as response_format with nested json_schema and strict', async () => { +test('buildTargetPayload wraps output_config.format json_schema as response_format with nested json_schema and strict', async () => { const schema = { type: 'object', properties: { test: { type: 'string' } }, @@ -368,7 +496,7 @@ test('buildTargetRequest wraps output_config.format json_schema as response_form }); }); -test('buildTargetRequest omits response_format when output_config has no format', async () => { +test('buildTargetPayload omits response_format when output_config has no format', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -379,7 +507,7 @@ test('buildTargetRequest omits response_format when output_config has no format' assertFalse('response_format' in result); }); -test('buildTargetRequest rejects an unknown assistant content block type', async () => { +test('buildTargetPayload rejects an unknown assistant content block type', async () => { await assertRejects( () => buildWithTestSidecar({ @@ -392,7 +520,7 @@ test('buildTargetRequest rejects an unknown assistant content block type', async ); }); -test('buildTargetRequest rejects an unknown user content block type', async () => { +test('buildTargetPayload rejects an unknown user content block type', async () => { await assertRejects( () => buildWithTestSidecar({ @@ -405,7 +533,7 @@ test('buildTargetRequest rejects an unknown user content block type', async () = ); }); -test('buildTargetRequest emits in-array role:"system" inline as a CC system message', async () => { +test('buildTargetPayload emits in-array role:"system" inline as a CC system message', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -422,7 +550,7 @@ test('buildTargetRequest emits in-array role:"system" inline as a CC system mess assertEquals(result.messages[2].role, 'user'); }); -test('buildTargetRequest preserves in-array system text blocks as separate content parts', async () => { +test('buildTargetPayload preserves in-array system text blocks as separate content parts', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -447,7 +575,7 @@ test('buildTargetRequest preserves in-array system text blocks as separate conte }); }); -test('buildTargetRequest preserves top-level system text blocks as separate content parts', async () => { +test('buildTargetPayload preserves top-level system text blocks as separate content parts', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -469,7 +597,7 @@ test('buildTargetRequest preserves top-level system text blocks as separate cont }); }); -test('buildTargetRequest skips system message when top-level system is empty array', async () => { +test('buildTargetPayload skips system message when top-level system is empty array', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -481,7 +609,7 @@ test('buildTargetRequest skips system message when top-level system is empty arr assertEquals(result.messages[0].role, 'user'); }); -test('buildTargetRequest preserves chronology of multiple in-array system messages', async () => { +test('buildTargetPayload preserves chronology of multiple in-array system messages', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -505,7 +633,7 @@ test('buildTargetRequest preserves chronology of multiple in-array system messag assertEquals(result.messages[5].role, 'user'); }); -test('buildTargetRequest rejects an unknown message role', async () => { +test('buildTargetPayload rejects an unknown message role', async () => { await assertRejects( () => buildWithTestSidecar({ @@ -520,7 +648,7 @@ test('buildTargetRequest rejects an unknown message role', async () => { // ── speed ↔ service_tier bridge ── -test('buildTargetRequest maps speed:fast to service_tier:fast on the outbound OpenAI Chat Completions payload', async () => { +test('buildTargetPayload maps speed:fast to service_tier:fast on the outbound OpenAI Chat Completions payload', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -531,7 +659,7 @@ test('buildTargetRequest maps speed:fast to service_tier:fast on the outbound Op assertEquals(result.service_tier, 'priority'); }); -test('buildTargetRequest omits service_tier when speed is absent', async () => { +test('buildTargetPayload omits service_tier when speed is absent', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -541,7 +669,7 @@ test('buildTargetRequest omits service_tier when speed is absent', async () => { assertFalse('service_tier' in result); }); -test('buildTargetRequest drops speed values other than fast without emitting service_tier', async () => { +test('buildTargetPayload drops speed values other than fast without emitting service_tier', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -552,7 +680,7 @@ test('buildTargetRequest drops speed values other than fast without emitting ser assertFalse('service_tier' in result); }); -test('buildTargetRequest forwards Anthropic service_tier to OpenAI Chat Completions when speed is absent', async () => { +test('buildTargetPayload forwards Anthropic service_tier to OpenAI Chat Completions when speed is absent', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -563,7 +691,7 @@ test('buildTargetRequest forwards Anthropic service_tier to OpenAI Chat Completi assertEquals(result.service_tier, 'auto'); }); -test('buildTargetRequest forwards service_tier:standard_only to OpenAI Chat Completions when speed is absent', async () => { +test('buildTargetPayload forwards service_tier:standard_only to OpenAI Chat Completions when speed is absent', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, @@ -574,13 +702,83 @@ test('buildTargetRequest forwards service_tier:standard_only to OpenAI Chat Comp assertEquals(result.service_tier, 'standard_only'); }); -test('buildTargetRequest asks upstream for continuous streaming usage stats', async () => { +test('buildTargetPayload asks upstream for continuous streaming usage stats', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', max_tokens: 256, messages: [{ role: 'user', content: 'hi' }], + stream: true, }); assertEquals(result.stream, true); assertEquals(result.stream_options, { include_usage: true, continuous_usage_stats: true }); }); + +test('rejects a metadata value the neutral model cannot state as a string pair', async () => { + // Our input extension accepts arbitrary metadata values; the reader omits nulls and rejects non-string values. + await assertRejects( + () => buildWithTestSidecar({ + model: 'gpt-test', + stream: true, + max_tokens: 16, + messages: [{ role: 'user', content: 'hi' }], + metadata: { user_id: 'u', nested: { depth: 1 } } as never, + }), + TranslateInputException, + 'nested', + ); +}); + +test('buildTargetPayload marks a plain-text document around its text', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [{ + role: 'user', + content: [{ type: 'document', source: { type: 'text', media_type: 'text/plain', data: 'hello world' } }], + }], + }); + const part = result.messages[0].content; + if (!Array.isArray(part)) throw new Error('expected a content array'); + assertEquals(part, [ + { type: 'text', text: '' }, + { type: 'text', text: 'hello world' }, + { type: 'text', text: '' }, + ]); +}); + +test('buildTargetPayload keeps mixed document content inside provenance tags', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', max_tokens: 256, messages: [ + { + role: 'user', + content: [{ + type: 'document', + source: { + type: 'content', + content: [ + { type: 'text', text: 'caption' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } }, + ], + }, + }], + }, + ], + }); + assertEquals(result.messages[0].content, [ + { type: 'text', text: '' }, + { type: 'text', text: 'caption' }, + { type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } }, + { type: 'text', text: '' }, + ]); +}); + +test.each([true, false])('maps disable_parallel_tool_use %s without changing declarations', async disabled => { + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [], + tools: [{ name: 'f', input_schema: { type: 'object', properties: {} } }], + tool_choice: { type: 'auto', disable_parallel_tool_use: disabled }, + }); + assertEquals(result.parallel_tool_calls, !disabled); + assertEquals(result.tools?.length, 1); +}); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-events-protocol_test.ts similarity index 86% rename from packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts rename to packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-events-protocol_test.ts index d2295824ac..2e3d140ce3 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events-protocol_test.ts +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-events-protocol_test.ts @@ -1,13 +1,18 @@ import { test } from 'vitest'; -import { translateToSourceEvents } from '../../../../src/protocols/anthropic-messages/via-openai-responses/events.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; -import { structuredResponsesFixture } from '../../openai-responses/fixtures.ts'; +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; +import { structuredResponsesFixture } from '../openai-responses/fixtures.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { openaiResponsesResultToEvents, type OpenAIResponsesResultEx, type OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; +const codec = createTestSidecarCodec(); +const translate = (frames: AsyncIterable>) => buildSourceEventsFor( + pairs.messagesToResponses[0], pairs.messagesToResponses[1], emptyPayload.messages, { sidecarCodec: codec }, frames, +); + const makeResponse = (status: OpenAIResponsesResultEx['status']): OpenAIResponsesResultEx => ({ id: 'resp_123', object: 'response', @@ -48,7 +53,7 @@ test('translateToSourceEvents emits structured Anthropic Messages events from th const frames = []; - for await (const frame of translateToSourceEvents(structuredResponsesFixture(stream()), createTestSidecarCodec())) { + for await (const frame of translate(structuredResponsesFixture(stream()))) { frames.push(frame); } @@ -80,7 +85,7 @@ test('translateToSourceEvents stops after OpenAI Responses terminal', async () = const frames = []; - for await (const frame of translateToSourceEvents(structuredResponsesFixture(stream()), createTestSidecarCodec())) { + for await (const frame of translate(structuredResponsesFixture(stream()))) { frames.push(frame); } @@ -118,7 +123,7 @@ test('translateToSourceEvents preserves refusal semantics from JSON fallback', a let refusalDelta: Extract | undefined; - for await (const frame of translateToSourceEvents(structuredResponsesFixture(stream()), createTestSidecarCodec())) { + for await (const frame of translate(structuredResponsesFixture(stream()))) { if (frame.type !== 'event') continue; if (frame.event.type === 'message_delta') refusalDelta = frame.event; } @@ -153,7 +158,7 @@ test('translateToSourceEvents translates OpenAI Responses failed terminal to Ant const frames = []; - for await (const frame of translateToSourceEvents(structuredResponsesFixture(stream()), createTestSidecarCodec())) { + for await (const frame of translate(structuredResponsesFixture(stream()))) { frames.push(frame); } @@ -184,7 +189,7 @@ test('translateToSourceEvents translates OpenAI Responses error terminal to Anth const frames = []; - for await (const frame of translateToSourceEvents(structuredResponsesFixture(stream()), createTestSidecarCodec())) { + for await (const frame of translate(structuredResponsesFixture(stream()))) { frames.push(frame); } @@ -212,7 +217,7 @@ test('translateToSourceEvents rejects truncated OpenAI Responses streams without } await assertRejects( - async () => await drain(translateToSourceEvents(structuredResponsesFixture(stream()), createTestSidecarCodec())), + async () => await drain(translate(structuredResponsesFixture(stream()))), Error, 'before the initial response envelope', ); diff --git a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-events_test.ts similarity index 64% rename from packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events_test.ts rename to packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-events_test.ts index 3234ee801f..61ad07c086 100644 --- a/packages/translate/__tests__/protocols/anthropic-messages/via-openai-responses/events_test.ts +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-events_test.ts @@ -1,10 +1,16 @@ import { expect, test } from 'vitest'; -import { translateToSourceEvents } from '../../../../src/protocols/anthropic-messages/via-openai-responses/events.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; -import { testOutputTranslation } from '../../fixtures.ts'; +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; +import { testOutputTranslation } from '../fixtures.ts'; import { parseAnthropicMessagesStream, collectAnthropicMessagesProtocolEventsToResult, anthropicMessagesProtocolFrameToSSEFrame } from '@floway-dev/protocols/anthropic-messages'; import { parseOpenAIResponsesStream } from '@floway-dev/protocols/openai-responses'; +import { routeAnthropicMessagesViaOpenAIResponses } from '@floway-dev/translate'; + +testOutputTranslation('anthropic-messages', 'openai-responses', (frames, codec) => { + const [source, target] = pairs.messagesToResponses; + return buildSourceEventsFor(source, target, emptyPayload.messages, { sidecarCodec: codec }, frames); +}); test('Responses late fixed usage reaches the Messages client through full usage delta', async () => { const response = { id: 'resp_usage', model: 'gpt-test', object: 'response', output: [], error: null, incomplete_details: null }; @@ -13,8 +19,10 @@ test('Responses late fixed usage reaches the Messages client through full usage { type: 'response.completed', response: { ...response, status: 'completed', service_tier: 'priority', usage: { input_tokens: 3, output_tokens: 4, total_tokens: 7 } } }, ]; const sourceSSE = events.map((event, sequence_number) => `event: ${event.type}\ndata: ${JSON.stringify({ ...event, sequence_number })}\n\n`).join(''); + const trip = await routeAnthropicMessagesViaOpenAIResponses({ model: 'gpt-test', max_tokens: 64, stream: true, messages: [] }, { sidecarCodec: createTestSidecarCodec() }); + if (trip.type === 'input-error') throw new Error(trip.message); let clientSSE = ''; - for await (const frame of translateToSourceEvents(parseOpenAIResponsesStream(new Response(sourceSSE).body!), createTestSidecarCodec())) { + for await (const frame of trip.events(parseOpenAIResponsesStream(new Response(sourceSSE).body!))) { const sse = anthropicMessagesProtocolFrameToSSEFrame(frame); if (sse) clientSSE += `event: ${sse.event}\ndata: ${sse.data}\n\n`; } @@ -23,5 +31,3 @@ test('Responses late fixed usage reaches the Messages client through full usage expect(result.usage.input_tokens).toBe(3); expect(result.usage.output_tokens).toBe(4); }); - -testOutputTranslation('anthropic-messages', 'openai-responses', (frames, codec) => translateToSourceEvents(frames, codec)); diff --git a/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts new file mode 100644 index 0000000000..9aa4646a9d --- /dev/null +++ b/packages/translate/__tests__/protocols/pairs/anthropic-messages-to-openai-responses-request_test.ts @@ -0,0 +1,838 @@ +import { test } from 'vitest'; + +import { buildTargetPayloadFor, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; +import type { AnthropicMessagesPayload, AnthropicMessagesAssistantContentBlock, AnthropicMessagesUserContentBlock } from '@floway-dev/protocols/anthropic-messages'; +import type { OpenAIResponsesFunctionTool, OpenAIResponsesInputReasoning } from '@floway-dev/protocols/openai-responses'; +import { assertEquals, assertFalse, assertRejects } from '@floway-dev/test-utils'; + +const buildWithTestSidecar = (payload: AnthropicMessagesPayload) => + buildTargetPayloadFor(pairs.messagesToResponses[0], pairs.messagesToResponses[1], { ...payload, stream: true }, { sidecarCodec: createTestSidecarCodec() }); + +test('server tool errors become incomplete Responses results', async () => { + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [{ + role: 'assistant', content: [ + { type: 'server_tool_use', id: 's', name: 'web_search', input: {} }, + { type: 'web_search_tool_result', tool_use_id: 's', content: { type: 'web_search_tool_result_error', error_code: 'too_many_requests' } }, + ], + }], + }); + const output = result.input.find(item => item.type === 'function_call_output'); + assertEquals(output?.type === 'function_call_output' ? output.status : undefined, 'incomplete'); +}); + +test('plain-text document tool results keep their document boundary', async () => { + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [ + { role: 'assistant', content: [{ type: 'tool_use', id: 'c', name: 'read', input: {} }] }, + { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'c', content: [{ type: 'document', source: { type: 'text', media_type: 'text/plain', data: 'hello' } }] as never }] }, + ], + }); + const output = result.input.find(item => item.type === 'function_call_output'); + assertEquals(output?.type === 'function_call_output' ? output.output : undefined, [ + { type: 'input_text', text: '' }, + { type: 'input_text', text: 'hello' }, + { type: 'input_text', text: '' }, + ]); +}); + +test('preserves metadata on a document inside a Responses tool result', async () => { + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [ + { role: 'assistant', content: [{ type: 'tool_use', id: 'c', name: 'read', input: {} }] }, + { + role: 'user', content: [{ + type: 'tool_result', tool_use_id: 'c', content: [{ + type: 'document', title: 'Report', context: 'USD', + citations: { enabled: true }, cache_control: { type: 'ephemeral', ttl: '5m' }, + source: { type: 'content', content: [{ type: 'text', text: 'revenue' }] }, + }], + }], + }, + ], + }); + const output = result.input.find(item => item.type === 'function_call_output'); + assertEquals(output?.type === 'function_call_output' ? output.output : undefined, [ + { type: 'input_text', text: '\nReport\nUSD' }, + { type: 'input_text', text: 'revenue' }, + { type: 'input_text', text: '', prompt_cache_breakpoint: { mode: 'explicit' } }, + ]); +}); + +test('mixed tool-result blocks keep documents formal and unknown blocks opaque', async () => { + const unknown = { type: 'tool_reference' as const, tool_name: 'read' }; + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [ + { role: 'assistant', content: [{ type: 'tool_use', id: 'c', name: 'read', input: {} }] }, + { + role: 'user', + content: [{ + type: 'tool_result', tool_use_id: 'c', content: [ + { type: 'document', source: { type: 'content', content: [{ type: 'text', text: 'hel' }, { type: 'text', text: 'lo' }] } }, + unknown, + ], + }], + }, + ], + }); + const output = result.input.find(item => item.type === 'function_call_output'); + assertEquals(output?.type === 'function_call_output' ? output.output : undefined, [ + { type: 'input_text', text: '' }, + { type: 'input_text', text: 'hel' }, + { type: 'input_text', text: 'lo' }, + { type: 'input_text', text: '' }, + { type: 'input_text', text: JSON.stringify(unknown) }, + ]); +}); + +test.each([ + ['string', 'hello', [{ type: 'input_text' as const, text: 'hello' }]], + ['text blocks', [{ type: 'text' as const, text: 'hel' }, { type: 'text' as const, text: 'lo' }], [ + { type: 'input_text' as const, text: 'hel' }, { type: 'input_text' as const, text: 'lo' }, + ]], + ['empty text blocks', [], []], +] as const)('inline content document %s retains its nested parts', async (_name, content, expected) => { + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [{ + role: 'user', content: [{ type: 'document', source: { type: 'content', content: typeof content === 'string' ? content : [...content] } }], + }], + }); + assertEquals(result.input, [{ + type: 'message', role: 'user', content: [ + { type: 'input_text', text: '' }, + ...expected, + { type: 'input_text', text: '' }, + ], + }]); +}); + +test('inline mixed document content retains text and image order inside tags', async () => { + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [ + { + role: 'user', + content: [{ + type: 'document', + source: { + type: 'content', + content: [ + { type: 'text', text: 'caption' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } }, + ], + }, + }], + }, + ], + }); + assertEquals(result.input, [{ + type: 'message', role: 'user', content: [ + { type: 'input_text', text: '' }, + { type: 'input_text', text: 'caption' }, + { type: 'input_image', image_url: 'data:image/png;base64,AAAA' }, + { type: 'input_text', text: '' }, + ], + }]); +}); + +test('projects document metadata and cache breakpoints while dropping unsupported citation controls', async () => { + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [{ + role: 'user', content: [{ + type: 'document', title: 'Report <1>', context: 'A & B', + citations: { enabled: true }, cache_control: { type: 'ephemeral', ttl: '1h' }, + source: { + type: 'content', content: [ + { type: 'text', text: 'caption', cache_control: { type: 'ephemeral', ttl: '5m' } }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' }, cache_control: null }, + ], + }, + }], + }], + }); + assertEquals(result.input, [{ + type: 'message', role: 'user', content: [ + { type: 'input_text', text: '\nReport <1>\nA & B' }, + { type: 'input_text', text: 'caption', prompt_cache_breakpoint: { mode: 'explicit' } }, + { type: 'input_image', image_url: 'data:image/png;base64,AAAA', prompt_cache_breakpoint: null }, + { type: 'input_text', text: '', prompt_cache_breakpoint: { mode: 'explicit' } }, + ], + }]); +}); + +test('keeps metadata around a Responses file without inventing a filename', async () => { + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [{ + role: 'user', content: [{ + type: 'document', title: 'Report', + source: { type: 'base64', media_type: 'application/pdf', data: 'AAAA' }, + }], + }], + }); + assertEquals(result.input, [{ + type: 'message', role: 'user', content: [ + { type: 'input_text', text: '\nReport' }, + { type: 'input_file', file_data: 'data:application/pdf;base64,AAAA' }, + { type: 'input_text', text: '' }, + ], + }]); +}); + +test('keeps search result runs between the text and images around them', async () => { + const first = { type: 'search_result' as const, source: 'https://example.com/1', title: 'First', content: [{ type: 'text' as const, text: 'one' }] }; + const second = { ...first, source: 'https://example.com/2', title: 'Second' }; + const third = { ...first, source: 'https://example.com/3', title: 'Third' }; + const result = await buildWithTestSidecar({ + model: 'model', max_tokens: 32, messages: [ + { role: 'assistant', content: [{ type: 'tool_use', id: 'c', name: 'lookup', input: {} }] }, + { + role: 'user', content: [{ + type: 'tool_result', tool_use_id: 'c', content: [ + { type: 'text', text: 'Before' }, first, second, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AAAA' } }, + third, { type: 'text', text: 'After' }, + ], + }], + }, + ], + }); + assertEquals(result.input[1], { + type: 'function_call_output', call_id: 'c', status: 'completed', output: [ + { type: 'input_text', text: 'Before' }, + { type: 'input_text', text: JSON.stringify([first, second]) }, + { type: 'input_image', image_url: 'data:image/png;base64,AAAA' }, + { type: 'input_text', text: JSON.stringify([third]) }, + { type: 'input_text', text: 'After' }, + ], + }); +}); + +test('buildTargetPayload omits tool_choice when Anthropic Messages omits it', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [{ role: 'user', content: 'hello' }], + tools: [{ name: 'lookup', input_schema: { type: 'object' } }], + }); + + assertFalse('tool_choice' in result); +}); + +test('buildTargetPayload omits tool_choice when Anthropic Messages carries no client tools to apply it to', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [{ role: 'user', content: 'hello' }], + tool_choice: { type: 'any' }, + }); + + assertFalse('tool_choice' in result); +}); + +test.each([ + { tools: undefined, name: 'lookup' }, + { tools: [{ name: 'other', input_schema: { type: 'object' as const } }], name: 'lookup' }, +])('buildTargetPayload rejects named tool_choice for an undeclared tool', async ({ tools, name }) => { + await assertRejects( + () => buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [{ role: 'user', content: 'hello' }], + tools, + tool_choice: { type: 'tool', name }, + }), + Error, + `Anthropic Messages tool_choice names an undeclared tool: ${name}`, + ); +}); + +test('buildTargetPayload drops filtered-native tool_choice and rewrites assistant native web-search history as function-call history', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + tool_choice: { type: 'any' }, + tools: [{ type: 'web_search_20260209', name: 'NativeSearch' }], + messages: [ + { + role: 'assistant', + content: [ + { + caller: { type: 'direct' }, + type: 'server_tool_use', + id: 'st_1', + name: 'web_search', + input: { query: 'React docs' }, + }, + { + type: 'web_search_tool_result', + tool_use_id: 'st_1', + content: [ + { + type: 'web_search_result', + url: 'https://react.dev', + title: 'React', + encrypted_content: 'opaque-payload', + }, + ], + }, + ], + }, + ], + }); + + // A hosted Messages tool has no declaration on this wire, so the request + // offers none and states no selector. + assertEquals(result.tools, undefined); + assertEquals(result.tool_choice, undefined); + assertEquals(result.input, [ + { + type: 'function_call', + call_id: 'st_1', + name: 'web_search', + arguments: '{"query":"React docs"}', + status: 'completed', + }, + { + type: 'function_call_output', + call_id: 'st_1', + output: '[{"type":"web_search_result","url":"https://react.dev","title":"React","encrypted_content":"opaque-payload"}]', + status: 'completed', + }, + ]); +}); + +test('buildTargetPayload pairs each server tool round with its own result', async () => { + const search = (id: string, query: string) => [ + { caller: { type: 'direct' as const }, type: 'server_tool_use' as const, id, name: 'web_search' as const, input: { query } }, + { + type: 'web_search_tool_result' as const, + tool_use_id: id, + content: [{ type: 'web_search_result' as const, url: `https://example.com/${id}`, title: id, encrypted_content: `enc-${id}` }], + }, + ]; + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [ + { + role: 'assistant', + content: [ + { type: 'text', text: 'First search.' }, + ...search('st_1', 'a'), + { type: 'text', text: 'Refining.' }, + ...search('st_2', 'b'), + { type: 'text', text: 'Final answer.' }, + ], + }, + ], + }); + + // Each round is a call and its own result, in the order the source stated + // them: the text that follows a result belongs to the next round, not to the + // end of the message. + assertEquals(result.input, [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'First search.' }] }, + { type: 'function_call', call_id: 'st_1', name: 'web_search', arguments: '{"query":"a"}', status: 'completed' }, + { + type: 'function_call_output', + call_id: 'st_1', + output: '[{"type":"web_search_result","url":"https://example.com/st_1","title":"st_1","encrypted_content":"enc-st_1"}]', + status: 'completed', + }, + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Refining.' }] }, + { type: 'function_call', call_id: 'st_2', name: 'web_search', arguments: '{"query":"b"}', status: 'completed' }, + { + type: 'function_call_output', + call_id: 'st_2', + output: '[{"type":"web_search_result","url":"https://example.com/st_2","title":"st_2","encrypted_content":"enc-st_2"}]', + status: 'completed', + }, + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Final answer.' }] }, + ]); +}); + +test('buildTargetPayload folds a trailing server result into the next client turn', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [ + { + role: 'assistant', + content: [ + { type: 'text', text: 'Searching.' }, + { caller: { type: 'direct' as const }, type: 'server_tool_use' as const, id: 'st_1', name: 'web_search' as const, input: { query: 'a' } }, + { type: 'web_search_tool_result' as const, tool_use_id: 'st_1', content: [] }, + ], + }, + { role: 'user', content: 'Now use those results.' }, + ], + }); + + // The result answers the call, and the client's own message follows it in the + // same turn rather than opening a second one. + assertEquals(result.input, [ + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'Searching.' }] }, + { type: 'function_call', call_id: 'st_1', name: 'web_search', arguments: '{"query":"a"}', status: 'completed' }, + { type: 'function_call_output', call_id: 'st_1', output: '[]', status: 'completed' }, + { type: 'message', role: 'user', content: 'Now use those results.' }, + ]); +}); + +test('buildTargetPayload maps output_config.effort directly to reasoning.effort', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + output_config: { effort: 'xhigh' }, + messages: [{ role: 'user', content: 'hi' }], + }); + + assertEquals(result.reasoning, { effort: 'xhigh' }); + assertFalse('include' in result); +}); + +test('buildTargetPayload forwards empty output_config.effort verbatim', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + output_config: { effort: '' }, + thinking: { type: 'disabled' }, + messages: [{ role: 'user', content: 'hi' }], + }); + + assertEquals(result.reasoning, { effort: '' }); +}); + +test('buildTargetPayload prefers output_config.effort over thinking.disabled', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + output_config: { effort: 'high' }, + thinking: { type: 'disabled' }, + messages: [{ role: 'user', content: 'hi' }], + }); + + assertEquals(result.reasoning, { effort: 'high' }); +}); + +test('buildTargetPayload preserves output_config.effort max at the translation boundary', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + output_config: { effort: 'max' }, + messages: [{ role: 'user', content: 'hi' }], + }); + + assertEquals(result.reasoning, { effort: 'max' }); +}); + +test('buildTargetPayload projects thinking.enabled budgets onto reasoning.effort', async () => { + for (const [budget, effort] of [[1024, 'low'], [16384, 'high']] as const) { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 4096, + thinking: { type: 'enabled', budget_tokens: budget }, + messages: [{ role: 'user', content: 'hi' }], + }); + + assertEquals(result.reasoning, { effort }); + } +}); + +test('buildTargetPayload maps thinking.adaptive to reasoning.effort medium', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 4096, + thinking: { type: 'adaptive' }, + messages: [{ role: 'user', content: 'hi' }], + }); + + assertEquals(result.reasoning, { effort: 'medium' }); +}); + +test('buildTargetPayload never invents reasoning.context from a source thinking block', async () => { + // The Anthropic Messages thinking shape carries no reasoning-context mode, so the + // target reasoning object must expose effort only — never a synthesized + // `all_turns` (or any other) context value. + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 4096, + thinking: { type: 'enabled', budget_tokens: 8192 }, + messages: [{ role: 'user', content: 'hi' }], + }); + + assertEquals(result.reasoning, { effort: 'medium' }); + assertEquals(result.reasoning?.context, undefined); + assertFalse('context' in (result.reasoning ?? {})); +}); + +test('buildTargetPayload preserves max_tokens at the translation boundary', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [{ role: 'user', content: 'hi' }], + }); + + assertEquals(result.max_output_tokens, 256); +}); + +test('buildTargetPayload maps thinking.disabled to reasoning.effort none', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + thinking: { type: 'disabled' }, + messages: [{ role: 'user', content: 'hi' }], + }); + + assertEquals(result.reasoning, { effort: 'none' }); + assertFalse('include' in result); +}); + +test('buildTargetPayload preserves explicit temperature and omits translated-path defaults', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + temperature: 0.2, + messages: [{ role: 'user', content: 'hi' }], + }); + + assertEquals(result.temperature, 0.2); + assertFalse('store' in result); + assertFalse('parallel_tool_calls' in result); +}); + +test('buildTargetPayload omits temperature when the source omitted it', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [{ role: 'user', content: 'hi' }], + }); + + assertFalse('temperature' in result); +}); + +test('buildTargetPayload writes multi-block top-level system through Responses instructions', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + system: [ + { type: 'text', text: 'Alpha' }, + { type: 'text', text: 'Beta' }, + ], + messages: [{ role: 'user', content: 'hi' }], + }); + + assertEquals(result.instructions, 'AlphaBeta'); + assertEquals(result.input, [{ type: 'message', role: 'user', content: 'hi' }]); +}); + +test('buildTargetPayload keeps a single-block top-level system in canonical `instructions` slot', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + system: [{ type: 'text', text: 'You are helpful.' }], + messages: [{ role: 'user', content: 'hi' }], + }); + + assertEquals(result.instructions, 'You are helpful.'); + const input = result.input as Array<{ type: string; role?: string }>; + assertEquals(input[0].role, 'user'); +}); + +test('buildTargetPayload preserves text-only thinking input', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [ + { + role: 'assistant', + content: [{ signature: '', type: 'thinking', thinking: 'trace' }], + }, + ], + }); + + if (!Array.isArray(result.input)) throw new Error('expected input array'); + const reasoning = result.input[0] as OpenAIResponsesInputReasoning; + assertEquals(reasoning, { + type: 'reasoning', + summary: [{ type: 'summary_text', text: 'trace' }], + }); +}); + +// OpenAI strict-mode JSON Schema validators reject {type: 'object'} without a +// `properties` field. Anthropic accepts that shape, so the input_schema must +// be normalized before forwarding to OpenAI Responses. Ref: +// https://github.com/caozhiyuan/copilot-api/commit/ad57069826843c5d17d7b0e5ef2f75050128893c +test('buildTargetPayload defaults missing input_schema.properties to {} for object tools', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + tools: [{ name: 'no_args', input_schema: { type: 'object' } }], + messages: [{ role: 'user', content: 'hi' }], + }); + + assertEquals(result.tools, [ + { + type: 'function', + name: 'no_args', + parameters: { type: 'object', properties: {} }, + strict: false, + }, + ]); +}); + +test('buildTargetPayload preserves declared input_schema.properties verbatim', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + tools: [ + { + name: 'with_args', + input_schema: { + type: 'object', + properties: { q: { type: 'string' } }, + required: ['q'], + }, + }, + ], + messages: [{ role: 'user', content: 'hi' }], + }); + + const tool = result.tools?.[0] as OpenAIResponsesFunctionTool; + assertEquals(tool.parameters, { + type: 'object', + properties: { q: { type: 'string' } }, + required: ['q'], + }); +}); + +test('buildTargetPayload does not inject properties for non-object input_schema', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + tools: [{ name: 'scalar', input_schema: { type: 'string' } }] as unknown as AnthropicMessagesPayload['tools'], + messages: [{ role: 'user', content: 'hi' }], + }); + + const tool = result.tools?.[0] as OpenAIResponsesFunctionTool; + assertEquals(tool.parameters, { type: 'string' }); +}); + +test('buildTargetPayload wraps output_config.format json_schema as text.format with synthesised name and strict', async () => { + const schema = { + type: 'object', + properties: { test: { type: 'string' } }, + required: ['test'], + additionalProperties: false, + }; + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [{ role: 'user', content: 'Hi' }], + output_config: { format: { type: 'json_schema', schema } }, + }); + + assertEquals(result.text, { + format: { type: 'json_schema', name: 'messages_response', strict: true, schema }, + }); +}); + +test('buildTargetPayload omits text when output_config has no format', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [{ role: 'user', content: 'Hi' }], + output_config: { effort: 'high' }, + }); + + assertFalse('text' in result); +}); + +test('buildTargetPayload rejects an unknown assistant content block type', async () => { + await assertRejects( + () => + buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [{ role: 'assistant', content: [{ type: 'audio' } as unknown as AnthropicMessagesAssistantContentBlock] }], + }), + Error, + "messages.0.content.0.type: 'audio' assistant content blocks are not supported", + ); +}); + +test('buildTargetPayload rejects an unknown user content block type', async () => { + await assertRejects( + () => + buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [{ role: 'user', content: [{ type: 'audio' } as unknown as AnthropicMessagesUserContentBlock] }], + }), + Error, + "messages.0.content.0.type: 'audio' content blocks are not supported in user content.", + ); +}); + +test('buildTargetPayload emits in-array role:"system" inline as an OpenAI Responses message input item', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [ + { role: 'user', content: 'hi' }, + { role: 'system', content: 'be terse' }, + { role: 'user', content: 'who are you' }, + ], + }); + + const input = result.input as Array<{ type: string; role?: string; content?: unknown }>; + assertEquals(input.length, 3); + assertEquals(input[0].role, 'user'); + assertEquals(input[1], { type: 'message', role: 'system', content: 'be terse' }); + assertEquals(input[2].role, 'user'); +}); + +test('buildTargetPayload preserves in-array system text blocks as separate input_text parts', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [ + { + role: 'system', + content: [ + { type: 'text', text: 'Para A' }, + { type: 'text', text: 'Para B' }, + ], + }, + { role: 'user', content: 'hi' }, + ], + }); + + const input = result.input as Array<{ type: string; role?: string; content?: unknown }>; + assertEquals(input[0], { + type: 'message', + role: 'system', + content: [ + { type: 'input_text', text: 'Para A' }, + { type: 'input_text', text: 'Para B' }, + ], + }); +}); + +test('buildTargetPayload keeps in-array system messages in place', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [ + { role: 'system', content: 'mid-array A' }, + { role: 'user', content: 'q1' }, + { role: 'assistant', content: 'a1' }, + { role: 'system', content: 'mid-array B' }, + { role: 'user', content: 'q2' }, + ], + }); + + assertEquals(result.instructions, undefined); + const input = result.input as Array<{ type: string; role?: string }>; + assertEquals(input.length, 5); + assertEquals(input[0].role, 'system'); + assertEquals(input[1].role, 'user'); + assertEquals(input[2].role, 'assistant'); + assertEquals(input[3].role, 'system'); + assertEquals(input[4].role, 'user'); +}); + +test('buildTargetPayload rejects an unknown message role', async () => { + await assertRejects( + () => + buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [{ role: 'tool', content: 'oops' } as unknown as { role: 'user'; content: string }], + }), + Error, + "messages.0.role: role 'tool' is not supported", + ); +}); + +test('buildTargetPayload projects an Anthropic thinking budget onto reasoning.effort', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [{ role: 'user', content: 'hi' }], + thinking: { type: 'enabled', budget_tokens: 4096 }, + }); + + assertEquals(result.reasoning, { effort: 'medium' }); +}); + +// ── speed ↔ service_tier bridge ── + +test('buildTargetPayload maps speed:fast to service_tier:fast on the outbound OpenAI Responses payload', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + speed: 'fast', + messages: [{ role: 'user', content: 'hi' }], + }); + + assertEquals(result.service_tier, 'priority'); +}); + +test('buildTargetPayload omits service_tier when speed is absent', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + messages: [{ role: 'user', content: 'hi' }], + }); + + assertFalse('service_tier' in result); +}); + +test('buildTargetPayload drops speed values other than fast without emitting service_tier', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + speed: 'standard', + messages: [{ role: 'user', content: 'hi' }], + }); + + assertFalse('service_tier' in result); +}); + +test('buildTargetPayload forwards Anthropic service_tier to OpenAI Responses when speed is absent', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + service_tier: 'auto', + messages: [{ role: 'user', content: 'hi' }], + }); + + assertEquals(result.service_tier, 'auto'); +}); + +test('buildTargetPayload forwards service_tier:standard_only to OpenAI Responses when speed is absent', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + max_tokens: 256, + service_tier: 'standard_only', + messages: [{ role: 'user', content: 'hi' }], + }); + + assertEquals(result.service_tier, 'standard_only'); +}); + +test('preserves expressible Messages metadata extensions and rejects non-string values', async () => { + const payload = { model: 'm', max_tokens: 16, messages: [], metadata: { user_id: 'u', tenant: 't' } }; + assertEquals((await buildWithTestSidecar(payload)).metadata, { user_id: 'u', tenant: 't' }); + await assertRejects(() => buildWithTestSidecar({ ...payload, metadata: { user_id: 'u', tenant: { nested: 't' } } } as AnthropicMessagesPayload), undefined, "metadata field 'tenant'"); +}); + +test.each([true, false])('maps disable_parallel_tool_use %s without changing declarations', async disabled => { + const result = await buildWithTestSidecar({ + model: 'm', max_tokens: 32, messages: [], + tools: [{ name: 'f', input_schema: { type: 'object', properties: {} } }], + tool_choice: { type: 'auto', disable_parallel_tool_use: disabled }, + }); + assertEquals(result.parallel_tool_calls, !disabled); + assertEquals(result.tools?.length, 1); +}); diff --git a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-events_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-events_test.ts new file mode 100644 index 0000000000..22ba640384 --- /dev/null +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-events_test.ts @@ -0,0 +1,7 @@ +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { testOutputTranslation } from '../fixtures.ts'; + +testOutputTranslation('gemini-generate-content', 'anthropic-messages', (frames, codec) => { + const [source, target] = pairs.geminiToMessages; + return buildSourceEventsFor(source, target, { ...emptyPayload.gemini, generationConfig: { thinkingConfig: { includeThoughts: true } } }, { sidecarCodec: codec }, frames); +}); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/request_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-request_test.ts similarity index 63% rename from packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/request_test.ts rename to packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-request_test.ts index f09797878a..ff4f238db2 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/via-anthropic-messages/request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-anthropic-messages-request_test.ts @@ -1,22 +1,66 @@ import { test } from 'vitest'; -import { buildTargetRequest as buildTargetRequestWithSidecar } from '../../../../src/protocols/gemini-generate-content/via-anthropic-messages/request.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { TranslateInputException } from '../../../src/shared/input-error.ts'; +import { buildTargetPayloadFor, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; import { ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS } from '@floway-dev/protocols/anthropic-messages'; import type { GeminiGenerateContentPart, GeminiGenerateContentContent, GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; -const noOptions: Parameters[3] = { fallbackMaxOutputTokens: undefined }; +const noOptions: { fallbackMaxOutputTokens?: number } = { fallbackMaxOutputTokens: undefined }; const withMaxOutputTokens = (maxOutputTokens: number) => ({ fallbackMaxOutputTokens: maxOutputTokens }); const buildWithTestSidecar = ( - payload: Parameters[0], - model: Parameters[1], - options: Parameters[3], -) => buildTargetRequestWithSidecar(payload, model, createTestSidecarCodec(), options); + payload: GeminiGenerateContentPayload, + model: string, + options: { fallbackMaxOutputTokens?: number }, +) => buildTargetPayloadFor(pairs.geminiToMessages[0], pairs.geminiToMessages[1], { ...payload, model }, { sidecarCodec: createTestSidecarCodec(), ...options }); + +test('a ProtoJSON null function-call args value reaches Messages unchanged', async () => { + const result = await buildWithTestSidecar({ + contents: [{ role: 'model', parts: [{ functionCall: { name: 'f', args: null as never } }] }], + }, 'claude-test', noOptions); + const message = result.messages[0]; + assertEquals(message.role === 'assistant' && Array.isArray(message.content) && message.content[0]?.type === 'tool_use' ? message.content[0].input : undefined, null); +}); -test('buildTargetRequest translates LOW to output_config.effort', async () => { +test('function response media enters Messages tool results and tool results precede user text', async () => { + const result = await buildWithTestSidecar({ + contents: [ + { role: 'model', parts: [{ functionCall: { name: 'fetch', args: {} } }] }, + { + role: 'user', + parts: [ + { text: 'Here is the result' }, + { functionResponse: { name: 'fetch', response: { status: 'ok' }, parts: [{ inlineData: { mimeType: 'image/png', data: 'aGVsbG8=' } }] } }, + ], + }, + ], + }, 'claude-test', noOptions); + const user = result.messages[1]; + assertEquals(user, { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'fetch_call_1', + content: [ + { type: 'text', text: '{"status":"ok"}' }, + { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'aGVsbG8=' } }, + ], + }, + { type: 'text', text: 'Here is the result', cache_control: { type: 'ephemeral' } }, + ], + }); +}); + +test('buildTargetPayload preserves an empty thought as a thinking turn', async () => { + const result = await buildWithTestSidecar({ contents: [{ role: 'model', parts: [{ text: '', thought: true }] }] }, 'claude-test', noOptions); + assertEquals(result.messages, [{ role: 'assistant', content: [{ type: 'thinking', thinking: '', signature: '' }] }]); +}); + +test('buildTargetPayload translates a named thinking level to the effort name', async () => { const request = await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingLevel: 'LOW' } }, @@ -25,7 +69,7 @@ test('buildTargetRequest translates LOW to output_config.effort', async () => { assertEquals(request.output_config, { effort: 'low' }); }); -test('buildTargetRequest forwards an empty thinkingLevel verbatim', async () => { +test('buildTargetPayload forwards an empty thinkingLevel verbatim', async () => { const request = await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingLevel: '' } }, @@ -34,17 +78,19 @@ test('buildTargetRequest forwards an empty thinkingLevel verbatim', async () => assertEquals(request.output_config, { effort: '' }); }); -test('buildTargetRequest preserves native thinkingBudget beside an empty thinkingLevel', async () => { +test('buildTargetPayload preserves native thinkingBudget beside an empty thinkingLevel', async () => { const request = await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: 2048, thinkingLevel: '' } }, }, 'claude-test', noOptions); assertEquals(request.thinking, { type: 'enabled', budget_tokens: 2048 }); + // Messages states a level and a budget on different slots, so an empty level + // travels verbatim beside the budget rather than being replaced by it. assertEquals(request.output_config, { effort: '' }); }); -test('buildTargetRequest maps system, default max tokens, and multimodal user content', async () => { +test('buildTargetPayload maps system, default max tokens, and multimodal user content', async () => { const payload: GeminiGenerateContentPayload = { systemInstruction: { parts: [{ text: 'Be precise.' }, { text: 'Use markdown.' }], @@ -60,7 +106,7 @@ test('buildTargetRequest maps system, default max tokens, and multimodal user co model: 'claude-test', stream: true, max_tokens: ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS, - system: [{ type: 'text', text: 'Be precise.\n\nUse markdown.', cache_control: { type: 'ephemeral' } }], + system: [{ type: 'text', text: 'Be precise.Use markdown.', cache_control: { type: 'ephemeral' } }], messages: [ { role: 'user', @@ -89,20 +135,29 @@ test('buildTargetRequest maps system, default max tokens, and multimodal user co }); }); -test.each(['audio/wav', 'audio/mp3', 'audio/mpeg', 'text/plain'])('buildTargetRequest rejects Gemini inline data MIME type %s for Anthropic Messages', async mimeType => { +test.each(['audio/wav', 'audio/mp3', 'audio/mpeg'])('buildTargetPayload rejects Gemini inline audio MIME type %s for Anthropic Messages', async mimeType => { await assertRejects( () => buildWithTestSidecar({ contents: [{ role: 'user', parts: [{ inlineData: { mimeType, data: 'ZGF0YQ==' } }] }] }, 'claude-test', noOptions), Error, - `Cannot translate Gemini generateContent inlineData MIME type '${mimeType}' to Anthropic Messages.`, + `Cannot translate an audio part of MIME type '${mimeType}' to Anthropic Messages.`, ); }); -test('buildTargetRequest prefers limits.max_output_tokens over the gateway default when payload omits maxOutputTokens', async () => { +test('buildTargetPayload forwards a non-audio inline MIME type from Gemini as a Messages document', async () => { + // GenerateContent states any MIME type inline; the document's media type + // travels verbatim so the selected upstream decides whether it accepts it. + // The target accepts inline bytes, so text data remains base64 instead of + // being decoded under an assumed charset. + const request = await buildWithTestSidecar({ contents: [{ role: 'user', parts: [{ inlineData: { mimeType: 'text/plain', data: 'ZGF0YQ==' } }] }] }, 'claude-test', noOptions); + assertEquals(request.messages, [{ role: 'user', content: [{ type: 'document', source: { type: 'base64', media_type: 'text/plain', data: 'ZGF0YQ==' } }] }]); +}); + +test('buildTargetPayload prefers limits.max_output_tokens over the gateway default when payload omits maxOutputTokens', async () => { const request = await buildWithTestSidecar({ contents: [] }, 'claude-test', withMaxOutputTokens(6144)); assertEquals(request.max_tokens, 6144); }); -test('buildTargetRequest maps generation config and thinking controls', async () => { +test('buildTargetPayload maps generation config and thinking controls', async () => { const payload: GeminiGenerateContentPayload = { contents: [], generationConfig: { @@ -112,7 +167,6 @@ test('buildTargetRequest maps generation config and thinking controls', async () topK: 40, stopSequences: ['END'], thinkingConfig: { - thinkingBudget: 2048, thinkingLevel: 'high', }, }, @@ -127,14 +181,24 @@ test('buildTargetRequest maps generation config and thinking controls', async () top_p: 0.8, top_k: 40, stop_sequences: ['END'], - thinking: { type: 'enabled', budget_tokens: 2048 }, output_config: { effort: 'high' }, }); assertEquals((await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: 0 } } }, 'claude-test', noOptions)).thinking, { type: 'disabled' }); }); -test('buildTargetRequest keeps readable thinking and tool calls while dropping thought signatures', async () => { +test('buildTargetPayload refuses a stated budget beside a stated level', async () => { + await assertRejects( + () => buildWithTestSidecar({ + contents: [], + generationConfig: { thinkingConfig: { thinkingBudget: 2048, thinkingLevel: 'HIGH' } }, + }, 'claude-test', noOptions), + TranslateInputException, + 'thinking_budget and thinking_level are not supported together', + ); +}); + +test('buildTargetPayload keeps readable thinking and tool calls while dropping thought signatures', async () => { const payload: GeminiGenerateContentPayload = { contents: [ { @@ -170,14 +234,9 @@ test('buildTargetRequest keeps readable thinking and tool calls while dropping t name: 'lookup', input: { q: 'docs' }, }, - ], - }, - { - role: 'assistant', - content: [ { type: 'tool_use', - id: 'gemini_call_1_0', + id: 'fallback_call_1', name: 'fallback', input: {}, cache_control: { type: 'ephemeral' }, @@ -187,7 +246,7 @@ test('buildTargetRequest keeps readable thinking and tool calls while dropping t ]); }); -test('buildTargetRequest correlates omitted function response ids in call order', async () => { +test('buildTargetPayload correlates omitted function response ids in call order', async () => { const payload: GeminiGenerateContentPayload = { contents: [ { @@ -219,13 +278,13 @@ test('buildTargetRequest correlates omitted function response ids in call order' content: [ { type: 'tool_use', - id: 'gemini_call_0_0', + id: 'lookup_call_1', name: 'lookup', input: { q: 'first' }, }, { type: 'tool_use', - id: 'gemini_call_0_1', + id: 'lookup_call_2', name: 'lookup', input: { q: 'second' }, }, @@ -236,17 +295,12 @@ test('buildTargetRequest correlates omitted function response ids in call order' content: [ { type: 'tool_result', - tool_use_id: 'gemini_call_0_0', + tool_use_id: 'lookup_call_1', content: '{"answer":"first"}', }, - ], - }, - { - role: 'user', - content: [ { type: 'tool_result', - tool_use_id: 'gemini_call_0_1', + tool_use_id: 'lookup_call_2', content: '{"answer":"second"}', cache_control: { type: 'ephemeral' }, }, @@ -255,7 +309,7 @@ test('buildTargetRequest correlates omitted function response ids in call order' ]); }); -test('buildTargetRequest maps tool declarations and tool choice modes', async () => { +test('buildTargetPayload maps tool declarations and tool choice modes', async () => { const payload: GeminiGenerateContentPayload = { contents: [], tools: [ @@ -297,8 +351,8 @@ test('buildTargetRequest maps tool declarations and tool choice modes', async () type: 'object', properties: { query: { type: 'string' } }, }, - cache_control: { type: 'ephemeral' }, }, + { type: 'custom', name: 'ping', input_schema: { type: 'object', properties: {} }, cache_control: { type: 'ephemeral' } }, ], tool_choice: { type: 'tool', name: 'lookup' }, }); @@ -306,10 +360,11 @@ test('buildTargetRequest maps tool declarations and tool choice modes', async () assertEquals((await buildWithTestSidecar({ contents: [], toolConfig: { functionCallingConfig: { mode: 'NONE' } } }, 'claude-test', noOptions)).tool_choice, { type: 'none' }); assertEquals((await buildWithTestSidecar({ contents: [], toolConfig: { functionCallingConfig: { mode: 'AUTO' } } }, 'claude-test', noOptions)).tool_choice, { type: 'auto' }); assertEquals((await buildWithTestSidecar({ contents: [], toolConfig: { functionCallingConfig: { mode: 'VALIDATED' } } }, 'claude-test', noOptions)).tool_choice, { type: 'auto' }); - assertEquals((await buildWithTestSidecar({ contents: [], toolConfig: { functionCallingConfig: { mode: 'ANY' } } }, 'claude-test', noOptions)).tool_choice, { type: 'any' }); + // Without declarations there is no callable to require, so the target choice is omitted. + assertEquals((await buildWithTestSidecar({ contents: [], toolConfig: { functionCallingConfig: { mode: 'ANY' } } }, 'claude-test', noOptions)).tool_choice, undefined); }); -test('buildTargetRequest filters tools to multiple allowed names for ANY mode', async () => { +test('buildTargetPayload rejects an unrepresentable ANY subset without narrowing declarations', async () => { const payload: GeminiGenerateContentPayload = { contents: [], tools: [ @@ -325,40 +380,25 @@ test('buildTargetRequest filters tools to multiple allowed names for ANY mode', }, }; - assertEquals(await buildWithTestSidecar(payload, 'claude-test', noOptions), { - model: 'claude-test', - stream: true, - messages: [], - max_tokens: ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS, - tools: [ - { - type: 'custom', - name: 'lookup', - input_schema: { type: 'object', properties: {} }, - }, - { - type: 'custom', - name: 'ping', - input_schema: { type: 'object', properties: {} }, - cache_control: { type: 'ephemeral' }, - }, - ], - tool_choice: { type: 'any' }, - }); + await assertRejects( + () => buildWithTestSidecar(payload, 'claude-test', noOptions), + TranslateInputException, + 'cannot express an allowed_tools required subset', + ); }); -test('buildTargetRequest maps dynamic thinking budget to adaptive thinking', async () => { +test('buildTargetPayload maps dynamic thinking budget to adaptive thinking', async () => { assertEquals((await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: -1 } } }, 'claude-test', noOptions)).thinking, { type: 'adaptive' }); }); -test('buildTargetRequest wraps generationConfig.responseSchema as output_config.format', async () => { +test('buildTargetPayload wraps generationConfig.responseSchema as output_config.format', async () => { const schema = { type: 'object', properties: { x: { type: 'string' } }, required: ['x'], additionalProperties: false }; const request = await buildWithTestSidecar({ contents: [], generationConfig: { responseSchema: schema } }, 'claude-test', noOptions); assertEquals(request.output_config, { format: { type: 'json_schema', schema } }); }); -test('buildTargetRequest merges thinking-level effort with responseSchema format on a single output_config', async () => { +test('buildTargetPayload merges thinking-level effort with responseSchema format on a single output_config', async () => { const schema = { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'], additionalProperties: false }; const request = await buildWithTestSidecar( { contents: [], generationConfig: { responseSchema: schema, thinkingConfig: { thinkingLevel: 'high' } } }, @@ -369,7 +409,7 @@ test('buildTargetRequest merges thinking-level effort with responseSchema format assertEquals(request.output_config, { effort: 'high', format: { type: 'json_schema', schema } }); }); -test('buildTargetRequest rejects an unknown content role', async () => { +test('buildTargetPayload rejects an unknown content role', async () => { await assertRejects( () => buildWithTestSidecar( @@ -384,7 +424,7 @@ test('buildTargetRequest rejects an unknown content role', async () => { ); }); -test('buildTargetRequest rejects a part with an unsupported kind in user content', async () => { +test('buildTargetPayload rejects a part with an unsupported kind in user content', async () => { await assertRejects( () => buildWithTestSidecar( @@ -399,7 +439,7 @@ test('buildTargetRequest rejects a part with an unsupported kind in user content ); }); -test('buildTargetRequest rejects a function_call part in user content', async () => { +test('buildTargetPayload rejects a function_call part in user content', async () => { await assertRejects( () => buildWithTestSidecar( @@ -414,7 +454,7 @@ test('buildTargetRequest rejects a function_call part in user content', async () ); }); -test('buildTargetRequest rejects an inline_data part in model content', async () => { +test('buildTargetPayload rejects an inline_data part in model content', async () => { await assertRejects( () => buildWithTestSidecar( @@ -429,7 +469,7 @@ test('buildTargetRequest rejects an inline_data part in model content', async () ); }); -test('buildTargetRequest rejects a part that sets conflicting content fields', async () => { +test('buildTargetPayload rejects a part that sets conflicting content fields', async () => { await assertRejects( () => buildWithTestSidecar( @@ -444,7 +484,7 @@ test('buildTargetRequest rejects a part that sets conflicting content fields', a ); }); -test('buildTargetRequest rejects a part with no recognized content field', async () => { +test('buildTargetPayload rejects a part with no recognized content field', async () => { await assertRejects( () => buildWithTestSidecar( @@ -464,3 +504,17 @@ test.each([undefined, []])('preserves an empty model turn with parts %j', async const result = await buildWithTestSidecar(payload, 'target-model', noOptions); assertEquals((result.messages as Array<{ role?: string; content?: unknown }>).filter(item => item.role === 'assistant'), [{ role: 'assistant', content: '' }]); }); + +test('states an empty argument object for a call that carries no args', async () => { + // `input` is required on a Messages tool_use, and the empty object is how + // every target spells a call that takes no argument object. + const result = await buildWithTestSidecar({ + contents: [ + { role: 'user', parts: [{ text: 'go' }] }, + { role: 'model', parts: [{ functionCall: { name: 'f' } }] }, + { role: 'user', parts: [{ functionResponse: { name: 'f', response: { r: 1 } } }] }, + ], + }, 'target-model', noOptions); + const assistant = (result.messages as Array<{ role?: string; content?: unknown }>).find(item => item.role === 'assistant'); + assertEquals((assistant?.content as Array<{ input?: unknown }>)[0]?.input, {}); +}); diff --git a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-events_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-events_test.ts new file mode 100644 index 0000000000..e6dbf6cfe2 --- /dev/null +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-events_test.ts @@ -0,0 +1,7 @@ +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { testOutputTranslation } from '../fixtures.ts'; + +testOutputTranslation('gemini-generate-content', 'openai-chat-completions', (frames, codec) => { + const [source, target] = pairs.geminiToChat; + return buildSourceEventsFor(source, target, { ...emptyPayload.gemini, generationConfig: { thinkingConfig: { includeThoughts: true } } }, { sidecarCodec: codec }, frames); +}); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/request_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts similarity index 73% rename from packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/request_test.ts rename to packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts index 1ada2bca4e..d37fcc7a9a 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-chat-completions/request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-chat-completions-request_test.ts @@ -1,17 +1,27 @@ import { test } from 'vitest'; -import { buildTargetRequest as buildTargetRequestWithSidecar } from '../../../../src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { buildTargetPayloadFor, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; import type { GeminiGenerateContentPart, GeminiGenerateContentContent, GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIChatCompletionsAssistantMessageEx } from '@floway-dev/protocols/openai-chat-completions'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; const buildWithTestSidecar = ( - payload: Parameters[0], - model: Parameters[1], -) => buildTargetRequestWithSidecar(payload, model, createTestSidecarCodec()); + payload: GeminiGenerateContentPayload, + model: string, +) => buildTargetPayloadFor(pairs.geminiToChat[0], pairs.geminiToChat[1], { ...payload, model }, { sidecarCodec: createTestSidecarCodec() }); -test('buildTargetRequest translates LOW to reasoning_effort', async () => { +test('ProtoJSON null scalar members are treated as unset', async () => { + const request = await buildWithTestSidecar({ + contents: [], + generationConfig: { candidateCount: null as never, presencePenalty: null as never, frequencyPenalty: null as never }, + }, 'gpt-test'); + assertEquals(request.n, undefined); + assertEquals(request.presence_penalty, undefined); + assertEquals(request.frequency_penalty, undefined); +}); + +test('buildTargetPayload translates LOW to reasoning_effort', async () => { const request = await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingLevel: 'LOW' } }, @@ -20,7 +30,7 @@ test('buildTargetRequest translates LOW to reasoning_effort', async () => { assertEquals(request.reasoning_effort, 'low'); }); -test('buildTargetRequest forwards an empty thinkingLevel verbatim', async () => { +test('buildTargetPayload forwards an empty thinkingLevel verbatim', async () => { const request = await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingLevel: '' } }, @@ -29,16 +39,16 @@ test('buildTargetRequest forwards an empty thinkingLevel verbatim', async () => assertEquals(request.reasoning_effort, ''); }); -test('buildTargetRequest gives thinkingBudget precedence over an empty thinkingLevel', async () => { +test('buildTargetPayload preserves an explicit empty thinkingLevel beside a budget', async () => { const request = await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: 2048, thinkingLevel: '' } }, }, 'gpt-test'); - assertEquals(request.reasoning_effort, 'low'); + assertEquals(request.reasoning_effort, ''); }); -test('buildTargetRequest maps system instruction and multimodal user content', async () => { +test('buildTargetPayload maps system instruction and multimodal user content', async () => { const payload: GeminiGenerateContentPayload = { systemInstruction: { parts: [{ text: 'Be precise.' }, { text: 'Use markdown.' }], @@ -54,8 +64,9 @@ test('buildTargetRequest maps system instruction and multimodal user content', a assertEquals(await buildWithTestSidecar(payload, 'gpt-test'), { model: 'gpt-test', stream: true, + stream_options: { include_usage: true, continuous_usage_stats: true }, messages: [ - { role: 'system', content: 'Be precise.\n\nUse markdown.' }, + { role: 'system', content: 'Be precise.Use markdown.' }, { role: 'user', content: [ @@ -74,7 +85,7 @@ test('buildTargetRequest maps system instruction and multimodal user content', a }); }); -test('buildTargetRequest maps Gemini inline audio to Chat Completions input audio', async () => { +test('buildTargetPayload maps Gemini inline audio to Chat Completions input audio', async () => { const request = await buildWithTestSidecar({ contents: [ { @@ -98,15 +109,14 @@ test('buildTargetRequest maps Gemini inline audio to Chat Completions input audi }]); }); -test('buildTargetRequest rejects unsupported Gemini inline data MIME types for Chat Completions', async () => { - await assertRejects( - () => buildWithTestSidecar({ contents: [{ role: 'user', parts: [{ inlineData: { mimeType: 'text/plain', data: 'dGV4dA==' } }] }] }, 'gpt-test'), - Error, - "Cannot translate Gemini generateContent inlineData MIME type 'text/plain' to OpenAI Chat Completions.", - ); +test('buildTargetPayload forwards a non-audio inline MIME type from Gemini as a Chat file part', async () => { + // Chat carries the media type in the data URL, so it travels verbatim and the + // selected upstream decides whether it accepts it. + const request = await buildWithTestSidecar({ contents: [{ role: 'user', parts: [{ inlineData: { mimeType: 'text/plain', data: 'dGV4dA==' } }] }] }, 'gpt-test'); + assertEquals(request.messages, [{ role: 'user', content: [{ type: 'file', file: { file_data: 'data:text/plain;base64,dGV4dA==' } }] }]); }); -test('buildTargetRequest maps function calls, tool responses, and readable reasoning history', async () => { +test('buildTargetPayload maps function calls, tool responses, and readable reasoning history', async () => { const payload: GeminiGenerateContentPayload = { contents: [ { @@ -134,7 +144,7 @@ test('buildTargetRequest maps function calls, tool responses, and readable reaso reasoning_text: 'private trace', tool_calls: [ { - id: 'gemini_call_0_2', + id: 'lookup_call_1', type: 'function', function: { name: 'lookup', arguments: '{"query":"docs"}' }, }, @@ -142,13 +152,13 @@ test('buildTargetRequest maps function calls, tool responses, and readable reaso } as OpenAIChatCompletionsAssistantMessageEx, { role: 'tool', - tool_call_id: 'gemini_call_0_2', + tool_call_id: 'lookup_call_1', content: '{"answer":42}', }, ]); }); -test('buildTargetRequest matches omitted functionResponse ids to same-name calls in call order', async () => { +test('buildTargetPayload matches omitted functionResponse ids to same-name calls in call order', async () => { const payload: GeminiGenerateContentPayload = { contents: [ { @@ -180,12 +190,12 @@ test('buildTargetRequest matches omitted functionResponse ids to same-name calls content: null, tool_calls: [ { - id: 'gemini_call_0_0', + id: 'lookup_call_1', type: 'function', function: { name: 'lookup', arguments: '{"query":"first"}' }, }, { - id: 'gemini_call_0_1', + id: 'lookup_call_2', type: 'function', function: { name: 'lookup', arguments: '{"query":"second"}' }, }, @@ -193,30 +203,30 @@ test('buildTargetRequest matches omitted functionResponse ids to same-name calls }, { role: 'tool', - tool_call_id: 'gemini_call_0_0', + tool_call_id: 'lookup_call_1', content: '{"answer":"first"}', }, { role: 'tool', - tool_call_id: 'gemini_call_0_1', + tool_call_id: 'lookup_call_2', content: '{"answer":"second"}', }, ]); }); -test('buildTargetRequest does not rematch a prior call already answered by explicit id', async () => { +test('buildTargetPayload does not rematch a prior call already answered by explicit id', async () => { const payload: GeminiGenerateContentPayload = { contents: [ { role: 'model', - parts: [{ functionCall: { name: 'lookup', args: { query: 'first' } } }, { functionCall: { name: 'lookup', args: { query: 'second' } } }], + parts: [{ functionCall: { name: 'lookup', args: { query: 'first' } } }, { functionCall: { id: 'call_second', name: 'lookup', args: { query: 'second' } } }], }, { role: 'user', parts: [ { functionResponse: { - id: 'gemini_call_0_1', + id: 'call_second', name: 'lookup', response: { answer: 'second' }, }, @@ -240,12 +250,12 @@ test('buildTargetRequest does not rematch a prior call already answered by expli content: null, tool_calls: [ { - id: 'gemini_call_0_0', + id: 'lookup_call_1', type: 'function', function: { name: 'lookup', arguments: '{"query":"first"}' }, }, { - id: 'gemini_call_0_1', + id: 'call_second', type: 'function', function: { name: 'lookup', arguments: '{"query":"second"}' }, }, @@ -253,18 +263,18 @@ test('buildTargetRequest does not rematch a prior call already answered by expli }, { role: 'tool', - tool_call_id: 'gemini_call_0_1', + tool_call_id: 'call_second', content: '{"answer":"second"}', }, { role: 'tool', - tool_call_id: 'gemini_call_0_0', + tool_call_id: 'lookup_call_1', content: '{"answer":"first"}', }, ]); }); -test('buildTargetRequest maps generation config and reasoning effort', async () => { +test('buildTargetPayload maps generation config and reasoning effort', async () => { const payload: GeminiGenerateContentPayload = { contents: [], generationConfig: { @@ -284,8 +294,10 @@ test('buildTargetRequest maps generation config and reasoning effort', async () assertEquals(await buildWithTestSidecar(payload, 'gpt-test'), { model: 'gpt-test', stream: true, + stream_options: { include_usage: true, continuous_usage_stats: true }, messages: [], max_tokens: 512, + max_completion_tokens: 512, temperature: 0.25, top_p: 0.8, stop: ['END'], @@ -298,7 +310,7 @@ test('buildTargetRequest maps generation config and reasoning effort', async () }); }); -test('buildTargetRequest maps structured output schema and zero thinking budget', async () => { +test('buildTargetPayload maps structured output schema and zero thinking budget', async () => { const schema = { type: 'object', properties: { answer: { type: 'string' } }, @@ -320,6 +332,7 @@ test('buildTargetRequest maps structured output schema and zero thinking budget' { model: 'gpt-test', stream: true, + stream_options: { include_usage: true, continuous_usage_stats: true }, messages: [], reasoning_effort: 'none', response_format: { @@ -330,7 +343,7 @@ test('buildTargetRequest maps structured output schema and zero thinking budget' ); }); -test('buildTargetRequest maps tool declarations and tool choice modes', async () => { +test('buildTargetPayload maps tool declarations and tool choice modes', async () => { const payload: GeminiGenerateContentPayload = { contents: [], tools: [ @@ -361,6 +374,7 @@ test('buildTargetRequest maps tool declarations and tool choice modes', async () assertEquals(await buildWithTestSidecar(payload, 'gpt-test'), { model: 'gpt-test', stream: true, + stream_options: { include_usage: true, continuous_usage_stats: true }, messages: [], tools: [ { @@ -374,8 +388,9 @@ test('buildTargetRequest maps tool declarations and tool choice modes', async () }, }, }, + { type: 'function', function: { name: 'ping' } }, ], - tool_choice: { type: 'function', function: { name: 'lookup' } }, + tool_choice: { type: 'allowed_tools', allowed_tools: { mode: 'required', tools: [{ type: 'function', function: { name: 'lookup' } }] } }, }); assertEquals( @@ -425,7 +440,7 @@ test('buildTargetRequest maps tool declarations and tool choice modes', async () assertEquals((await buildWithTestSidecar({ contents: [], toolConfig: { functionCallingConfig: { mode: 'ANY' } } }, 'gpt-test')).tool_choice, undefined); }); -test('buildTargetRequest filters tools to allowed function names for ANY mode', async () => { +test('buildTargetPayload preserves declarations and restricts callable names for ANY mode', async () => { const result = await buildWithTestSidecar( { contents: [], @@ -459,11 +474,12 @@ test('buildTargetRequest filters tools to allowed function names for ANY mode', type: 'function', function: { name: 'ping' }, }, + { type: 'function', function: { name: 'forbidden' } }, ]); - assertEquals(result.tool_choice, 'required'); + assertEquals(result.tool_choice, { type: 'allowed_tools', allowed_tools: { mode: 'required', tools: [{ type: 'function', function: { name: 'lookup' } }, { type: 'function', function: { name: 'ping' } }] } }); }); -test('buildTargetRequest maps thinking budget thresholds', async () => { +test('buildTargetPayload maps thinking budget thresholds', async () => { assertEquals((await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: 0 } } }, 'gpt-test')).reasoning_effort, 'none'); assertEquals((await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: -1 } } }, 'gpt-test')).reasoning_effort, undefined); assertEquals((await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: 2048 } } }, 'gpt-test')).reasoning_effort, 'low'); @@ -471,7 +487,7 @@ test('buildTargetRequest maps thinking budget thresholds', async () => { assertEquals((await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: 8193 } } }, 'gpt-test')).reasoning_effort, 'high'); }); -test('buildTargetRequest rejects an unknown content role', async () => { +test('buildTargetPayload rejects an unknown content role', async () => { await assertRejects( () => buildWithTestSidecar( @@ -485,7 +501,7 @@ test('buildTargetRequest rejects an unknown content role', async () => { ); }); -test('buildTargetRequest rejects a part with an unsupported kind in user content', async () => { +test('buildTargetPayload rejects a part with an unsupported kind in user content', async () => { await assertRejects( () => buildWithTestSidecar( @@ -499,7 +515,7 @@ test('buildTargetRequest rejects a part with an unsupported kind in user content ); }); -test('buildTargetRequest rejects a function_call part in user content', async () => { +test('buildTargetPayload rejects a function_call part in user content', async () => { await assertRejects( () => buildWithTestSidecar( @@ -513,7 +529,7 @@ test('buildTargetRequest rejects a function_call part in user content', async () ); }); -test('buildTargetRequest rejects a function_response part in model content', async () => { +test('buildTargetPayload rejects a function_response part in model content', async () => { await assertRejects( () => buildWithTestSidecar( @@ -527,7 +543,7 @@ test('buildTargetRequest rejects a function_response part in model content', asy ); }); -test('buildTargetRequest rejects a part that sets conflicting content fields', async () => { +test('buildTargetPayload rejects a part that sets conflicting content fields', async () => { await assertRejects( () => buildWithTestSidecar( @@ -541,7 +557,7 @@ test('buildTargetRequest rejects a part that sets conflicting content fields', a ); }); -test('buildTargetRequest rejects a part with no recognized content field', async () => { +test('buildTargetPayload rejects a part with no recognized content field', async () => { await assertRejects( () => buildWithTestSidecar( @@ -555,7 +571,7 @@ test('buildTargetRequest rejects a part with no recognized content field', async ); }); -test('buildTargetRequest extends reasoning_effort enum to recognize xhigh and max', async () => { +test('buildTargetPayload extends reasoning_effort enum to recognize xhigh and max', async () => { const xhigh = await buildWithTestSidecar( { contents: [{ role: 'user', parts: [{ text: 'hi' }] }], generationConfig: { thinkingConfig: { thinkingLevel: 'xhigh' } } }, 'gpt-test', @@ -574,7 +590,7 @@ test('buildTargetRequest extends reasoning_effort enum to recognize xhigh and ma assertEquals(minimal.reasoning_effort, 'minimal'); }); -test('buildTargetRequest forwards a vendor-specific thinkingLevel verbatim (no enum gate)', async () => { +test('buildTargetPayload forwards a vendor-specific thinkingLevel verbatim (no enum gate)', async () => { const turbo = await buildWithTestSidecar( { contents: [{ role: 'user', parts: [{ text: 'hi' }] }], generationConfig: { thinkingConfig: { thinkingLevel: 'turbo' } } }, 'gpt-test', @@ -582,7 +598,7 @@ test('buildTargetRequest forwards a vendor-specific thinkingLevel verbatim (no e assertEquals(turbo.reasoning_effort, 'turbo'); }); -test('buildTargetRequest preserves an explicitly empty model text turn', async () => { +test('buildTargetPayload preserves an explicitly empty model text turn', async () => { assertEquals((await buildWithTestSidecar({ contents: [ { role: 'user', parts: [{ text: 'first' }] }, @@ -601,3 +617,23 @@ test.each([undefined, []])('preserves an empty model turn with parts %j', async const result = await buildWithTestSidecar(payload, 'target-model'); assertEquals((result.messages as Array<{ role?: string; content?: unknown }>).filter(item => item.role === 'assistant'), [{ role: 'assistant', content: '' }]); }); + +test('VALIDATED restricts calls without changing declarations while leaving the choice free', async () => { + // allowed_function_names applies to ANY and VALIDATED; VALIDATED also permits natural language. + // https://github.com/googleapis/googleapis/blob/bc7e3baa28fbb223fa93782e130260fab8205bfc/google/ai/generativelanguage/v1beta/content.proto + const result = await buildWithTestSidecar({ + contents: [], + tools: [{ functionDeclarations: [{ name: 'allowed' }, { name: 'excluded' }] }], + toolConfig: { functionCallingConfig: { mode: 'VALIDATED', allowedFunctionNames: ['allowed'] } }, + }, 'gpt-test'); + + assertEquals(result.tools?.map(tool => tool.type === 'function' ? tool.function.name : undefined), ['allowed', 'excluded']); + assertEquals(result.tool_choice, { type: 'allowed_tools', allowed_tools: { mode: 'auto', tools: [{ type: 'function', function: { name: 'allowed' } }] } }); +}); + +test('a call with no argument object is written with an empty one', async () => { + const request = await buildWithTestSidecar({ contents: [{ role: 'model', parts: [{ functionCall: { name: 'f' } }] }] }, 'gpt-test'); + + const call = request.messages.flatMap(message => message.role === 'assistant' && message.tool_calls !== undefined ? message.tool_calls : [])[0]; + assertEquals(call?.type === 'function' ? call.function.arguments : undefined, '{}'); +}); diff --git a/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-events_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-events_test.ts new file mode 100644 index 0000000000..24a0eaadbe --- /dev/null +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-events_test.ts @@ -0,0 +1,7 @@ +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { testOutputTranslation } from '../fixtures.ts'; + +testOutputTranslation('gemini-generate-content', 'openai-responses', (frames, codec) => { + const [source, target] = pairs.geminiToResponses; + return buildSourceEventsFor(source, target, { ...emptyPayload.gemini, generationConfig: { thinkingConfig: { includeThoughts: true } } }, { sidecarCodec: codec }, frames); +}); diff --git a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/request_test.ts b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts similarity index 64% rename from packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/request_test.ts rename to packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts index c7beb60e92..24dd7c7f79 100644 --- a/packages/translate/__tests__/protocols/gemini-generate-content/via-openai-responses/request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/gemini-generate-content-to-openai-responses-request_test.ts @@ -1,43 +1,94 @@ import { test } from 'vitest'; -import { buildTargetRequest as buildTargetRequestWithSidecar } from '../../../../src/protocols/gemini-generate-content/via-openai-responses/request.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { buildTargetPayloadFor, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; import type { GeminiGenerateContentPart, GeminiGenerateContentContent, GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; import { assertEquals, assertFalse, assertRejects } from '@floway-dev/test-utils'; const buildWithTestSidecar = ( - payload: Parameters[0], - model: Parameters[1], -) => buildTargetRequestWithSidecar(payload, model, createTestSidecarCodec()); + payload: GeminiGenerateContentPayload, + model: string, +) => buildTargetPayloadFor(pairs.geminiToResponses[0], pairs.geminiToResponses[1], { ...payload, model }, { sidecarCodec: createTestSidecarCodec() }); -test('buildTargetRequest translates LOW to reasoning.effort', async () => { +test('ProtoJSON null configuration members are treated as unset', async () => { + const absentConfig = await buildWithTestSidecar({ contents: [], generationConfig: null as never }, 'gpt-test'); + assertEquals(absentConfig.reasoning, undefined); + + const nullAllowedNames = await buildWithTestSidecar({ + contents: [], tools: [{ functionDeclarations: [{ name: 'f' }] }], + toolConfig: { functionCallingConfig: { mode: 'ANY', allowedFunctionNames: null as never } }, + }, 'gpt-test'); + assertEquals(nullAllowedNames.tool_choice, 'required'); + + const nullBudget = await buildWithTestSidecar({ + contents: [], generationConfig: { thinkingConfig: { thinkingLevel: 'LOW', thinkingBudget: null as never } }, + }, 'gpt-test'); + assertEquals(nullBudget.reasoning, { effort: 'low' }); + + const nullLevel = await buildWithTestSidecar({ + contents: [], generationConfig: { thinkingConfig: { thinkingLevel: null as never, thinkingBudget: 1024 } }, + }, 'gpt-test'); + assertEquals(nullLevel.reasoning, { effort: 'low' }); + + const nullScalars = await buildWithTestSidecar({ + contents: [{ role: null as never, parts: [{ text: 'hello' }] }], + generationConfig: { + candidateCount: null as never, + presencePenalty: null as never, + frequencyPenalty: null as never, + responseSchema: null as never, + }, + tools: [{ functionDeclarations: [{ name: 'lookup', parameters: null as never }] }], + }, 'gpt-test'); + assertEquals(nullScalars.input, [{ type: 'message', role: 'user', content: 'hello' }]); + assertFalse('presence_penalty' in nullScalars); + assertFalse('frequency_penalty' in nullScalars); + assertEquals(nullScalars.text, undefined); + assertEquals(nullScalars.tools?.[0]?.type === 'function' ? nullScalars.tools[0].parameters : undefined, { type: 'object', properties: {} }); +}); + +test('generated Responses call IDs stay within the wire limit', async () => { + const name = 'f'.repeat(64); + const result = await buildWithTestSidecar({ + contents: [ + { role: 'model', parts: [{ functionCall: { name, args: {} } }] }, + { role: 'user', parts: [{ functionResponse: { name, response: { ok: true } } }] }, + ], + }, 'gpt-test'); + const call = result.input.find(item => item.type === 'function_call'); + const output = result.input.find(item => item.type === 'function_call_output'); + assertEquals(call?.type === 'function_call' ? call.call_id.length : undefined, 64); + assertEquals(output?.type === 'function_call_output' ? output.call_id : undefined, call?.type === 'function_call' ? call.call_id : undefined); +}); + +test('buildTargetPayload translates LOW to reasoning.effort', async () => { const request = await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingLevel: 'LOW', includeThoughts: true } }, }, 'gpt-test'); - assertEquals(request.reasoning, { effort: 'low', summary: 'detailed' }); + assertEquals(request.reasoning, { effort: 'low' }); }); -test('buildTargetRequest forwards an empty thinkingLevel verbatim', async () => { +test('buildTargetPayload forwards an empty thinkingLevel verbatim', async () => { const request = await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingLevel: '', includeThoughts: true } }, }, 'gpt-test'); - assertEquals(request.reasoning, { effort: '', summary: 'detailed' }); + assertEquals(request.reasoning, { effort: '' }); }); -test('buildTargetRequest gives thinkingBudget precedence over an empty thinkingLevel', async () => { +test('buildTargetPayload preserves an explicit empty thinkingLevel beside a budget', async () => { const request = await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: 2048, thinkingLevel: '', includeThoughts: true } }, }, 'gpt-test'); - assertEquals(request.reasoning, { effort: 'low', summary: 'detailed' }); + assertEquals(request.reasoning, { effort: '' }); }); -test('buildTargetRequest maps instructions and multimodal user input without defaults', async () => { +test('buildTargetPayload maps instructions and multimodal user input without defaults', async () => { const payload: GeminiGenerateContentPayload = { systemInstruction: { parts: [{ text: 'Be precise.' }, { text: 'Use markdown.' }], @@ -52,7 +103,7 @@ test('buildTargetRequest maps instructions and multimodal user input without def assertEquals(await buildWithTestSidecar(payload, 'gpt-test'), { model: 'gpt-test', stream: true, - instructions: 'Be precise.\n\nUse markdown.', + instructions: 'Be precise.Use markdown.', input: [ { type: 'message', @@ -73,15 +124,22 @@ test('buildTargetRequest maps instructions and multimodal user input without def }); }); -test.each(['audio/wav', 'audio/mp3', 'audio/mpeg', 'text/plain'])('buildTargetRequest rejects Gemini inline data MIME type %s for Responses', async mimeType => { +test.each(['audio/wav', 'audio/mp3', 'audio/mpeg'])('buildTargetPayload rejects Gemini inline audio MIME type %s for Responses', async mimeType => { await assertRejects( () => buildWithTestSidecar({ contents: [{ role: 'user', parts: [{ inlineData: { mimeType, data: 'ZGF0YQ==' } }] }] }, 'gpt-test'), Error, - `Cannot translate Gemini generateContent inlineData MIME type '${mimeType}' to OpenAI Responses.`, + `Cannot translate an audio part of MIME type '${mimeType}' to OpenAI Responses.`, ); }); -test('buildTargetRequest maps assistant reasoning, function calls, and call-order outputs', async () => { +test('buildTargetPayload forwards a non-audio inline MIME type from Gemini as a Responses file', async () => { + // Responses carries the media type in the data URL, so it travels verbatim and + // the selected upstream decides whether it accepts it. + const request = await buildWithTestSidecar({ contents: [{ role: 'user', parts: [{ inlineData: { mimeType: 'text/plain', data: 'ZGF0YQ==' } }] }] }, 'gpt-test'); + assertEquals(request.input, [{ type: 'message', role: 'user', content: [{ type: 'input_file', file_data: 'data:text/plain;base64,ZGF0YQ==' }] }]); +}); + +test('buildTargetPayload maps assistant reasoning, function calls, and call-order outputs', async () => { const payload: GeminiGenerateContentPayload = { contents: [ { @@ -129,7 +187,7 @@ test('buildTargetRequest maps assistant reasoning, function calls, and call-orde }, { type: 'function_call', - call_id: 'gemini_call_0_2', + call_id: 'lookup_call_1', name: 'lookup', arguments: '{"query":"first"}', status: 'completed', @@ -143,7 +201,7 @@ test('buildTargetRequest maps assistant reasoning, function calls, and call-orde }, { type: 'function_call_output', - call_id: 'gemini_call_0_2', + call_id: 'lookup_call_1', output: '{"answer":"first"}', status: 'completed', }, @@ -156,7 +214,7 @@ test('buildTargetRequest maps assistant reasoning, function calls, and call-orde ]); }); -test('buildTargetRequest ignores thought signatures when translating to OpenAI Responses', async () => { +test('buildTargetPayload ignores thought signatures when translating to OpenAI Responses', async () => { const payload: GeminiGenerateContentPayload = { contents: [ { @@ -180,7 +238,7 @@ test('buildTargetRequest ignores thought signatures when translating to OpenAI R }, { type: 'function_call', - call_id: 'gemini_call_0_1', + call_id: 'lookup_call_1', name: 'lookup', arguments: '{}', status: 'completed', @@ -188,7 +246,7 @@ test('buildTargetRequest ignores thought signatures when translating to OpenAI R ]); }); -test('buildTargetRequest maps generation config, JSON schema, and reasoning controls', async () => { +test('buildTargetPayload maps generation config, JSON schema, and reasoning controls', async () => { const schema = { type: 'object', properties: { answer: { type: 'string' } }, @@ -219,13 +277,13 @@ test('buildTargetRequest maps generation config, JSON schema, and reasoning cont name: 'gemini_response', schema, }, }, - reasoning: { effort: 'medium', summary: 'detailed' }, + reasoning: { effort: 'medium' }, }); assertEquals((await buildWithTestSidecar({ contents: [], generationConfig: { responseMimeType: 'application/json' } }, 'gpt-test')).text, { format: { type: 'json_object' } }); }); -test('buildTargetRequest never invents reasoning.context from Gemini generateContent thinking controls', async () => { +test('buildTargetPayload never invents reasoning.context from Gemini generateContent thinking controls', async () => { const result = await buildWithTestSidecar({ contents: [], generationConfig: { @@ -233,12 +291,12 @@ test('buildTargetRequest never invents reasoning.context from Gemini generateCon }, }, 'gpt-test'); - assertEquals(result.reasoning, { effort: 'high', summary: 'detailed' }); + assertEquals(result.reasoning, { effort: 'high' }); assertEquals(result.reasoning?.context, undefined); assertFalse('context' in (result.reasoning ?? {})); }); -test('buildTargetRequest filters tools to allowed function names for ANY mode', async () => { +test('buildTargetPayload preserves declarations and restricts callable names for ANY mode', async () => { const result = await buildWithTestSidecar( { contents: [], @@ -276,11 +334,12 @@ test('buildTargetRequest filters tools to allowed function names for ANY mode', parameters: { type: 'object', properties: {} }, strict: false, }, + { type: 'function', name: 'forbidden', parameters: { type: 'object', properties: {} }, strict: false }, ]); - assertEquals(result.tool_choice, 'required'); + assertEquals(result.tool_choice, { type: 'allowed_tools', mode: 'required', tools: [{ type: 'function', name: 'lookup' }, { type: 'function', name: 'ping' }] }); }); -test('buildTargetRequest maps thinking budget thresholds and zero-budget disable', async () => { +test('buildTargetPayload maps thinking budget thresholds and zero-budget disable', async () => { assertEquals((await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: 2048 } } }, 'gpt-test')).reasoning, { effort: 'low' }); assertEquals((await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: 8192 } } }, 'gpt-test')).reasoning, { effort: 'medium' }); assertEquals((await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: 8193 } } }, 'gpt-test')).reasoning, { effort: 'high' }); @@ -299,7 +358,7 @@ test('buildTargetRequest maps thinking budget thresholds and zero-budget disable assertEquals((await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget: -1 } } }, 'gpt-test')).reasoning, undefined); }); -test('buildTargetRequest maps tool declarations and tool choice modes only when tools exist', async () => { +test('buildTargetPayload maps tool declarations and tool choice modes only when tools exist', async () => { const payload: GeminiGenerateContentPayload = { contents: [], tools: [ @@ -342,8 +401,9 @@ test('buildTargetRequest maps tool declarations and tool choice modes only when }, strict: false, }, + { type: 'function', name: 'ping', parameters: { type: 'object', properties: {} }, strict: false }, ], - tool_choice: { type: 'function', name: 'lookup' }, + tool_choice: { type: 'allowed_tools', mode: 'required', tools: [{ type: 'function', name: 'lookup' }] }, }); assertEquals( @@ -393,7 +453,7 @@ test('buildTargetRequest maps tool declarations and tool choice modes only when assertEquals((await buildWithTestSidecar({ contents: [], toolConfig: { functionCallingConfig: { mode: 'ANY' } } }, 'gpt-test')).tool_choice, undefined); }); -test('buildTargetRequest rejects an unknown content role', async () => { +test('buildTargetPayload rejects an unknown content role', async () => { await assertRejects( () => buildWithTestSidecar( @@ -407,7 +467,7 @@ test('buildTargetRequest rejects an unknown content role', async () => { ); }); -test('buildTargetRequest rejects a part with an unsupported kind in user content', async () => { +test('buildTargetPayload rejects a part with an unsupported kind in user content', async () => { await assertRejects( () => buildWithTestSidecar( @@ -421,7 +481,7 @@ test('buildTargetRequest rejects a part with an unsupported kind in user content ); }); -test('buildTargetRequest rejects a function_call part in user content', async () => { +test('buildTargetPayload rejects a function_call part in user content', async () => { await assertRejects( () => buildWithTestSidecar( @@ -435,7 +495,7 @@ test('buildTargetRequest rejects a function_call part in user content', async () ); }); -test('buildTargetRequest rejects an inline_data part in model content', async () => { +test('buildTargetPayload rejects an inline_data part in model content', async () => { await assertRejects( () => buildWithTestSidecar( @@ -449,7 +509,7 @@ test('buildTargetRequest rejects an inline_data part in model content', async () ); }); -test('buildTargetRequest rejects a part that sets conflicting content fields', async () => { +test('buildTargetPayload rejects a part that sets conflicting content fields', async () => { await assertRejects( () => buildWithTestSidecar( @@ -463,7 +523,7 @@ test('buildTargetRequest rejects a part that sets conflicting content fields', a ); }); -test('buildTargetRequest rejects a part with no recognized content field', async () => { +test('buildTargetPayload rejects a part with no recognized content field', async () => { await assertRejects( () => buildWithTestSidecar( @@ -482,3 +542,24 @@ test.each([undefined, []])('preserves an empty model turn with parts %j', async const result = await buildWithTestSidecar(payload, 'target-model'); assertEquals((result.input as Array<{ role?: string; content?: unknown }>).filter(item => item.role === 'assistant'), [{ type: 'message', role: 'assistant', content: '' }]); }); + +test('a call with no argument object is written with an empty one', async () => { + const request = await buildWithTestSidecar({ contents: [{ role: 'model', parts: [{ functionCall: { name: 'f' } }] }] }, 'gpt-test'); + + const call = request.input.find(item => item.type === 'function_call'); + assertEquals(call?.type === 'function_call' ? call.arguments : undefined, '{}'); +}); + +test('an orphaned tool choice is not stated', async () => { + const request = await buildWithTestSidecar({ + contents: [{ role: 'user', parts: [{ text: 'hi' }] }], + toolConfig: { functionCallingConfig: { mode: 'AUTO' } }, + }, 'gpt-test'); + + assertEquals(request.tool_choice, undefined); +}); + +test.each([undefined, 1024, -1])('preserves includeThoughts without a thinking level for budget %s', async thinkingBudget => { + const request = await buildWithTestSidecar({ contents: [], generationConfig: { thinkingConfig: { thinkingBudget, includeThoughts: true } } }, 'm'); + assertEquals(request.reasoning, thinkingBudget === 1024 ? { effort: 'low' } : undefined); +}); diff --git a/packages/translate/__tests__/protocols/pairs/input-controls-request_test.ts b/packages/translate/__tests__/protocols/pairs/input-controls-request_test.ts new file mode 100644 index 0000000000..70421c8b51 --- /dev/null +++ b/packages/translate/__tests__/protocols/pairs/input-controls-request_test.ts @@ -0,0 +1,255 @@ +import { expect, test } from 'vitest'; + +import { + routeAnthropicMessagesViaOpenAIChatCompletions, + routeAnthropicMessagesViaOpenAIResponses, + routeOpenAIChatCompletionsViaAnthropicMessages, + routeOpenAIChatCompletionsViaOpenAIResponses, + routeOpenAIResponsesViaAnthropicMessages, + routeOpenAIResponsesViaOpenAIChatCompletions, +} from '../../../src/index.ts'; +import { readAnthropicMessagesRequest } from '../../../src/protocols/anthropic-messages/as-source/input-to.ts'; +import { readOpenAIChatCompletionsRequest } from '../../../src/protocols/openai-chat-completions/as-source/input-to.ts'; +import { readOpenAIResponsesRequest } from '../../../src/protocols/openai-responses/as-source/input-to.ts'; +import { buildTargetPayloadFor, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; +import type { OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; +import type { OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; + +const options = { sidecarCodec: createTestSidecarCodec() }; + +test('an explicitly empty Chat assistant content array remains a turn on both targets', async () => { + const source = { model: 'm', stream: true, messages: [{ role: 'assistant' as const, content: [] }] }; + const responses = await routeOpenAIChatCompletionsViaOpenAIResponses(source, options); + if (responses.type !== 'trip') throw new Error(responses.message); + expect(responses.target.input).toEqual([{ type: 'message', role: 'assistant', content: '' }]); + + const messages = await routeOpenAIChatCompletionsViaAnthropicMessages(source, { ...options, fallbackMaxOutputTokens: 16 }); + if (messages.type !== 'trip') throw new Error(messages.message); + expect(messages.target.messages).toEqual([{ role: 'assistant', content: [{ type: 'text', text: '', cache_control: { type: 'ephemeral' } }] }]); +}); + +test.each([ + { combined: 9, responsesCeiling: 9, messagesCeiling: 9 }, + { combined: 0, responsesCeiling: 0, messagesCeiling: 0 }, + { combined: null, responsesCeiling: null, messagesCeiling: 5 }, +])('Chat keeps both output bounds until target projection with combined ceiling $combined', async ({ combined, responsesCeiling, messagesCeiling }) => { + const source = { model: 'm', stream: true, messages: [{ role: 'user' as const, content: 'A' }], max_tokens: 5, max_completion_tokens: combined }; + const { settings } = readOpenAIChatCompletionsRequest(source).request; + expect(settings).toMatchObject({ max_visible_output_tokens: 5, max_output_tokens: combined }); + expect(settings).not.toHaveProperty('max_tokens'); + expect(settings).not.toHaveProperty('max_completion_tokens'); + + const responses = await routeOpenAIChatCompletionsViaOpenAIResponses(source, options); + expect(responses.type).toBe('trip'); + if (responses.type !== 'trip') throw new Error(responses.message); + expect(responses.target.max_output_tokens).toBe(responsesCeiling); + const messages = await routeOpenAIChatCompletionsViaAnthropicMessages(source, options); + expect(messages.type).toBe('trip'); + if (messages.type !== 'trip') throw new Error(messages.message); + expect(messages.target.max_tokens).toBe(messagesCeiling); +}); + +test('nullable token limits and cache controls remain explicit in source IR', async () => { + const source = { + model: 'm', stream: true, messages: [], max_tokens: null, max_completion_tokens: null, + prompt_cache_options: null, prompt_cache_retention: null, user: null, + }; + expect(readOpenAIChatCompletionsRequest(source).request.settings).toMatchObject({ + max_visible_output_tokens: null, max_output_tokens: null, prompt_cache_options: null, prompt_cache_retention: null, user: null, + }); + const responses = await routeOpenAIChatCompletionsViaOpenAIResponses(source, options); + if (responses.type !== 'trip') throw new Error(responses.message); + expect(responses.target.max_output_tokens).toBeNull(); + const chat = await routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'm', stream: true, input: 'hi', max_output_tokens: null }, options); + if (chat.type !== 'trip') throw new Error(chat.message); + expect(chat.target.max_tokens).toBeNull(); + expect(chat.target.max_completion_tokens).toBeNull(); +}); + +test('nullable Chat sampling, storage, parallelism and schema strictness remain explicit', async () => { + const source = { + model: 'm', stream: true, messages: [], temperature: null, top_p: null, store: null, parallel_tool_calls: null as never, + response_format: { type: 'json_schema' as const, json_schema: { name: 'result', schema: {}, strict: null } }, + }; + expect(readOpenAIChatCompletionsRequest(source).request.settings).toMatchObject({ + temperature: null, top_p: null, store: null, parallel_tool_calls: null, + output_format: { type: 'json_schema', name: 'result', schema: {}, strict: null }, + }); + const responses = await routeOpenAIChatCompletionsViaOpenAIResponses(source, options); + if (responses.type !== 'trip') throw new Error(responses.message); + expect(responses.target).toMatchObject({ + temperature: null, top_p: null, store: null, parallel_tool_calls: null, + text: { format: { type: 'json_schema', name: 'result', schema: {}, strict: null } }, + }); +}); + +test('distinct content and scalar assistant refusals both remain in history', async () => { + const result = await routeOpenAIChatCompletionsViaOpenAIResponses({ + model: 'm', stream: true, messages: [{ role: 'assistant', content: [{ type: 'refusal', refusal: 'first' }], refusal: 'second' }], + }, options); + if (result.type !== 'trip') throw new Error(result.message); + expect(result.target.input).toEqual([{ + type: 'message', role: 'assistant', + content: [{ type: 'refusal', refusal: 'first' }, { type: 'refusal', refusal: 'second' }], + }]); +}); + +test.each([ + { max_tokens: 5 }, + { max_tokens: 5, max_completion_tokens: 0 }, +])('Chat token limits %j reach the modern Chat spelling after a Responses hop', async limits => { + const first = await routeOpenAIChatCompletionsViaOpenAIResponses({ model: 'm', stream: true, messages: [], ...limits }, options); + if (first.type !== 'trip') throw new Error(first.message); + const second = await routeOpenAIResponsesViaOpenAIChatCompletions(first.target, options); + if (second.type !== 'trip') throw new Error(second.message); + const expected = limits.max_completion_tokens ?? limits.max_tokens; + expect(second.target.max_tokens).toBe(expected); + expect(second.target.max_completion_tokens).toBe(expected); +}); + +const commonControls = { + prompt_cache_retention: '24h', + prompt_cache_options: { mode: 'explicit', ttl: '30m' }, + user: 'opaque-u', + top_logprobs: 4, + presence_penalty: 0.7, + frequency_penalty: -0.2, +} as const; + +test('Chat controls cross the public Responses route with their independent semantics', async () => { + const result = await routeOpenAIChatCompletionsViaOpenAIResponses({ + model: 'm', stream: true, messages: [{ role: 'user', content: 'hi' }], ...commonControls, logprobs: true, + }, options); + if (result.type !== 'trip') throw new Error(result.message); + expect(result.target).toMatchObject(commonControls); + expect(result.target.include).toEqual(['message.output_text.logprobs']); +}); + +test('Responses controls cross the public Chat route without source-only include fields', async () => { + const result = await routeOpenAIResponsesViaOpenAIChatCompletions({ + model: 'm', stream: true, input: 'hi', ...commonControls, + include: ['reasoning.encrypted_content', 'message.output_text.logprobs', 'file_search_call.results'], + }, options); + if (result.type !== 'trip') throw new Error(result.message); + expect(result.target).toMatchObject({ ...commonControls, logprobs: true }); + expect(result.target).not.toHaveProperty('include'); +}); + +test.each([false, null] as const)('Chat logprobs %j survives source reading and Responses projection', async logprobs => { + const source = { + model: 'm', stream: true, messages: [], logprobs, top_logprobs: null, + prompt_cache_retention: null, prompt_cache_options: {}, user: '', presence_penalty: null, frequency_penalty: 0, + }; + expect(readOpenAIChatCompletionsRequest(source).request.settings).toMatchObject({ + logprobs, top_logprobs: null, prompt_cache_retention: null, prompt_cache_options: {}, user: '', presence_penalty: null, frequency_penalty: 0, + }); + const result = await routeOpenAIChatCompletionsViaOpenAIResponses(source, options); + if (result.type !== 'trip') throw new Error(result.message); + expect(result.target).toMatchObject({ + top_logprobs: null, prompt_cache_retention: null, prompt_cache_options: {}, user: '', presence_penalty: null, frequency_penalty: 0, + }); + expect(result.target.include).toEqual(logprobs === null ? null : []); +}); + +test.each([{ include: [] }, { include: null }])('Responses include $include states no requested logprobs while preserving zero controls', async ({ include }) => { + const result = await routeOpenAIResponsesViaOpenAIChatCompletions({ + model: 'm', stream: true, input: 'hi', include: include === null ? null : [...include], + prompt_cache_retention: '', prompt_cache_options: { mode: '', ttl: '' }, user: '', + top_logprobs: 0, presence_penalty: 0, frequency_penalty: null, + }, options); + if (result.type !== 'trip') throw new Error(result.message); + expect(result.target).toMatchObject({ + logprobs: include === null ? null : false, prompt_cache_retention: '', prompt_cache_options: { mode: '', ttl: '' }, user: '', + top_logprobs: 0, presence_penalty: 0, frequency_penalty: null, + }); +}); + +test('request settings own cache options separately from source and target mutations', async () => { + const source = { + model: 'm', stream: true, messages: [], prompt_cache_options: { mode: 'explicit', ttl: '30m' }, + } satisfies OpenAIChatCompletionsPayload; + const reading = readOpenAIChatCompletionsRequest(source); + source.prompt_cache_options.mode = 'source-mutated'; + expect(reading.request.settings.prompt_cache_options).toEqual({ mode: 'explicit', ttl: '30m' }); + const target = await buildTargetPayloadFor(pairs.chatToResponses[0], pairs.chatToResponses[1], source, options, reading); + expect(target.prompt_cache_options).toEqual({ mode: 'explicit', ttl: '30m' }); + if (target.prompt_cache_options === undefined) throw new Error('Expected cache options.'); + target.prompt_cache_options.mode = 'target-mutated'; + expect(reading.request.settings.prompt_cache_options).toEqual({ mode: 'explicit', ttl: '30m' }); + + const reverseSource = { ...target, prompt_cache_options: { mode: 'explicit', ttl: '30m', prewarm: false } }; + const reverseReading = readOpenAIResponsesRequest(reverseSource); + reverseSource.prompt_cache_options.mode = 'source-mutated'; + const reverseTarget = await buildTargetPayloadFor(pairs.responsesToChat[0], pairs.responsesToChat[1], reverseSource, options, reverseReading); + expect(reverseTarget.prompt_cache_options).toEqual({ mode: 'explicit', ttl: '30m' }); + if (reverseTarget.prompt_cache_options == null) throw new Error('Expected cache options.'); + reverseTarget.prompt_cache_options.mode = 'target-mutated'; + expect(reverseReading.request.settings.prompt_cache_options).toEqual({ mode: 'explicit', ttl: '30m' }); +}); + +test.each(['auto', 'none'] as const)('orphan Messages %s stays in source IR and is omitted by both OpenAI writers', async type => { + const source = { model: 'm', stream: true, max_tokens: 10, messages: [{ role: 'user' as const, content: 'A' }], tool_choice: { type } }; + expect(readAnthropicMessagesRequest(source).request.tool_choice).toBe(type); + for (const route of [routeAnthropicMessagesViaOpenAIChatCompletions, routeAnthropicMessagesViaOpenAIResponses]) { + const result = await route(source, options); + if (result.type !== 'trip') throw new Error(result.message); + expect(result.target).not.toHaveProperty('tools'); + expect(result.target).not.toHaveProperty('tool_choice'); + } +}); + +test('parallel tool control without declarations does not invent a Messages tool choice', async () => { + const result = await routeOpenAIResponsesViaAnthropicMessages({ + model: 'm', stream: true, input: 'hi', tools: [], parallel_tool_calls: false, + }, { ...options, fallbackMaxOutputTokens: 16 }); + if (result.type !== 'trip') throw new Error(result.message); + expect(result.target).not.toHaveProperty('tool_choice'); +}); + +test('tool selection never changes the complete allocated declaration inventory', async () => { + const tools = [ + { type: 'namespace' as const, name: 'x', description: 'group', tools: [{ type: 'function' as const, name: 'f' }, { type: 'custom' as const, name: 'f' }] }, + { type: 'function' as const, name: 'x_f' }, + ]; + const choices: (OpenAIResponsesToolChoice | undefined)[] = [ + undefined, 'auto', 'none', 'required', { type: 'function', namespace: 'x', name: 'f' }, + { type: 'allowed_tools', mode: 'auto', tools: [] }, + { type: 'allowed_tools', mode: 'required', tools: [{ type: 'custom', namespace: 'x', name: 'f' }] }, + ]; + let declarations: unknown; + for (const tool_choice of choices) { + const result = await routeOpenAIResponsesViaOpenAIChatCompletions({ model: 'm', stream: true, input: 'hi', tools, tool_choice }, options); + if (result.type !== 'trip') throw new Error(result.message); + if (tool_choice === undefined) declarations = result.target.tools; + expect(result.target.tools).toEqual(declarations); + expect(result.target.tools).toHaveLength(3); + } +}); + +test.each([[1024, 'low'], [32768, 'high']] as const)('Messages budget %i remains numeric until OpenAI target projection', async (budget_tokens, effort) => { + const source = { model: 'm', stream: true, max_tokens: 60000, thinking: { type: 'enabled' as const, budget_tokens }, messages: [{ role: 'user' as const, content: 'hi' }] }; + const { settings } = readAnthropicMessagesRequest(source).request; + expect(settings.reasoning_budget).toBe(budget_tokens); + expect(settings).not.toHaveProperty('reasoning_effort'); + const chat = await routeAnthropicMessagesViaOpenAIChatCompletions(source, options); + if (chat.type !== 'trip') throw new Error(chat.message); + expect(chat.target.reasoning_effort).toBe(effort); + const responses = await routeAnthropicMessagesViaOpenAIResponses(source, options); + if (responses.type !== 'trip') throw new Error(responses.message); + expect(responses.target.reasoning).toEqual({ effort }); +}); + +test.each(['', 'custom-effort'] as const)('Messages explicit effort %j remains independent of its numeric budget', async effort => { + const source = { + model: 'm', stream: true, max_tokens: 60000, messages: [], + thinking: { type: 'enabled' as const, budget_tokens: 32768 }, output_config: { effort }, + }; + expect(readAnthropicMessagesRequest(source).request.settings).toMatchObject({ reasoning_budget: 32768, reasoning_effort: effort }); + const chat = await routeAnthropicMessagesViaOpenAIChatCompletions(source, options); + if (chat.type !== 'trip') throw new Error(chat.message); + expect(chat.target.reasoning_effort).toBe(effort); + const responses = await routeAnthropicMessagesViaOpenAIResponses(source, options); + if (responses.type !== 'trip') throw new Error(responses.message); + expect(responses.target.reasoning).toEqual({ effort }); +}); diff --git a/packages/translate/__tests__/protocols/pairs/media-values-request_test.ts b/packages/translate/__tests__/protocols/pairs/media-values-request_test.ts new file mode 100644 index 0000000000..096140857e --- /dev/null +++ b/packages/translate/__tests__/protocols/pairs/media-values-request_test.ts @@ -0,0 +1,66 @@ +import { expect, test } from 'vitest'; + +import { + routeOpenAIChatCompletionsViaAnthropicMessages, + routeOpenAIChatCompletionsViaOpenAIResponses, + routeOpenAIResponsesViaOpenAIChatCompletions, +} from '../../../src/index.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; + +const options = { sidecarCodec: createTestSidecarCodec(), fallbackMaxOutputTokens: 16 }; + +test.each([ + 'data:image/png;charset=utf-8;base64,AAAA', + 'data:image/svg+xml,%3Csvg%2F%3E', +])('image URL %s remains exact between OpenAI request protocols', async url => { + const responses = await routeOpenAIChatCompletionsViaOpenAIResponses({ + model: 'm', stream: true, messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url } }] }], + }, options); + if (responses.type !== 'trip') throw new Error(responses.message); + expect(responses.target.input).toEqual([{ type: 'message', role: 'user', content: [{ type: 'input_image', image_url: url }] }]); + + const chat = await routeOpenAIResponsesViaOpenAIChatCompletions({ + model: 'm', stream: true, input: [{ type: 'message', role: 'user', content: [{ type: 'input_image', image_url: url }] }], + }, options); + if (chat.type !== 'trip') throw new Error(chat.message); + expect(chat.target.messages).toEqual([{ role: 'user', content: [{ type: 'image_url', image_url: { url } }] }]); +}); + +test('raw file_data remains exact between OpenAI request protocols', async () => { + const data = 'JVBERi0xLjQ='; + const responses = await routeOpenAIChatCompletionsViaOpenAIResponses({ + model: 'm', stream: true, messages: [{ role: 'user', content: [{ type: 'file', file: { file_data: data } }] }], + }, options); + if (responses.type !== 'trip') throw new Error(responses.message); + expect(responses.target.input).toEqual([{ type: 'message', role: 'user', content: [{ type: 'input_file', file_data: data }] }]); + + const chat = await routeOpenAIResponsesViaOpenAIChatCompletions({ + model: 'm', stream: true, input: [{ type: 'message', role: 'user', content: [{ type: 'input_file', file_data: data }] }], + }, options); + if (chat.type !== 'trip') throw new Error(chat.message); + expect(chat.target.messages).toEqual([{ role: 'user', content: [{ type: 'file', file: { file_data: data } }] }]); +}); + +test('Messages decodes a data URL only at its target boundary', async () => { + const result = await routeOpenAIChatCompletionsViaAnthropicMessages({ + model: 'm', stream: true, messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'data:image/png;charset=utf-8;base64,AAAA' } }] }], + }, options); + if (result.type !== 'trip') throw new Error(result.message); + expect(result.target.messages).toEqual([{ role: 'user', content: [{ type: 'image', source: { type: 'base64', media_type: 'image/png;charset=utf-8', data: 'AAAA' }, cache_control: { type: 'ephemeral' } }] }]); +}); + +test.each([ + ['data:;charset=utf-8,hi', 'text/plain;charset=utf-8', 'aGk='], + ['data:text/plain;base64,SGk%3D', 'text/plain', 'SGk='], +] as const)('Messages decodes target-boundary data URL %s', async (url, mediaType, data) => { + const result = await routeOpenAIChatCompletionsViaAnthropicMessages({ + model: 'm', stream: true, messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url } }] }], + }, options); + if (result.type !== 'trip') throw new Error(result.message); + expect(result.target.messages).toEqual([{ + role: 'user', + content: [{ + type: 'image', source: { type: 'base64', media_type: mediaType, data }, cache_control: { type: 'ephemeral' }, + }], + }]); +}); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events-protocol_test.ts b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-events-protocol_test.ts similarity index 73% rename from packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events-protocol_test.ts rename to packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-events-protocol_test.ts index 5a357962d7..93c9088b96 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/events-protocol_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-events-protocol_test.ts @@ -1,11 +1,16 @@ import { test } from 'vitest'; -import { translateToSourceEvents } from '../../../../src/protocols/openai-chat-completions/via-anthropic-messages/events.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; +const codec = createTestSidecarCodec(); +const translate = (frames: AsyncIterable>) => buildSourceEventsFor( + pairs.chatToMessages[0], pairs.chatToMessages[1], emptyPayload.chat, { sidecarCodec: codec }, frames, +); + const drain = async (frames: AsyncIterable): Promise => { for await (const _frame of frames) { // Exhaust the stream so async translator errors surface to the caller. @@ -24,7 +29,7 @@ test('translateToSourceEvents emits native ChatCompletions errors for Messages f } const frames = []; - for await (const frame of translateToSourceEvents(stream(), {}, createTestSidecarCodec())) frames.push(frame); + for await (const frame of translate(stream())) frames.push(frame); assertEquals(frames, [eventFrame({ error: { type: 'overloaded_error', message: 'upstream overloaded' } })]); }); @@ -46,5 +51,5 @@ test('translateToSourceEvents rejects truncated Anthropic Messages streams witho }); } - await assertRejects(async () => await drain(translateToSourceEvents(stream(), {}, createTestSidecarCodec())), Error, 'Messages stream ended without message_stop'); + await assertRejects(async () => await drain(translate(stream())), Error, 'Messages stream ended without message_stop'); }); diff --git a/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-events_test.ts b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-events_test.ts new file mode 100644 index 0000000000..a7fc9a7f3f --- /dev/null +++ b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-events_test.ts @@ -0,0 +1,7 @@ +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { testOutputTranslation } from '../fixtures.ts'; + +testOutputTranslation('openai-chat-completions', 'anthropic-messages', (frames, codec) => { + const [source, target] = pairs.chatToMessages; + return buildSourceEventsFor(source, target, emptyPayload.chat, { sidecarCodec: codec }, frames); +}); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-request_test.ts similarity index 85% rename from packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/request_test.ts rename to packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-request_test.ts index dc95b357e5..1cf0eedb32 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/via-anthropic-messages/request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-anthropic-messages-request_test.ts @@ -1,8 +1,7 @@ import { test } from 'vitest'; -import { buildTargetRequest as buildTargetRequestWithSidecar } from '../../../../src/protocols/openai-chat-completions/via-anthropic-messages/request.ts'; -import type { RemoteImageLoader } from '../../../../src/types.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { buildTargetPayloadFor, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; import { ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS, type AnthropicMessagesAssistantContentBlock, @@ -13,7 +12,7 @@ import { type AnthropicMessagesToolUseBlock, type AnthropicMessagesUserContentBlock, } from '@floway-dev/protocols/anthropic-messages'; -import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsMessage, OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; +import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsMessage, OpenAIChatCompletionsPayload, OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; import { assertEquals, assertExists, assertFalse, assertRejects } from '@floway-dev/test-utils'; // ── Helpers ── @@ -23,7 +22,7 @@ function mkPayload( messages: OpenAIChatCompletionsPayload['messages']; }, ): OpenAIChatCompletionsPayload { - return { model: 'claude-sonnet-4', ...overrides }; + return { model: 'claude-sonnet-4', stream: true, ...overrides }; } function assistantBlocks(result: AnthropicMessagesPayload, msgIndex = 0): AnthropicMessagesAssistantContentBlock[] { @@ -38,21 +37,16 @@ function userBlocks(result: AnthropicMessagesPayload, msgIndex = 0): AnthropicMe return Array.isArray(msg.content) ? (msg.content as AnthropicMessagesUserContentBlock[]) : [{ type: 'text', text: msg.content as string }]; } -function stubRemoteImageLoader(result: Awaited>): RemoteImageLoader { - return () => Promise.resolve(result); -} - -const testBuildOptions: Parameters[2] = { +const testBuildOptions: { fallbackMaxOutputTokens?: number } = { fallbackMaxOutputTokens: undefined, - loadRemoteImage: async () => null, }; const buildWithTestSidecar = ( - payload: Parameters[0], - options: Partial[2]> = {}, -) => buildTargetRequestWithSidecar(payload, createTestSidecarCodec(), { ...testBuildOptions, ...options }); + payload: OpenAIChatCompletionsPayloadEx, + options: Partial = {}, +) => buildTargetPayloadFor(pairs.chatToMessages[0], pairs.chatToMessages[1], payload, { sidecarCodec: createTestSidecarCodec(), ...testBuildOptions, ...options }); -test('buildTargetRequest preserves scalar and content-part assistant refusals as history text', async () => { +test('buildTargetPayload preserves scalar and content-part assistant refusals as history text', async () => { const result = await buildWithTestSidecar(mkPayload({ messages: [ { role: 'assistant', content: null, refusal: 'Scalar refusal.' }, @@ -70,7 +64,7 @@ test('buildTargetRequest preserves scalar and content-part assistant refusals as // OpenAI renamed priority processing to Fast mode; both spellings ask for the // same lane, so both map onto Anthropic's `speed`. -test.each(['fast', 'priority'])('buildTargetRequest maps service_tier:%s to speed:fast (no service_tier on target)', async serviceTier => { +test.each(['fast', 'priority'])('buildTargetPayload maps service_tier:%s to speed:fast (no service_tier on target)', async serviceTier => { const result = await buildWithTestSidecar( mkPayload({ messages: [{ role: 'user', content: 'hi' }], @@ -82,7 +76,7 @@ test.each(['fast', 'priority'])('buildTargetRequest maps service_tier:%s to spee assertFalse('service_tier' in result); }); -test('buildTargetRequest passes service_tier:auto through as service_tier', async () => { +test('buildTargetPayload passes service_tier:auto through as service_tier', async () => { const result = await buildWithTestSidecar( mkPayload({ messages: [{ role: 'user', content: 'hi' }], @@ -94,7 +88,7 @@ test('buildTargetRequest passes service_tier:auto through as service_tier', asyn assertFalse('speed' in result); }); -test('buildTargetRequest omits both speed and service_tier when service_tier is absent', async () => { +test('buildTargetPayload omits both speed and service_tier when service_tier is absent', async () => { const result = await buildWithTestSidecar( mkPayload({ messages: [{ role: 'user', content: 'hi' }], @@ -616,65 +610,22 @@ test('image_url with data URL → base64 image block', async () => { assertEquals(img.source.data, 'iVBORw0KGgo='); }); -test('image_url with remote image loader → base64 image block', async () => { - const result = await buildWithTestSidecar( - mkPayload({ - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'What is this?' }, - { - type: 'image_url', - image_url: { url: 'https://example.com/image.png' }, - }, - ], - }, +test.each(['http', 'https', 'HTTP', 'HTTPS', 'hTtP', 'hTtPs'])('preserves a remote image URL with %s scheme', async scheme => { + const url = `${scheme}://example.com/image.png`; + const result = await buildWithTestSidecar(mkPayload({ + messages: [{ + role: 'user', + content: [ + { type: 'text', text: 'What is this?' }, + { type: 'image_url', image_url: { url } }, ], - }), - { - loadRemoteImage: stubRemoteImageLoader({ - mediaType: 'image/png', - data: new Uint8Array([1, 2, 3]), - }), - }, - ); - const blocks = userBlocks(result, 0); - assertEquals(blocks.length, 2); - assertEquals(blocks[0].type, 'text'); - assertEquals(blocks[1].type, 'image'); - const img = blocks[1] as { - type: 'image'; - source: { type: string; media_type: string; data: string }; - }; - assertEquals(img.source.type, 'base64'); - assertEquals(img.source.media_type, 'image/png'); - assertEquals(img.source.data, 'AQID'); -}); + }], + })); -test('image_url with remote image loader failure → gracefully skipped', async () => { - const result = await buildWithTestSidecar( - mkPayload({ - messages: [ - { - role: 'user', - content: [ - { type: 'text', text: 'What?' }, - { - type: 'image_url', - image_url: { url: 'https://example.com/nonexistent.png' }, - }, - ], - }, - ], - }), - { - loadRemoteImage: stubRemoteImageLoader(null), - }, - ); + // Preserve remote image URLs; provider-specific fetching belongs at the gateway boundary. const blocks = userBlocks(result, 0); - assertEquals(blocks.length, 1); - assertEquals(blocks[0].type, 'text'); + assertEquals(blocks.length, 2); + assertEquals(blocks[1], { type: 'image', source: { type: 'url', url }, cache_control: { type: 'ephemeral' } }); }); test('image with jpeg media type', async () => { @@ -699,7 +650,7 @@ test('image with jpeg media type', async () => { assertEquals(img.source.media_type, 'image/jpeg'); }); -test('data URL with unsupported media type → skipped', async () => { +test('a data URL is stated with the media type it declares', async () => { const result = await buildWithTestSidecar( mkPayload({ messages: [ @@ -710,27 +661,23 @@ test('data URL with unsupported media type → skipped', async () => { ], }), ); + // Preserve the caller's media type so the selected upstream decides whether it accepts the image. const blocks = userBlocks(result, 0); assertEquals(blocks.length, 1); - assertEquals(blocks[0].type, 'text'); - assertEquals((blocks[0] as AnthropicMessagesTextBlock).text, ''); + assertEquals(blocks[0], { type: 'image', source: { type: 'base64', media_type: 'image/bmp', data: 'Qk0=' }, cache_control: { type: 'ephemeral' } }); }); -test('content with only non-parseable image → empty text fallback', async () => { - const result = await buildWithTestSidecar( - mkPayload({ - messages: [ - { - role: 'user', - content: [{ type: 'image_url', image_url: { url: 'not-a-url-at-all' } }], - }, - ], - }), +test('content with a value that is not a URL is rejected rather than written', async () => { + await assertRejects( + () => buildWithTestSidecar(mkPayload({ + messages: [{ + role: 'user', + content: [{ type: 'image_url', image_url: { url: 'not-a-url-at-all' } }], + }], + })), + Error, + 'requires an http or https URL', ); - const blocks = userBlocks(result, 0); - assertEquals(blocks.length, 1); - assertEquals(blocks[0].type, 'text'); - assertEquals((blocks[0] as AnthropicMessagesTextBlock).text, ''); }); // ── Field mapping ── @@ -822,24 +769,20 @@ test('stop array → stop_sequences array', async () => { assertEquals(result.stop_sequences, ['END', 'STOP']); }); -test('always emits stream: true regardless of source stream flag', async () => { - // Translation assumes streaming upstream (provider forces stream=true); - // source `respond.ts` collects SSE when client wants non-stream. - const streamed = await buildWithTestSidecar( - mkPayload({ - messages: [{ role: 'user', content: 'Hi' }], - stream: true, - }), - ); - assertEquals(streamed.stream, true); +test('states the streaming flag the trip requires', async () => { + const result = await buildWithTestSidecar(mkPayload({ messages: [{ role: 'user', content: 'Hi' }] })); + assertEquals(result.stream, true); +}); - const nonStreamed = await buildWithTestSidecar( - mkPayload({ - messages: [{ role: 'user', content: 'Hi' }], - stream: false, - }), +test('rejects a request the trip cannot carry because it states no stream', () => { + // A trip's answer arrives as a stream, so an IR that says otherwise has no + // translation. The boundary above a trip guarantees streaming, so this is a + // broken contract rather than an unsupported request. + return assertRejects( + () => buildWithTestSidecar(mkPayload({ messages: [{ role: 'user', content: 'Hi' }], stream: false })), + Error, + 'streaming', ); - assertEquals(nonStreamed.stream, true); }); // ── Tool choice mapping ── @@ -1115,7 +1058,7 @@ test('interleaved tool and text turns preserve chronology', async () => { assertEquals(a2[0].type, 'text'); }); -test('buildTargetRequest extracts nested response_format json_schema into output_config.format', async () => { +test('buildTargetPayload extracts nested response_format json_schema into output_config.format', async () => { const schema = { type: 'object', properties: { x: { type: 'string' } }, required: ['x'], additionalProperties: false }; const result = await buildWithTestSidecar( mkPayload({ @@ -1127,7 +1070,7 @@ test('buildTargetRequest extracts nested response_format json_schema into output assertEquals(result.output_config, { format: { type: 'json_schema', schema } }); }); -test('buildTargetRequest merges reasoning_effort with structured-output format on a single output_config', async () => { +test('buildTargetPayload merges reasoning_effort with structured-output format on a single output_config', async () => { const schema = { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'], additionalProperties: false }; const result = await buildWithTestSidecar( mkPayload({ @@ -1140,7 +1083,7 @@ test('buildTargetRequest merges reasoning_effort with structured-output format o assertEquals(result.output_config, { effort: 'high', format: { type: 'json_schema', schema } }); }); -test('buildTargetRequest forwards empty reasoning_effort verbatim', async () => { +test('buildTargetPayload forwards empty reasoning_effort verbatim', async () => { const result = await buildWithTestSidecar( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], @@ -1151,7 +1094,7 @@ test('buildTargetRequest forwards empty reasoning_effort verbatim', async () => assertEquals(result.output_config, { effort: '' }); }); -test('buildTargetRequest drops response_format json_object (no Anthropic equivalent)', async () => { +test('buildTargetPayload drops response_format json_object (no Anthropic equivalent)', async () => { const result = await buildWithTestSidecar( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], @@ -1162,19 +1105,19 @@ test('buildTargetRequest drops response_format json_object (no Anthropic equival assertEquals(result.output_config, undefined); }); -test('buildTargetRequest rejects an unknown message role', async () => { +test('buildTargetPayload rejects an unknown message role', async () => { await assertRejects( () => buildWithTestSidecar({ model: 'claude-test', - messages: [{ role: 'function', content: 'hi' } as unknown as OpenAIChatCompletionsMessage], + messages: [{ role: 'critic', content: 'hi' } as unknown as OpenAIChatCompletionsMessage], }), Error, - "Invalid role 'function'", + "Invalid role 'critic'", ); }); -test('buildTargetRequest rejects an unknown user content part type', async () => { +test('buildTargetPayload rejects an unknown user content part type', async () => { await assertRejects( () => buildWithTestSidecar({ @@ -1188,7 +1131,7 @@ test('buildTargetRequest rejects an unknown user content part type', async () => // ── service_tier forwarding ── -test('buildTargetRequest forwards a non-accelerated service_tier verbatim', async () => { +test('buildTargetPayload forwards a non-accelerated service_tier verbatim', async () => { const result = await buildWithTestSidecar( mkPayload({ messages: [{ role: 'user', content: 'hi' }], @@ -1199,7 +1142,7 @@ test('buildTargetRequest forwards a non-accelerated service_tier verbatim', asyn assertEquals(result.service_tier, 'flex'); }); -test('buildTargetRequest maps reasoning_effort none to thinking.disabled', async () => { +test('buildTargetPayload maps reasoning_effort none to thinking.disabled', async () => { const result = await buildWithTestSidecar( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], @@ -1211,7 +1154,7 @@ test('buildTargetRequest maps reasoning_effort none to thinking.disabled', async assertFalse('output_config' in result); }); -test('buildTargetRequest keeps a structured-output format alongside thinking.disabled', async () => { +test('buildTargetPayload keeps a structured-output format alongside thinking.disabled', async () => { const schema = { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'], additionalProperties: false }; const result = await buildWithTestSidecar( mkPayload({ @@ -1225,7 +1168,7 @@ test('buildTargetRequest keeps a structured-output format alongside thinking.dis assertEquals(result.output_config, { format: { type: 'json_schema', schema } }); }); -test('buildTargetRequest leaves thinking absent when reasoning_effort is not none', async () => { +test('buildTargetPayload leaves thinking absent when reasoning_effort is not none', async () => { const result = await buildWithTestSidecar( mkPayload({ messages: [{ role: 'user', content: 'Hi' }], @@ -1237,12 +1180,46 @@ test('buildTargetRequest leaves thinking absent when reasoning_effort is not non assertEquals(result.output_config, { effort: 'high' }); }); -test.each(['auto', 'required'] as const)('allowed_tools %s restricts native Messages declarations', async mode => { +test('allowed_tools required selects one Messages tool without changing declarations', async () => { const result = await buildWithTestSidecar({ - model: 'claude-test', messages: [{ role: 'user', content: 'hello' }], tools: [ + model: 'claude-test', stream: true, messages: [{ role: 'user', content: 'hello' }], tools: [ { type: 'function', function: { name: 'allowed' } }, { type: 'function', function: { name: 'excluded' } }, - ], tool_choice: { type: 'allowed_tools', allowed_tools: { mode, tools: [{ type: 'function', function: { name: 'allowed' } }] } }, + ], tool_choice: { type: 'allowed_tools', allowed_tools: { mode: 'required', tools: [{ type: 'function', function: { name: 'allowed' } }] } }, + }); + assertEquals(result.tools?.map(tool => 'name' in tool ? tool.name : undefined), ['allowed', 'excluded']); + assertEquals(result.tool_choice, { type: 'tool', name: 'allowed' }); +}); + +test('translates a PDF data URL into a typed Messages document', async () => { + const result = await buildWithTestSidecar({ + model: 'model', stream: true, messages: [{ + role: 'user', content: [{ + type: 'file', file: { filename: 'test.pdf', file_data: 'data:application/pdf;base64,JVBERi0xLjQ=' }, + }], + }], }); - assertEquals(result.tools?.map(tool => 'name' in tool ? tool.name : undefined), ['allowed']); - assertEquals(result.tool_choice, { type: mode === 'required' ? 'any' : 'auto' }); + assertEquals(userBlocks(result)[0], { + type: 'document', source: { type: 'base64', media_type: 'application/pdf', data: 'JVBERi0xLjQ=' }, + }); +}); + +test('translates legacy declarations and forced choice into Messages tooling', async () => { + const result = await buildWithTestSidecar({ + model: 'model', stream: true, messages: [], functions: [{ name: 'lookup', parameters: { type: 'object', properties: {} } }], + function_call: { name: 'lookup' }, + }); + assertEquals(result.tools?.map(tool => 'name' in tool ? tool.name : undefined), ['lookup']); + assertEquals(result.tool_choice, { type: 'tool', name: 'lookup' }); +}); + +test('preserves required tool choice without declarations', async () => { + const result = await buildWithTestSidecar({ model: 'model', stream: true, messages: [], tool_choice: 'required' }); + assertEquals(result.tool_choice, { type: 'any' }); +}); + +test.each([true, false])('maps parallel_tool_calls %s into Messages tool choice', async parallel => { + for (const tool_choice of [undefined, 'auto', 'required', 'none'] as const) { + const result = await buildWithTestSidecar(mkPayload({ messages: [], tools: [{ type: 'function', function: { name: 'f', parameters: { type: 'object', properties: {} } } }], tool_choice, parallel_tool_calls: parallel })); + assertEquals(result.tool_choice, tool_choice === 'none' ? { type: 'none' } : { type: tool_choice === 'required' ? 'any' : 'auto', disable_parallel_tool_use: !parallel }); + } }); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-openai-responses-events_test.ts similarity index 93% rename from packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts rename to packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-openai-responses-events_test.ts index fcdc09e3ba..e27f6815d1 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/events_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-openai-responses-events_test.ts @@ -1,10 +1,11 @@ import { expect, test } from 'vitest'; -import { translateToSourceEvents } from '../../../../src/protocols/openai-chat-completions/via-openai-responses/events.ts'; -import { buildTargetRequest } from '../../../../src/protocols/openai-chat-completions/via-openai-responses/request.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; -import { testOutputTranslation } from '../../fixtures.ts'; -import { structuredResponsesFixture } from '../../openai-responses/fixtures.ts'; +import { openaiChatCompletionsSourceProtocol } from '../../../src/protocols/openai-chat-completions/as-source.ts'; +import { openaiResponsesTargetProtocol } from '../../../src/protocols/openai-responses/as-target.ts'; +import { buildSourceEventsFor, buildTargetPayloadFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; +import { testOutputTranslation } from '../fixtures.ts'; +import { structuredResponsesFixture } from '../openai-responses/fixtures.ts'; import { eventFrame, type ProtocolFrame, type SseFrame, sseFrame } from '@floway-dev/protocols/common'; import { collectOpenAIChatCompletionsProtocolEventsToResult, parseOpenAIChatCompletionsStream, openaiChatCompletionsProtocolFrameToSSEFrame as writeChatFrame, type OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; import { openaiResponsesResultToEvents, parseOpenAIResponsesStream, collectOpenAIResponsesProtocolEventsToResult, type OpenAIResponsesResultEx, type OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; @@ -49,8 +50,10 @@ const toProtocolFrame = (event: OpenAIResponsesStreamEventEx): ProtocolFrame>) => translateToSourceEvents( - structuredResponsesFixture(frames), {}, createTestSidecarCodec(), +const translate = (frames: AsyncIterable>) => buildSourceEventsFor( + pairs.chatToResponses[0], pairs.chatToResponses[1], emptyPayload.chat, + { sidecarCodec: createTestSidecarCodec() }, + structuredResponsesFixture(frames), ); const chatSseFrames = async function* (frames: AsyncIterable>) { @@ -489,9 +492,12 @@ test.each([false, true].flatMap(mixed => [ } const client = await collectOpenAIChatCompletionsProtocolEventsToResult(parseOpenAIChatCompletionsStream(new Response(clientSSE).body!)); expect(client.choices[0].finish_reason).toBe('tool_calls'); - const replay = await buildTargetRequest({ model: response.model, messages: [client.choices[0].message, { role: 'tool', tool_call_id: custom.call_id, content: 'edited' }] }, createTestSidecarCodec()); + const replay = await buildTargetPayloadFor(openaiChatCompletionsSourceProtocol, openaiResponsesTargetProtocol, { model: response.model, stream: true, messages: [client.choices[0].message, { role: 'tool', tool_call_id: custom.call_id, content: 'edited' }] }, { sidecarCodec: createTestSidecarCodec() }); expect(replay.input.slice(0, -1)).toEqual(upstream.output); expect(replay.input.at(-1)).toEqual({ type: 'custom_tool_call_output', call_id: custom.call_id, output: 'edited' }); }); -testOutputTranslation('openai-chat-completions', 'openai-responses', (frames, codec) => translateToSourceEvents(structuredResponsesFixture(frames), {}, codec)); +testOutputTranslation('openai-chat-completions', 'openai-responses', (frames, codec) => { + const [source, target] = pairs.chatToResponses; + return buildSourceEventsFor(source, target, emptyPayload.chat, { sidecarCodec: codec }, structuredResponsesFixture(frames)); +}); diff --git a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-openai-responses-request_test.ts similarity index 66% rename from packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/request_test.ts rename to packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-openai-responses-request_test.ts index 826b190ded..4ae804f3be 100644 --- a/packages/translate/__tests__/protocols/openai-chat-completions/via-openai-responses/request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-chat-completions-to-openai-responses-request_test.ts @@ -1,15 +1,27 @@ import { test } from 'vitest'; -import { buildTargetRequest as buildTargetRequestWithSidecar } from '../../../../src/protocols/openai-chat-completions/via-openai-responses/request.ts'; -import { TranslateInputException } from '../../../../src/shared/input-error.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; -import type { OpenAIChatCompletionsPayload, OpenAIChatCompletionsMessage } from '@floway-dev/protocols/openai-chat-completions'; +import { TranslateInputException } from '../../../src/shared/input-error.ts'; +import { buildTargetPayloadFor, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; +import type { OpenAIChatCompletionsPayload, OpenAIChatCompletionsMessage, OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; import { assertEquals, assertFalse, assertRejects } from '@floway-dev/test-utils'; -const buildWithTestSidecar = (payload: Parameters[0]) => - buildTargetRequestWithSidecar(payload, createTestSidecarCodec()); +const buildWithTestSidecar = (payload: OpenAIChatCompletionsPayloadEx) => + buildTargetPayloadFor(pairs.chatToResponses[0], pairs.chatToResponses[1], { ...payload, stream: true }, { sidecarCodec: createTestSidecarCodec() }); -test('buildTargetRequest preserves scalar and content-part assistant refusals', async () => { +test.each(['auto', 'none', { name: 'lookup' }] as const)('translates legacy declarations and choice %j into modern tooling', async function_call => { + const result = await buildWithTestSidecar({ + model: 'model', messages: [], + functions: [{ name: 'lookup', description: 'Look up facts', parameters: { type: 'object', properties: {} } }], + function_call, + }); + assertEquals(result.tools, [{ type: 'function', name: 'lookup', description: 'Look up facts', parameters: { type: 'object', properties: {} }, strict: false }]); + assertEquals(result.tool_choice, typeof function_call === 'string' ? function_call : { type: 'function', name: 'lookup' }); + assertFalse('functions' in result); + assertFalse('function_call' in result); +}); + +test('buildTargetPayload preserves scalar and content-part assistant refusals', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [ @@ -24,7 +36,7 @@ test('buildTargetRequest preserves scalar and content-part assistant refusals', ]); }); -test('buildTargetRequest rejects tool messages without tool_call_id', async () => { +test('buildTargetPayload rejects tool messages without tool_call_id', async () => { await assertRejects( () => buildWithTestSidecar({ @@ -36,7 +48,7 @@ test('buildTargetRequest rejects tool messages without tool_call_id', async () = ); }); -test('buildTargetRequest maps native custom tool declarations and choices', async () => { +test('buildTargetPayload maps native custom tool declarations and choices', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hello' }], @@ -47,7 +59,7 @@ test('buildTargetRequest maps native custom tool declarations and choices', asyn assertEquals(result.tool_choice, { type: 'custom', name: 'apply_patch' }); }); -test('buildTargetRequest maps custom tool history and its output', async () => { +test('buildTargetPayload maps custom tool history and its output', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [ @@ -61,7 +73,7 @@ test('buildTargetRequest maps custom tool history and its output', async () => { ]); }); -test('buildTargetRequest preserves translated OpenAI request fields', async () => { +test('buildTargetPayload preserves translated OpenAI request fields', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hello' }], @@ -86,7 +98,7 @@ test('buildTargetRequest preserves translated OpenAI request fields', async () = assertFalse('include' in result); }); -test('buildTargetRequest forwards empty reasoning_effort verbatim', async () => { +test('buildTargetPayload forwards empty reasoning_effort verbatim', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hello' }], @@ -96,7 +108,7 @@ test('buildTargetRequest forwards empty reasoning_effort verbatim', async () => assertEquals(result.reasoning, { effort: '' }); }); -test('buildTargetRequest never invents reasoning.context from reasoning_effort', async () => { +test('buildTargetPayload never invents reasoning.context from reasoning_effort', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hello' }], @@ -108,7 +120,7 @@ test('buildTargetRequest never invents reasoning.context from reasoning_effort', assertFalse('context' in (result.reasoning ?? {})); }); -test('buildTargetRequest omits store when OpenAI Chat Completions omits store', async () => { +test('buildTargetPayload omits store when OpenAI Chat Completions omits store', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hello' }], @@ -117,7 +129,7 @@ test('buildTargetRequest omits store when OpenAI Chat Completions omits store', assertFalse('store' in result); }); -test('buildTargetRequest omits tool_choice when OpenAI Chat Completions omits it', async () => { +test('buildTargetPayload omits tool_choice when OpenAI Chat Completions omits it', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hello' }], @@ -127,7 +139,7 @@ test('buildTargetRequest omits tool_choice when OpenAI Chat Completions omits it assertFalse('tool_choice' in result); }); -test('buildTargetRequest omits tool_choice when OpenAI Chat Completions carries no tools to apply it to', async () => { +test('buildTargetPayload omits tool_choice when OpenAI Chat Completions carries no tools to apply it to', async () => { for (const tools of [undefined, null, []]) { const result = await buildWithTestSidecar({ model: 'gpt-test', @@ -140,7 +152,7 @@ test('buildTargetRequest omits tool_choice when OpenAI Chat Completions carries } }); -test('buildTargetRequest preserves explicit null prompt cache and safety fields', async () => { +test('buildTargetPayload preserves explicit null prompt cache and safety fields', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hello' }], @@ -154,7 +166,7 @@ test('buildTargetRequest preserves explicit null prompt cache and safety fields' assertEquals(result.safety_identifier, null); }); -test('buildTargetRequest hoists only the initial contiguous system prefix', async () => { +test('buildTargetPayload keeps historical system and developer messages inline', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [ @@ -167,8 +179,10 @@ test('buildTargetRequest hoists only the initial contiguous system prefix', asyn ], }); - assertEquals(result.instructions, 'sys-1\n\nsys-2'); + assertEquals(result.instructions, undefined); assertEquals(result.input, [ + { type: 'message', role: 'system', content: 'sys-1' }, + { type: 'message', role: 'system', content: 'sys-2' }, { type: 'message', role: 'user', content: 'u1' }, { type: 'message', role: 'developer', content: 'dev-late' }, { type: 'message', role: 'system', content: 'sys-late' }, @@ -180,7 +194,25 @@ test('buildTargetRequest hoists only the initial contiguous system prefix', asyn ]); }); -test('buildTargetRequest preserves explicit tool strict and defaults omission to false', async () => { +test('buildTargetPayload preserves leading developer and system message roles', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + messages: [ + { role: 'developer', content: 'dev-1' }, + { role: 'system', content: 'sys-1' }, + { role: 'user', content: 'u1' }, + ], + }); + + assertEquals(result.instructions, undefined); + assertEquals(result.input, [ + { type: 'message', role: 'developer', content: 'dev-1' }, + { type: 'message', role: 'system', content: 'sys-1' }, + { type: 'message', role: 'user', content: 'u1' }, + ]); +}); + +test('buildTargetPayload preserves explicit tool strict and defaults omission to false', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hello' }], @@ -207,31 +239,35 @@ test('buildTargetRequest preserves explicit tool strict and defaults omission to { type: 'function', name: 'explicit_strict', - parameters: { type: 'object' }, + // An object schema states its property list explicitly: OpenAI's + // strict-mode validators reject one that omits it. + parameters: { type: 'object', properties: {} }, strict: true, }, { type: 'function', name: 'implicit_non_strict', - parameters: { type: 'object' }, + parameters: { type: 'object', properties: {} }, strict: false, }, ]); }); -test('buildTargetRequest rejects an unknown message role', async () => { +test('buildTargetPayload rejects an unknown message role', async () => { + // `function` is a legacy role the reader accepts; only a role no Chat wire + // defines reaches this rejection. await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', - messages: [{ role: 'function', content: 'hi' } as unknown as OpenAIChatCompletionsMessage], + messages: [{ role: 'critic', content: 'hi' } as unknown as OpenAIChatCompletionsMessage], }), Error, - "Invalid role 'function'", + "Invalid role 'critic'", ); }); -test('buildTargetRequest forwards reasoning_effort and service_tier onto the native slots', async () => { +test('buildTargetPayload forwards reasoning_effort and service_tier onto the native slots', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hi' }], @@ -243,7 +279,7 @@ test('buildTargetRequest forwards reasoning_effort and service_tier onto the nat assertEquals(result.service_tier, 'priority'); }); -test.each(['none', '', 'future_effort'])('buildTargetRequest forwards reasoning_effort=%j verbatim', async reasoning_effort => { +test.each(['none', '', 'future_effort'])('buildTargetPayload forwards reasoning_effort=%j verbatim', async reasoning_effort => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hi' }], @@ -253,7 +289,7 @@ test.each(['none', '', 'future_effort'])('buildTargetRequest forwards reasoning_ assertEquals(result.reasoning, { effort: reasoning_effort }); }); -test.each([null, undefined])('buildTargetRequest omits unset reasoning_effort=%j', async reasoning_effort => { +test.each([null, undefined])('buildTargetPayload omits unset reasoning_effort=%j', async reasoning_effort => { const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hi' }], reasoning_effort }); assertEquals(Object.hasOwn(result, 'reasoning'), false); }); @@ -276,3 +312,12 @@ test('allowed_tools retains both native callable kinds', async () => { }); assertEquals(result.tool_choice, { type: 'allowed_tools', mode: 'required', tools: [{ type: 'function', name: 'lookup' }, { type: 'custom', name: 'edit' }] }); }); + +test.each([ + { name: 'max_completion_tokens only', input: { max_completion_tokens: 64 }, expected: 64 }, + { name: 'max_tokens only', input: { max_tokens: 32 }, expected: 32 }, + { name: 'both, newer spelling wins', input: { max_tokens: 32, max_completion_tokens: 64 }, expected: 64 }, +])('buildTargetPayload forwards $name as max_output_tokens', async ({ input, expected }) => { + const result = await buildWithTestSidecar({ model: 'gpt-test', messages: [{ role: 'user', content: 'hi' }], ...input }); + assertEquals(result.max_output_tokens, expected); +}); diff --git a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-events-protocol_test.ts similarity index 76% rename from packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts rename to packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-events-protocol_test.ts index 1e177c38f0..1050404e2b 100644 --- a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/events-protocol_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-events-protocol_test.ts @@ -1,13 +1,17 @@ import { test } from 'vitest'; -import { translateToSourceEvents } from '../../../../src/protocols/openai-responses/via-anthropic-messages/events.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; -import { emptyNamespaceToolNames } from '../../../round-trip/test-helpers.ts'; -import { fixtureFrames } from '../../fixtures.ts'; +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; +import { fixtureFrames } from '../fixtures.ts'; import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; +const codec = createTestSidecarCodec(); +const translate = (frames: AsyncIterable>) => buildSourceEventsFor( + pairs.responsesToMessages[0], pairs.responsesToMessages[1], emptyPayload.responses, { sidecarCodec: codec }, frames, +); + const drain = async (frames: AsyncIterable): Promise => { for await (const _frame of frames) { // Exhaust the stream so async translator errors surface to the caller. @@ -32,7 +36,7 @@ test('translateToSourceEvents stops after Anthropic Messages message_stop', asyn }); } - const frames = await collect(translateToSourceEvents(stream(), 'resp_123', new Set(), createTestSidecarCodec(), emptyNamespaceToolNames())); + const frames = await collect(translate(stream())); assertEquals( frames.map(frame => (frame.type === 'event' ? frame.event.type : frame.type)), @@ -52,7 +56,7 @@ test('translateToSourceEvents translates Anthropic Messages error terminal and s yield eventFrame({ type: 'message_stop' }); } - const frames = await collect(translateToSourceEvents(stream(), 'resp_123', new Set(), createTestSidecarCodec(), emptyNamespaceToolNames())); + const frames = await collect(translate(stream())); assertEquals(frames.length, 1); assertEquals( @@ -84,5 +88,5 @@ test('translateToSourceEvents rejects truncated Anthropic Messages streams witho }); } - await assertRejects(async () => await drain(translateToSourceEvents(stream(), 'resp_123', new Set(), createTestSidecarCodec(), emptyNamespaceToolNames())), Error, 'Messages stream ended without message_stop'); + await assertRejects(async () => await drain(translate(stream())), Error, 'Messages stream ended without message_stop'); }); diff --git a/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-events_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-events_test.ts new file mode 100644 index 0000000000..c9fc802641 --- /dev/null +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-events_test.ts @@ -0,0 +1,7 @@ +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { testOutputTranslation } from '../fixtures.ts'; + +testOutputTranslation('openai-responses', 'anthropic-messages', (frames, codec) => { + const [source, target] = pairs.responsesToMessages; + return buildSourceEventsFor(source, target, emptyPayload.responses, { sidecarCodec: codec }, frames); +}); diff --git a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts similarity index 73% rename from packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/request_test.ts rename to packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts index f0897ffe5c..461a867844 100644 --- a/packages/translate/__tests__/protocols/openai-responses/via-anthropic-messages/request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-anthropic-messages-request_test.ts @@ -1,13 +1,11 @@ import { test } from 'vitest'; -import { buildTargetRequest as buildTargetRequestWithSidecar } from '../../../../src/protocols/openai-responses/via-anthropic-messages/request.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { buildTargetPayloadFor, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; import { ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS, type AnthropicMessagesClientTool, type AnthropicMessagesToolResultBlock, type AnthropicMessagesUserContentBlock } from '@floway-dev/protocols/anthropic-messages'; -import type { OpenAIResponsesInputMultiAgentCallOutputItem, OpenAIResponsesTool } from '@floway-dev/protocols/openai-responses'; +import type { OpenAIResponsesInputMultiAgentCallOutputItem, OpenAIResponsesTool, OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; import { assert, assertEquals, assertFalse, assertRejects } from '@floway-dev/test-utils'; -const stubRemoteImageLoader = (result: { mediaType: string | null; data: Uint8Array } | null) => () => Promise.resolve(result); - const minimalPayload = { model: 'claude-test', input: [{ type: 'message' as const, role: 'user' as const, content: 'hi' }], @@ -23,17 +21,20 @@ const minimalPayload = { parallel_tool_calls: true, }; -const testBuildOptions: Parameters[2] = { +const testBuildOptions: { fallbackMaxOutputTokens?: number } = { fallbackMaxOutputTokens: undefined, - loadRemoteImage: async () => null, }; -const buildWithTestSidecar = ( - payload: Parameters[0], - options: Partial[2]> = {}, -) => buildTargetRequestWithSidecar(payload, createTestSidecarCodec(), { ...testBuildOptions, ...options }); +const buildWithTestSidecar = async ( + payload: OpenAIResponsesRequestPayloadEx, + options: Partial = {}, +) => { + const streamed = { ...payload, stream: true }; + const built = await buildTargetPayloadFor(pairs.responsesToMessages[0], pairs.responsesToMessages[1], streamed, { sidecarCodec: createTestSidecarCodec(), ...testBuildOptions, ...options }); + return { target: built }; +}; -test('buildTargetRequest preserves unsafe JSON number tokens in function history', async () => { +test('buildTargetPayload preserves unsafe JSON number tokens in function history', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, input: [{ type: 'function_call', call_id: 'call_1', name: 'tool', arguments: '{"n":1e400}', status: 'completed' }], @@ -45,7 +46,25 @@ test('buildTargetRequest preserves unsafe JSON number tokens in function history assertEquals(JSON.stringify(tool.input), '{"n":1e400}'); }); -test('buildTargetRequest preserves OpenAI Responses assistant refusal history as Anthropic Messages text', async () => { +test('top-level instructions stay separate from leading historical role messages', async () => { + const result = await buildWithTestSidecar({ + ...minimalPayload, + instructions: 'base', + input: [ + { type: 'message', role: 'system', content: 'rule' }, + { type: 'message', role: 'developer', content: 'dev' }, + { type: 'message', role: 'user', content: 'question' }, + ], + }); + assertEquals(result.target.system, [{ type: 'text', text: 'base', cache_control: { type: 'ephemeral' } }]); + assertEquals(result.target.messages, [ + { role: 'system', content: [{ type: 'text', text: 'rule' }] }, + { role: 'system', content: [{ type: 'text', text: 'dev' }] }, + { role: 'user', content: [{ type: 'text', text: 'question', cache_control: { type: 'ephemeral' } }] }, + ]); +}); + +test('buildTargetPayload preserves OpenAI Responses assistant refusal history as Anthropic Messages text', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, input: [{ @@ -61,7 +80,22 @@ test('buildTargetRequest preserves OpenAI Responses assistant refusal history as assertEquals(message.content[0], { type: 'text', text: 'Cannot help.', cache_control: { type: 'ephemeral' } }); }); -test('buildTargetRequest accepts an implicit message discriminator', async () => { +test('buildTargetPayload keeps an explicitly empty assistant message', async () => { + // An empty string is a stated assistant turn, not an absent one: the source + // chose to record it, so the target receives a message of its own. + const result = await buildWithTestSidecar({ + ...minimalPayload, + input: [ + { type: 'message', role: 'assistant', content: '' }, + { type: 'message', role: 'user', content: 'hi' }, + ], + }); + + assertEquals(result.target.messages.map(message => message.role), ['assistant', 'user']); + assertEquals(result.target.messages[0], { role: 'assistant', content: '' }); +}); + +test('buildTargetPayload accepts an implicit message discriminator', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, input: [{ role: 'user', content: 'hello' }], @@ -75,7 +109,7 @@ test('buildTargetRequest accepts an implicit message discriminator', async () => ]); }); -test('buildTargetRequest rejects an agent_message that no interceptor lowered', async () => { +test('buildTargetPayload rejects an agent_message that no interceptor lowered', async () => { await assertRejects( () => buildWithTestSidecar({ ...minimalPayload, @@ -91,6 +125,15 @@ test('buildTargetRequest rejects an agent_message that no interceptor lowered', ); }); +test('an empty namespace description does not prefix a child description', async () => { + const result = await buildWithTestSidecar({ + ...minimalPayload, + tools: [{ type: 'namespace', name: 'group', description: '', tools: [{ type: 'function', name: 'f', description: 'meaning', parameters: { type: 'object' } }] }], + }); + const tool = result.target.tools?.[0]; + assertEquals(tool !== undefined && 'description' in tool ? tool.description : undefined, 'meaning'); +}); + test.each([ { name: 'program', input: [{ type: 'program', id: 'prog_1', call_id: 'call_prog_1', code: 'return 1', fingerprint: 'opaque' }] }, { name: 'program_output', input: [{ type: 'program_output', id: 'prog_out_1', call_id: 'call_prog_1', result: '1', status: 'completed' }] }, @@ -98,7 +141,7 @@ test.each([ { name: 'multi_agent_call_output', input: [{ type: 'multi_agent_call_output', action: 'spawn_agent', call_id: 'call_1', output: [] as OpenAIResponsesInputMultiAgentCallOutputItem['output'] }] }, { name: 'context_compaction', input: [{ type: 'context_compaction', encrypted_content: 'opaque' }] }, { name: 'item_reference', input: [{ type: 'item_reference', id: 'msg_1' }] }, -] as const)('buildTargetRequest rejects OpenAI-Responses-only $name input', async ({ name, input }) => { +] as const)('buildTargetPayload rejects OpenAI-Responses-only $name input', async ({ name, input }) => { await assertRejects( () => buildWithTestSidecar({ ...minimalPayload, input: [...input] }), Error, @@ -106,7 +149,7 @@ test.each([ ); }); -test('buildTargetRequest wires OpenAI Responses tooling guards', async () => { +test('buildTargetPayload wires OpenAI Responses tooling guards', async () => { await assertRejects( () => buildWithTestSidecar({ ...minimalPayload, @@ -122,34 +165,49 @@ test('buildTargetRequest wires OpenAI Responses tooling guards', async () => { ); }); -test('buildTargetRequest accepts null tool_choice', async () => { +test('buildTargetPayload accepts null tool_choice', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, tool_choice: null }); assertEquals(result.target.tool_choice, undefined); }); -test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetRequest rejects input_file in %s', async type => { - await assertRejects( - () => buildWithTestSidecar({ - ...minimalPayload, - input: [{ type, call_id: 'call_1', output: [{ type: 'input_file', file_id: 'file_1' }] }], - }), - Error, - 'input_file tool output', - ); +test('buildTargetPayload preserves required tool choice without declarations', async () => { + const result = await buildWithTestSidecar({ ...minimalPayload, tools: null, tool_choice: 'required' }); + assertEquals(result.target.tool_choice, { type: 'any' }); }); -test('buildTargetRequest rejects file message content', async () => { - await assertRejects( - () => buildWithTestSidecar({ - ...minimalPayload, - input: [{ type: 'message', role: 'user', content: [{ type: 'input_file', file_id: 'file_1' }] }], - }), - Error, - 'input_file message content', - ); +test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetPayload maps input_file in %s to a document', async type => { + const result = await buildWithTestSidecar({ + ...minimalPayload, + input: [ + ...(type === 'function_call_output' + ? [{ type: 'function_call' as const, call_id: 'call_1', name: 'lookup', arguments: '{}' }] + : [{ type: 'custom_tool_call' as const, call_id: 'call_1', name: 'lookup', input: '{}' }]), + { type, call_id: 'call_1', output: [{ type: 'input_file', file_id: 'file_1' }] }, + ], + }); + const user = result.target.messages.find(message => message.role === 'user'); + assertEquals(user, { + role: 'user', + content: [{ + type: 'tool_result', tool_use_id: 'call_1', content: [{ type: 'document', source: { type: 'file', file_id: 'file_1' } }], + cache_control: { type: 'ephemeral' }, + }], + }); +}); + +test.each([ + { name: 'file_id', part: { type: 'input_file' as const, file_id: 'file_1' }, source: { type: 'file', file_id: 'file_1' } }, + { name: 'file_url', part: { type: 'input_file' as const, file_url: 'https://example.com/a.pdf' }, source: { type: 'url', url: 'https://example.com/a.pdf' } }, + { name: 'file_data data URL', part: { type: 'input_file' as const, file_data: 'data:application/pdf;base64,JVBERi0=' }, source: { type: 'base64', media_type: 'application/pdf', data: 'JVBERi0=' } }, +])('buildTargetPayload maps input_file $name to a Messages document', async ({ part, source }) => { + const result = await buildWithTestSidecar({ + ...minimalPayload, + input: [{ type: 'message', role: 'user', content: [part] }], + }); + assertEquals(result.target.messages.at(-1), { role: 'user', content: [{ type: 'document', source }] }); }); -test('buildTargetRequest rejects file assistant content', async () => { +test('buildTargetPayload rejects file assistant content', async () => { await assertRejects( () => buildWithTestSidecar({ ...minimalPayload, @@ -160,7 +218,7 @@ test('buildTargetRequest rejects file assistant content', async () => { ); }); -test('buildTargetRequest preserves assistant input_text', async () => { +test('buildTargetPayload preserves assistant input_text', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, input: [{ type: 'message', role: 'assistant', content: [{ type: 'input_text', text: 'prior reply' }] }], @@ -171,7 +229,7 @@ test('buildTargetRequest preserves assistant input_text', async () => { ]); }); -test('buildTargetRequest rejects assistant images', async () => { +test('buildTargetPayload rejects assistant images', async () => { await assertRejects( () => buildWithTestSidecar({ ...minimalPayload, @@ -182,74 +240,74 @@ test('buildTargetRequest rejects assistant images', async () => { ); }); -test('buildTargetRequest rejects file_id-only images', async () => { - await assertRejects( - () => buildWithTestSidecar({ - ...minimalPayload, - input: [{ type: 'message', role: 'user', content: [{ type: 'input_image', file_id: 'file_1', detail: 'auto' }] }], - }), - Error, - 'file_id-only image content', - ); -}); - -test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetRequest rejects file_id-only images in %s', async type => { - await assertRejects( - () => buildWithTestSidecar({ - ...minimalPayload, - input: [{ type, call_id: 'call_1', output: [{ type: 'input_image', file_id: 'file_1', detail: 'auto' }] }], - }), - Error, - 'file_id-only image tool output', - ); +test('buildTargetPayload passes a file_id-only image through to the Messages target', async () => { + const result = await buildWithTestSidecar({ + ...minimalPayload, + input: [{ type: 'message', role: 'user', content: [{ type: 'input_image', file_id: 'file_1', detail: 'auto' }] }], + }); + // Messages accepts a file ID, and whether the upstream behind this Messages + // endpoint knows that ID is the upstream's question. + assertEquals(result.target.messages, [{ + role: 'user', + content: [{ type: 'image', source: { type: 'file', file_id: 'file_1' }, cache_control: { type: 'ephemeral' } }], + }]); }); -test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetRequest rejects unavailable images in %s', async type => { - await assertRejects( - () => buildWithTestSidecar({ - ...minimalPayload, - input: [{ - type, - call_id: 'call_1', - output: [{ type: 'input_image', image_url: 'https://example.com/unavailable.png' }], - }], - }, { loadRemoteImage: stubRemoteImageLoader(null) }), - Error, - 'unavailable or unsupported image tool output', - ); +test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetPayload passes file_id-only images through in %s', async type => { + const result = await buildWithTestSidecar({ + ...minimalPayload, + input: [ + ...(type === 'function_call_output' + ? [{ type: 'function_call' as const, call_id: 'call_1', name: 'lookup', arguments: '{}' }] + : [{ type: 'custom_tool_call' as const, call_id: 'call_1', name: 'lookup', input: '{}' }]), + { type, call_id: 'call_1', output: [{ type: 'input_image', file_id: 'file_1', detail: 'auto' }] }, + ], + }); + assertEquals(result.target.messages, [ + { + role: 'assistant', + // The freeform arm keeps its verbatim string; the function arm states the + // argument object it declares. + content: [{ type: 'tool_use', id: 'call_1', name: 'lookup', input: type === 'function_call_output' ? {} : { input: '{}' } }], + }, + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'call_1', content: [{ type: 'image', source: { type: 'file', file_id: 'file_1' } }], cache_control: { type: 'ephemeral' } }], + }, + ]); }); // ── service_tier → speed mapping ── -test('buildTargetRequest maps service_tier:fast to speed:fast (no service_tier on target)', async () => { +test('buildTargetPayload maps service_tier:fast to speed:fast (no service_tier on target)', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, service_tier: 'fast' }); assertEquals(result.target.speed, 'fast'); assertFalse('service_tier' in result.target); }); -test('buildTargetRequest maps service_tier:priority to speed:fast (no service_tier on target)', async () => { +test('buildTargetPayload maps service_tier:priority to speed:fast (no service_tier on target)', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, service_tier: 'priority' }); assertEquals(result.target.speed, 'fast'); assertFalse('service_tier' in result.target); }); -test('buildTargetRequest passes service_tier:auto through as service_tier', async () => { +test('buildTargetPayload passes service_tier:auto through as service_tier', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, service_tier: 'auto' }); assertEquals(result.target.service_tier, 'auto'); assertFalse('speed' in result.target); }); -test('buildTargetRequest omits both speed and service_tier when service_tier is absent', async () => { +test('buildTargetPayload omits both speed and service_tier when service_tier is absent', async () => { const result = await buildWithTestSidecar(minimalPayload); assertFalse('speed' in result.target); assertFalse('service_tier' in result.target); }); -test('buildTargetRequest maps reasoning.effort none to thinking.disabled (summary ignored when reasoning is disabled)', async () => { +test('buildTargetPayload maps reasoning.effort none to thinking.disabled (summary ignored when reasoning is disabled)', async () => { const result = await buildWithTestSidecar({ model: 'claude-test', input: [{ type: 'message', role: 'user', content: 'hi' }], @@ -270,7 +328,7 @@ test('buildTargetRequest maps reasoning.effort none to thinking.disabled (summar assertFalse('output_config' in result.target); }); -test('buildTargetRequest maps reasoning.effort directly to output_config.effort', async () => { +test('buildTargetPayload maps reasoning.effort directly to output_config.effort', async () => { const result = await buildWithTestSidecar({ model: 'claude-test', input: [{ type: 'message', role: 'user', content: 'hi' }], @@ -291,7 +349,7 @@ test('buildTargetRequest maps reasoning.effort directly to output_config.effort' assertFalse('thinking' in result.target); }); -test('buildTargetRequest forwards empty reasoning.effort verbatim', async () => { +test('buildTargetPayload forwards empty reasoning.effort verbatim', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, reasoning: { effort: '' }, @@ -301,7 +359,7 @@ test('buildTargetRequest forwards empty reasoning.effort verbatim', async () => assertFalse('thinking' in result.target); }); -test('buildTargetRequest defaults max_tokens to ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS when neither source nor fallbackMaxOutputTokens supplies one', async () => { +test('buildTargetPayload defaults max_tokens to ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS when neither source nor fallbackMaxOutputTokens supplies one', async () => { const result = await buildWithTestSidecar({ model: 'claude-test', input: [{ type: 'message', role: 'user', content: 'hi' }], @@ -320,7 +378,7 @@ test('buildTargetRequest defaults max_tokens to ANTHROPIC_MESSAGES_FALLBACK_MAX_ assertEquals(result.target.max_tokens, ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS); }); -test('buildTargetRequest uses fallbackMaxOutputTokens over the gateway const when the source omitted max_output_tokens', async () => { +test('buildTargetPayload uses fallbackMaxOutputTokens over the gateway const when the source omitted max_output_tokens', async () => { const result = await buildWithTestSidecar( { model: 'claude-test', @@ -342,7 +400,7 @@ test('buildTargetRequest uses fallbackMaxOutputTokens over the gateway const whe assertEquals(result.target.max_tokens, 4096); }); -test('buildTargetRequest omits the signature for a reasoning with no encrypted_content', async () => { +test('buildTargetPayload omits the signature for a reasoning with no encrypted_content', async () => { const result = await buildWithTestSidecar({ model: 'claude-test', input: [ @@ -372,7 +430,7 @@ test('buildTargetRequest omits the signature for a reasoning with no encrypted_c assertEquals(assistant.content[0], { signature: '', type: 'thinking', thinking: 'trace' }); }); -test('buildTargetRequest omits generic metadata instead of coercing it to metadata.user_id', async () => { +test('buildTargetPayload omits generic metadata instead of coercing it to metadata.user_id', async () => { const result = await buildWithTestSidecar({ model: 'claude-test', input: [{ type: 'message', role: 'user', content: 'hi' }], @@ -391,7 +449,7 @@ test('buildTargetRequest omits generic metadata instead of coercing it to metada assertFalse('metadata' in result.target); }); -test('buildTargetRequest resolves remote input images through the shared loader', async () => { +test('buildTargetPayload resolves remote input images through the shared loader', async () => { const result = await buildWithTestSidecar( { model: 'claude-test', @@ -419,12 +477,6 @@ test('buildTargetRequest resolves remote input images through the shared loader' store: false, parallel_tool_calls: true, }, - { - loadRemoteImage: stubRemoteImageLoader({ - mediaType: 'IMAGE/PNG; charset=binary', - data: new Uint8Array([1, 2, 3]), - }), - }, ); const message = result.target.messages[0]; @@ -435,17 +487,13 @@ test('buildTargetRequest resolves remote input images through the shared loader' assertEquals(message.content, [ { type: 'image', - source: { - type: 'base64', - media_type: 'image/png', - data: 'AQID', - }, + source: { type: 'url', url: 'https://example.com/image.png' }, cache_control: { type: 'ephemeral' }, }, ]); }); -test('buildTargetRequest drops reasoning input without readable summary', async () => { +test('buildTargetPayload drops reasoning input without readable summary', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [ @@ -478,7 +526,7 @@ test('buildTargetRequest drops reasoning input without readable summary', async ); }); -test('buildTargetRequest wraps custom tools as single-string function tools and records their names', async () => { +test('buildTargetPayload wraps custom tools as single-string function tools and records their names', async () => { const result = await buildWithTestSidecar({ model: 'claude-test', input: 'hi', @@ -501,9 +549,9 @@ test('buildTargetRequest wraps custom tools as single-string function tools and parallel_tool_calls: true, }); - assertEquals(result.customToolNames.has('apply_patch'), true); assertEquals(result.target.tools, [ { + type: 'custom', name: 'apply_patch', description: 'apply a patch', input_schema: { @@ -522,30 +570,30 @@ test('buildTargetRequest wraps custom tools as single-string function tools and cache_control: { type: 'ephemeral' }, }, ]); - assertEquals(result.target.tool_choice, { type: 'tool', name: 'apply_patch' }); + assertEquals(result.target.tool_choice, { type: 'tool', name: 'apply_patch', disable_parallel_tool_use: false }); }); -test.each(['web_search', 'file_search'])('buildTargetRequest rejects unsupported tool type %s', async type => { +test.each(['web_search', 'file_search'])('buildTargetPayload rejects unsupported tool type %s', async type => { await assertRejects( () => buildWithTestSidecar({ ...minimalPayload, tools: [{ type } as unknown as OpenAIResponsesTool] }), Error, - `Cannot translate OpenAI Responses tool type '${type}' to Anthropic Messages.`, + `Cannot translate OpenAI Responses tool type '${type}'.`, ); }); -test.each(['web_search', 'file_search'])('buildTargetRequest rejects unsupported string tool choice %s', async toolChoice => { +test.each(['web_search', 'file_search'])('buildTargetPayload rejects unsupported string tool choice %s', async toolChoice => { await assertRejects( () => buildWithTestSidecar({ ...minimalPayload, tool_choice: toolChoice as unknown as typeof minimalPayload.tool_choice }), Error, - `Cannot translate OpenAI Responses tool choice '${toolChoice}' to Anthropic Messages.`, + `Cannot translate OpenAI Responses tool choice '${toolChoice}'.`, ); }); -test.each(['web_search', 'file_search'])('buildTargetRequest rejects unsupported object tool choice type %s', async type => { +test.each(['web_search', 'file_search'])('buildTargetPayload rejects unsupported object tool choice type %s', async type => { await assertRejects( () => buildWithTestSidecar({ ...minimalPayload, tool_choice: { type } as unknown as typeof minimalPayload.tool_choice }), Error, - `Cannot translate OpenAI Responses tool choice type '${type}' to Anthropic Messages.`, + `Cannot translate OpenAI Responses tool choice type '${type}'.`, ); }); @@ -553,7 +601,7 @@ test.each([ { name: 'string', output: 'ok', expected: 'ok' }, { name: 'text array', output: [{ type: 'input_text' as const, text: 'first\n' }, { type: 'input_text' as const, text: 'second' }], expected: [{ type: 'text', text: 'first\n' }, { type: 'text', text: 'second' }] }, { name: 'empty array', output: [], expected: '' }, -])('buildTargetRequest projects custom_tool_call history with $name output into wrapped tool_use shape', async ({ output, expected }) => { +])('buildTargetPayload projects custom_tool_call history with $name output into wrapped tool_use shape', async ({ output, expected }) => { const result = await buildWithTestSidecar({ model: 'claude-test', input: [ @@ -608,7 +656,7 @@ test.each([ }); }); -test('buildTargetRequest flattens namespace functions collision-safely and maps replay history', async () => { +test('buildTargetPayload flattens namespace functions collision-safely and maps replay history', async () => { const namespaceTool: OpenAIResponsesTool = { type: 'namespace', name: 'web', @@ -621,7 +669,7 @@ test('buildTargetRequest flattens namespace functions collision-safely and maps strict: false, }], }; - const result = await buildWithTestSidecar({ + const payload: OpenAIResponsesRequestPayloadEx = { ...minimalPayload, input: [ { type: 'message', role: 'user', content: 'search' }, @@ -632,18 +680,21 @@ test('buildTargetRequest flattens namespace functions collision-safely and maps namespaceTool, ], tool_choice: { type: 'function', name: 'web.run' }, - }); + }; + const result = await buildWithTestSidecar(payload); - assertEquals(result.namespaceToolNames.sourceToTarget, new Map([['web.run', 'web_run_2']])); - assertEquals(result.namespaceToolNames.targetToSource, new Map([['web_run_2', { namespace: 'web', name: 'run', type: 'function_call' }]])); + // A grouped declaration and the flat one it would collide with both reach + // this wire, so the group's spelling has to avoid the name already in use. + assertEquals(result.target.tools?.map(tool => 'name' in tool ? tool.name : undefined), ['web_run', 'web_run_2']); assertEquals(result.target.tools, [ { + type: 'custom', name: 'web_run', - description: undefined, input_schema: { type: 'object' }, strict: false, }, { + type: 'custom', name: 'web_run_2', description: 'Web tools.\n\nAccess the web.', input_schema: { type: 'object', properties: { search_query: { type: 'array' } } }, @@ -661,10 +712,10 @@ test('buildTargetRequest flattens namespace functions collision-safely and maps cache_control: { type: 'ephemeral' }, }], }); - assertEquals(result.target.tool_choice, { type: 'tool', name: 'web_run_2' }); + assertEquals(result.target.tool_choice, { type: 'tool', name: 'web_run_2', disable_parallel_tool_use: false }); }); -test('buildTargetRequest gives a schema-less function tool the empty object schema', async () => { +test('buildTargetPayload gives a schema-less function tool the empty object schema', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, tools: [{ type: 'function', name: 'ping' }], @@ -672,6 +723,7 @@ test('buildTargetRequest gives a schema-less function tool the empty object sche assertEquals(result.target.tools, [ { + type: 'custom', name: 'ping', input_schema: { type: 'object', properties: {} }, cache_control: { type: 'ephemeral' }, @@ -679,7 +731,7 @@ test('buildTargetRequest gives a schema-less function tool the empty object sche ]); }); -test('buildTargetRequest keeps plain-text function_call_output as string content', async () => { +test('buildTargetPayload keeps plain-text function_call_output as string content', async () => { const result = await buildWithTestSidecar({ model: 'claude-test', input: [ @@ -706,7 +758,7 @@ test('buildTargetRequest keeps plain-text function_call_output as string content assertEquals(toolResult.content, 'plain text body'); }); -test('buildTargetRequest preserves out-of-range numeric tool arguments as raw JSON', async () => { +test('buildTargetPayload preserves out-of-range numeric tool arguments as raw JSON', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, input: [{ type: 'function_call', call_id: 'call_1', name: 'tool', arguments: '{"value":1e400}', status: 'completed' }], @@ -720,7 +772,8 @@ test('buildTargetRequest preserves out-of-range numeric tool arguments as raw JS assertEquals(JSON.stringify(toolUse.input), '{"value":1e400}'); }); -test.each(['not json', '[1,2,3]'])('buildTargetRequest keeps raw_arguments fallback for invalid or non-object tool arguments', async argumentsJson => { +test('buildTargetPayload keeps raw_arguments fallback for malformed tool arguments', async () => { + const argumentsJson = 'not json'; const result = await buildWithTestSidecar({ ...minimalPayload, input: [{ type: 'function_call', call_id: 'call_1', name: 'tool', arguments: argumentsJson, status: 'completed' }], @@ -734,7 +787,19 @@ test.each(['not json', '[1,2,3]'])('buildTargetRequest keeps raw_arguments fallb assertEquals(toolUse.input, { raw_arguments: argumentsJson }); }); -test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetRequest maps multimodal %s into tool_result image and text blocks', async type => { +test('buildTargetPayload preserves a valid non-object JSON argument value', async () => { + const result = await buildWithTestSidecar({ + ...minimalPayload, + input: [{ type: 'function_call', call_id: 'call_1', name: 'tool', arguments: '[1,2,3]', status: 'completed' }], + }); + const assistantMessage = result.target.messages[0]; + assert(assistantMessage.role === 'assistant' && Array.isArray(assistantMessage.content)); + const toolUse = assistantMessage.content[0]; + assert(toolUse.type === 'tool_use'); + assertEquals(toolUse.input, [1, 2, 3]); +}); + +test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetPayload maps multimodal %s into tool_result image and text blocks', async type => { const result = await buildWithTestSidecar({ model: 'claude-test', input: [ @@ -773,7 +838,7 @@ test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTa ]); }); -test('buildTargetRequest maps incomplete function output to an Anthropic tool error', async () => { +test('buildTargetPayload maps incomplete function output to an Anthropic tool error', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, input: [ @@ -791,8 +856,7 @@ test('buildTargetRequest maps incomplete function output to an Anthropic tool er }); }); -test('buildTargetRequest loads custom tool result images without interpreting its open status', async () => { - const loaded: string[] = []; +test('buildTargetPayload states a custom tool result image as the URL it is', async () => { const result = await buildWithTestSidecar({ ...minimalPayload, input: [ @@ -806,21 +870,16 @@ test('buildTargetRequest loads custom tool result images without interpreting it ], }, ], - }, { - loadRemoteImage: url => { - loaded.push(url); - return Promise.resolve({ mediaType: 'image/png', data: new Uint8Array([1, 2, 3]) }); - }, }); - assertEquals(loaded, ['https://example.com/capture.png']); assertEquals(result.target.messages[1], { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'call_exec', + is_error: true, content: [ { type: 'text', text: 'partial capture' }, - { type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AQID' } }, + { type: 'image', source: { type: 'url', url: 'https://example.com/capture.png' } }, { type: 'text', text: 'capture failed' }, ], cache_control: { type: 'ephemeral' }, @@ -828,7 +887,7 @@ test('buildTargetRequest loads custom tool result images without interpreting it }); }); -test('buildTargetRequest throws on a stray web_search_call input item (shim owns the reverse path)', async () => { +test('buildTargetPayload throws on a stray web_search_call input item (shim owns the reverse path)', async () => { // The OpenAI Responses web-search shim rewrites web_search_call input items into // upstream function_call + function_call_output pairs before this // translator runs. Reaching the translator with a raw web_search_call @@ -858,11 +917,11 @@ test('buildTargetRequest throws on a stray web_search_call input item (shim owns parallel_tool_calls: true, }), Error, - "Invalid input item type 'web_search_call'", + 'Invalid input item: {"type":"web_search_call"', ); }); -test('buildTargetRequest throws on a stray compaction_trigger input item (compact-shim owns the strip)', async () => { +test('buildTargetPayload throws on a stray compaction_trigger input item (compact-shim owns the strip)', async () => { // The compact-shim is structurally required on non-openai-responses targets and // strips compaction_trigger items before reaching this translator. // Reaching here with one in input means the shim disengaged; the @@ -890,7 +949,7 @@ test('buildTargetRequest throws on a stray compaction_trigger input item (compac ); }); -test('buildTargetRequest throws on a stray compaction input item (compact-shim owns the expansion)', async () => { +test('buildTargetPayload throws on a stray compaction input item (compact-shim owns the expansion)', async () => { // The compact-shim expands its own shim-encoded compaction items inline // before reaching this translator and round-trips foreign compactions // back to the upstream as raw items. Either way the translator should @@ -918,7 +977,7 @@ test('buildTargetRequest throws on a stray compaction input item (compact-shim o ); }); -test('buildTargetRequest attaches ephemeral cache breakpoints to system, last function tool, and last message block', async () => { +test('buildTargetPayload attaches ephemeral cache breakpoints to system, last function tool, and last message block', async () => { const result = await buildWithTestSidecar({ model: 'claude-test', input: [ @@ -953,7 +1012,7 @@ test('buildTargetRequest attaches ephemeral cache breakpoints to system, last fu assertEquals(lastBlock.cache_control, { type: 'ephemeral' }); }); -test('buildTargetRequest extracts flat text.format json_schema into output_config.format and drops OpenAI-only fields', async () => { +test('buildTargetPayload extracts flat text.format json_schema into output_config.format and drops OpenAI-only fields', async () => { const schema = { type: 'object', properties: { x: { type: 'string' } }, required: ['x'], additionalProperties: false }; const result = await buildWithTestSidecar({ model: 'claude-test', @@ -974,7 +1033,7 @@ test('buildTargetRequest extracts flat text.format json_schema into output_confi assertEquals(result.target.output_config, { format: { type: 'json_schema', schema } }); }); -test('buildTargetRequest merges reasoning.effort with structured-output format on a single output_config', async () => { +test('buildTargetPayload merges reasoning.effort with structured-output format on a single output_config', async () => { const schema = { type: 'object', properties: { ok: { type: 'boolean' } }, required: ['ok'], additionalProperties: false }; const result = await buildWithTestSidecar({ model: 'claude-test', @@ -996,7 +1055,7 @@ test('buildTargetRequest merges reasoning.effort with structured-output format o assertEquals(result.target.output_config, { effort: 'high', format: { type: 'json_schema', schema } }); }); -test('buildTargetRequest drops text.format json_object (no Anthropic equivalent)', async () => { +test('buildTargetPayload drops text.format json_object (no Anthropic equivalent)', async () => { const result = await buildWithTestSidecar({ model: 'claude-test', input: [{ type: 'message', role: 'user', content: 'hi' }], @@ -1016,7 +1075,7 @@ test('buildTargetRequest drops text.format json_object (no Anthropic equivalent) assertFalse('output_config' in result.target); }); -test('buildTargetRequest hoists leading role:"system" to top-level system field', async () => { +test('buildTargetPayload hoists leading role:"system" to top-level system field', async () => { const result = await buildWithTestSidecar( { model: 'claude-test', @@ -1035,7 +1094,6 @@ test('buildTargetRequest hoists leading role:"system" to top-level system field' store: false, parallel_tool_calls: true, }, - { loadRemoteImage: stubRemoteImageLoader(null) }, ); assertEquals(result.target.system, [{ type: 'text', text: 'be terse', cache_control: { type: 'ephemeral' } }]); @@ -1043,7 +1101,7 @@ test('buildTargetRequest hoists leading role:"system" to top-level system field' assertEquals(result.target.messages[0].role, 'user'); }); -test('buildTargetRequest keeps non-leading role:"system" inline', async () => { +test('buildTargetPayload keeps non-leading role:"system" inline', async () => { const result = await buildWithTestSidecar( { model: 'claude-test', @@ -1063,7 +1121,6 @@ test('buildTargetRequest keeps non-leading role:"system" inline', async () => { store: false, parallel_tool_calls: true, }, - { loadRemoteImage: stubRemoteImageLoader(null) }, ); assertEquals(result.target.messages.length, 3); @@ -1073,7 +1130,7 @@ test('buildTargetRequest keeps non-leading role:"system" inline', async () => { assertFalse('system' in result.target); }); -test('buildTargetRequest hoists leading role:"developer" to top-level system field', async () => { +test('buildTargetPayload hoists leading role:"developer" to top-level system field', async () => { const result = await buildWithTestSidecar( { model: 'claude-test', @@ -1092,7 +1149,6 @@ test('buildTargetRequest hoists leading role:"developer" to top-level system fie store: false, parallel_tool_calls: true, }, - { loadRemoteImage: stubRemoteImageLoader(null) }, ); assertEquals(result.target.messages.length, 1); @@ -1100,7 +1156,7 @@ test('buildTargetRequest hoists leading role:"developer" to top-level system fie assertEquals(result.target.system, [{ type: 'text', text: 'dev rule', cache_control: { type: 'ephemeral' } }]); }); -test('buildTargetRequest preserves payload.instructions and leading system as separate blocks; non-leading stays inline', async () => { +test('buildTargetPayload preserves payload.instructions and leading system as separate blocks; non-leading stays inline', async () => { const result = await buildWithTestSidecar( { model: 'claude-test', @@ -1121,20 +1177,19 @@ test('buildTargetRequest preserves payload.instructions and leading system as se store: false, parallel_tool_calls: true, }, - { loadRemoteImage: stubRemoteImageLoader(null) }, ); assertEquals(result.target.system, [ - { type: 'text', text: 'canonical top-level instructions' }, - { type: 'text', text: 'leading note', cache_control: { type: 'ephemeral' } }, + { type: 'text', text: 'canonical top-level instructions', cache_control: { type: 'ephemeral' } }, ]); - assertEquals(result.target.messages.length, 3); - assertEquals(result.target.messages[0].role, 'user'); - assertEquals(result.target.messages[1], { role: 'system', content: [{ type: 'text', text: 'mid-array note' }] }); - assertEquals(result.target.messages[2].role, 'user'); + assertEquals(result.target.messages.length, 4); + assertEquals(result.target.messages[0], { role: 'system', content: [{ type: 'text', text: 'leading note' }] }); + assertEquals(result.target.messages[1].role, 'user'); + assertEquals(result.target.messages[2], { role: 'system', content: [{ type: 'text', text: 'mid-array note' }] }); + assertEquals(result.target.messages[3].role, 'user'); }); -test('buildTargetRequest throws when a system input message contains an image part', async () => { +test('buildTargetPayload throws when a system input message contains an image part', async () => { await assertRejects( () => buildWithTestSidecar( @@ -1162,14 +1217,13 @@ test('buildTargetRequest throws when a system input message contains an image pa store: false, parallel_tool_calls: true, }, - { loadRemoteImage: stubRemoteImageLoader(null) }, ), Error, "Invalid 'input_image' content part in system message", ); }); -test('buildTargetRequest throws when a non-leading developer input message contains an image part', async () => { +test('buildTargetPayload throws when a non-leading developer input message contains an image part', async () => { await assertRejects( () => buildWithTestSidecar( @@ -1197,13 +1251,22 @@ test('buildTargetRequest throws when a non-leading developer input message conta store: false, parallel_tool_calls: true, }, - { loadRemoteImage: stubRemoteImageLoader(null) }, ), Error, "Invalid 'input_image' content part in developer message", ); }); -// (Native native↔native only — OpenAI Responses no longer carries thinking-mode -// extension fields; alias overlays land on the Anthropic Messages target payload at the wire -// call via `applyRulesToUpstreamAnthropicMessages`.) +test('keeps an empty assistant message as the turn that separates two client runs', async () => { + // Dropping it would leave two adjacent user messages, which this wire merges + // into one turn, losing the boundary the client stated. + const result = await buildWithTestSidecar({ + model: 'gpt-test', + input: [ + { type: 'message', role: 'user', content: 'one' }, + { type: 'message', role: 'assistant', content: [] }, + { type: 'message', role: 'user', content: 'two' }, + ], + }); + assertEquals(result.target.messages.map(message => message.role), ['user', 'assistant', 'user']); +}); diff --git a/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-events_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-events_test.ts new file mode 100644 index 0000000000..326a0b6419 --- /dev/null +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-events_test.ts @@ -0,0 +1,7 @@ +import { buildSourceEventsFor, emptyPayload, pairs } from '../../composition/helpers.ts'; +import { testOutputTranslation } from '../fixtures.ts'; + +testOutputTranslation('openai-responses', 'openai-chat-completions', (frames, codec) => { + const [source, target] = pairs.responsesToChat; + return buildSourceEventsFor(source, target, emptyPayload.responses, { sidecarCodec: codec }, frames); +}); diff --git a/packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/request_test.ts b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts similarity index 77% rename from packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/request_test.ts rename to packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts index 853c1cc749..4411cb904f 100644 --- a/packages/translate/__tests__/protocols/openai-responses/via-openai-chat-completions/request_test.ts +++ b/packages/translate/__tests__/protocols/pairs/openai-responses-to-openai-chat-completions-request_test.ts @@ -1,15 +1,55 @@ import { test } from 'vitest'; -import { buildTargetRequest as buildTargetRequestWithSidecar } from '../../../../src/protocols/openai-responses/via-openai-chat-completions/request.ts'; -import { createTestSidecarCodec } from '../../../round-trip/sidecar-codec.ts'; +import { buildTargetPayloadFor, pairs } from '../../composition/helpers.ts'; +import { createTestSidecarCodec } from '../../round-trip/sidecar-codec.ts'; import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsFunctionTool, OpenAIChatCompletionsToolMessage } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesRequestPayloadEx, OpenAIResponsesInputMultiAgentCallOutputItem, OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; import { assertEquals, assertRejects } from '@floway-dev/test-utils'; -const buildWithTestSidecar = (payload: Parameters[0]) => - buildTargetRequestWithSidecar(payload, createTestSidecarCodec()); +const buildWithTestSidecar = async (payload: OpenAIResponsesRequestPayloadEx) => { + const streamed = { ...payload, stream: true }; + return { + target: await buildTargetPayloadFor(pairs.responsesToChat[0], pairs.responsesToChat[1], streamed, { sidecarCodec: createTestSidecarCodec() }), + }; +}; + +test('preserves a refusal-only assistant turn between client turns', async () => { + const { target } = await buildWithTestSidecar({ + model: 'model', input: [ + { type: 'message', role: 'user', content: 'Q' }, + { type: 'message', role: 'assistant', content: [{ type: 'refusal', refusal: 'Cannot help.' }] }, + { type: 'message', role: 'user', content: 'Why?' }, + ], + }); + assertEquals(target.messages, [ + { role: 'user', content: 'Q' }, + { role: 'assistant', content: '', refusal: 'Cannot help.' }, + { role: 'user', content: 'Why?' }, + ]); +}); -test('buildTargetRequest accepts an implicit message discriminator', async () => { +test('preserves an empty assistant turn between client turns', async () => { + const { target } = await buildWithTestSidecar({ + model: 'model', input: [ + { type: 'message', role: 'user', content: 'A' }, + { type: 'message', role: 'assistant', content: [] }, + { type: 'message', role: 'user', content: 'B' }, + ], + }); + assertEquals(target.messages, [ + { role: 'user', content: 'A' }, + { role: 'assistant', content: '' }, + { role: 'user', content: 'B' }, + ]); +}); + +test('preserves nullable metadata and service tier controls', async () => { + const { target } = await buildWithTestSidecar({ model: 'model', input: 'hi', metadata: null, service_tier: null }); + assertEquals(target.metadata, null); + assertEquals(target.service_tier, null); +}); + +test('buildTargetPayload accepts an implicit message discriminator', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ role: 'system', content: 'rules' }], @@ -20,7 +60,7 @@ test('buildTargetRequest accepts an implicit message discriminator', async () => ]); }); -test('buildTargetRequest rejects an agent_message that no interceptor lowered', async () => { +test('buildTargetPayload rejects an agent_message that no interceptor lowered', async () => { await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', @@ -32,11 +72,11 @@ test('buildTargetRequest rejects an agent_message that no interceptor lowered', }], }), Error, - "Invalid input item type 'agent_message'.", + 'Invalid input item: {"type":"agent_message"', ); }); -test('buildTargetRequest merges adjacent assistant reasoning text and tool calls', async () => { +test('buildTargetPayload merges adjacent assistant reasoning text and tool calls', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [ @@ -89,6 +129,7 @@ test('buildTargetRequest merges adjacent assistant reasoning text and tool calls assertEquals(result.target.model, 'gpt-test'); assertEquals(result.target.max_tokens, 256); + assertEquals(result.target.max_completion_tokens, 256); assertEquals(result.target.metadata, { trace_id: 'trace_123' }); assertEquals(result.target.store, false); assertEquals(result.target.parallel_tool_calls, true); @@ -129,7 +170,7 @@ test('buildTargetRequest merges adjacent assistant reasoning text and tool calls ]); }); -test('buildTargetRequest preserves explicit null prompt cache and safety fields', async () => { +test('buildTargetPayload preserves explicit null prompt cache and safety fields', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: 'hello', @@ -143,7 +184,7 @@ test('buildTargetRequest preserves explicit null prompt cache and safety fields' assertEquals(result.target.safety_identifier, null); }); -test('buildTargetRequest omits response_format when OpenAI Responses text.format is absent', async () => { +test('buildTargetPayload omits response_format when OpenAI Responses text.format is absent', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: 'Hi', @@ -153,7 +194,7 @@ test('buildTargetRequest omits response_format when OpenAI Responses text.format assertEquals('response_format' in result.target, false); }); -test('buildTargetRequest omits a null text format from the Chat response-format field', async () => { +test('buildTargetPayload omits a null text format from the Chat response-format field', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: 'Hi', @@ -163,7 +204,7 @@ test('buildTargetRequest omits a null text format from the Chat response-format assertEquals(result.target.response_format, undefined); }); -test('buildTargetRequest reshapes flat json_schema text format into OpenAI Chat Completions shape', async () => { +test('buildTargetPayload reshapes flat json_schema text format into OpenAI Chat Completions shape', async () => { const schema = { type: 'object', properties: { ok: { type: 'boolean' } }, @@ -192,7 +233,19 @@ test('buildTargetRequest reshapes flat json_schema text format into OpenAI Chat }); }); -test('buildTargetRequest passes through plain text format without wrapping', async () => { +test('buildTargetPayload preserves null json_schema strictness', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + input: 'Hi', + text: { format: { type: 'json_schema', name: 'result', schema: {}, strict: null } }, + }); + assertEquals(result.target.response_format, { + type: 'json_schema', + json_schema: { name: 'result', schema: {}, strict: null }, + }); +}); + +test('buildTargetPayload passes through plain text format without wrapping', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: 'Hi', @@ -202,7 +255,7 @@ test('buildTargetRequest passes through plain text format without wrapping', asy assertEquals(result.target.response_format, { type: 'text' }); }); -test('buildTargetRequest does not double-wrap an already-wrapped json_schema', async () => { +test('buildTargetPayload does not double-wrap an already-wrapped json_schema', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: 'Hi', @@ -220,7 +273,7 @@ test('buildTargetRequest does not double-wrap an already-wrapped json_schema', a }); }); -test.each(['web_search_preview', 'file_search', 'image_generation', 'local_shell'])('buildTargetRequest rejects unsupported tool type %s', async type => { +test.each(['web_search_preview', 'file_search', 'image_generation', 'local_shell'])('buildTargetPayload rejects unsupported tool type %s', async type => { await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', @@ -228,11 +281,11 @@ test.each(['web_search_preview', 'file_search', 'image_generation', 'local_shell tools: [{ type } as unknown as OpenAIResponsesTool], }), Error, - `Cannot translate OpenAI Responses tool type '${type}' to OpenAI Chat Completions.`, + `Cannot translate OpenAI Responses tool type '${type}'.`, ); }); -test('buildTargetRequest omits parameters and strict for a schema-less function tool', async () => { +test('buildTargetPayload omits parameters and strict for a schema-less function tool', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: 'Hi' }], @@ -244,23 +297,23 @@ test('buildTargetRequest omits parameters and strict for a schema-less function assertEquals(Object.keys((result.target.tools![0] as OpenAIChatCompletionsFunctionTool).function), ['name']); }); -test.each(['web_search', 'file_search'])('buildTargetRequest rejects unsupported string tool choice %s', async toolChoice => { +test.each(['web_search', 'file_search'])('buildTargetPayload rejects unsupported string tool choice %s', async toolChoice => { await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', input: 'Hi', tool_choice: toolChoice as OpenAIResponsesToolChoice }), Error, - `Cannot translate OpenAI Responses tool choice '${toolChoice}' to OpenAI Chat Completions.`, + `Cannot translate OpenAI Responses tool choice '${toolChoice}'.`, ); }); -test.each(['web_search_preview', 'file_search'])('buildTargetRequest rejects unsupported object tool choice type %s', async type => { +test.each(['web_search_preview', 'file_search'])('buildTargetPayload rejects unsupported object tool choice type %s', async type => { await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', input: 'Hi', tool_choice: { type } as unknown as OpenAIResponsesToolChoice }), Error, - `Cannot translate OpenAI Responses tool choice type '${type}' to OpenAI Chat Completions.`, + `Cannot translate OpenAI Responses tool choice type '${type}'.`, ); }); -test('buildTargetRequest keeps function tool_choice', async () => { +test('buildTargetPayload keeps function tool_choice', async () => { const resultWithFunctionChoice = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: 'Hi' }], @@ -268,7 +321,7 @@ test('buildTargetRequest keeps function tool_choice', async () => { temperature: null, top_p: null, max_output_tokens: null, - tools: null, + tools: [{ type: 'function', name: 'get_weather', parameters: { type: 'object', properties: {} } }], tool_choice: { type: 'function' as const, name: 'get_weather', @@ -286,7 +339,7 @@ test('buildTargetRequest keeps function tool_choice', async () => { }); }); -test.each(['auto', 'required', 'none'] as const)('buildTargetRequest keeps supported string tool choice %s', async toolChoice => { +test.each(['auto', 'none'] as const)('buildTargetPayload omits orphan string tool choice %s', async toolChoice => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: 'Hi' }], @@ -303,10 +356,30 @@ test.each(['auto', 'required', 'none'] as const)('buildTargetRequest keeps suppo text: null, }); - assertEquals(result.target.tool_choice, toolChoice); + assertEquals(result.target.tool_choice, undefined); +}); + +test('buildTargetPayload retains a required tool choice without declarations', async () => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + input: [{ type: 'message', role: 'user', content: 'Hi' }], + instructions: null, + temperature: null, + top_p: null, + max_output_tokens: null, + tools: null, + tool_choice: 'required', + metadata: null, + stream: null, + store: null, + parallel_tool_calls: null, + text: null, + }); + + assertEquals(result.target.tool_choice, 'required'); }); -test('buildTargetRequest wraps custom tools as single-string function tools and records their names', async () => { +test('buildTargetPayload wraps custom tools as single-string function tools and records their names', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: 'hi', @@ -330,7 +403,6 @@ test('buildTargetRequest wraps custom tools as single-string function tools and text: null, }); - assertEquals(result.customToolNames.has('apply_patch'), true); assertEquals(result.target.tools, [ { type: 'function', @@ -359,7 +431,7 @@ test.each([ { name: 'string', output: 'ok', expected: 'ok' }, { name: 'text array', output: [{ type: 'input_text' as const, text: 'first\n' }, { type: 'input_text' as const, text: 'second' }], expected: 'first\nsecond' }, { name: 'empty array', output: [], expected: '' }, -])('buildTargetRequest projects custom_tool_call history with $name output into wrapped tool_calls shape', async ({ output, expected }) => { +])('buildTargetPayload projects custom_tool_call history with $name output into wrapped tool_calls shape', async ({ output, expected }) => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [ @@ -415,15 +487,15 @@ test.each([ { name: 'multi_agent_call_output', input: [{ type: 'multi_agent_call_output', action: 'spawn_agent', call_id: 'call_1', output: [] as OpenAIResponsesInputMultiAgentCallOutputItem['output'] }] }, { name: 'context_compaction', input: [{ type: 'context_compaction', encrypted_content: 'opaque' }] }, { name: 'item_reference', input: [{ type: 'item_reference', id: 'msg_1' }] }, -] as const)('buildTargetRequest rejects OpenAI-Responses-only $name input', async ({ name, input }) => { +] as const)('buildTargetPayload rejects OpenAI-Responses-only $name input', async ({ name, input }) => { await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', input: [...input] }), Error, - `Invalid input item type '${name}'`, + `Invalid input item: {"type":"${name}"`, ); }); -test('buildTargetRequest wires OpenAI Responses tooling guards', async () => { +test('buildTargetPayload wires OpenAI Responses tooling guards', async () => { await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', @@ -439,23 +511,28 @@ test('buildTargetRequest wires OpenAI Responses tooling guards', async () => { ); }); -test('buildTargetRequest accepts null tool_choice', async () => { +test('buildTargetPayload accepts null tool_choice', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: 'hi', tool_choice: null }); assertEquals(result.target.tool_choice, undefined); }); -test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetRequest rejects input_file in %s', async type => { - await assertRejects( - () => buildWithTestSidecar({ - model: 'gpt-test', - input: [{ type, call_id: 'call_1', output: [{ type: 'input_file', file_id: 'file_1' }] }], - }), - Error, - 'input_file tool output', - ); +test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetPayload lifts input_file from %s', async type => { + const result = await buildWithTestSidecar({ + model: 'gpt-test', + input: [ + ...(type === 'function_call_output' + ? [{ type: 'function_call' as const, call_id: 'call_1', name: 'lookup', arguments: '{}' }] + : [{ type: 'custom_tool_call' as const, call_id: 'call_1', name: 'lookup', input: '{}' }]), + { type, call_id: 'call_1', output: [{ type: 'input_file', file_id: 'file_1', filename: 'result.txt' }] }, + ], + }); + assertEquals(result.target.messages.at(-1)?.content, [ + { type: 'text', text: 'File output from tool call call_1:' }, + { type: 'file', file: { file_id: 'file_1', filename: 'result.txt' } }, + ]); }); -test('buildTargetRequest rejects file assistant content', async () => { +test('buildTargetPayload rejects file assistant content', async () => { await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', @@ -466,7 +543,7 @@ test('buildTargetRequest rejects file assistant content', async () => { ); }); -test('buildTargetRequest rejects image assistant content', async () => { +test('buildTargetPayload rejects image assistant content', async () => { await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', @@ -477,7 +554,7 @@ test('buildTargetRequest rejects image assistant content', async () => { ); }); -test('buildTargetRequest rejects file_id-only images', async () => { +test('buildTargetPayload rejects file_id-only images', async () => { await assertRejects( () => buildWithTestSidecar({ model: 'gpt-test', @@ -488,7 +565,7 @@ test('buildTargetRequest rejects file_id-only images', async () => { ); }); -test('buildTargetRequest forwards image details OpenAI Chat Completions does not publish', async () => { +test('buildTargetPayload forwards image details OpenAI Chat Completions does not publish', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ @@ -507,7 +584,7 @@ test('buildTargetRequest forwards image details OpenAI Chat Completions does not ]); }); -test('buildTargetRequest omits image detail when the client sends none', async () => { +test('buildTargetPayload omits image detail when the client sends none', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: [{ type: 'input_image', image_url: 'data:image/png;base64,AQID' }] }], @@ -518,7 +595,7 @@ test('buildTargetRequest omits image detail when the client sends none', async ( ]); }); -test('buildTargetRequest omits image detail when the client sends null', async () => { +test('buildTargetPayload omits image detail when the client sends null', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: [{ type: 'input_image', image_url: 'data:image/png;base64,AQID', detail: null }] }], @@ -527,7 +604,7 @@ test('buildTargetRequest omits image detail when the client sends null', async ( assertEquals(result.target.messages[0].content, [{ type: 'image_url', image_url: { url: 'data:image/png;base64,AQID' } }]); }); -test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetRequest omits image detail on lifted %s images', async type => { +test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTargetPayload omits image detail on lifted %s images', async type => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [ @@ -548,7 +625,7 @@ test.each(['function_call_output', 'custom_tool_call_output'] as const)('buildTa ]); }); -test('buildTargetRequest throws on a stray web_search_call input item (shim owns the reverse path)', async () => { +test('buildTargetPayload throws on a stray web_search_call input item (shim owns the reverse path)', async () => { // The OpenAI Responses web-search shim rewrites web_search_call input items into // upstream function_call + function_call_output pairs before this // translator runs. Reaching the translator with a raw web_search_call @@ -578,11 +655,11 @@ test('buildTargetRequest throws on a stray web_search_call input item (shim owns parallel_tool_calls: true, }), Error, - "Invalid input item type 'web_search_call'", + 'Invalid input item: {"type":"web_search_call"', ); }); -test('buildTargetRequest throws on a stray compaction_trigger input item (compact-shim owns the strip)', async () => { +test('buildTargetPayload throws on a stray compaction_trigger input item (compact-shim owns the strip)', async () => { // The compact-shim is structurally required on non-openai-responses targets and // strips compaction_trigger items before reaching this translator. // Reaching here with one in input means the shim disengaged; the @@ -606,11 +683,11 @@ test('buildTargetRequest throws on a stray compaction_trigger input item (compac parallel_tool_calls: true, }), Error, - "Invalid input item type 'compaction_trigger'", + 'Invalid input item: {"type":"compaction_trigger"', ); }); -test('buildTargetRequest throws on a stray compaction input item (compact-shim owns the expansion)', async () => { +test('buildTargetPayload throws on a stray compaction input item (compact-shim owns the expansion)', async () => { // The compact-shim expands its own shim-encoded compaction items inline // before reaching this translator and round-trips foreign compactions // back to the upstream as raw items. Either way the translator should @@ -634,11 +711,11 @@ test('buildTargetRequest throws on a stray compaction input item (compact-shim o parallel_tool_calls: true, }), Error, - "Invalid input item type 'compaction'", + 'Invalid input item: {"type":"compaction"', ); }); -test('buildTargetRequest lifts tool-output images into a following user message', async () => { +test('buildTargetPayload lifts tool-output images into a following user message', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [ @@ -685,7 +762,7 @@ test('buildTargetRequest lifts tool-output images into a following user message' ]); }); -test('buildTargetRequest keeps grouped tool results contiguous before lifted images', async () => { +test('buildTargetPayload keeps grouped tool results contiguous before lifted images', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [ @@ -735,7 +812,7 @@ test('buildTargetRequest keeps grouped tool results contiguous before lifted ima ]); }); -test('buildTargetRequest places lifted images before a later source message', async () => { +test('buildTargetPayload places lifted images before a later source message', async () => { for (const trailing of [ { type: 'message' as const, role: 'user' as const, content: 'new user turn' }, { type: 'message' as const, role: 'system' as const, content: 'new system turn' }, @@ -760,7 +837,7 @@ test('buildTargetRequest places lifted images before a later source message', as // ── Native field forwarding ── -test('buildTargetRequest maps text.verbosity onto verbosity', async () => { +test('buildTargetPayload maps text.verbosity onto verbosity', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: 'hi' }], @@ -770,7 +847,7 @@ test('buildTargetRequest maps text.verbosity onto verbosity', async () => { assertEquals(result.target.verbosity, 'low'); }); -test('buildTargetRequest co-emits reasoning.effort onto reasoning_effort and service_tier verbatim', async () => { +test('buildTargetPayload co-emits reasoning.effort onto reasoning_effort and service_tier verbatim', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: 'hi' }], @@ -782,7 +859,7 @@ test('buildTargetRequest co-emits reasoning.effort onto reasoning_effort and ser assertEquals(result.target.service_tier, 'priority'); }); -test('buildTargetRequest forwards empty reasoning.effort verbatim', async () => { +test('buildTargetPayload forwards empty reasoning.effort verbatim', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: 'hi' }], @@ -792,7 +869,7 @@ test('buildTargetRequest forwards empty reasoning.effort verbatim', async () => assertEquals(result.target.reasoning_effort, ''); }); -test('buildTargetRequest drops reasoning.summary (OpenAI Chat Completions has no slot)', async () => { +test('buildTargetPayload drops reasoning.summary (OpenAI Chat Completions has no slot)', async () => { const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role: 'user', content: 'hi' }], @@ -807,3 +884,16 @@ test.each(['system', 'developer'] as const)('preserves %s text part boundaries', const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role, content: [{ type: 'input_text', text: 'a' }, { type: 'input_text', text: 'b' }] }] }); assertEquals(result.target.messages, [{ role, content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }] }]); }); + +test.each(['system', 'developer'] as const)('collapses an empty %s message to the empty string', async role => { + const result = await buildWithTestSidecar({ model: 'gpt-test', input: [{ type: 'message', role, content: '' }] }); + assertEquals(result.target.messages, [{ role, content: '' }]); +}); + +test('carries Responses penalties into the Chat request', async () => { + const { target } = await buildWithTestSidecar({ + model: 'm', input: 'hello', presence_penalty: 0.6, frequency_penalty: -0.4, + }); + assertEquals(target.presence_penalty, 0.6); + assertEquals(target.frequency_penalty, -0.4); +}); diff --git a/packages/translate/__tests__/protocols/public-boundary_test.ts b/packages/translate/__tests__/protocols/public-boundary_test.ts index 7c2cf32399..31d2ff39e6 100644 --- a/packages/translate/__tests__/protocols/public-boundary_test.ts +++ b/packages/translate/__tests__/protocols/public-boundary_test.ts @@ -8,11 +8,11 @@ import { routeOpenAIResponsesViaOpenAIChatCompletions, } from '@floway-dev/translate'; -const options = { model: 'resolved-model', sidecarCodec: createTestSidecarCodec('public-boundary') }; +const options = { sidecarCodec: createTestSidecarCodec('public-boundary') }; test('public translators return ordinary input-failure data before preparing a trip', async () => { const failure = await routeOpenAIChatCompletionsViaOpenAIResponses({ - model: 'source-model', messages: [], + model: 'source-model', stream: true, messages: [], response_format: { type: 'json_schema', json_schema: { name: 'shape' } }, }, options); expect(failure).toEqual({ type: 'input-error', message: expect.stringContaining('without a schema') }); @@ -26,32 +26,29 @@ test('public translators return ordinary input-failure data before preparing a t test('public operations preserve unexpected errors and their causes', async () => { const cause = new Error('transport'); - const failure = new Error('image loader', { cause }); + const failure = new Error('request read', { cause }); await expect(routeOpenAIChatCompletionsViaAnthropicMessages({ - model: 'source-model', messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://example.com/image.png' } }] }], - }, { ...options, fallbackMaxOutputTokens: 32, loadRemoteImage: async () => { throw failure; } })).rejects.toBe(failure); + model: 'source-model', get messages(): never { throw failure; }, + }, { ...options, fallbackMaxOutputTokens: 32 })).rejects.toBe(failure); expect(failure.cause).toBe(cause); }); -test('Messages translation retains the injected remote-image loader', async () => { - const urls: string[] = []; +test('Messages translation preserves URL media in its native request', async () => { const trip = await routeOpenAIChatCompletionsViaAnthropicMessages({ - model: 'source-model', messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://example.com/image.png' } }] }], + model: 'source-model', stream: true, messages: [{ role: 'user', content: [{ type: 'image_url', image_url: { url: 'https://example.com/image.png' } }] }], }, { ...options, fallbackMaxOutputTokens: 32, - loadRemoteImage: async url => { urls.push(url); return { mediaType: 'image/png', data: new Uint8Array([1, 2, 3]) }; }, }); expect(trip.type).toBe('trip'); if (trip.type !== 'trip') throw new Error(trip.message); - expect(urls).toEqual(['https://example.com/image.png']); - expect(trip.target.messages[0].content).toEqual([{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: 'AQID' }, cache_control: { type: 'ephemeral' } }]); + expect(trip.target.messages[0].content).toEqual([{ type: 'image', source: { type: 'url', url: 'https://example.com/image.png' }, cache_control: { type: 'ephemeral' } }]); expect(trip.target.max_tokens).toBe(32); expect(trip.error(418, new Headers(), new Uint8Array())).toBeUndefined(); }); -test('Gemini translation retains the resolved model supplied by the gateway', async () => { - const trip = await routeGeminiGenerateContentViaOpenAIChatCompletions({ contents: [{ role: 'user', parts: [{ text: 'hello' }] }] }, options); +test('Gemini translation retains the model in the formal source request', async () => { + const trip = await routeGeminiGenerateContentViaOpenAIChatCompletions({ model: 'resolved-model', contents: [{ role: 'user', parts: [{ text: 'hello' }] }] }, options); expect(trip.type).toBe('trip'); if (trip.type !== 'trip') throw new Error(trip.message); expect(trip.target.model).toBe('resolved-model'); diff --git a/packages/translate/__tests__/protocols/round-trip-receive_test.ts b/packages/translate/__tests__/protocols/round-trip-receive_test.ts index e9f67a5a5d..eb0bda567a 100644 --- a/packages/translate/__tests__/protocols/round-trip-receive_test.ts +++ b/packages/translate/__tests__/protocols/round-trip-receive_test.ts @@ -1,32 +1,32 @@ import { expect, test } from 'vitest'; -import { requireTrip } from './require-trip.ts'; import type { IRJSONObject } from '../../src/ir/common/json.ts'; import { computeAnthropicMessagesTurnDigest } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; import { computeGeminiGenerateContentTurnDigest } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; import { computeOpenAIChatCompletionsTurnDigest } from '../../src/protocols/openai-chat-completions/as-source/round-trip.ts'; import { computeOpenAIResponsesTurnDigest } from '../../src/protocols/openai-responses/as-source/round-trip.ts'; import { hashRoundTripContent } from '../../src/round-trip/hash.ts'; -import type { InputAssistantTurnFor, AssistantTurnProtocol } from '../../src/round-trip/input-assistant-turn.ts'; +import type { AssistantTurnProtocol, InputAssistantTurnFor } from '../../src/round-trip/input-assistant-turn.ts'; import type { ReferenceableInputAssistantTurnFor } from '../../src/round-trip/reference/assistant-turn-types.ts'; import { ContentReference } from '../../src/round-trip/reference/types.ts'; import { encodeRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; import { replayMaterialsByType } from '../../src/round-trip/turn-digest.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; +import { expectTrip } from '../trip.ts'; import type { AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; -import type { GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; +import type { GeminiGenerateContentRequest } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesRequestPayloadEx } from '@floway-dev/protocols/openai-responses'; import { routeAnthropicMessagesViaOpenAIChatCompletions, routeAnthropicMessagesViaOpenAIResponses, routeGeminiGenerateContentViaAnthropicMessages, routeGeminiGenerateContentViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIResponses, routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses, routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; -const context = { model: 'target', sidecarCodec: createTestSidecarCodec('test-codec-scope'), loadRemoteImage: async () => null, fallbackMaxOutputTokens: undefined }; +const context = { sidecarCodec: createTestSidecarCodec('test-codec-scope'), fallbackMaxOutputTokens: undefined }; const chat = (carrier: string, text = 'answer'): OpenAIChatCompletionsPayloadEx => { const message: OpenAIChatCompletionsAssistantMessageEx = { role: 'assistant', content: text, reasoning_details: [{ type: 'reasoning.encrypted', data: carrier }] }; - return { model: 'source', messages: [message] }; + return { model: 'source', stream: true, messages: [message] }; }; -const messages = (carrier: string): AnthropicMessagesPayload => ({ model: 'source', max_tokens: 16, messages: [{ role: 'assistant', content: [{ type: 'text', text: 'answer' }, { type: 'redacted_thinking', data: carrier }] }] }); -const responses = (carrier: string): OpenAIResponsesRequestPayloadEx => ({ model: 'source', input: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'answer' }] }, { type: 'reasoning', id: 'carrier', summary: [], encrypted_content: carrier }] }); -const gemini = (carrier: string): GeminiGenerateContentPayload => ({ contents: [{ role: 'model', parts: [{ text: 'answer', thoughtSignature: carrier }] }] }); +const messages = (carrier: string): AnthropicMessagesPayload => ({ model: 'source', max_tokens: 16, stream: true, messages: [{ role: 'assistant', content: [{ type: 'text', text: 'answer' }, { type: 'redacted_thinking', data: carrier }] }] }); +const responses = (carrier: string): OpenAIResponsesRequestPayloadEx => ({ model: 'source', stream: true, input: [{ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'answer' }] }, { type: 'reasoning', id: 'carrier', summary: [], encrypted_content: carrier }] }); +const gemini = (carrier: string): GeminiGenerateContentRequest => ({ model: 'model', contents: [{ role: 'model', parts: [{ text: 'answer', thoughtSignature: carrier }] }] }); const turnDigest = async (source: AssistantTurnProtocol) => source === 'openaiChatCompletions' ? await computeOpenAIChatCompletionsTurnDigest({ role: 'assistant', content: 'answer' }) @@ -44,21 +44,21 @@ const nativeTurn = (target: NativeProtocol, text = 'restored'): InputAssistantTu : [{ type: 'message', role: 'assistant', content: text }]; const cases = [ - { source: 'openaiChatCompletions', target: 'anthropicMessages', run: (carrier: string) => requireTrip(routeOpenAIChatCompletionsViaAnthropicMessages(chat(carrier), context)) }, - { source: 'openaiChatCompletions', target: 'openaiResponses', run: (carrier: string) => requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses(chat(carrier), context)) }, - { source: 'anthropicMessages', target: 'openaiChatCompletions', run: (carrier: string) => requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions(messages(carrier), context)) }, - { source: 'anthropicMessages', target: 'openaiResponses', run: (carrier: string) => requireTrip(routeAnthropicMessagesViaOpenAIResponses(messages(carrier), context)) }, - { source: 'openaiResponses', target: 'anthropicMessages', run: (carrier: string) => requireTrip(routeOpenAIResponsesViaAnthropicMessages(responses(carrier), context)) }, - { source: 'openaiResponses', target: 'openaiChatCompletions', run: (carrier: string) => requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(responses(carrier), context)) }, - { source: 'geminiGenerateContent', target: 'anthropicMessages', run: (carrier: string) => requireTrip(routeGeminiGenerateContentViaAnthropicMessages(gemini(carrier), context)) }, - { source: 'geminiGenerateContent', target: 'openaiChatCompletions', run: (carrier: string) => requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(gemini(carrier), context)) }, - { source: 'geminiGenerateContent', target: 'openaiResponses', run: (carrier: string) => requireTrip(routeGeminiGenerateContentViaOpenAIResponses(gemini(carrier), context)) }, + { source: 'openaiChatCompletions', target: 'anthropicMessages', run: (carrier: string) => routeOpenAIChatCompletionsViaAnthropicMessages(chat(carrier), context) }, + { source: 'openaiChatCompletions', target: 'openaiResponses', run: (carrier: string) => routeOpenAIChatCompletionsViaOpenAIResponses(chat(carrier), context) }, + { source: 'anthropicMessages', target: 'openaiChatCompletions', run: (carrier: string) => routeAnthropicMessagesViaOpenAIChatCompletions(messages(carrier), context) }, + { source: 'anthropicMessages', target: 'openaiResponses', run: (carrier: string) => routeAnthropicMessagesViaOpenAIResponses(messages(carrier), context) }, + { source: 'openaiResponses', target: 'anthropicMessages', run: (carrier: string) => routeOpenAIResponsesViaAnthropicMessages(responses(carrier), context) }, + { source: 'openaiResponses', target: 'openaiChatCompletions', run: (carrier: string) => routeOpenAIResponsesViaOpenAIChatCompletions(responses(carrier), context) }, + { source: 'geminiGenerateContent', target: 'anthropicMessages', run: (carrier: string) => routeGeminiGenerateContentViaAnthropicMessages(gemini(carrier), context) }, + { source: 'geminiGenerateContent', target: 'openaiChatCompletions', run: (carrier: string) => routeGeminiGenerateContentViaOpenAIChatCompletions(gemini(carrier), context) }, + { source: 'geminiGenerateContent', target: 'openaiResponses', run: (carrier: string) => routeGeminiGenerateContentViaOpenAIResponses(gemini(carrier), context) }, ] as const; test.each(cases)('receives recognized $source history via $target', async ({ source, target, run }) => { const native = nativeTurn(target); const carrier = await encodeRoundTripSidecar(createTestSidecarCodec(), { v: 1, source, target, targetTurn: native, sourceTurnDigest: await turnDigest(source) }); - const trip = await run(carrier); + const trip = expectTrip(await run(carrier)); expect('input' in trip.target ? trip.target.input : trip.target.messages).toEqual(target === 'openaiResponses' ? native : [native]); }); @@ -72,7 +72,7 @@ test.each(cases)('hydrates $source text references before restoring $target hist const carrier = await encodeRoundTripSidecar(createTestSidecarCodec(), { v: 1, source, target, targetTurn, referencedContentHashes: [await hashRoundTripContent('answer')], sourceTurnDigest: await turnDigest(source), }); - const trip = await run(carrier); + const trip = expectTrip(await run(carrier)); const native = nativeTurn(target, 'ans'); expect('input' in trip.target ? trip.target.input : trip.target.messages).toEqual(target === 'openaiResponses' ? native : [native]); }); @@ -89,7 +89,7 @@ test('hydrates a native JSON field from a replayed GenerateContent argument cand referencedContentHashes: [await hashRoundTripContent(JSON.stringify(args))], sourceTurnDigest: await hashRoundTripContent(JSON.stringify(checkMaterial)), }); - const trip = await requireTrip(routeGeminiGenerateContentViaAnthropicMessages({ contents: [{ role: 'model', parts: [{ functionCall: { name: 'lookup', args }, thoughtSignature: carrier }] }] }, context)); + const trip = expectTrip(await routeGeminiGenerateContentViaAnthropicMessages({ model: 'model', contents: [{ role: 'model', parts: [{ functionCall: { name: 'lookup', args }, thoughtSignature: carrier }] }] }, context)); expect(trip.target.messages).toEqual([{ role: 'assistant', content: [{ type: 'tool_use', id: 'native-call', name: 'native-tool', input: args }] }]); }); @@ -107,7 +107,7 @@ test('exposes an authenticated missing reference as an internal error after repl test.each(cases)('leaves a terminal sidecar unrecognized by the injected decoder ordinary on $source via $target', async ({ run }) => { const carrier = 'unrecognized sidecar handle'; - const trip = await run(carrier); + const trip = expectTrip(await run(carrier)); expect(JSON.stringify('input' in trip.target ? trip.target.input : trip.target.messages)).toContain('answer'); }); @@ -117,36 +117,38 @@ test.each(['different codec scope', 'changed sidecar handle', 'changed replay'] const encoded = await encodeRoundTripSidecar(codec, { v: 1, source: 'openaiChatCompletions', target: 'anthropicMessages', targetTurn: native, sourceTurnDigest: await turnDigest('openaiChatCompletions') }); const carrier = failure === 'changed sidecar handle' ? `${encoded.startsWith('A') ? 'B' : 'A'}${encoded.slice(1)}` : encoded; const text = failure === 'changed replay' ? 'edited' : 'answer'; - const trip = await requireTrip(routeOpenAIChatCompletionsViaAnthropicMessages(chat(carrier, text), context)); + const trip = expectTrip(await routeOpenAIChatCompletionsViaAnthropicMessages(chat(carrier, text), context)); expect(trip.target.messages).toEqual([{ role: 'assistant', content: [{ type: 'text', text, cache_control: { type: 'ephemeral' } }] }]); }); test.each([{ input: 'text' }, { input: null }, { input: [1] }])('preserves ordinary Messages tool input $input', async ({ input }) => { const payload: AnthropicMessagesPayload = { - model: 'source', max_tokens: 16, + model: 'source', max_tokens: 16, stream: true, messages: [{ role: 'assistant', content: [{ type: 'tool_use', id: 'call', name: 'echo', input }] }], }; - const chatTrip = await requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)); + const chatTrip = expectTrip(await routeAnthropicMessagesViaOpenAIChatCompletions(payload, context)); expect(chatTrip.target.messages).toMatchObject([ { role: 'assistant', tool_calls: [{ id: 'call', function: { name: 'echo', arguments: JSON.stringify(input) } }] }, ]); - const responsesTrip = await requireTrip(routeAnthropicMessagesViaOpenAIResponses(payload, context)); + const responsesTrip = expectTrip(await routeAnthropicMessagesViaOpenAIResponses(payload, context)); expect(responsesTrip.target.input).toMatchObject([ { type: 'function_call', call_id: 'call', name: 'echo', arguments: JSON.stringify(input) }, ]); }); test('preserves ordinary GenerateContent null-argument handling', async () => { - const payload: GeminiGenerateContentPayload = JSON.parse('{"contents":[{"role":"model","parts":[{"functionCall":{"name":"lookup","args":null}}]}]}'); - const chatTrip = await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(payload, context)); + const payload: GeminiGenerateContentRequest = { ...JSON.parse('{"contents":[{"role":"model","parts":[{"functionCall":{"name":"lookup","args":null}}]}]}'), model: 'model' }; + const chatTrip = expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions(payload, context)); expect(chatTrip.target.messages).toMatchObject([ { role: 'assistant', tool_calls: [{ function: { name: 'lookup', arguments: 'null' } }] }, ]); - const responsesTrip = await requireTrip(routeGeminiGenerateContentViaOpenAIResponses(payload, context)); + const responsesTrip = expectTrip(await routeGeminiGenerateContentViaOpenAIResponses(payload, context)); expect(responsesTrip.target.input).toMatchObject([ { type: 'function_call', name: 'lookup', arguments: 'null' }, ]); - const messagesTrip = await requireTrip(routeGeminiGenerateContentViaAnthropicMessages(payload, context)); + const messagesTrip = expectTrip(await routeGeminiGenerateContentViaAnthropicMessages(payload, context)); + // ProtoJSON null remains the source value instead of being wrapped in an + // invented argument object. expect(messagesTrip.target.messages).toMatchObject([ { role: 'assistant', content: [{ type: 'tool_use', name: 'lookup', input: null }] }, ]); @@ -161,7 +163,7 @@ test('exposes a recognized malformed assistant envelope as an internal error', a targetTurn: { role: 'user', content: [] }, sourceTurnDigest: await turnDigest('openaiChatCompletions'), }); - await expect(requireTrip(routeOpenAIChatCompletionsViaAnthropicMessages(chat(carrier), context))).rejects.toThrow('Messages assistant turn is invalid'); + await expect(routeOpenAIChatCompletionsViaAnthropicMessages(chat(carrier), context)).rejects.toThrow('Messages assistant turn is invalid'); }); test('accepts incomplete Responses call history and requires its replayed status to match', async () => { @@ -177,9 +179,9 @@ test('accepts incomplete Responses call history and requires its replayed status sourceTurnDigest: await hashRoundTripContent(JSON.stringify(expected)), }); const reasoning = { type: 'reasoning' as const, id: 'carrier', summary: [], encrypted_content: carrier }; - const replayed = await requireTrip(routeOpenAIResponsesViaAnthropicMessages({ model: 'source', input: [call, reasoning] }, context)); + const replayed = expectTrip(await routeOpenAIResponsesViaAnthropicMessages({ model: 'source', stream: true, input: [call, reasoning] }, context)); expect(replayed.target.messages).toEqual([native]); - const changed = await requireTrip(routeOpenAIResponsesViaAnthropicMessages({ model: 'source', input: [{ ...call, status: 'completed' }, reasoning] }, context)); + const changed = expectTrip(await routeOpenAIResponsesViaAnthropicMessages({ model: 'source', stream: true, input: [{ ...call, status: 'completed' }, reasoning] }, context)); expect(changed.target.messages).not.toEqual([native]); expect(changed.target.messages).toEqual(expect.arrayContaining([ expect.objectContaining({ content: expect.arrayContaining([expect.objectContaining({ type: 'tool_use', id: 'call', name: 'lookup', input: { x: 1 } })]) }), diff --git a/packages/translate/__tests__/protocols/routes_test.ts b/packages/translate/__tests__/protocols/routes_test.ts index 3ee86d9314..678c5e61e4 100644 --- a/packages/translate/__tests__/protocols/routes_test.ts +++ b/packages/translate/__tests__/protocols/routes_test.ts @@ -1,7 +1,6 @@ import { test } from 'vitest'; import { fixtureFrames, type nativeResult } from './fixtures.ts'; -import { requireTrip } from './require-trip.ts'; import { partitionAnthropicMessagesTurns } from '../../src/protocols/anthropic-messages/as-source/conversation-turns.ts'; import { findAnthropicMessagesSidecarOccurrences } from '../../src/protocols/anthropic-messages/as-source/round-trip.ts'; import { findGeminiGenerateContentSidecarOccurrences } from '../../src/protocols/gemini-generate-content/as-source/round-trip.ts'; @@ -11,22 +10,25 @@ import { partitionOpenAIResponsesTurns } from '../../src/protocols/openai-respon import { findOpenAIResponsesSidecarOccurrences } from '../../src/protocols/openai-responses/as-source/round-trip.ts'; import { decodeRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; import { requireJSONObject } from '../../src/shared/json/object.ts'; +import type { TranslateInputError } from '../../src/types.ts'; import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; import { collect, iterate } from '../shared/iterables.ts'; +import { expectTrip } from '../trip.ts'; import { collectAnthropicMessagesProtocolEventsToResult, type AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; -import { collectGeminiGenerateContentProtocolEventsToResult, type GeminiGenerateContentContent, type GeminiGenerateContentPayload } from '@floway-dev/protocols/gemini-generate-content'; +import { collectGeminiGenerateContentProtocolEventsToResult, type GeminiGenerateContentContent, type GeminiGenerateContentRequest } from '@floway-dev/protocols/gemini-generate-content'; import { toOpenAIChatCompletionsInputAssistantMessage } from '@floway-dev/protocols/openai-chat-completions'; import { collectOpenAIChatCompletionsProtocolEventsToResult, type OpenAIChatCompletionsAssistantMessageEx, type OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; import { toOpenAIResponsesInputItems } from '@floway-dev/protocols/openai-responses'; import { collectOpenAIResponsesProtocolEventsToResult, type CanonicalOpenAIResponsesInputItem, type CanonicalOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; import { assert, assertEquals } from '@floway-dev/test-utils'; -import { routeAnthropicMessagesViaOpenAIChatCompletions, routeAnthropicMessagesViaOpenAIResponses, routeGeminiGenerateContentViaAnthropicMessages, routeGeminiGenerateContentViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIResponses, routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses, routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; +import { routeAnthropicMessagesViaOpenAIChatCompletions, routeAnthropicMessagesViaOpenAIResponses, routeOpenAIChatCompletionsViaAnthropicMessages, routeOpenAIChatCompletionsViaOpenAIResponses, routeGeminiGenerateContentViaAnthropicMessages, routeGeminiGenerateContentViaOpenAIChatCompletions, routeGeminiGenerateContentViaOpenAIResponses, routeOpenAIResponsesViaAnthropicMessages, routeOpenAIResponsesViaOpenAIChatCompletions } from '@floway-dev/translate'; const schema = { type: 'object' as const, properties: { x: { type: 'string' } } }; const formatSchema = { type: 'object', properties: { y: { type: 'string' } } }; const responses: CanonicalOpenAIResponsesPayload = { model: 'm', + stream: true, input: [ { type: 'reasoning', id: 'rs1', summary: [{ type: 'summary_text', text: 'thought' }] }, { type: 'message', role: 'user', content: 'hi' }, @@ -37,6 +39,7 @@ const responses: CanonicalOpenAIResponsesPayload = { }; const chat: OpenAIChatCompletionsPayload = { model: 'm', + stream: true, messages: [ { role: 'assistant', content: 'hello', reasoning_text: 'thought' } as OpenAIChatCompletionsAssistantMessageEx, { role: 'user', content: 'hi' }, @@ -48,14 +51,15 @@ const chat: OpenAIChatCompletionsPayload = { tool_choice: 'auto', }; const anthropic: AnthropicMessagesPayload = { - model: 'm', max_tokens: 16, + model: 'm', max_tokens: 16, stream: true, messages: [{ role: 'user', content: 'hi' }], tools: [{ name: 'f', input_schema: schema }], output_config: { format: { type: 'json_schema', schema: formatSchema } }, stop_sequences: ['END'], metadata: { user_id: 'u', extension: 'retained' } as AnthropicMessagesPayload['metadata'], }; -const gemini: GeminiGenerateContentPayload = { +const gemini: GeminiGenerateContentRequest = { + model: 'model', contents: [ { role: 'model', parts: [{ functionCall: { name: 'f', args: { nested: { value: 'x' } } } }] }, { role: 'user', parts: [{ text: 'hi' }] }, @@ -76,15 +80,15 @@ const objectsIn = (value: unknown): Set => { }; const translations: Array<{ name: string; source: unknown; translate: () => Promise }> = [ - { name: 'Responses to Chat Completions', source: responses, translate: async () => (await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(responses, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' }))).target }, - { name: 'Responses to Anthropic Messages', source: responses, translate: async () => (await requireTrip(routeOpenAIResponsesViaAnthropicMessages(responses, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm', loadRemoteImage: async () => { throw new Error('Unexpected remote image'); } }))).target }, - { name: 'Chat Completions to Responses', source: chat, translate: async () => (await requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses(chat, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' }))).target }, - { name: 'Chat Completions to Anthropic Messages', source: chat, translate: async () => (await requireTrip(routeOpenAIChatCompletionsViaAnthropicMessages(chat, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm', loadRemoteImage: async () => { throw new Error('Unexpected remote image'); } }))).target }, - { name: 'Anthropic Messages to Responses', source: anthropic, translate: async () => (await requireTrip(routeAnthropicMessagesViaOpenAIResponses(anthropic, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' }))).target }, - { name: 'Anthropic Messages to Chat Completions', source: anthropic, translate: async () => (await requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions(anthropic, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' }))).target }, - { name: 'Gemini to Responses', source: gemini, translate: async () => (await requireTrip(routeGeminiGenerateContentViaOpenAIResponses(gemini, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' }))).target }, - { name: 'Gemini to Chat Completions', source: gemini, translate: async () => (await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(gemini, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm' }))).target }, - { name: 'Gemini to Anthropic Messages', source: gemini, translate: async () => (await requireTrip(routeGeminiGenerateContentViaAnthropicMessages(gemini, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), model: 'm', fallbackMaxOutputTokens: 16 }))).target }, + { name: 'Responses to Chat Completions', source: responses, translate: async () => (expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions(responses, { sidecarCodec: createTestSidecarCodec('test-codec-scope') }))).target }, + { name: 'Responses to Anthropic Messages', source: responses, translate: async () => (expectTrip(await routeOpenAIResponsesViaAnthropicMessages(responses, { sidecarCodec: createTestSidecarCodec('test-codec-scope') }))).target }, + { name: 'Chat Completions to Responses', source: chat, translate: async () => (expectTrip(await routeOpenAIChatCompletionsViaOpenAIResponses(chat, { sidecarCodec: createTestSidecarCodec('test-codec-scope') }))).target }, + { name: 'Chat Completions to Anthropic Messages', source: chat, translate: async () => (expectTrip(await routeOpenAIChatCompletionsViaAnthropicMessages(chat, { sidecarCodec: createTestSidecarCodec('test-codec-scope') }))).target }, + { name: 'Anthropic Messages to Responses', source: anthropic, translate: async () => (expectTrip(await routeAnthropicMessagesViaOpenAIResponses(anthropic, { sidecarCodec: createTestSidecarCodec('test-codec-scope') }))).target }, + { name: 'Anthropic Messages to Chat Completions', source: anthropic, translate: async () => (expectTrip(await routeAnthropicMessagesViaOpenAIChatCompletions(anthropic, { sidecarCodec: createTestSidecarCodec('test-codec-scope') }))).target }, + { name: 'Gemini to Responses', source: gemini, translate: async () => (expectTrip(await routeGeminiGenerateContentViaOpenAIResponses(gemini, { sidecarCodec: createTestSidecarCodec('test-codec-scope') }))).target }, + { name: 'Gemini to Chat Completions', source: gemini, translate: async () => (expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions(gemini, { sidecarCodec: createTestSidecarCodec('test-codec-scope') }))).target }, + { name: 'Gemini to Anthropic Messages', source: gemini, translate: async () => (expectTrip(await routeGeminiGenerateContentViaAnthropicMessages(gemini, { sidecarCodec: createTestSidecarCodec('test-codec-scope'), fallbackMaxOutputTokens: 16 }))).target }, ]; test.each(translations)('$name owns its target payload without retaining source or prior target objects', async ({ source, translate }) => { @@ -106,16 +110,16 @@ test.each(translations)('$name owns its target payload without retaining source type Protocol = 'openai-chat-completions' | 'openai-responses' | 'anthropic-messages' | 'gemini-generate-content'; const codecScope = 'test-codec-scope'; -const returnedTrips: { a: Protocol; b: Protocol; trip: (codecScope: string) => Promise<{ events: (frames: AsyncIterable) => AsyncIterable }> }[] = [ - { a: 'openai-responses', b: 'openai-chat-completions', trip: codecScope => requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions(responses, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })) }, - { a: 'openai-responses', b: 'anthropic-messages', trip: codecScope => requireTrip(routeOpenAIResponsesViaAnthropicMessages(responses, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', loadRemoteImage: async () => null })) }, - { a: 'openai-chat-completions', b: 'openai-responses', trip: codecScope => requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses(chat, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })) }, - { a: 'openai-chat-completions', b: 'anthropic-messages', trip: codecScope => requireTrip(routeOpenAIChatCompletionsViaAnthropicMessages(chat, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', loadRemoteImage: async () => null })) }, - { a: 'anthropic-messages', b: 'openai-responses', trip: codecScope => requireTrip(routeAnthropicMessagesViaOpenAIResponses(anthropic, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })) }, - { a: 'anthropic-messages', b: 'openai-chat-completions', trip: codecScope => requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions(anthropic, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })) }, - { a: 'gemini-generate-content', b: 'openai-responses', trip: codecScope => requireTrip(routeGeminiGenerateContentViaOpenAIResponses(gemini, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })) }, - { a: 'gemini-generate-content', b: 'openai-chat-completions', trip: codecScope => requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions(gemini, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' })) }, - { a: 'gemini-generate-content', b: 'anthropic-messages', trip: codecScope => requireTrip(routeGeminiGenerateContentViaAnthropicMessages(gemini, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', fallbackMaxOutputTokens: 16 })) }, +const returnedTrips: { a: Protocol; b: Protocol; trip: (codecScope: string) => Promise) => AsyncIterable }> }[] = [ + { a: 'openai-responses', b: 'openai-chat-completions', trip: codecScope => routeOpenAIResponsesViaOpenAIChatCompletions(responses, { sidecarCodec: createTestSidecarCodec(codecScope) }) }, + { a: 'openai-responses', b: 'anthropic-messages', trip: codecScope => routeOpenAIResponsesViaAnthropicMessages(responses, { sidecarCodec: createTestSidecarCodec(codecScope) }) }, + { a: 'openai-chat-completions', b: 'openai-responses', trip: codecScope => routeOpenAIChatCompletionsViaOpenAIResponses(chat, { sidecarCodec: createTestSidecarCodec(codecScope) }) }, + { a: 'openai-chat-completions', b: 'anthropic-messages', trip: codecScope => routeOpenAIChatCompletionsViaAnthropicMessages(chat, { sidecarCodec: createTestSidecarCodec(codecScope) }) }, + { a: 'anthropic-messages', b: 'openai-responses', trip: codecScope => routeAnthropicMessagesViaOpenAIResponses(anthropic, { sidecarCodec: createTestSidecarCodec(codecScope) }) }, + { a: 'anthropic-messages', b: 'openai-chat-completions', trip: codecScope => routeAnthropicMessagesViaOpenAIChatCompletions(anthropic, { sidecarCodec: createTestSidecarCodec(codecScope) }) }, + { a: 'gemini-generate-content', b: 'openai-responses', trip: codecScope => routeGeminiGenerateContentViaOpenAIResponses(gemini, { sidecarCodec: createTestSidecarCodec(codecScope) }) }, + { a: 'gemini-generate-content', b: 'openai-chat-completions', trip: codecScope => routeGeminiGenerateContentViaOpenAIChatCompletions(gemini, { sidecarCodec: createTestSidecarCodec(codecScope) }) }, + { a: 'gemini-generate-content', b: 'anthropic-messages', trip: codecScope => routeGeminiGenerateContentViaAnthropicMessages(gemini, { sidecarCodec: createTestSidecarCodec(codecScope), fallbackMaxOutputTokens: 16 }) }, ] as const; const protocolKeys = { @@ -216,25 +220,46 @@ type TargetPayload = const replayAssistantTurn = async (assistant: AssistantTurn, target: Protocol): Promise => { if (assistant.protocol === 'openai-responses') { - if (target === 'openai-chat-completions') return { protocol: target, payload: (await requireTrip(routeOpenAIResponsesViaOpenAIChatCompletions({ ...responses, input: [...assistant.turn, { type: 'message', role: 'user', content: 'next question' }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }))).target }; - if (target === 'anthropic-messages') return { protocol: target, payload: (await requireTrip(routeOpenAIResponsesViaAnthropicMessages({ ...responses, input: [...assistant.turn, { type: 'message', role: 'user', content: 'next question' }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', loadRemoteImage: async () => null }))).target }; + const request = { ...responses, input: [...assistant.turn, { type: 'message' as const, role: 'user' as const, content: 'next question' }] }; + if (target === 'openai-chat-completions') { + return { protocol: 'openai-chat-completions', payload: (expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions(request, { sidecarCodec: createTestSidecarCodec(codecScope) }))).target }; + } + if (target === 'anthropic-messages') { + return { protocol: 'anthropic-messages', payload: (expectTrip(await routeOpenAIResponsesViaAnthropicMessages(request, { sidecarCodec: createTestSidecarCodec(codecScope) }))).target }; + } } if (assistant.protocol === 'openai-chat-completions') { - if (target === 'openai-responses') return { protocol: target, payload: (await requireTrip(routeOpenAIChatCompletionsViaOpenAIResponses({ ...chat, messages: [assistant.turn, { role: 'user', content: 'next question' }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }))).target }; - if (target === 'anthropic-messages') return { protocol: target, payload: (await requireTrip(routeOpenAIChatCompletionsViaAnthropicMessages({ ...chat, messages: [assistant.turn, { role: 'user', content: 'next question' }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', loadRemoteImage: async () => null }))).target }; + const request = { ...chat, messages: [assistant.turn, { role: 'user' as const, content: 'next question' }] }; + if (target === 'openai-responses') { + return { protocol: 'openai-responses', payload: (expectTrip(await routeOpenAIChatCompletionsViaOpenAIResponses(request, { sidecarCodec: createTestSidecarCodec(codecScope) }))).target }; + } + if (target === 'anthropic-messages') { + return { protocol: 'anthropic-messages', payload: (expectTrip(await routeOpenAIChatCompletionsViaAnthropicMessages(request, { sidecarCodec: createTestSidecarCodec(codecScope) }))).target }; + } } if (assistant.protocol === 'anthropic-messages') { - if (target === 'openai-responses') return { protocol: target, payload: (await requireTrip(routeAnthropicMessagesViaOpenAIResponses({ ...anthropic, messages: [assistant.turn, { role: 'user', content: 'next question' }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }))).target }; - if (target === 'openai-chat-completions') return { protocol: target, payload: (await requireTrip(routeAnthropicMessagesViaOpenAIChatCompletions({ ...anthropic, messages: [assistant.turn, { role: 'user', content: 'next question' }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }))).target }; + const request = { ...anthropic, messages: [assistant.turn, { role: 'user' as const, content: 'next question' }] }; + if (target === 'openai-responses') { + return { protocol: 'openai-responses', payload: (expectTrip(await routeAnthropicMessagesViaOpenAIResponses(request, { sidecarCodec: createTestSidecarCodec(codecScope) }))).target }; + } + if (target === 'openai-chat-completions') { + return { protocol: 'openai-chat-completions', payload: (expectTrip(await routeAnthropicMessagesViaOpenAIChatCompletions(request, { sidecarCodec: createTestSidecarCodec(codecScope) }))).target }; + } } if (assistant.protocol === 'gemini-generate-content') { - if (target === 'openai-responses') return { protocol: target, payload: (await requireTrip(routeGeminiGenerateContentViaOpenAIResponses({ ...gemini, contents: [...assistant.turn, { role: 'user', parts: [{ text: 'next question' }] }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }))).target }; - if (target === 'openai-chat-completions') return { protocol: target, payload: (await requireTrip(routeGeminiGenerateContentViaOpenAIChatCompletions({ ...gemini, contents: [...assistant.turn, { role: 'user', parts: [{ text: 'next question' }] }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm' }))).target }; - if (target === 'anthropic-messages') return { protocol: target, payload: (await requireTrip(routeGeminiGenerateContentViaAnthropicMessages({ ...gemini, contents: [...assistant.turn, { role: 'user', parts: [{ text: 'next question' }] }] }, { sidecarCodec: createTestSidecarCodec(codecScope), model: 'm', fallbackMaxOutputTokens: 16 }))).target }; + const request = { ...gemini, contents: [...assistant.turn, { role: 'user' as const, parts: [{ text: 'next question' }] }] }; + if (target === 'openai-responses') { + return { protocol: 'openai-responses', payload: (expectTrip(await routeGeminiGenerateContentViaOpenAIResponses(request, { sidecarCodec: createTestSidecarCodec(codecScope) }))).target }; + } + if (target === 'openai-chat-completions') { + return { protocol: 'openai-chat-completions', payload: (expectTrip(await routeGeminiGenerateContentViaOpenAIChatCompletions(request, { sidecarCodec: createTestSidecarCodec(codecScope) }))).target }; + } + if (target === 'anthropic-messages') { + return { protocol: 'anthropic-messages', payload: (expectTrip(await routeGeminiGenerateContentViaAnthropicMessages(request, { sidecarCodec: createTestSidecarCodec(codecScope), fallbackMaxOutputTokens: 16 }))).target }; + } } throw new Error(`Unsupported route: ${assistant.protocol} via ${target}`); }; - const targetAssistantTurn = (target: TargetPayload) => { if (target.protocol === 'openai-responses') return partitionOpenAIResponsesTurns(target.payload.input).find(turn => turn.role === 'assistant')?.items; if (target.protocol === 'openai-chat-completions') return partitionOpenAIChatCompletionsTurns(target.payload.messages).find(turn => turn.role === 'assistant')?.items[0]; @@ -243,7 +268,7 @@ const targetAssistantTurn = (target: TargetPayload) => { test.each(returnedTrips)('$a via $b returns generated content and a decodable sidecar through its public TranslateTrip', async ({ a, b, trip }) => { const codec = createTestSidecarCodec(codecScope); - const translation = await trip(codecScope); + const translation = expectTrip(await trip(codecScope)); const fixture = { text: ['return ', 'path'], thinking: ['upstream thought'], @@ -272,3 +297,27 @@ test.each(returnedTrips)('$a via $b returns generated content and a decodable si assertEquals(assistant, before); assertEquals(targetAssistantTurn(await replayAssistantTurn(moved, b)), sidecar.targetTurn); }); + +test('a Responses client sees its own declarations echoed on the response object', async () => { + // The upstream states no tools of its own, so the response objects carry the + // declarations the client asked with. A client that reads this field expects + // its groups back, not the flattened names the request was written with. + const declaration = { type: 'namespace' as const, name: 'public', description: '', tools: [{ type: 'function' as const, name: 'lookup', parameters: schema }] }; + const payload: CanonicalOpenAIResponsesPayload = { + model: 'm', + stream: true, + input: [{ type: 'message', role: 'user', content: 'hi' }], + tools: [declaration], + tool_choice: 'auto', + }; + const trip = expectTrip(await routeOpenAIResponsesViaOpenAIChatCompletions(payload, { sidecarCodec: createTestSidecarCodec('test-codec-scope') })); + const frames = await collect(trip.events(iterate(fixtureFrames('openai-chat-completions', { + text: ['ok'], thinking: [], signature: '', tools: [], + })))); + + const created = frames.find(frame => frame.type === 'event' && frame.event.type === 'response.created'); + assert(created?.type === 'event'); + const createdResponse = 'response' in created.event ? created.event.response : undefined; + assertEquals(createdResponse?.tools, [declaration]); + assertEquals(createdResponse?.tool_choice, 'auto'); +}); diff --git a/packages/translate/__tests__/protocols/streams_test.ts b/packages/translate/__tests__/protocols/streams_test.ts index e2527e4bb1..45421aeb08 100644 --- a/packages/translate/__tests__/protocols/streams_test.ts +++ b/packages/translate/__tests__/protocols/streams_test.ts @@ -73,7 +73,7 @@ const translateChatFrames = async (nativeFrames: readonly any[]) => { }; }; -describe('IR streaming', () => { +describe('output IR streaming', () => { it.each([['ChatCompletions', chat], ['Responses', responses], ['Messages', messages]] as const)('normalizes %s without flattening source strings', async (_name, source) => { const value = await collectIR(source()); expect(value.usage?.input_tokens_inclusive).toBe(11); @@ -363,7 +363,7 @@ describe('IR streaming', () => { expect(result.output.map(item => item.type)).toEqual(['message', 'image_generation_call', 'message', 'reasoning']); }); - it('marks ChatCompletions length termination incomplete and exposes IR errors', async () => { + it('marks ChatCompletions length termination incomplete and exposes output IR errors', async () => { const frames = await collect(readOpenAIChatCompletionsEvents(iterate([chatFrame([{ index: 0, delta: { content: 'text' }, finish_reason: 'length' }]), doneFrame()]))); expect(frames.find(record => record.type === 'finish')).toMatchObject({ status: 'incomplete' }); await expect(collectIR(iterate([{ type: 'error', error: { message: 'failure' } }]))).rejects.toThrow(); @@ -435,7 +435,7 @@ describe('IR streaming', () => { await expect(collect(writeOpenAIChatCompletionsEvents(iterate(frames), options))).rejects.toThrow(); }); - it('uses the same lifecycle validation when collecting IR', async () => { + it('uses the same lifecycle validation when collecting output IR', async () => { for (const records of [[{ type: 'finish', status: 'completed' }], [{ type: 'start', id: 's', model: 'm' }, { type: 'start', id: 's', model: 'm' }, { type: 'finish', status: 'completed' }]]) { await expect(collectIR(iterate(records as any))).rejects.toThrow(); } @@ -481,7 +481,7 @@ describe('IR streaming', () => { ['Responses', writeOpenAIResponsesEvents], ['Messages', writeAnthropicMessagesEvents], ['GenerateContent', writeGeminiGenerateContentEvents], - ] as const)('%s rejects IR start without a model', async (_name, adapter) => { + ] as const)('%s rejects output IR start without a model', async (_name, adapter) => { await expect(collect(adapter(iterate([{ type: 'start', id: 'source' }] as any), options))).rejects.toThrow('requires an IR model'); }); diff --git a/packages/translate/__tests__/protocols/unrecognized-assistant-turn-warning_test.ts b/packages/translate/__tests__/protocols/unrecognized-assistant-turn-warning_test.ts deleted file mode 100644 index cebc92905e..0000000000 --- a/packages/translate/__tests__/protocols/unrecognized-assistant-turn-warning_test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { afterEach, expect, test, vi } from 'vitest'; - -import { buildTargetRequest as messagesViaChat } from '../../src/protocols/anthropic-messages/via-openai-chat-completions/request.ts'; -import { buildTargetRequest as messagesViaResponses } from '../../src/protocols/anthropic-messages/via-openai-responses/request.ts'; -import { buildTargetRequest as geminiViaMessages } from '../../src/protocols/gemini-generate-content/via-anthropic-messages/request.ts'; -import { buildTargetRequest as geminiViaChat } from '../../src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts'; -import { buildTargetRequest as geminiViaResponses } from '../../src/protocols/gemini-generate-content/via-openai-responses/request.ts'; -import { buildTargetRequest as chatViaMessages } from '../../src/protocols/openai-chat-completions/via-anthropic-messages/request.ts'; -import { buildTargetRequest as chatViaResponses } from '../../src/protocols/openai-chat-completions/via-openai-responses/request.ts'; -import { buildTargetRequest as responsesViaMessages } from '../../src/protocols/openai-responses/via-anthropic-messages/request.ts'; -import { buildTargetRequest as responsesViaChat } from '../../src/protocols/openai-responses/via-openai-chat-completions/request.ts'; -import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; -import type { OpenAIChatCompletionsAssistantMessageEx } from '@floway-dev/protocols/openai-chat-completions'; - -const codec = () => createTestSidecarCodec(); -const loadRemoteImage = () => Promise.resolve(null); -const foreignChatAssistant = (): OpenAIChatCompletionsAssistantMessageEx => ({ role: 'assistant', content: 'answer', reasoning: 'trace' }); -const chatViaMessagesOptions: Parameters[2] = { loadRemoteImage, fallbackMaxOutputTokens: undefined }; -const responsesViaMessagesOptions: Parameters[2] = { loadRemoteImage, fallbackMaxOutputTokens: undefined }; -const geminiViaMessagesOptions: Parameters[3] = { fallbackMaxOutputTokens: undefined }; - -const cases: { source: string; target: string; translate: () => Promise }[] = [ - { - source: 'openaiChatCompletions', - target: 'anthropicMessages', - translate: () => chatViaMessages({ model: 'model', messages: [foreignChatAssistant()] }, codec(), chatViaMessagesOptions), - }, - { - source: 'openaiChatCompletions', - target: 'openaiResponses', - translate: () => chatViaResponses({ model: 'model', messages: [foreignChatAssistant()] }, codec()), - }, - { - source: 'openaiResponses', - target: 'anthropicMessages', - translate: () => responsesViaMessages({ model: 'model', input: [{ type: 'reasoning', summary: [], encrypted_content: 'foreign' }] }, codec(), responsesViaMessagesOptions), - }, - { - source: 'openaiResponses', - target: 'openaiChatCompletions', - translate: () => responsesViaChat({ model: 'model', input: [{ type: 'reasoning', summary: [], encrypted_content: 'foreign' }] }, codec()), - }, - { - source: 'anthropicMessages', - target: 'openaiChatCompletions', - translate: () => messagesViaChat({ model: 'model', max_tokens: 1, messages: [{ role: 'assistant', content: [{ type: 'redacted_thinking', data: 'foreign' }] }] }, codec()), - }, - { - source: 'anthropicMessages', - target: 'openaiResponses', - translate: () => messagesViaResponses({ model: 'model', max_tokens: 1, messages: [{ role: 'assistant', content: [{ type: 'redacted_thinking', data: 'foreign' }] }] }, codec()), - }, - { - source: 'geminiGenerateContent', - target: 'anthropicMessages', - translate: () => geminiViaMessages({ contents: [{ role: 'model', parts: [{ thoughtSignature: 'foreign' }] }] }, 'model', codec(), geminiViaMessagesOptions), - }, - { - source: 'geminiGenerateContent', - target: 'openaiChatCompletions', - translate: () => geminiViaChat({ contents: [{ role: 'model', parts: [{ thoughtSignature: 'foreign' }] }] }, 'model', codec()), - }, - { - source: 'geminiGenerateContent', - target: 'openaiResponses', - translate: () => geminiViaResponses({ contents: [{ role: 'model', parts: [{ thoughtSignature: 'foreign' }] }] }, 'model', codec()), - }, -]; - -const ordinaryCases: { name: string; translate: () => Promise }[] = [ - { - name: 'ChatCompletions content', - translate: () => chatViaResponses({ model: 'model', messages: [{ role: 'assistant', content: 'answer' }] }, codec()), - }, - { - name: 'Responses readable reasoning', - translate: () => responsesViaChat({ model: 'model', input: [{ type: 'reasoning', summary: [{ type: 'summary_text', text: 'trace' }] }] }, codec()), - }, - { - name: 'Messages readable thinking', - translate: () => messagesViaResponses({ model: 'model', max_tokens: 1, messages: [{ role: 'assistant', content: [{ type: 'thinking', thinking: 'trace', signature: '' }] }] }, codec()), - }, - { - name: 'GenerateContent unsigned text', - translate: () => geminiViaChat({ contents: [{ role: 'model', parts: [{ text: 'answer' }] }] }, 'model', codec()), - }, -]; - -afterEach(() => { vi.restoreAllMocks(); }); - -test.each(cases)('$source via $target warns when bare translation drops foreign reasoning', async ({ source, target, translate }) => { - const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await translate(); - - expect(warning).toHaveBeenCalledTimes(1); - expect(warning).toHaveBeenCalledWith( - 'Dropping reasoning from an unidentifiable foreign assistant turn', - { source, target }, - ); -}); - -test.each(ordinaryCases)('$name does not warn during bare translation', async ({ translate }) => { - const warning = vi.spyOn(console, 'warn').mockImplementation(() => {}); - - await translate(); - - expect(warning).not.toHaveBeenCalled(); -}); diff --git a/packages/translate/__tests__/round-trip/protocols_test.ts b/packages/translate/__tests__/round-trip/protocols_test.ts index a889465ef1..73e37ff9fc 100644 --- a/packages/translate/__tests__/round-trip/protocols_test.ts +++ b/packages/translate/__tests__/round-trip/protocols_test.ts @@ -13,6 +13,7 @@ import type { AnyRoundTripSidecar } from '../../src/round-trip/sidecar.ts'; import type { AnthropicMessagesAssistantContentBlock, AnthropicMessagesAssistantMessage, AnthropicMessagesMessage } from '@floway-dev/protocols/anthropic-messages'; import type { GeminiGenerateContentContent } from '@floway-dev/protocols/gemini-generate-content'; import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsMessage } from '@floway-dev/protocols/openai-chat-completions'; +import { toOpenAIResponsesInputItems } from '@floway-dev/protocols/openai-responses'; import type { CanonicalOpenAIResponsesInputItem, OpenAIResponsesOutputItemEx } from '@floway-dev/protocols/openai-responses'; const chatTurn = (): OpenAIChatCompletionsAssistantMessageEx => ({ @@ -204,7 +205,7 @@ describe('assistant-turn sidecar protocols', () => { ]; const turns = partitionOpenAIChatCompletionsTurns(messages); expect(turns.map(({ role, items }) => [role, items.length])).toEqual([ - ['bare', 1], ['assistant', 1], ['bare', 1], ['assistant', 1], + ['client', 1], ['assistant', 1], ['client', 1], ['assistant', 1], ]); expect(turns.map(({ items }) => items).flat()).toEqual(messages); }); @@ -259,7 +260,7 @@ describe('assistant-turn sidecar protocols', () => { ]; const turns = partitionOpenAIResponsesTurns(input); expect(turns.map(({ role, items }) => [role, items.length])).toEqual([ - ['assistant', 2], ['bare', 1], ['assistant', 1], ['bare', 1], + ['assistant', 2], ['client', 1], ['assistant', 1], ['client', 1], ]); expect(turns.flatMap(({ items }) => items)).toEqual(input); }); @@ -386,13 +387,40 @@ describe('assistant-turn sidecar protocols', () => { expect(await verifyOpenAIResponsesTurnDigest(cases[2].original, statuslessCheck, 1)).toBe(false); }); - it('preserves the Responses sidecar shape and checks generated image replay', async () => { + it('builds canonical Responses history items', async () => { expect(createOpenAIResponsesSidecarItem('rs_sidecar', 'opaque-sidecar')).toEqual({ type: 'reasoning', id: 'rs_sidecar', summary: [], encrypted_content: 'opaque-sidecar', }); const imageCall: Extract = { type: 'image_generation_call', id: 'ig-data', result: 'image-base64', status: 'completed', action: 'generate', output_format: 'png', }; + const messageWithSDKFields = { + type: 'message', id: 'message-a', role: 'assistant', status: 'completed', phase: 'final_answer', created_by: 'sdk', + content: [{ type: 'output_text', text: 'answer', annotations: [{ type: 'url_citation', url: 'https://example.test', title: 'source', start_index: 0, end_index: 6 }], logprobs: [], parsed: { answer: true } }], + } satisfies Extract & { created_by: string; content: (Extract['content'][number], { type: 'output_text' }> & { parsed: { answer: boolean } })[] }; + const functionCallWithSDKFields = { + type: 'function_call', id: 'function-a', call_id: 'call-a', name: 'lookup', arguments: '{"q":"a"}', parsed_arguments: { q: 'a' }, status: 'completed', created_by: 'sdk', + } satisfies Extract & { parsed_arguments: { q: string }; created_by: string }; + const outputHistory: OpenAIResponsesOutputItemEx[] = [ + messageWithSDKFields, + functionCallWithSDKFields, + { type: 'shell_call_output', id: 'shell-output', call_id: 'shell-call', output: [{ stdout: 'ok', stderr: '', outcome: { type: 'exit', exit_code: 0 }, created_by: 'sdk' }], status: 'completed', created_by: 'sdk' }, + { type: 'computer_call_output', call_id: 'computer-call', output: { type: 'computer_screenshot', file_id: 'screenshot' }, status: 'failed', created_by: 'sdk' }, + { type: 'custom_tool_call_output', call_id: 'custom-incomplete', output: 'incomplete', status: 'incomplete' }, + { type: 'custom_tool_call_output', id: 'custom-completed', call_id: 'custom-completed-call', output: 'complete', status: 'completed' }, + { type: 'additional_tools', id: 'additional-tools-developer', role: 'developer', tools: [] }, + { type: 'image_generation_call', id: 'ig-null', result: null, status: 'completed' }, + imageCall, + ]; + expect(toOpenAIResponsesInputItems(outputHistory)).toEqual([ + { type: 'message', id: 'message-a', role: 'assistant', status: 'completed', phase: 'final_answer', content: [{ type: 'output_text', text: 'answer', annotations: [{ type: 'url_citation', url: 'https://example.test', title: 'source', start_index: 0, end_index: 6 }], logprobs: [] }] }, + { type: 'function_call', id: 'function-a', call_id: 'call-a', name: 'lookup', arguments: '{"q":"a"}', status: 'completed' }, + { type: 'shell_call_output', id: 'shell-output', call_id: 'shell-call', output: [{ stdout: 'ok', stderr: '', outcome: { type: 'exit', exit_code: 0 } }], status: 'completed' }, + { type: 'custom_tool_call_output', id: 'custom-completed', call_id: 'custom-completed-call', output: 'complete' }, + { type: 'additional_tools', id: 'additional-tools-developer', role: 'developer', tools: [] }, + { type: 'image_generation_call', id: 'ig-null', result: null, status: 'completed' }, + imageCall, + ]); const imageTurn: OpenAIResponsesOutputItemEx[] = [ imageCall, { type: 'reasoning', id: 'rs-image', summary: [], encrypted_content: 'opaque-sidecar' }, @@ -485,7 +513,7 @@ describe('assistant-turn sidecar protocols', () => { { role: 'user', parts: [{ text: 'question' }] }, { role: 'model', parts: [{ text: 'next turn' }] }, ]); - expect(turns.map(({ role, items }) => [role, items.length])).toEqual([['assistant', 2], ['bare', 1], ['assistant', 1]]); + expect(turns.map(({ role, items }) => [role, items.length])).toEqual([['assistant', 2], ['client', 1], ['assistant', 1]]); const check = await computeGeminiGenerateContentTurnDigest(fixtureSidecar(findGeminiGenerateContentSidecarOccurrences(turn)).turn); expect(await verifyGeminiTurn([{ ...turn[0], parts: [turn[0].parts![1], turn[0].parts![0], turn[0].parts![3], turn[0].parts![2]] }, turn[1]], check)).toBe(true); const moved = [ diff --git a/packages/translate/__tests__/round-trip/resolve_test.ts b/packages/translate/__tests__/round-trip/resolve_test.ts index b09d957ea9..7c9a8ca720 100644 --- a/packages/translate/__tests__/round-trip/resolve_test.ts +++ b/packages/translate/__tests__/round-trip/resolve_test.ts @@ -13,9 +13,7 @@ describe('assistant-turn replay boundary', () => { 'anthropicMessages', { role: 'assistant', content: 'answer' }, createTestSidecarCodec(), - findOpenAIChatCompletionsSidecarOccurrences, - verifyOpenAIChatCompletionsTurnDigest, - () => [], + { inspect: findOpenAIChatCompletionsSidecarOccurrences, verify: verifyOpenAIChatCompletionsTurnDigest, collectCandidates: () => [] }, )).resolves.toBeUndefined(); }); @@ -44,10 +42,8 @@ describe('assistant-turn replay boundary', () => { 'anthropicMessages', sourceTurn, codec, - findOpenAIChatCompletionsSidecarOccurrences, - verify, - () => [], - )).resolves.toEqual({ turn: targetTurn }); + { inspect: findOpenAIChatCompletionsSidecarOccurrences, verify, collectCandidates: () => [] }, + )).resolves.toEqual(targetTurn); expect(verify).toHaveBeenCalledExactlyOnceWith({ role: 'assistant', content: 'answer', reasoning_details: [] }, expect.any(Object), 1); }); @@ -65,7 +61,7 @@ describe('assistant-turn replay boundary', () => { }); const turn = { ...source, reasoning_details: [...earlier, ...createOpenAIChatCompletionsSidecarDetails(data)] }; const verify = vi.fn(verifyOpenAIChatCompletionsTurnDigest); - await expect(roundTripAssistantTurn('openaiChatCompletions', 'openaiChatCompletions', turn, codec, findOpenAIChatCompletionsSidecarOccurrences, verify, () => [])).resolves.toEqual({ turn: targetTurn }); + await expect(roundTripAssistantTurn('openaiChatCompletions', 'openaiChatCompletions', turn, codec, { inspect: findOpenAIChatCompletionsSidecarOccurrences, verify, collectCandidates: () => [] })).resolves.toEqual(targetTurn); expect(verify).toHaveBeenCalledExactlyOnceWith(source, expect.any(Object), 1); expect(turn.reasoning_details[0].data).toBe(foreign); }); @@ -82,7 +78,7 @@ describe('assistant-turn replay boundary', () => { }); const turn = { ...source, reasoning_details: [...createOpenAIChatCompletionsSidecarDetails(data), ...createOpenAIChatCompletionsSidecarDetails(foreign)] }; const verify = vi.fn(verifyOpenAIChatCompletionsTurnDigest); - await expect(roundTripAssistantTurn('openaiChatCompletions', 'openaiChatCompletions', turn, codec, findOpenAIChatCompletionsSidecarOccurrences, verify, () => [])).resolves.toEqual({ turn: source }); + await expect(roundTripAssistantTurn('openaiChatCompletions', 'openaiChatCompletions', turn, codec, { inspect: findOpenAIChatCompletionsSidecarOccurrences, verify, collectCandidates: () => [] })).resolves.toEqual(source); expect(verify).toHaveBeenCalledExactlyOnceWith(source, expect.any(Object), 1); }); }); diff --git a/packages/translate/__tests__/round-trip/sidecar-candidates_test.ts b/packages/translate/__tests__/round-trip/sidecar-candidates_test.ts index b7aa48caec..0c87fd2ce4 100644 --- a/packages/translate/__tests__/round-trip/sidecar-candidates_test.ts +++ b/packages/translate/__tests__/round-trip/sidecar-candidates_test.ts @@ -25,7 +25,7 @@ const sourceCase = ( const restored = { role: 'assistant' as const, content: 'native target answer' }; const owned = await encodeRoundTripSidecar(codec, { v: 1, source, target: 'openaiChatCompletions', targetTurn: restored, sourceTurnDigest: check }); const checked = vi.fn(verify); - const run = (turn: Turn) => roundTripAssistantTurn(source, 'openaiChatCompletions', turn, codec, inspect, checked, () => []); + const run = (turn: Turn) => roundTripAssistantTurn(source, 'openaiChatCompletions', turn, codec, { inspect, verify: checked, collectCandidates: () => [] }); if (scenario === 'foreign') { const before = structuredClone(ordinary); await expect(run(ordinary)).resolves.toBeUndefined(); @@ -34,7 +34,7 @@ const sourceCase = ( } else if (scenario === 'middle') { const turn = makeTurn(['foreign-before', owned, 'foreign-after']); const before = structuredClone(turn); - await expect(run(turn)).resolves.toEqual({ turn: restored }); + await expect(run(turn)).resolves.toEqual(restored); expect(checked).toHaveBeenCalledExactlyOnceWith(ordinary, check, 1); expect(turn).toEqual(before); } else if (scenario === 'wrong-direction') { @@ -42,7 +42,7 @@ const sourceCase = ( const foreign = await encodeRoundTripSidecar(codec, { v: 1, source: wrongSource, target: 'openaiChatCompletions', targetTurn: restored, sourceTurnDigest: check }); const history = makeTurn([foreign]); const own = await encodeRoundTripSidecar(codec, { v: 1, source, target: 'openaiChatCompletions', targetTurn: restored, sourceTurnDigest: await compute(history) }); - await expect(run(makeTurn([own, foreign]))).resolves.toEqual({ turn: restored }); + await expect(run(makeTurn([own, foreign]))).resolves.toEqual(restored); expect(checked).toHaveBeenCalledExactlyOnceWith(history, expect.any(Object), 1); } else if (scenario === 'malformed') { // Deliberately bypass the typed encoder to exercise authenticated envelope validation. diff --git a/packages/translate/__tests__/round-trip/test-helpers.ts b/packages/translate/__tests__/round-trip/test-helpers.ts index a2ec099d9b..33ae8a8e1d 100644 --- a/packages/translate/__tests__/round-trip/test-helpers.ts +++ b/packages/translate/__tests__/round-trip/test-helpers.ts @@ -1,4 +1,3 @@ -import { flattenNamespaceTools } from '../../src/protocols/openai-responses/as-source/namespace-tools.ts'; import type { InputAssistantTurnByProtocol, InputAssistantTurnFor } from '../../src/round-trip/input-assistant-turn.ts'; import type { RoundTripReader } from '../../src/round-trip/reader.ts'; import type { InputAssistantTurnStateFor } from '../../src/round-trip/reference/assistant-turn-types.ts'; @@ -36,5 +35,3 @@ export const captureRoundTripWriter = flattenNamespaceTools({ model: 'test', input: [], tools: [] }).names; diff --git a/packages/translate/__tests__/shared/api-error_test.ts b/packages/translate/__tests__/shared/api-error_test.ts index 53ad7b7095..a79bc6e78d 100644 --- a/packages/translate/__tests__/shared/api-error_test.ts +++ b/packages/translate/__tests__/shared/api-error_test.ts @@ -7,7 +7,7 @@ import { createTestSidecarCodec } from '../round-trip/sidecar-codec.ts'; const createTrip = async () => { const trip = await routeOpenAIChatCompletionsViaOpenAIResponses({ model: 'm', stream: true, messages: [{ role: 'user', content: 'hello' }], - }, { model: 'm', sidecarCodec: createTestSidecarCodec() }); + }, { sidecarCodec: createTestSidecarCodec() }); if (trip.type !== 'trip') throw new Error(trip.message); return trip; }; diff --git a/packages/translate/__tests__/protocols/openai-responses/common/custom-tool-wrap_test.ts b/packages/translate/__tests__/shared/custom-tool_test.ts similarity index 74% rename from packages/translate/__tests__/protocols/openai-responses/common/custom-tool-wrap_test.ts rename to packages/translate/__tests__/shared/custom-tool_test.ts index 52ed8b3906..46b8855e55 100644 --- a/packages/translate/__tests__/protocols/openai-responses/common/custom-tool-wrap_test.ts +++ b/packages/translate/__tests__/shared/custom-tool_test.ts @@ -1,10 +1,8 @@ import { test, vi } from 'vitest'; -import { buildCustomToolInputSchema, unwrapCustomToolInput } from '../../../../src/protocols/openai-responses/common/custom-tool-wrap.ts'; +import { buildCustomToolInputSchema, unwrapCustomToolInput } from '../../src/shared/custom-tool.ts'; import { assertEquals } from '@floway-dev/test-utils'; -// ── buildCustomToolInputSchema ── - test('buildCustomToolInputSchema returns plain single-string schema when format is absent', () => { assertEquals(buildCustomToolInputSchema(), { type: 'object', @@ -14,17 +12,8 @@ test('buildCustomToolInputSchema returns plain single-string schema when format }); }); -test('buildCustomToolInputSchema returns plain single-string schema when format has no definition', () => { - assertEquals(buildCustomToolInputSchema({ type: 'grammar', syntax: 'lark' }), { - type: 'object', - additionalProperties: false, - required: ['input'], - properties: { input: { type: 'string' } }, - }); -}); - test('buildCustomToolInputSchema returns plain single-string schema when format.definition is empty', () => { - assertEquals(buildCustomToolInputSchema({ definition: '' }), { + assertEquals(buildCustomToolInputSchema({ type: 'grammar', syntax: 'lark', definition: '' }), { type: 'object', additionalProperties: false, required: ['input'], @@ -50,17 +39,8 @@ test('buildCustomToolInputSchema preserves regex syntax in the description', () }); }); -test('buildCustomToolInputSchema uses a generic label for an unknown syntax', () => { - assertEquals(buildCustomToolInputSchema({ definition: 'constraint' }), { - type: 'object', - additionalProperties: false, - required: ['input'], - properties: { input: { type: 'string', description: 'Grammar: constraint' } }, - }); -}); - test('buildCustomToolInputSchema preserves an open-string syntax label', () => { - assertEquals(buildCustomToolInputSchema({ syntax: 'peg', definition: 'start <- value' }), { + assertEquals(buildCustomToolInputSchema({ type: 'grammar', syntax: 'peg', definition: 'start <- value' }), { type: 'object', additionalProperties: false, required: ['input'], @@ -68,17 +48,6 @@ test('buildCustomToolInputSchema preserves an open-string syntax label', () => { }); }); -test('buildCustomToolInputSchema ignores non-string definition values', () => { - assertEquals(buildCustomToolInputSchema({ definition: 42 as unknown as string }), { - type: 'object', - additionalProperties: false, - required: ['input'], - properties: { input: { type: 'string' } }, - }); -}); - -// ── unwrapCustomToolInput ── - test('unwrapCustomToolInput returns empty string for empty input', () => { assertEquals(unwrapCustomToolInput(''), ''); }); diff --git a/packages/translate/__tests__/shared/reasoning-budget_test.ts b/packages/translate/__tests__/shared/reasoning-budget_test.ts new file mode 100644 index 0000000000..e8f58aa243 --- /dev/null +++ b/packages/translate/__tests__/shared/reasoning-budget_test.ts @@ -0,0 +1,15 @@ +import { test } from 'vitest'; + +import { reasoningEffortForBudget } from '../../src/shared/reasoning-budget.ts'; +import { assertEquals } from '@floway-dev/test-utils'; + +test.each([ + [0, 'none'], + [1, 'low'], + [2048, 'low'], + [2049, 'medium'], + [8192, 'medium'], + [8193, 'high'], +] as const)('budget %i approximates effort %s on a target without a budget slot', (budget, effort) => { + assertEquals(reasoningEffortForBudget(budget), effort); +}); diff --git a/packages/translate/__tests__/trip.ts b/packages/translate/__tests__/trip.ts new file mode 100644 index 0000000000..4e1e661922 --- /dev/null +++ b/packages/translate/__tests__/trip.ts @@ -0,0 +1,6 @@ +import type { TranslateInputError } from '../src/types.ts'; + +export const expectTrip = (result: Trip | TranslateInputError): Trip => { + if (result.type === 'input-error') throw new Error(`Expected a translation trip: ${result.message}`); + return result; +}; diff --git a/packages/translate/src/composition/index.ts b/packages/translate/src/composition/index.ts new file mode 100644 index 0000000000..82f604d9e9 --- /dev/null +++ b/packages/translate/src/composition/index.ts @@ -0,0 +1,84 @@ +import { resolveReading } from './reading.ts'; +import type { SourceReading, RestoredAssistantTurn } from './reading.ts'; +import type { IRInputRequestHeader, IRInputTurn } from '../ir/input/types.ts'; +import type { IRRecord } from '../ir/output/records.ts'; +import { createRoundTripCoordinator } from '../round-trip/coordinator.ts'; +import type { AssistantTurnProtocol, InputAssistantTurnFor } from '../round-trip/input-assistant-turn.ts'; +import type { RoundTripReader } from '../round-trip/reader.ts'; +import type { SidecarRecognition } from '../round-trip/resolve.ts'; +import { roundTripAssistantTurn } from '../round-trip/resolve.ts'; +import type { SourceAssistantTurnFor } from '../round-trip/source-assistant-turn.ts'; +import type { RoundTripWriter } from '../round-trip/writer.ts'; +import { TranslateInputException } from '../shared/input-error.ts'; +import type { ApiErrorTranslator, TranslateOptions, Translator } from '../types.ts'; +import type { ProtocolFrame } from '@floway-dev/protocols/common'; + +export interface SourceProtocol { + readonly name: Name; + readonly read: (payload: Payload) => SourceReading>; + readonly sidecarRecognition: SidecarRecognition>; + readonly error: ApiErrorTranslator; + /** + * States the returned stream to this protocol's client. + * + * It is handed the payload the client sent, because what the client sees + * stated back is what it asked for, and the payload is the only place a + * spelling the reader normalized away — a declaration group, say — survives. + */ + readonly writeEvents: ( + payload: Payload, + records: AsyncIterable, + roundTrip: RoundTripWriter, + ) => AsyncIterable>; +} + +export interface TargetProtocol { + readonly name: Name; + /** + * Writes the input IR as this protocol's request. + * + * A resolved turn arrives as the structure this protocol states natively, so + * whatever the writer needs to know about it — which calls it answers, say — + * it reads from there rather than being told separately. + */ + readonly write: ( + request: IRInputRequestHeader, + turns: readonly (IRInputTurn | RestoredAssistantTurn>)[], + options: TranslateOptions, + ) => { readonly payload: Payload; readonly context: Context }; + /** Where request writing allocates names, this context preserves their logical identities for event reading. */ + readonly readEvents: ( + frames: AsyncIterable>, + context: Context, + roundTripReader: RoundTripReader, + ) => AsyncIterable; +} + +export const composeTranslation = < + SrcName extends AssistantTurnProtocol, SrcPayload, SrcEvent, + TgtName extends AssistantTurnProtocol, TgtPayload extends { model: string }, TgtEvent, Context, +>( + source: SourceProtocol, + target: TargetProtocol, +): Translator => async (payload, options) => { + try { + const reading = source.read(payload); + const { payload: targetPayload, context } = target.write( + reading.request, await resolveReading(reading, turn => roundTripAssistantTurn( + source.name, target.name, turn.assistantTurn, options.sidecarCodec, source.sidecarRecognition, + )), options, + ); + return { + type: 'trip', + target: targetPayload, + error: source.error, + events: frames => { + const coordinator = createRoundTripCoordinator(source.name, target.name, options.sidecarCodec); + return source.writeEvents(payload, target.readEvents(frames, context, coordinator.reader), coordinator.writer); + }, + }; + } catch (error) { + if (error instanceof TranslateInputException) return error.failure; + throw error; + } +}; diff --git a/packages/translate/src/composition/reading.ts b/packages/translate/src/composition/reading.ts new file mode 100644 index 0000000000..fcdb20cdc4 --- /dev/null +++ b/packages/translate/src/composition/reading.ts @@ -0,0 +1,62 @@ +import type { IRInputRequestHeader, IRInputTurn } from '../ir/input/types.ts'; + +export interface RestoredAssistantTurn { + readonly kind: 'restored'; + readonly turn: Turn; +} + +/** Native turns authenticate sidecars and bypass lazy IR conversion. */ +export type SourceTurnSlot = + | { readonly kind: 'client'; readonly toIRInputTurns: () => IRInputTurn[] } + | { + readonly kind: 'assistant'; + readonly assistantTurn: AssistantTurn; + readonly toIRInputTurns: () => IRInputTurn[]; + }; + +/** + * A request read into what a writer consumes. + * + * The request's own instruction field opens `turns` when the protocol has one. It is + * a client turn rather than a field of its own, which is what lets a writer + * place it by position the same way it places every other turn. + */ +export interface SourceReading { + readonly request: IRInputRequestHeader; + readonly turns: readonly SourceTurnSlot[]; +} + +export const resolveReading = async ( + reading: SourceReading, + resolve: (turn: Extract, { kind: 'assistant' }>) => Promise, +): Promise<(IRInputTurn | RestoredAssistantTurn)[]> => { + const turns: (IRInputTurn | RestoredAssistantTurn)[] = []; + const append = (input: readonly IRInputTurn[]): void => { + for (const turn of input) { + const previous = turns.at(-1); + // Instructions and system/developer messages retain their source boundaries. Other + // adjacent client pieces belong to the same turn, including server + // results followed by the client's next message. + if (previous?.kind === 'client' && turn.kind === 'client' + && !previous.items.some(item => item.type === 'instruction' || item.type === 'message' && item.role !== 'user') + && !turn.items.some(item => item.type === 'instruction' || item.type === 'message' && item.role !== 'user')) { + turns[turns.length - 1] = { kind: 'client', items: [...previous.items, ...turn.items] }; + } else { + turns.push(turn); + } + } + }; + for (const turn of reading.turns) { + if (turn.kind === 'client') { + append(turn.toIRInputTurns()); + continue; + } + const result = await resolve(turn); + if (result === undefined) { + append(turn.toIRInputTurns()); + continue; + } + turns.push({ kind: 'restored', turn: result }); + } + return turns; +}; diff --git a/packages/translate/src/composition/request/call-ids.ts b/packages/translate/src/composition/request/call-ids.ts new file mode 100644 index 0000000000..bdc0ab7641 --- /dev/null +++ b/packages/translate/src/composition/request/call-ids.ts @@ -0,0 +1,107 @@ +import type { IRInputAssistantItem, IRInputClientMessageItem, IRInputCustomToolCallItem, IRInputFunctionCallItem, IRInputInstructionItem, IRInputToolResultItem, IRInputTurn } from '../../ir/input/types.ts'; +import { TranslateInputException } from '../../shared/input-error.ts'; +import type { RestoredAssistantTurn } from '../reading.ts'; + +// Target writers use this shape after allocating missing call IDs; every call and result has an ID. +export type IRInputTurnWithCallIds = IRInputClientTurnWithCallIds | IRInputAssistantTurnWithCallIds; +export interface IRInputClientTurnWithCallIds { + readonly kind: 'client'; + readonly items: readonly IRInputClientItemWithCallIds[]; +} +export interface IRInputAssistantTurnWithCallIds { + readonly kind: 'assistant'; + readonly items: readonly IRInputAssistantItemWithCallIds[]; +} +export type IRInputClientItemWithCallIds = IRInputInstructionItem | IRInputClientMessageItem | IRInputToolResultItemWithCallId; +export type IRInputToolResultItemWithCallId = IRInputToolResultItem & { readonly call_id: string }; +export type IRInputAssistantItemWithCallIds = + | Exclude + | (IRInputFunctionCallItem & { readonly call_id: string }) + | (IRInputCustomToolCallItem & { readonly call_id: string }); + +export interface PendingCall { + readonly name: string; + readonly callId: string; +} + +export interface AssignCallIdsOptions { + readonly resultCallIds?: ReadonlyMap; + readonly maxGeneratedCallIdLength?: number; +} + +/** + * Allocate missing IDs in conversation order so id-less results answer pending calls. + * Explicit result IDs must refer to a call present in the conversation. Restored native + * turns contribute their pending calls; target writers use the assigned IDs on calls and results. + */ +export const assignCallIds = ( + turns: readonly (IRInputTurn | RestoredAssistantTurn)[], + restoredCalls: (turn: Restored) => readonly PendingCall[], + options: AssignCallIdsOptions = {}, +): readonly (IRInputTurnWithCallIds | RestoredAssistantTurn)[] => { + const pending: PendingCall[] = []; + let allocated = 0; + // Every id this conversation states, read before allocating anything: an id + // this step invents must not collide with one stated later in the same + // conversation, or two calls would share it. + const claimed = new Set(); + for (const piece of turns) { + if (piece.kind === 'restored') { + for (const call of restoredCalls(piece.turn)) claimed.add(call.callId); + } else if (piece.kind === 'assistant') { + for (const item of piece.items) { + if ((item.type === 'function_call' || item.type === 'custom_tool_call') && item.call_id !== undefined) claimed.add(item.call_id); + } + } + } + const allocate = (name: string): string => { + let candidate: string; + do { + allocated += 1; + const suffix = `_call_${allocated}`; + const prefix = options.maxGeneratedCallIdLength === undefined + ? name + : name.slice(0, Math.max(0, options.maxGeneratedCallIdLength - suffix.length)); + candidate = `${prefix}${suffix}`; + } while (claimed.has(candidate)); + claimed.add(candidate); + return candidate; + }; + // A stated answer releases the call it names; an unstated one takes the + // oldest call of its name, or the oldest call at all when the source stated + // no name either. + const answer = (callId: string | undefined, name: string | undefined): PendingCall => { + const index = callId !== undefined + ? pending.findIndex(call => call.callId === callId) + : name === undefined ? 0 : pending.findIndex(call => call.name === name); + if (index < 0) { + throw new TranslateInputException(callId === undefined + ? `Cannot translate a tool result that answers no call${name === undefined ? ' (name: unstated)' : ` (name: ${name})`}.` + : `Cannot translate a tool result answering '${callId}': no call of this conversation states that id.`); + } + const [answered] = pending.splice(index, 1); + if (answered === undefined) throw new TranslateInputException('Cannot translate a tool result that answers no call.'); + return answered; + }; + + return turns.map((piece): IRInputTurnWithCallIds | RestoredAssistantTurn => { + if (piece.kind === 'restored') { + pending.push(...restoredCalls(piece.turn)); + return piece; + } + if (piece.kind === 'client') { + const items: IRInputClientItemWithCallIds[] = piece.items.map((item): IRInputClientItemWithCallIds => { + if (item.type !== 'tool_result') return item; + return { ...item, call_id: answer(options.resultCallIds?.get(item) ?? item.call_id, item.name).callId }; + }); + return { kind: 'client', items }; + } + const items: IRInputAssistantItemWithCallIds[] = piece.items.map(item => { + if (item.type !== 'function_call' && item.type !== 'custom_tool_call') return item; + const callId = item.call_id ?? allocate(item.name); + pending.push({ name: item.name, callId }); + return { ...item, call_id: callId }; + }); + return { kind: 'assistant', items }; + }); +}; diff --git a/packages/translate/src/composition/request/segments.ts b/packages/translate/src/composition/request/segments.ts new file mode 100644 index 0000000000..f84252117e --- /dev/null +++ b/packages/translate/src/composition/request/segments.ts @@ -0,0 +1,40 @@ +import type { IRInputTurnWithCallIds } from './call-ids.ts'; +import type { RestoredAssistantTurn } from '../reading.ts'; + +// A conversation translates in segments split at sidecar-resolved assistant +// turns. Those turns are already native target structure, so a writer emits +// them verbatim instead of reconstructing them from the IR. +// +// Segmentation is what preserves cross-turn behaviour: a writer that folds +// adjacent user turns into one native message must not fold across a resolved +// assistant turn, because that turn sits between them in the output. +export interface Segment { + readonly kind: 'ir'; + readonly turns: readonly IRInputTurnWithCallIds[]; + // Only the first segment of a conversation may hoist a leading system run into + // a protocol's top-level instructions slot. + readonly leading: boolean; +} + +export const segmentTurns = (turns: readonly (IRInputTurnWithCallIds | RestoredAssistantTurn)[]): readonly (Segment | RestoredAssistantTurn)[] => { + const segments: (Segment | RestoredAssistantTurn)[] = []; + let current: IRInputTurnWithCallIds[] = []; + let leading = true; + const flush = (): void => { + if (current.length === 0) return; + segments.push({ kind: 'ir', turns: current, leading }); + current = []; + leading = false; + }; + for (const piece of turns) { + if (piece.kind === 'restored') { + flush(); + leading = false; + segments.push(piece); + continue; + } + current.push(piece); + } + flush(); + return segments; +}; diff --git a/packages/translate/src/composition/request/tool-names.ts b/packages/translate/src/composition/request/tool-names.ts new file mode 100644 index 0000000000..e121ead99f --- /dev/null +++ b/packages/translate/src/composition/request/tool-names.ts @@ -0,0 +1,138 @@ +import type { IRInputTurnWithCallIds } from './call-ids.ts'; +import type { IRInputRequestHeader, IRInputTool, IRInputToolChoice, IRInputToolSelector } from '../../ir/input/types.ts'; +import { TranslateInputException } from '../../shared/input-error.ts'; +import type { RestoredAssistantTurn } from '../reading.ts'; + +/** Records allocated custom-call names and their original identities for the target event reader. */ +export interface ToolNameContext { + readonly customToolNames: ReadonlySet; + readonly originalToolNames: ReadonlyMap>; +} + +const customNamesOf = (request: IRInputRequestHeader): ReadonlySet => + new Set((request.tools ?? []) + .flatMap(tool => tool.type === 'custom' ? [tool.name] : [])); + +const callableKey = ({ type, namespace, name }: IRInputToolSelector): string => JSON.stringify([type, namespace, name]); + +/** + * States this target's request and conversation with wireNames callable names. + * + * Declarations and historical calls share one allocation keyed by kind, group, + * and name. Bare names stay unchanged unless two callable kinds share one. The + * grouping itself is dropped, because this wire has nowhere to state it. What the + * returned context carries is what the same spelling decided: which + * declarations were freeform, and which identity each allocated name came from. + */ +export const allocateToolNames = ( + request: IRInputRequestHeader, + inputTurns: readonly (IRInputTurnWithCallIds | RestoredAssistantTurn)[], +): { readonly request: IRInputRequestHeader; readonly turns: readonly (IRInputTurnWithCallIds | RestoredAssistantTurn)[]; readonly context: ToolNameContext } => { + const callables = [ + ...(request.tools ?? []), + ...inputTurns.flatMap(turn => turn.kind === 'restored' ? [] : turn.items.flatMap(item => + item.type === 'function_call' || item.type === 'custom_tool_call' + ? [{ type: item.type === 'function_call' ? 'function' as const : 'custom' as const, namespace: item.namespace, name: item.name }] + : [])), + ]; + const reserved = new Set(callables.flatMap(callable => callable.namespace === undefined ? [callable.name] : [])); + const wireNames = assignToolNames(callables, reserved); + const originalToolNames = new Map>(); + for (const callable of callables) { + const wireName = wireNames.get(callableKey(callable))!; + if (callable.namespace !== undefined || wireName !== callable.name) { + originalToolNames.set(wireName, { name: callable.name, ...(callable.namespace === undefined ? {} : { namespace: callable.namespace }) }); + } + } + + const wireNameOf = (callable: IRInputToolSelector): string => { + const wireName = wireNames.get(callableKey(callable)); + if (wireName !== undefined) return wireName; + if (callable.namespace === undefined) return callable.name; + throw new TranslateInputException(`Cannot translate a selector for undeclared grouped tool '${callable.namespace}.${callable.name}'.`); + }; + const allocateChoiceNames = (choice: IRInputToolChoice): IRInputToolChoice => { + if (typeof choice === 'string') return choice; + if (choice.type === 'allowed_tools') return { ...choice, tools: choice.tools.map(allocateSelectorName) }; + return allocateSelectorName(choice); + }; + const allocateSelectorName = (selection: IRInputToolSelector): IRInputToolSelector => { + const { namespace: _namespace, ...rest } = selection; + return { ...rest, name: wireNameOf(selection) }; + }; + const toolChoice = request.tool_choice === undefined ? undefined : allocateChoiceNames(request.tool_choice); + const allocateDeclarationName = (tool: IRInputTool): IRInputTool => { + const { namespace: _namespace, ...rest } = tool; + return { ...rest, name: wireNameOf(tool) }; + }; + const allocatedRequest: IRInputRequestHeader = { + ...request, + ...(request.tools === undefined ? {} : { tools: request.tools.map(allocateDeclarationName) }), + ...(toolChoice === undefined ? {} : { tool_choice: toolChoice }), + }; + return { + request: allocatedRequest, + context: { customToolNames: customNamesOf(allocatedRequest), originalToolNames }, + turns: inputTurns.map((turn): IRInputTurnWithCallIds | RestoredAssistantTurn => { + if (turn.kind === 'restored') return turn; + if (turn.kind === 'assistant') { + return { + kind: 'assistant', + items: turn.items.map(item => item.type === 'function_call' || item.type === 'custom_tool_call' + ? { ...item, name: wireNameOf({ type: item.type === 'function_call' ? 'function' : 'custom', namespace: item.namespace, name: item.name }) } + : item), + }; + } + return turn; + }), + }; +}; + +/** + * Assigns names independently of tool choice. Both callable kinds become function + * declarations on these targets, so even ungrouped names may need a suffix. + * Original bare names are reserved before allocation to keep aliases from + * taking a later declaration's own name. + * + * Grouped names are limited to Chat's 64 ASCII characters; collision suffixes + * follow allocation order across the complete declarations and history. + * https://github.com/openai/openai-node/blob/61539248cbe04665de68a71e6fd878127ae4db87/src/resources/chat/completions/completions.ts + */ +const assignToolNames = ( + callables: readonly IRInputToolSelector[], + reserved: ReadonlySet, +): Map => { + const taken = new Set(reserved); + const nextSuffixes = new Map(); + const assigned = new Map(); + const bareNames = new Set(); + for (const callable of callables) { + const { namespace, name } = callable; + const key = callableKey(callable); + if (assigned.has(key)) continue; + if (namespace === undefined && !bareNames.has(name)) { + bareNames.add(name); + assigned.set(key, name); + continue; + } + const scope = namespace === undefined ? '' : `${namespace.slice(0, 64)}_`.slice(0, 64); + const base = namespace === undefined ? name : `${scope}${name.slice(0, 64 - scope.length)}`.replaceAll(/[^a-zA-Z0-9_-]/g, '_'); + for (let suffix = 1; ; suffix++) { + const ending = suffix === 1 ? '' : `_${suffix}`; + const prefix = base.slice(0, 64 - ending.length); + if (suffix > 1) { + // Share cursors only when both the truncated prefix and suffix width match. + const cursor = `${ending.length}:${prefix}`; + const next = nextSuffixes.get(cursor); + if (next !== undefined && next > suffix) { suffix = next - 1; continue; } + nextSuffixes.set(cursor, suffix + 1); + } + const spelling = `${prefix}${ending}`; + if (taken.has(spelling)) continue; + taken.add(spelling); + assigned.set(key, spelling); + break; + } + } + return assigned; +}; diff --git a/packages/translate/src/index.ts b/packages/translate/src/index.ts index 6ee8eb37df..727636c3fd 100644 --- a/packages/translate/src/index.ts +++ b/packages/translate/src/index.ts @@ -1,5 +1,7 @@ -export type { RemoteImageData, RemoteImageLoader, TranslateTrip, TranslateInputError, TranslateOptions, Translator } from './types.ts'; -export { SidecarTaggedValue, type SidecarCodec } from './round-trip/codec.ts'; +export type { SidecarCodec } from './round-trip/codec.ts'; +export type { TranslateTrip, TranslateInputError, TranslateOptions, Translator } from './types.ts'; +export { SidecarTaggedValue } from './round-trip/codec.ts'; + export { routeAnthropicMessagesViaOpenAIChatCompletions, routeAnthropicMessagesViaOpenAIResponses, diff --git a/packages/translate/src/ir/input/streaming.ts b/packages/translate/src/ir/input/streaming.ts new file mode 100644 index 0000000000..c178049ad7 --- /dev/null +++ b/packages/translate/src/ir/input/streaming.ts @@ -0,0 +1,6 @@ +import type { IRInputSettings } from './types.ts'; + +export const requireStreaming = (settings: IRInputSettings): true => { + if (settings.stream !== true) throw new Error('A translation target requires a streaming request, but the IR states a non-streaming one.'); + return true; +}; diff --git a/packages/translate/src/ir/input/types.ts b/packages/translate/src/ir/input/types.ts new file mode 100644 index 0000000000..289de14082 --- /dev/null +++ b/packages/translate/src/ir/input/types.ts @@ -0,0 +1,319 @@ +// Input Fit IR contains cross-protocol request semantics; authenticated native replay bypasses it. +// Coverage comments name the source protocols that can populate each type or field. +// All +export interface IRInputSettings { + readonly model: string; // All + readonly stream: boolean; // All + readonly temperature?: number | null; // All + readonly top_p?: number | null; // All + readonly top_k?: number; // Messages, GenerateContent + readonly stop?: readonly string[]; // ChatCompletions, Messages, GenerateContent + readonly seed?: number; // ChatCompletions, GenerateContent + // The visible-output ceiling is distinct from a cap that includes reasoning. + readonly max_visible_output_tokens?: number | null; // ChatCompletions + readonly max_output_tokens?: number | null; // All + readonly output_format?: IRInputOutputFormat; // All + readonly metadata?: Readonly> | null; // ChatCompletions, Responses, Messages + readonly store?: boolean | null; // ChatCompletions, Responses + readonly service_tier?: string | null; // ChatCompletions, Responses, Messages + readonly parallel_tool_calls?: boolean | null; // ChatCompletions, Responses, Messages + // Effort and numeric budget are independent source controls. + readonly reasoning_effort?: string; // All + readonly reasoning_budget?: number; // Messages, GenerateContent + readonly reasoning_budget_dynamic?: boolean; // Messages, GenerateContent + readonly reasoning_summary?: string; // Responses + readonly verbosity?: string; // ChatCompletions, Responses + readonly prompt_cache_key?: string | null; // ChatCompletions, Responses + readonly prompt_cache_retention?: string | null; // ChatCompletions, Responses + readonly prompt_cache_options?: IRInputPromptCacheOptions | null; // ChatCompletions, Responses + readonly safety_identifier?: string | null; // ChatCompletions, Responses + readonly user?: string | null; // ChatCompletions, Responses + readonly logprobs?: boolean | null; // ChatCompletions, Responses + readonly top_logprobs?: number | null; // ChatCompletions, Responses + readonly choice_count?: number; // ChatCompletions, GenerateContent + readonly presence_penalty?: number | null; // ChatCompletions, Responses, GenerateContent + readonly frequency_penalty?: number | null; // ChatCompletions, Responses, GenerateContent +} + +// ChatCompletions, Responses +export interface IRInputPromptCacheOptions { + readonly mode?: string; // ChatCompletions, Responses + readonly ttl?: string; // ChatCompletions, Responses +} + +// All +export interface IRInputOutputFormat { + readonly type: 'text' | 'json_object' | 'json_schema'; // All + readonly name?: string; // All + readonly description?: string; // ChatCompletions, Responses + readonly schema?: Readonly>; // All + readonly strict?: boolean | null; // ChatCompletions, Responses, Messages +} + +// Turns stay separate so restored native assistant turns can be interleaved. +// All +export interface IRInputRequestHeader { + readonly settings: IRInputSettings; // All + // Absent and empty are the same thing: a request that declares no tool. + readonly tools?: readonly IRInputTool[]; // All + readonly tool_choice?: IRInputToolChoice; // All +} + +// Hosted tools have no cross-protocol IR form. +// All +export type IRInputTool = IRInputFunctionTool | IRInputCustomTool; + +// All +export interface IRInputFunctionTool extends IRInputToolSelector { + readonly type: 'function'; // All + readonly description?: string; // All + readonly parameters?: Readonly>; // All + readonly strict?: boolean; // ChatCompletions, Responses, Messages +} + +// ChatCompletions, Responses +export interface IRInputCustomTool extends IRInputToolSelector { + readonly type: 'custom'; // ChatCompletions, Responses + readonly description?: string; // ChatCompletions, Responses + readonly format?: IRInputCustomToolFormat; // ChatCompletions, Responses +} + +// ChatCompletions, Responses +export type IRInputCustomToolFormat = + | { readonly type: 'text' } // ChatCompletions, Responses + | { + readonly type: 'grammar'; // ChatCompletions, Responses + readonly definition: string; // ChatCompletions, Responses + readonly syntax: string; // ChatCompletions, Responses + } + | { + readonly type: 'unrecognized'; // ChatCompletions, Responses + readonly value: object; // ChatCompletions, Responses + }; + +// `allowed_tools` restricts calls without changing the visible declarations. +// All +export type IRInputToolChoice = + | 'auto' + | 'none' + | 'required' + | IRInputToolSelector + | { + readonly type: 'allowed_tools'; // ChatCompletions, Responses, GenerateContent + readonly mode: 'auto' | 'required'; // ChatCompletions, Responses, GenerateContent + readonly tools: readonly IRInputToolSelector[]; // ChatCompletions, Responses, GenerateContent + }; + +// All +export interface IRInputToolSelector { + readonly type: 'function' | 'custom'; // All + readonly name: string; // All + readonly namespace?: string; // Responses +} + +// All +export type IRInputTurn = IRInputClientTurn | IRInputAssistantTurn; + +// All +export interface IRInputClientTurn { + readonly kind: 'client'; // All + readonly items: readonly IRInputClientItem[]; // All +} + +// All +export interface IRInputAssistantTurn { + readonly kind: 'assistant'; // All + readonly items: readonly IRInputAssistantItem[]; // All +} + +// Flat ordered items let each target reconstruct its own message grouping. +// All +export type IRInputClientItem = IRInputInstructionItem | IRInputClientMessageItem | IRInputToolResultItem; + +// All +export type IRInputAssistantItem = + | IRInputAssistantMessageItem + | IRInputReasoningItem + | IRInputFunctionCallItem + | IRInputCustomToolCallItem; + +// Messages, Responses, GenerateContent +export interface IRInputInstructionItem { + readonly type: 'instruction'; // Messages, Responses, GenerateContent + readonly content: readonly IRInputTextPart[]; // Messages, Responses, GenerateContent +} + +// All +export type IRInputClientMessageItem = + | { + readonly type: 'message'; // All + readonly role: 'user'; // All + readonly content: readonly IRInputUserContentPart[]; // All + } + | { + readonly type: 'message'; // ChatCompletions, Responses, Messages + readonly role: 'system' | 'developer'; // ChatCompletions, Responses, Messages + readonly content: readonly IRInputTextPart[]; // ChatCompletions, Responses, Messages + }; + +// All +export interface IRInputAssistantMessageItem { + readonly type: 'message'; // All + readonly content: readonly IRInputAssistantContentPart[]; // All +} + +// All +export type IRInputUserContentPart = + | IRInputTextPart + | IRInputImagePart + | IRInputFilePart + | IRInputDocumentPart + | IRInputAudioPart; + +// All +export type IRInputAssistantContentPart = IRInputTextPart | IRInputRefusalPart; + +// All +export interface IRInputTextPart { + readonly type: 'text'; // All + readonly text: string; // All +} + +// ChatCompletions, Responses +export interface IRInputRefusalPart { + readonly type: 'refusal'; // ChatCompletions, Responses + readonly refusal: string; // ChatCompletions, Responses +} + +// All +export interface IRInputImagePart { + readonly type: 'image'; // All + readonly image: IRInputMedia; // All + readonly detail?: string | null; // ChatCompletions, Responses +} + +// ChatCompletions, Responses, GenerateContent +export interface IRInputFilePart { + readonly type: 'file'; // ChatCompletions, Responses, GenerateContent + readonly file: IRInputMedia; // ChatCompletions, Responses, GenerateContent + readonly filename?: string | null; // ChatCompletions, Responses +} + +// Messages +export interface IRInputDocumentPart { + readonly type: 'document'; // Messages + readonly document: IRInputDocument; // Messages +} + +// Messages +export interface IRInputDocument { + readonly source: IRInputDocumentSource; // Messages + readonly title?: string | null; // Messages + readonly context?: string | null; // Messages + readonly citations?: { readonly enabled?: boolean } | null; // Messages + readonly cache_control?: IRInputCacheControl | null; // Messages +} + +// Messages +export interface IRInputCacheControl { + readonly type: 'ephemeral'; // Messages + readonly ttl?: '5m' | '1h'; // Messages +} + +// Messages +export type IRInputDocumentSource = + | { + readonly source_kind: 'media'; // Messages + readonly media: IRInputMedia; // Messages + } + | { + readonly source_kind: 'text'; // Messages + readonly media_type: 'text/plain'; // Messages + readonly text: string; // Messages + } + | { + readonly source_kind: 'content'; // Messages + readonly content: readonly IRInputDocumentContentPart[]; // Messages + }; + +// Messages +export type IRInputDocumentContentPart = ( + | (IRInputTextPart & { + readonly citations?: readonly object[] | null; // Messages + }) + | (IRInputImagePart & { + readonly transformations?: { readonly oversized_image?: 'downsize' | 'error' } | null; // Messages + }) +) & { + readonly cache_control?: IRInputCacheControl | null; // Messages +}; + +// ChatCompletions, GenerateContent +export interface IRInputAudioPart { + readonly type: 'audio'; // ChatCompletions, GenerateContent + readonly audio: IRInputAudio; // ChatCompletions, GenerateContent +} + +// ChatCompletions, GenerateContent +export interface IRInputAudio { + readonly media_type: string; // ChatCompletions, GenerateContent + readonly data: string; // ChatCompletions, GenerateContent +} + +// All +export type IRInputMedia = + | { + readonly source_kind: 'url'; // ChatCompletions, Responses, Messages + readonly url: string; // ChatCompletions, Responses, Messages + } + | { + readonly source_kind: 'data'; // ChatCompletions, Responses + readonly data: string; // ChatCompletions, Responses + } + | { + readonly source_kind: 'base64'; // Messages, GenerateContent + readonly media_type?: string; // Messages, GenerateContent + readonly data: string; // Messages, GenerateContent + } + | { + readonly source_kind: 'file'; // ChatCompletions, Responses, Messages + readonly file_id: string; // ChatCompletions, Responses, Messages + }; + +// Opaque signed reasoning remains in authenticated sidecars. +// Messages, Responses, GenerateContent +export interface IRInputReasoningItem { + readonly type: 'reasoning'; // Messages, Responses, GenerateContent + readonly id?: string; // GenerateContent + readonly summary?: readonly string[]; // Messages, Responses, GenerateContent + readonly content?: readonly string[]; // Responses +} + +// All +export interface IRInputFunctionCallItem extends Pick { + readonly type: 'function_call'; // All + readonly call_id?: string; // All + // Raw JSON preserves malformed input and distinguishes omission from null or {}. + readonly arguments?: string; // All +} + +// ChatCompletions, Responses +export interface IRInputCustomToolCallItem extends Pick { + readonly type: 'custom_tool_call'; // ChatCompletions, Responses + readonly call_id?: string; // ChatCompletions, Responses + readonly input: string; // ChatCompletions, Responses +} + +// All +export interface IRInputToolResultItem { + readonly type: 'tool_result'; // All + readonly call_id?: string; // All + readonly name?: string; // GenerateContent + // Strings stay opaque; arrays preserve structured multimodal output. + readonly output: string | readonly IRInputToolResultContentPart[]; // All + readonly tool_kind?: 'function' | 'custom'; // ChatCompletions, Responses + readonly is_error?: boolean; // Messages, Responses +} + +// All +export type IRInputToolResultContentPart = IRInputTextPart | IRInputImagePart | IRInputFilePart | IRInputDocumentPart; diff --git a/packages/translate/src/ir/output/schema.ts b/packages/translate/src/ir/output/schema.ts index b5377c5294..f7d5dc651c 100644 --- a/packages/translate/src/ir/output/schema.ts +++ b/packages/translate/src/ir/output/schema.ts @@ -69,8 +69,8 @@ const item = union('type', { summary: array(appendString), content: array(appendString), }, { optional: ['summary', 'content'] }), - function_call: object({ call_id: absentString, name: string, arguments: appendString }, { optional: ['call_id'] }), - custom_tool_call: object({ call_id: absentString, name: string, input: appendString }, { optional: ['call_id'] }), + function_call: object({ call_id: absentString, name: string, namespace: nullable(string), arguments: appendString }, { optional: ['call_id', 'namespace'] }), + custom_tool_call: object({ call_id: absentString, name: string, namespace: nullable(string), input: appendString }, { optional: ['call_id', 'namespace'] }), }, 'item'); const logprobs = nullable(array(union('scope', { diff --git a/packages/translate/src/ir/output/types.ts b/packages/translate/src/ir/output/types.ts index e1991f9c86..8c52455cb4 100644 --- a/packages/translate/src/ir/output/types.ts +++ b/packages/translate/src/ir/output/types.ts @@ -63,6 +63,7 @@ type IROutputFunctionCallItem = { readonly type: 'function_call'; // All readonly call_id?: string | IRAbsent; // All readonly name: string; // All + readonly namespace?: string | null; // ChatCompletions, Responses, Messages readonly arguments: string; // All }; @@ -71,6 +72,7 @@ type IROutputCustomToolCallItem = { readonly type: 'custom_tool_call'; // ChatCompletions, Responses, Messages readonly call_id?: string | IRAbsent; // ChatCompletions, Responses, Messages readonly name: string; // ChatCompletions, Responses, Messages + readonly namespace?: string | null; // ChatCompletions, Responses, Messages readonly input: string; // ChatCompletions, Responses, Messages }; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source.ts b/packages/translate/src/protocols/anthropic-messages/as-source.ts new file mode 100644 index 0000000000..b6f76bafbc --- /dev/null +++ b/packages/translate/src/protocols/anthropic-messages/as-source.ts @@ -0,0 +1,21 @@ +import { restateAnthropicMessagesError } from './as-source/error-body.ts'; +import { writeAnthropicMessagesEvents } from './as-source/events.ts'; +import { readAnthropicMessagesRequest } from './as-source/input-to.ts'; +import { collectAnthropicMessagesReplayContents, findAnthropicMessagesSidecarOccurrences, verifyAnthropicMessagesTurnDigest } from './as-source/round-trip.ts'; +import type { SourceProtocol } from '../../composition/index.ts'; +import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; + +/** Messages sidecars ride in redacted_thinking blocks and authenticate the native run of assistant messages. */ +export const anthropicMessagesSourceProtocol: SourceProtocol< + 'anthropicMessages', AnthropicMessagesPayload, AnthropicMessagesStreamEventEx +> = { + name: 'anthropicMessages', + read: readAnthropicMessagesRequest, + sidecarRecognition: { + inspect: findAnthropicMessagesSidecarOccurrences, + verify: verifyAnthropicMessagesTurnDigest, + collectCandidates: collectAnthropicMessagesReplayContents, + }, + error: restateAnthropicMessagesError, + writeEvents: (_payload, records, roundTrip) => writeAnthropicMessagesEvents(records, { roundTrip }), +}; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/client-tools.ts b/packages/translate/src/protocols/anthropic-messages/as-source/client-tools.ts deleted file mode 100644 index 3734aba569..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/as-source/client-tools.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { AnthropicMessagesClientTool, AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; - -export const filterAnthropicMessagesClientTools = (tools: AnthropicMessagesPayload['tools'] | undefined): AnthropicMessagesClientTool[] | undefined => { - const clientTools = tools?.filter((tool): tool is AnthropicMessagesClientTool => tool.type === undefined || tool.type === 'custom'); - return clientTools?.length ? clientTools : undefined; -}; diff --git a/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts new file mode 100644 index 0000000000..46437f5105 --- /dev/null +++ b/packages/translate/src/protocols/anthropic-messages/as-source/input-to.ts @@ -0,0 +1,443 @@ +import { klona } from 'klona/json'; + +import { partitionAnthropicMessagesTurns, type AnthropicMessagesConversationTurn } from './conversation-turns.ts'; +import type { AnthropicMessagesSourceAssistantTurn } from './round-trip.ts'; +import type { SourceReading, SourceTurnSlot } from '../../../composition/reading.ts'; +import type { + IRInputAssistantContentPart, + IRInputRequestHeader, + IRInputSettings, + IRInputTool, + IRInputToolChoice, + IRInputOutputFormat, + IRInputAssistantItem, + IRInputDocument, + IRInputDocumentContentPart, + IRInputMedia, + IRInputTextPart, + IRInputToolResultContentPart, + IRInputTurn, + IRInputUserContentPart, + IRInputClientItem, +} from '../../../ir/input/types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import type { + AnthropicMessagesAssistantInputContentBlock, + AnthropicMessagesClientTool, + AnthropicMessagesImageBlock, + AnthropicMessagesMetadataEx, + AnthropicMessagesMessage, + AnthropicMessagesPayload, + AnthropicMessagesSearchResultBlock, + AnthropicMessagesTextBlockParam, + AnthropicMessagesToolResultBlock, + AnthropicMessagesUserContentBlock, +} from '@floway-dev/protocols/anthropic-messages'; +import { FAST_SERVICE_TIER } from '@floway-dev/protocols/common'; + +const imageMedia = (source: AnthropicMessagesImageBlock['source']): IRInputMedia => { + switch (source.type) { + case 'base64': return { source_kind: 'base64', media_type: source.media_type, data: source.data }; + case 'url': return { source_kind: 'url', url: source.url }; + case 'file': return { source_kind: 'file', file_id: source.file_id }; + } +}; + +const textPartsToIR = (content: string | readonly AnthropicMessagesTextBlockParam[]): IRInputTextPart[] => typeof content === 'string' + ? content === '' ? [] : [{ type: 'text', text: content }] + : content.map(block => { + if (block.type !== 'text') throw new TranslateInputException('Only text system content is supported.'); + return { type: 'text', text: block.text }; + }); + +const documentFromBlock = (block: Extract): IRInputDocument => { + const native = block.source; + let source: IRInputDocument['source']; + switch (native.type) { + case 'base64': source = { source_kind: 'media', media: { source_kind: 'base64', media_type: native.media_type, data: native.data } }; break; + case 'url': source = { source_kind: 'media', media: { source_kind: 'url', url: native.url } }; break; + case 'file': source = { source_kind: 'media', media: { source_kind: 'file', file_id: native.file_id } }; break; + case 'text': source = { source_kind: 'text', media_type: native.media_type, text: native.data }; break; + case 'content': { + const content: IRInputDocumentContentPart[] = typeof native.content === 'string' + ? [{ type: 'text', text: native.content }] + : native.content.map(part => { + const cache = part.cache_control === undefined ? {} : { cache_control: part.cache_control }; + if (part.type === 'text') return { + type: 'text', text: part.text, ...cache, + ...(part.citations === undefined ? {} : { citations: part.citations }), + }; + if (part.type === 'image') return { + type: 'image', image: imageMedia(part.source), ...cache, + ...(part.transformations === undefined ? {} : { transformations: part.transformations }), + }; + throw new TranslateInputException(`'${(part as { type: string }).type}' document content block is not supported.`); + }); + source = { source_kind: 'content', content }; + break; + } + } + return { + source, + ...(block.title === undefined ? {} : { title: block.title }), + ...(block.context === undefined ? {} : { context: block.context }), + ...(block.citations === undefined ? {} : { citations: block.citations }), + ...(block.cache_control === undefined ? {} : { cache_control: block.cache_control }), + }; +}; + +interface MessagePosition { + readonly messageIdx: number; + readonly blockIdx: number; +} + +const userContentPart = ( + block: Exclude, + at: MessagePosition, +): IRInputUserContentPart => { + switch (block.type) { + case 'text': return { type: 'text', text: block.text }; + case 'image': return { type: 'image', image: imageMedia(block.source) }; + case 'document': return { type: 'document', document: documentFromBlock(block) }; + default: throw new TranslateInputException(`messages.${at.messageIdx}.content.${at.blockIdx}.type: '${(block as { type: string }).type}' content blocks are not supported in user content.`); + } +}; + +const toolResultPart = (block: AnthropicMessagesToolResultBlock['content']): IRInputToolResultContentPart[] => { + if (block === undefined) return []; + if (typeof block === 'string') return [{ type: 'text', text: block }]; + const parts: IRInputToolResultContentPart[] = []; + const searchResults: AnthropicMessagesSearchResultBlock[] = []; + // Only consecutive search results share an array, so text and images retain + // their positions while an all-search result still becomes one JSON value. + const flushSearchResults = (): void => { + if (searchResults.length === 0) return; + parts.push({ type: 'text', text: JSON.stringify(searchResults) }); + searchResults.length = 0; + }; + for (const part of block) { + if (part.type === 'search_result') { + searchResults.push(part); + continue; + } + flushSearchResults(); + if (part.type === 'text') parts.push({ type: 'text', text: part.text }); + else if (part.type === 'image') parts.push({ type: 'image', image: imageMedia(part.source) }); + else if (part.type === 'document') parts.push({ type: 'document', document: documentFromBlock(part) }); + else parts.push({ type: 'text', text: JSON.stringify(part) }); + } + flushSearchResults(); + return parts; +}; + +const clientItems = ( + message: Exclude, + messageIdx: number, +): IRInputClientItem[] => { + if (message.role === 'system') { + return [{ type: 'message', role: 'system', content: textPartsToIR(message.content as string | readonly AnthropicMessagesTextBlockParam[]) }]; + } + if (typeof message.content === 'string') { + return [{ type: 'message', role: 'user', content: message.content === '' ? [] : [{ type: 'text', text: message.content }] }]; + } + + const items: IRInputClientItem[] = []; + const pending: IRInputUserContentPart[] = []; + const flush = (): void => { + if (pending.length === 0) return; + items.push({ type: 'message', role: 'user', content: [...pending] }); + pending.length = 0; + }; + + // Messages keeps tool results inline with user content. Flat input IR items keep + // the chronology and let each target regroup. + for (const [blockIdx, block] of message.content.entries()) { + if (block.type === 'tool_result') { + flush(); + const output = toolResultPart(block.content); + items.push({ + type: 'tool_result', + call_id: block.tool_use_id, + output: output.length === 0 ? '' : output.length === 1 && output[0].type === 'text' ? output[0].text : output, + ...(block.is_error === true ? { is_error: true } : {}), + }); + continue; + } + pending.push(userContentPart(block, { messageIdx, blockIdx })); + } + flush(); + return items; +}; + +// Server tools return results inside assistant content. Lower each call/result round into assistant and client turns to preserve source order. +// https://platform.claude.com/docs/en/agents-and-tools/tool-use/web-search-tool +type ServerToolResultBlock = Extract; +const SERVER_TOOL_RESULT_TYPES: ReadonlySet = new Set([ + 'web_search_tool_result', + 'web_fetch_tool_result', + 'code_execution_tool_result', + 'bash_code_execution_tool_result', + 'text_editor_code_execution_tool_result', + 'tool_search_tool_result', +]); + +const isServerToolResult = (block: AnthropicMessagesAssistantInputContentBlock): block is ServerToolResultBlock => + SERVER_TOOL_RESULT_TYPES.has(block.type); + +interface AnthropicMessagesAssistantSegments { + // Each run of assistant items is followed by the server results it produced, preserving their source order. + readonly segments: readonly { + readonly assistant: readonly IRInputAssistantItem[]; + readonly serverResults: readonly IRInputClientItem[]; + }[]; +} + +const assistantSegments = (message: Extract, messageIdx: number): AnthropicMessagesAssistantSegments => { + if (typeof message.content === 'string') { + // An explicitly empty assistant message still occupies a turn of its own; + // dropping it would let two client turns merge across where it stood. + return { segments: [{ assistant: [{ type: 'message', content: [{ type: 'text', text: message.content }] }], serverResults: [] }] }; + } + + const segments: { assistant: IRInputAssistantItem[]; serverResults: IRInputClientItem[] }[] = []; + let assistant: IRInputAssistantItem[] = []; + let serverResults: IRInputClientItem[] = []; + const pending: IRInputAssistantContentPart[] = []; + const flushText = (): void => { + if (pending.length === 0) return; + assistant.push({ type: 'message', content: [...pending] }); + pending.length = 0; + }; + // A result closes the run it answers, so the pair becomes one segment. + const flushSegment = (): void => { + flushText(); + if (assistant.length === 0 && serverResults.length === 0) return; + segments.push({ assistant, serverResults }); + assistant = []; + serverResults = []; + }; + + for (const [blockIdx, block] of message.content.entries()) { + if (isServerToolResult(block)) { + flushText(); + const result = block; + serverResults.push({ + type: 'tool_result', + call_id: result.tool_use_id, + output: JSON.stringify(result.content), + ...(typeof result.content === 'object' && result.content !== null && !Array.isArray(result.content) + && 'type' in result.content && typeof result.content.type === 'string' && result.content.type.endsWith('_error') + ? { is_error: true } + : {}), + }); + flushSegment(); + continue; + } + switch (block.type) { + case 'text': + pending.push({ type: 'text', text: block.text }); + break; + case 'thinking': + flushText(); + if (block.thinking !== '') assistant.push({ type: 'reasoning', summary: [block.thinking] }); + break; + case 'redacted_thinking': + // Opaque redacted thinking has no translatable input-IR representation. + break; + // A client tool and a server tool are the same fact to every target: a call + // the rest of the conversation answers. Only the client answers a client + // call, which is what the next real user turn carries. + case 'tool_use': + case 'server_tool_use': + flushText(); + assistant.push({ type: 'function_call', call_id: block.id, name: block.name, arguments: JSON.stringify(block.input) }); + break; + + default: + throw new TranslateInputException(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' assistant content blocks are not supported on this model`); + } + } + flushSegment(); + return { segments }; +}; + +const anthropicMessagesSystemToIR = (system: AnthropicMessagesPayload['system']): IRInputTurn | undefined => { + if (system === undefined) return undefined; + const content = textPartsToIR(system); + return content.length === 0 ? undefined : { kind: 'client', items: [{ type: 'instruction', content }] }; +}; + +const anthropicMessagesTurnToIR = ( + turn: AnthropicMessagesConversationTurn, + firstMessageIndex: number, +): IRInputTurn[] => { + if (turn.role === 'assistant') { + const turns: IRInputTurn[] = []; + for (const [index, message] of turn.items.entries()) { + for (const segment of assistantSegments(message, firstMessageIndex + index).segments) { + // The pair a server tool states is one call and the result written into + // the same message, so the result becomes the client turn that answers + // it before the next run of assistant content begins. + if (segment.assistant.length > 0) turns.push({ kind: 'assistant', items: segment.assistant }); + if (segment.serverResults.length > 0) turns.push({ kind: 'client', items: segment.serverResults }); + } + } + return turns; + } + return [{ + kind: 'client', + items: turn.items.flatMap((message, index) => { + if (message.role !== 'user' && message.role !== 'system') { + throw new TranslateInputException(`messages.${firstMessageIndex + index}.role: role '${(message as { role: string }).role}' is not supported on this model`); + } + return clientItems(message, firstMessageIndex + index); + }), + }]; +}; + +// Both Messages client-tool declaration forms carry JSON Schema, so they use the function-tool IR arm. +const toolToIR = (tool: AnthropicMessagesClientTool): IRInputTool => { + return { + type: 'function', + name: tool.name, + ...(tool.description === undefined ? {} : { description: tool.description }), + parameters: klona(tool.input_schema), + ...(tool.strict === undefined ? {} : { strict: tool.strict }), + }; +}; + +const toolChoiceToIR = (choice: NonNullable, declarations: readonly IRInputTool[]): IRInputToolChoice => { + switch (choice.type) { + case 'auto': return 'auto'; + case 'any': return 'required'; + case 'none': return 'none'; + default: { + // A selector names a declaration the same request supplies; a request that + // points at nothing cannot be translated into any target's vocabulary. + const name = choice.name; + if (!declarations.some(tool => tool.name === name)) { + throw new TranslateInputException(`Anthropic Messages tool_choice names an undeclared tool: ${String(name)}`); + } + return { type: 'function', name }; + } + } +}; + +const anthropicMessagesToolsToIR = (payload: AnthropicMessagesPayload): Pick => { + // A hosted declaration runs on the vendor's own infrastructure and has no + // meaning in another protocol, so only the callable arms are read. + const declarations = (payload.tools ?? []) + .filter((tool): tool is AnthropicMessagesClientTool => tool.type === undefined || tool.type === 'custom') + .map(toolToIR); + // A selector is validated against the declarations either way: dropping one + // that names nothing would widen what the model may call. + const toolChoice = payload.tool_choice == null ? undefined : toolChoiceToIR(payload.tool_choice, declarations); + return { + ...(declarations.length === 0 ? {} : { tools: declarations }), + ...(toolChoice === undefined || declarations.length === 0 && toolChoice === 'required' ? {} : { tool_choice: toolChoice }), + }; +}; + +const anthropicMessagesSettingsToIR = (payload: AnthropicMessagesPayload): IRInputSettings => { + const format = payload.output_config?.format; + // Messages schemas are strict. We assign a stable name because the source format has none. + // https://platform.claude.com/docs/en/build-with-claude/structured-outputs + const outputFormat: IRInputOutputFormat | undefined = format?.type === 'json_schema' + ? { type: 'json_schema', name: 'messages_response', schema: klona(format.schema), strict: true } + : undefined; + + const reasoningEffort = reasoningEffortToIR(payload); + const serviceTier = serviceTierToIR(payload); + const metadata = metadataToIR(payload.metadata); + return { + model: payload.model, + stream: payload.stream === true, + ...(payload.temperature == null ? {} : { temperature: payload.temperature }), + ...(payload.top_p == null ? {} : { top_p: payload.top_p }), + ...(payload.top_k == null ? {} : { top_k: payload.top_k }), + ...(payload.stop_sequences == null ? {} : { stop: [...payload.stop_sequences] }), + ...(payload.max_tokens == null ? {} : { max_output_tokens: payload.max_tokens }), + ...(outputFormat === undefined ? {} : { output_format: outputFormat }), + ...(serviceTier === undefined ? {} : { service_tier: serviceTier }), + ...(metadata === undefined ? {} : { metadata }), + ...(payload.tool_choice !== undefined && 'disable_parallel_tool_use' in payload.tool_choice && payload.tool_choice.disable_parallel_tool_use != null + ? { parallel_tool_calls: !payload.tool_choice.disable_parallel_tool_use } + : {}), + ...(reasoningEffort === undefined ? {} : { reasoning_effort: reasoningEffort }), + ...(payload.thinking?.type === 'enabled' ? { reasoning_budget: payload.thinking.budget_tokens } : {}), + ...(payload.thinking?.type === 'adaptive' ? { reasoning_budget_dynamic: true } : {}), + }; +}; + +// Our metadata input extension is normalized to string values; null-valued entries are omitted and non-string values fail here. +const metadataToIR = (metadata: AnthropicMessagesMetadataEx | undefined): Record | undefined => { + if (metadata === undefined) return undefined; + const entries = Object.entries(metadata).flatMap(([key, value]): [string, string][] => { + if (value == null) return []; + if (typeof value !== 'string') throw new TranslateInputException(`Cannot translate Anthropic Messages metadata field '${key}' to a string metadata value.`); + return [[key, value]]; + }); + return entries.length === 0 ? undefined : Object.fromEntries(entries); +}; + +// Messages speed takes precedence over service_tier when projecting onto the shared tier field. +// https://docs.claude.com/en/build-with-claude/fast-mode +const serviceTierToIR = (payload: AnthropicMessagesPayload): string | undefined => { + if (payload.speed === 'fast') return FAST_SERVICE_TIER; + + if (payload.speed === 'standard') return undefined; + return payload.service_tier ?? undefined; +}; + +// A level stated outright wins over the one a thinking mode implies, because the +// caller wrote it explicitly while the mode only bounds it. +const reasoningEffortToIR = (payload: AnthropicMessagesPayload): string | undefined => { + if (payload.output_config?.effort != null) return payload.output_config.effort; + if (payload.thinking?.type === 'disabled') return 'none'; + if (payload.thinking?.type === 'adaptive') return 'medium'; + return undefined; +}; + +/** + * A Messages request read into the turns a writer consumes. + * + * Messages states its instruction in a top-level field and its conversation in + * `messages`; both enter the IR, and the request-wide fields come from the same + * payload. + * + * A server tool's result sits inside the assistant message that called it, and + * an assistant turn can therefore yield both assistant and client turns. The + * lazy conversion preserves those boundaries until composition resolves the + * sidecar path and joins adjacent client turns. + */ +export const readAnthropicMessagesRequest = (payload: AnthropicMessagesPayload): SourceReading => { + const request: IRInputRequestHeader = { + settings: anthropicMessagesSettingsToIR(payload), + ...anthropicMessagesToolsToIR(payload), + }; + + const turns: SourceTurnSlot[] = []; + let messageIndex = 0; + + const instruction = anthropicMessagesSystemToIR(payload.system); + if (instruction !== undefined) turns.push({ kind: 'client', toIRInputTurns: () => [instruction] }); + + for (const turn of partitionAnthropicMessagesTurns(payload.messages)) { + const firstMessageIndex = messageIndex; + messageIndex += turn.items.length; + if (turn.role === 'assistant') { + turns.push({ + kind: 'assistant', + assistantTurn: turn.items, + toIRInputTurns: () => anthropicMessagesTurnToIR(turn, firstMessageIndex), + }); + continue; + } + turns.push({ kind: 'client', toIRInputTurns: () => anthropicMessagesTurnToIR(turn, firstMessageIndex) }); + } + + return { request, turns }; +}; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target.ts b/packages/translate/src/protocols/anthropic-messages/as-target.ts new file mode 100644 index 0000000000..2ed96800b0 --- /dev/null +++ b/packages/translate/src/protocols/anthropic-messages/as-target.ts @@ -0,0 +1,37 @@ +import { readAnthropicMessagesEvents } from './as-target/events.ts'; +import { anthropicMessagesPayloadFromSegments } from './as-target/segmented-request.ts'; +import type { TargetProtocol } from '../../composition/index.ts'; +import type { RestoredAssistantTurn } from '../../composition/reading.ts'; +import { assignCallIds, type PendingCall } from '../../composition/request/call-ids.ts'; +import { allocateToolNames, type ToolNameContext } from '../../composition/request/tool-names.ts'; +import type { IRInputRequestHeader, IRInputTurn } from '../../ir/input/types.ts'; +import type { InputAssistantTurnFor } from '../../round-trip/input-assistant-turn.ts'; +import type { TranslateOptions } from '../../types.ts'; +import type { AnthropicMessagesMessage, AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; + +// Restored tool calls supply IDs for matching later results. +const callsOf = (turn: Extract): readonly PendingCall[] => + Array.isArray(turn.content) + ? turn.content.flatMap(block => block.type === 'tool_use' || block.type === 'server_tool_use' ? [{ callId: block.id, name: block.name }] : []) + : []; + +export const anthropicMessagesTargetProtocol: TargetProtocol< + 'anthropicMessages', AnthropicMessagesPayload, AnthropicMessagesStreamEventEx, ToolNameContext +> = { + name: 'anthropicMessages', + write: (request: IRInputRequestHeader, turns: readonly (IRInputTurn | RestoredAssistantTurn>)[], options: TranslateOptions) => { + // This wire addresses a callable by one bare name, so every grouped + // declaration and call is stated under the flat spelling it computes. + const assigned = assignCallIds(turns, callsOf); + const flattened = allocateToolNames(request, assigned); + return { + payload: anthropicMessagesPayloadFromSegments(flattened.request, flattened.turns, { + fallbackMaxOutputTokens: options.fallbackMaxOutputTokens, + }), + // Both facts come from the same flattening that produced the request, so + // the reader sees exactly the names the upstream was asked with. + context: flattened.context, + }; + }, + readEvents: (frames, context, roundTripReader) => readAnthropicMessagesEvents(frames, { ...context, roundTrip: roundTripReader }), +}; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/cache-breakpoints.ts b/packages/translate/src/protocols/anthropic-messages/as-target/cache-breakpoints.ts index d84cf445d6..153e4ecdc5 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/cache-breakpoints.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/cache-breakpoints.ts @@ -1,24 +1,7 @@ -// Anthropic's prompt cache is opt-in: a request with no cache_control -// breakpoint anywhere returns cache_read_input_tokens=0 regardless of how -// stable the prompt prefix is. Source APIs without an explicit cache_control -// concept — Codex on the OpenAI Responses wire (opaque prompt_cache_key) and plain -// OpenAI Chat Completions (no caching field) — silently drop the field through -// translation, so every codex-through-anthropic-messages or chat-through-anthropic-messages turn -// is a cache miss while native Anthropic Messages clients hit cache fine because they -// inject breakpoints themselves. -// -// Place breakpoints in the translated payload. Up to 4 are allowed per -// request; we use 3 and leave one for downstream additions: -// -// - system block when system text is non-empty -// - last function-tool definition (caches the system+tools prefix, the most -// stable per-turn chunk) -// - last cacheable block of the latest message that has one (caches -// conversation history through the latest turn so subsequent turns build on -// a longer cached prefix) -// -// Reference: -// - https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching +// Translated Messages requests add up to three cache breakpoints: system text, the last custom tool, +// and the latest cacheable message block. Leave the fourth allowed slot for downstream additions. +// Prompt-cache keys are a separate mechanism. +// https://docs.anthropic.com/en/docs/build-with-claude/prompt-caching import type { AnthropicMessagesCacheControl, diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/data-url.ts b/packages/translate/src/protocols/anthropic-messages/as-target/data-url.ts new file mode 100644 index 0000000000..6eb88636f8 --- /dev/null +++ b/packages/translate/src/protocols/anthropic-messages/as-target/data-url.ts @@ -0,0 +1,46 @@ +import { TranslateInputException } from '../../../shared/input-error.ts'; + +const DATA_URL = /^data:([^,]*),([\s\S]*)$/; +const BASE64_MARKER = ';base64'; + +export interface ParsedDataURL { + readonly media_type?: string; + readonly data: string; +} + +const bytesOfPercentEncodedData = (value: string): number[] => { + const bytes: number[] = []; + for (let index = 0; index < value.length;) { + const encoded = value[index] === '%' ? value.slice(index + 1, index + 3) : ''; + if (/^[0-9a-f]{2}$/i.test(encoded)) { + bytes.push(Number.parseInt(encoded, 16)); + index += 3; + continue; + } + const codePoint = value.codePointAt(index)!; + bytes.push(...new TextEncoder().encode(String.fromCodePoint(codePoint))); + index += codePoint > 0xffff ? 2 : 1; + } + return bytes; +}; + +const stringOfBytes = (bytes: readonly number[]): string => { + let value = ''; + for (const byte of bytes) value += String.fromCharCode(byte); + return value; +}; + +export const parseDataURL = (url: string): ParsedDataURL => { + const match = DATA_URL.exec(url); + if (match === null) throw new TranslateInputException(`Expected a data URL, got '${url.slice(0, 32)}'.`); + const header = match[1]!; + const body = match[2]!; + const encodedAsBase64 = header.toLowerCase().endsWith(BASE64_MARKER); + const mediaType = encodedAsBase64 ? header.slice(0, -BASE64_MARKER.length) : header; + const normalizedMediaType = mediaType.startsWith(';') ? `text/plain${mediaType}` : mediaType; + const bytes = bytesOfPercentEncodedData(body); + return { + ...(normalizedMediaType === '' ? {} : { media_type: normalizedMediaType }), + data: encodedAsBase64 ? stringOfBytes(bytes) : btoa(stringOfBytes(bytes)), + }; +}; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/events.ts b/packages/translate/src/protocols/anthropic-messages/as-target/events.ts index 1dc62b6aa4..6c06fbcf6f 100644 --- a/packages/translate/src/protocols/anthropic-messages/as-target/events.ts +++ b/packages/translate/src/protocols/anthropic-messages/as-target/events.ts @@ -1,12 +1,13 @@ import type { AnthropicMessagesInputAssistantTurnState } from './assistant-turn.ts'; import { type AnthropicMessagesInputCounts, usageToIR } from './usage.ts'; +import type { ToolNameContext } from '../../../composition/request/tool-names.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 type { RoundTripReader } from '../../../round-trip/reader.ts'; import { appendReadField, createReadFieldState, type ReadFieldState, type ReadFieldStates } from '../../../round-trip/reference/reference-builder.ts'; +import { unwrapCustomToolInput } from '../../../shared/custom-tool.ts'; import { requireJSONObject } from '../../../shared/json/object.ts'; -import { unwrapCustomToolInput } from '../../openai-responses/common/custom-tool-wrap.ts'; import { type AnthropicMessagesAssistantContentBlock, type AnthropicMessagesStreamEventEx, @@ -63,7 +64,9 @@ interface MessagesBlockState { export const readAnthropicMessagesEvents = async function* ( frames: AsyncIterable>, - options: { customToolNames?: ReadonlySet; roundTrip: RoundTripReader<'anthropicMessages'> }, + options: Partial & { + roundTrip: RoundTripReader<'anthropicMessages'>; + }, ): AsyncGenerator { const ir = createIRBuilder(); const blocks = new Map(); @@ -109,9 +112,12 @@ export const readAnthropicMessagesEvents = async function* ( } } else if (block.type === 'tool_use') { custom = options.customToolNames?.has(block.name) === true; + // Restore allocated names, including bare names renamed to avoid collisions. + const identity = options.originalToolNames?.get(block.name); + const naming = identity ?? { name: block.name }; item = ir.item(0, custom - ? { type: 'custom_tool_call', call_id: block.id, name: block.name, input: '' } - : { type: 'function_call', call_id: block.id, name: block.name, arguments: '' }); + ? { type: 'custom_tool_call', call_id: block.id, ...naming, input: '' } + : { type: 'function_call', call_id: block.id, ...naming, arguments: '' }); } else if (!UNTRANSLATED_BLOCK_TYPES.includes(block.type)) { throw new TypeError(`Cannot translate Messages content block ${block.type}`); } diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts new file mode 100644 index 0000000000..e32e058502 --- /dev/null +++ b/packages/translate/src/protocols/anthropic-messages/as-target/input-from.ts @@ -0,0 +1,368 @@ +import { parseDataURL, type ParsedDataURL } from './data-url.ts'; +import type { IRInputAssistantItemWithCallIds, IRInputTurnWithCallIds } from '../../../composition/request/call-ids.ts'; +import { requireStreaming } from '../../../ir/input/streaming.ts'; +import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputDocument, IRInputMedia, IRInputTextPart, IRInputToolResultContentPart, IRInputClientItem } from '../../../ir/input/types.ts'; +import { buildCustomToolInputSchema } from '../../../shared/custom-tool.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import { type TranslateOptions } from '../../../types.ts'; +import type { + AnthropicMessagesAssistantInputContentBlock, + AnthropicMessagesClientTool, + AnthropicMessagesImageBlock, + AnthropicMessagesImageSource, + AnthropicMessagesMessage, + AnthropicMessagesPayload, + AnthropicMessagesTextBlockParam, + AnthropicMessagesToolResultContentBlock, + AnthropicMessagesUserContentBlock, +} from '@floway-dev/protocols/anthropic-messages'; +import { ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS } from '@floway-dev/protocols/anthropic-messages'; +import { isFastServiceTier, parseJSONWithRawNumbers } from '@floway-dev/protocols/common'; + +const requiredMediaType = (media: ParsedDataURL): string => media.media_type ?? 'application/octet-stream'; + +const inlineMedia = (media: Extract): ParsedDataURL => { + if (media.source_kind === 'base64') return media; + return media.data.startsWith('data:') ? parseDataURL(media.data) : { data: media.data }; +}; + +const imageSource = (media: IRInputMedia): AnthropicMessagesImageSource => { + switch (media.source_kind) { + case 'url': { + if (media.url.startsWith('data:')) { + const inline = parseDataURL(media.url); + return { type: 'base64', media_type: requiredMediaType(inline) as Extract['media_type'], data: inline.data }; + } + // Messages remote images require HTTP(S) URLs; invalid schemes are translation input errors. + const scheme = media.url.slice(0, media.url.indexOf(':')).toLowerCase(); + if (scheme !== 'http' && scheme !== 'https') { + throw new TranslateInputException(`Messages image input requires an http or https URL, got '${media.url}'.`); + } + return { type: 'url', url: media.url }; + } + case 'file': return { type: 'file', file_id: media.file_id }; + case 'data': + case 'base64': { + const inline = inlineMedia(media); + return { type: 'base64', media_type: requiredMediaType(inline) as Extract['media_type'], data: inline.data }; + } + } +}; + +const imageBlock = (media: IRInputMedia): AnthropicMessagesImageBlock => ({ type: 'image', source: imageSource(media) }); + +/** + * Inline documents remain bytes. The target requires a media type, so an + * untyped raw base64 value receives the generic binary type at this boundary. + */ +const mediaDocumentBlock = (media: IRInputMedia): Extract => { + switch (media.source_kind) { + case 'url': { + if (!media.url.startsWith('data:')) return { type: 'document', source: { type: 'url', url: media.url } }; + const inline = parseDataURL(media.url); + return { type: 'document', source: { type: 'base64', media_type: requiredMediaType(inline), data: inline.data } }; + } + case 'file': return { type: 'document', source: { type: 'file', file_id: media.file_id } }; + case 'data': + case 'base64': { + const inline = inlineMedia(media); + return { type: 'document', source: { type: 'base64', media_type: requiredMediaType(inline), data: inline.data } }; + } + } +}; + +const documentBlock = (document: IRInputDocument): Extract => { + const source = document.source; + const properties = { + ...(document.title === undefined ? {} : { title: document.title }), + ...(document.context === undefined ? {} : { context: document.context }), + ...(document.citations === undefined ? {} : { citations: document.citations }), + ...(document.cache_control === undefined ? {} : { cache_control: document.cache_control }), + }; + if (source.source_kind === 'media') return { ...mediaDocumentBlock(source.media), ...properties }; + if (source.source_kind === 'text') { + return { type: 'document', source: { type: 'text', media_type: source.media_type, data: source.text }, ...properties }; + } + return { + type: 'document', + source: { + type: 'content', + content: source.content.map(part => part.type === 'text' + ? { + type: 'text' as const, text: part.text, + ...(part.citations === undefined ? {} : { citations: part.citations as AnthropicMessagesTextBlockParam['citations'] }), + ...(part.cache_control === undefined ? {} : { cache_control: part.cache_control }), + } + : { + ...imageBlock(part.image), + ...(part.transformations === undefined ? {} : { transformations: part.transformations }), + ...(part.cache_control === undefined ? {} : { cache_control: part.cache_control }), + }), + }, + ...properties, + }; +}; + +const systemContent = (content: readonly IRInputTextPart[]): AnthropicMessagesTextBlockParam[] => content.map(part => ({ type: 'text', text: part.text })); + +const userContent = ( + item: Extract, +): AnthropicMessagesUserContentBlock[] => { + const blocks: AnthropicMessagesUserContentBlock[] = []; + for (const part of item.content) { + switch (part.type) { + case 'text': blocks.push({ type: 'text', text: part.text }); break; + case 'image': blocks.push(imageBlock(part.image)); break; + case 'file': + blocks.push(mediaDocumentBlock(part.file)); + break; + case 'document': + blocks.push(documentBlock(part.document)); + break; + case 'audio': throw new TranslateInputException(`Cannot translate an audio part of MIME type '${part.audio.media_type}' to Anthropic Messages.`); + default: throw new TranslateInputException(`Invalid '${(part as { type: string }).type}' content part.`); + } + } + return blocks.length === 0 ? [{ type: 'text', text: '' }] : blocks; +}; + +const toolResultContent = ( + output: string | readonly IRInputToolResultContentPart[], +): string | AnthropicMessagesToolResultContentBlock[] => { + if (typeof output === 'string') return output; + const blocks: AnthropicMessagesToolResultContentBlock[] = []; + for (const part of output) { + if (part.type === 'text') { blocks.push({ type: 'text', text: part.text }); continue; } + if (part.type === 'file') { blocks.push(mediaDocumentBlock(part.file)); continue; } + if (part.type === 'document') { blocks.push(documentBlock(part.document)); continue; } + blocks.push(imageBlock(part.image)); + } + // Messages spells an empty tool result as the empty string. + return blocks.length === 0 ? '' : blocks; +}; + +const assistantBlocks = (items: readonly IRInputAssistantItemWithCallIds[]): AnthropicMessagesAssistantInputContentBlock[] => { + const blocks = items.flatMap(item => { + switch (item.type) { + case 'message': + // An empty content list still represents an explicitly empty assistant + // message, so it emits one empty text block. + if (item.content.length === 0) return [{ type: 'text', text: '' }]; + return item.content.map((part): AnthropicMessagesAssistantInputContentBlock => part.type === 'text' + ? { type: 'text', text: part.text } + : { type: 'text', text: part.refusal }); + case 'reasoning': { + // Project readable reasoning summaries into Messages thinking blocks; opaque signed replay state uses sidecars. + return item.summary === undefined ? [] : [{ type: 'thinking', thinking: item.summary.join(''), signature: '' }]; + } + case 'function_call': + return [{ + type: 'tool_use' as const, + id: item.call_id, + name: item.name, + // Messages requires input; an absent argument object is written as {}. + input: item.arguments === undefined ? {} : parseArguments(item.arguments), + }] as AnthropicMessagesAssistantInputContentBlock[]; + case 'custom_tool_call': + // Messages has no freeform tool call; the wrapper schema a custom tool is + // declared with takes one `input` string, so the call is projected onto it. + return [{ type: 'tool_use', id: item.call_id, name: item.name, input: { input: item.input } }]; + } + }); + return blocks; +}; + +// Messages accepts any JSON tool input. Malformed JSON keeps its raw text under +// `raw_arguments` so the historical call is not discarded. +const parseArguments = (argumentsJson: string): unknown => { + // An out-of-range JSON number such as `1e400` parses to `Infinity` and would + // serialize back as `null`. Parsing with raw-number preservation keeps the + // caller's exact token. + try { + return parseJSONWithRawNumbers(argumentsJson); + } catch { + return { raw_arguments: argumentsJson }; + } +}; + +const toolFromIR = (tool: IRInputTool): AnthropicMessagesClientTool => { + const schema = tool.type === 'function' + ? tool.parameters ?? { type: 'object', properties: {} } + : buildCustomToolInputSchema(tool.format); + if (schema.type !== 'object') throw new TranslateInputException('Anthropic Messages function tools require an object input schema.'); + return { + // Messages states this kind for a declaration that is not one of its own + // native tool types, which is every declaration this IR carries. + type: 'custom', + name: tool.name, + ...(tool.description === undefined ? {} : { description: tool.description }), + input_schema: { ...schema, type: 'object' }, + ...(tool.type === 'function' && tool.strict !== undefined ? { strict: tool.strict } : {}), + }; +}; + +const toolChoiceFromIR = (choice: IRInputToolChoice, tools: readonly IRInputTool[]): NonNullable => { + if (choice === 'auto') return { type: 'auto' }; + if (choice === 'none') return { type: 'none' }; + if (choice === 'required') return { type: 'any' }; + if (choice.type === 'allowed_tools') { + if (choice.mode === 'required' && choice.tools.length === 1) return { type: 'tool', name: choice.tools[0].name }; + if (choice.mode === 'auto' && choice.tools.length === 0) return { type: 'none' }; + const permitted = new Set(choice.tools.map(tool => `${tool.type}:${tool.name}`)); + if (tools.every(tool => permitted.has(`${tool.type}:${tool.name}`))) return { type: choice.mode === 'required' ? 'any' : 'auto' }; + throw new TranslateInputException(`Anthropic Messages cannot express an allowed_tools ${choice.mode} subset of ${choice.tools.length} tools.`); + } + return { type: 'tool', name: choice.name }; +}; + +const toolChoiceForTools = (choice: IRInputToolChoice | undefined, tools: readonly IRInputTool[] | undefined): IRInputToolChoice | undefined => { + if (tools !== undefined && tools.length > 0) return choice; + if (choice === 'none' || choice === 'auto' || choice === 'required') return choice; + if (typeof choice === 'object' && choice.type === 'allowed_tools' && choice.mode === 'auto') return 'none'; + return undefined; +}; + +/** + * Only the first conversation segment may hoist an instruction or leading + * system/developer messages into top-level system blocks. Their text-part + * boundaries are preserved; later system messages stay in place, including + * after a restored assistant turn. + */ +interface AnthropicMessagesConversation { + readonly messages: AnthropicMessagesMessage[]; + readonly system: AnthropicMessagesTextBlockParam[]; +} + +export const anthropicMessagesConversationFromIR = ( + turns: readonly IRInputTurnWithCallIds[], + leading: boolean, +): AnthropicMessagesConversation => { + const system: AnthropicMessagesTextBlockParam[] = []; + let hoisting = leading; + const messages: AnthropicMessagesMessage[] = []; + + const userMessage = (blocks: AnthropicMessagesUserContentBlock[]): AnthropicMessagesMessage => ({ + role: 'user', + content: blocks.length === 1 && blocks[0].type === 'text' ? blocks[0].text : blocks, + }); + // Turn boundaries control user-message grouping; item kinds do not create new client turns. + const append = (blocks: AnthropicMessagesUserContentBlock[]): void => { + if (blocks.length > 0) messages.push(userMessage(blocks)); + }; + for (const turn of turns) { + if (turn.kind === 'assistant') { + hoisting = false; + const blocks = assistantBlocks(turn.items); + // A turn whose every item was dropped contributes no message, but the user + // turns around it were never adjacent, so a following user item must not + // merge into the previous message. An explicitly empty text item is not + // dropped: `assistantBlocks` emitted one empty block for it. + if (blocks.length === 0) continue; + const content = blocks.length === 1 && blocks[0].type === 'text' && blocks[0].text === '' ? '' : blocks; + messages.push({ role: 'assistant', content }); + continue; + } + const toolResultBlocks: AnthropicMessagesUserContentBlock[] = []; + const userBlocks: AnthropicMessagesUserContentBlock[] = []; + for (const item of turn.items) { + if (item.type === 'instruction') { + if (hoisting) { + system.push(...systemContent(item.content)); + hoisting = false; + continue; + } + const blocks = systemContent(item.content); + messages.push({ role: 'system', content: blocks.length === 0 ? '' : blocks }); + continue; + } + if (item.type === 'message' && item.role !== 'user') { + if (hoisting) { + system.push(...systemContent(item.content)); + continue; + } + // Messages states a system message's content as a non-empty block list + // or a bare string; an empty message is the empty string. + const blocks = systemContent(item.content); + messages.push({ role: 'system', content: blocks.length === 0 ? '' : blocks }); + continue; + } + hoisting = false; + if (item.type === 'tool_result') { + toolResultBlocks.push({ type: 'tool_result', tool_use_id: item.call_id, content: toolResultContent(item.output), ...(item.is_error === true ? { is_error: true } : {}) }); + } else { + userBlocks.push(...userContent(item as Extract)); + } + } + append([...toolResultBlocks, ...userBlocks]); + } + return { messages, system }; +}; + +/** + * Everything a Messages request states except its conversation. + * + * The conversation is written separately because a trip interleaves it with the + * assistant turns a sidecar already resolved, so only this half is computed once + * per payload. + */ +export const anthropicMessagesHeaderFromIR = ( + request: IRInputRequestHeader, + options: Pick, +): Omit => { + const tools = request.tools; + const choice = toolChoiceForTools(request.tool_choice, tools); + const selected = choice === undefined ? undefined : toolChoiceFromIR(choice, tools ?? []); + const parallel = request.settings.parallel_tool_calls ?? undefined; + const toolChoice = tools === undefined || tools.length === 0 || parallel === undefined || selected?.type === 'none' + ? selected + : { ...(selected ?? { type: 'auto' as const }), disable_parallel_tool_use: !parallel }; + return { + ...anthropicMessagesSettingsFromIR(request.settings), + model: request.settings.model, + max_tokens: request.settings.max_output_tokens ?? request.settings.max_visible_output_tokens + ?? options.fallbackMaxOutputTokens ?? ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS, + // A trip guarantees a streaming request; a request that says otherwise + // cannot be carried by this side at all. + stream: requireStreaming(request.settings), + ...(tools === undefined || tools.length === 0 ? {} : { tools: tools.map(toolFromIR) }), + ...(toolChoice === undefined ? {} : { tool_choice: toolChoice }), + }; +}; + +export const anthropicMessagesSettingsFromIR = (settings: IRInputSettings): Omit, 'model'> => { + const ceiling = settings.max_output_tokens ?? settings.max_visible_output_tokens; + const format = settings.output_format; + const formatConfig = format?.type === 'json_schema' && format.schema !== undefined + ? { format: { type: 'json_schema' as const, schema: format.schema } } + : undefined; + + // Messages separates thinking mode and budget from output_config.effort. Dynamic thinking takes precedence over a numeric budget. + // https://platform.claude.com/docs/en/build-with-claude/effort + const thinking = settings.reasoning_budget_dynamic === true + ? { type: 'adaptive' as const } + : settings.reasoning_budget !== undefined + ? settings.reasoning_budget === 0 + ? { type: 'disabled' as const } + : { type: 'enabled' as const, budget_tokens: settings.reasoning_budget } + : settings.reasoning_effort === 'none' + ? { type: 'disabled' as const } + : undefined; + const effort = settings.reasoning_effort === undefined || settings.reasoning_effort === 'none' ? undefined : settings.reasoning_effort; + const outputConfig = effort === undefined && formatConfig === undefined + ? undefined + : { ...(effort === undefined ? {} : { effort }), ...(formatConfig ?? {}) }; + + return { + ...(ceiling == null ? {} : { max_tokens: ceiling }), + ...(settings.temperature == null ? {} : { temperature: settings.temperature }), + ...(settings.top_p == null ? {} : { top_p: settings.top_p }), + ...(settings.top_k === undefined ? {} : { top_k: settings.top_k }), + ...(settings.stop === undefined ? {} : { stop_sequences: [...settings.stop] }), + ...(thinking === undefined ? {} : { thinking }), + ...(outputConfig === undefined ? {} : { output_config: outputConfig }), + // Anthropic spells the accelerated lane as `speed: 'fast'`; every other tier + // stays an open string on `service_tier`. + // https://docs.claude.com/en/build-with-claude/fast-mode + ...(settings.service_tier == null ? {} : isFastServiceTier(settings.service_tier) ? { speed: 'fast' as const } : { service_tier: settings.service_tier }), + }; +}; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/reasoning-effort.ts b/packages/translate/src/protocols/anthropic-messages/as-target/reasoning-effort.ts deleted file mode 100644 index 3a6903cc02..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/as-target/reasoning-effort.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { AnthropicMessagesPayload } from '@floway-dev/protocols/anthropic-messages'; - -// Map the gateway's discrete effort axis onto Anthropic Messages' target fields. -// -// Anthropic splits the gateway's single effort axis across two slots: -// `output_config.effort` carries a level, and `thinking.type: 'disabled'` is -// the off switch. Anthropic's effort scale is `low | medium | high | xhigh | -// max` with no `none` member, so `'none'` is the single value that changes -// slot rather than passing through — routing it to `output_config.effort` -// would send a level the upstream does not define, and dropping it would -// leave a thinking-by-default model reasoning. -// https://platform.claude.com/docs/en/build-with-claude/effort -// -// Whether the target honours the off switch is the target's business, and it -// is not uniform across Claude: thinking-always-on models (Fable 5, Mythos 5, -// Mythos Preview) reject `disabled` with a 400, and Opus 5 accepts it only at -// effort `high` or below. We emit the intent either way, exactly as the native -// `/v1/messages` path forwards a client's own `thinking: {type: 'disabled'}`, -// so a translated request and a native one get the same answer from the same -// model. -// https://platform.claude.com/docs/en/build-with-claude/thinking-troubleshooting#supported-models -// -// Every other value is forwarded verbatim; the upstream owns which levels it -// accepts. -export interface AnthropicMessagesReasoningFields { - thinking?: NonNullable; - effort?: string; -} - -export const anthropicMessagesReasoningFieldsFromEffort = (effort: string | null | undefined): AnthropicMessagesReasoningFields => { - if (effort === 'none') return { thinking: { type: 'disabled' } }; - return effort == null ? {} : { effort }; -}; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/remote-images.ts b/packages/translate/src/protocols/anthropic-messages/as-target/remote-images.ts deleted file mode 100644 index 356daaf5ff..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/as-target/remote-images.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { RemoteImageLoader } from '../../../types.ts'; -import type { AnthropicMessagesImageBlock } from '@floway-dev/protocols/anthropic-messages'; -import { encodeBase64, mediaTypeEssence } from '@floway-dev/protocols/common'; - -const ALLOWED_IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']); - -const parseDataUrl = (url: string): { mediaType: string; data: string } | null => { - const match = url.match(/^data:([^;]+);base64,(.+)$/); - if (!match) return null; - const mediaType = mediaTypeEssence(match[1]); - return mediaType === null ? null : { mediaType, data: match[2] }; -}; - -const inferMediaTypeFromUrl = (url: string): string | null => { - try { - const path = new URL(url).pathname.toLowerCase(); - if (path.endsWith('.jpg') || path.endsWith('.jpeg')) return 'image/jpeg'; - if (path.endsWith('.png')) return 'image/png'; - if (path.endsWith('.gif')) return 'image/gif'; - if (path.endsWith('.webp')) return 'image/webp'; - } catch { - return null; - } - - return null; -}; - -const resolveRemoteImage = async (url: string, loadRemoteImage: RemoteImageLoader): Promise => { - const image = await loadRemoteImage(url); - if (!image) return null; - - let mediaType = mediaTypeEssence(image.mediaType) ?? ''; - if (!ALLOWED_IMAGE_TYPES.has(mediaType)) { - mediaType = inferMediaTypeFromUrl(url) ?? ''; - } - if (!ALLOWED_IMAGE_TYPES.has(mediaType)) return null; - - return { - type: 'image', - source: { - type: 'base64', - media_type: mediaType as Extract['media_type'], - data: encodeBase64(image.data), - }, - }; -}; - -export const resolveImageUrlToAnthropicMessagesImage = async (url: string, loadRemoteImage: RemoteImageLoader): Promise => { - const dataUrl = parseDataUrl(url); - - if (dataUrl) { - if (!ALLOWED_IMAGE_TYPES.has(dataUrl.mediaType)) return null; - - return { - type: 'image', - source: { - type: 'base64', - media_type: dataUrl.mediaType as Extract['media_type'], - data: dataUrl.data, - }, - }; - } - - if (!url.startsWith('http://') && !url.startsWith('https://')) return null; - return await resolveRemoteImage(url, loadRemoteImage); -}; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/segmented-request.ts b/packages/translate/src/protocols/anthropic-messages/as-target/segmented-request.ts new file mode 100644 index 0000000000..a9a453434e --- /dev/null +++ b/packages/translate/src/protocols/anthropic-messages/as-target/segmented-request.ts @@ -0,0 +1,44 @@ +import { applyLastMessageCacheBreakpoint, applyLastSystemCacheBreakpoint, applyLastToolCacheBreakpoint } from './cache-breakpoints.ts'; +import { anthropicMessagesConversationFromIR, anthropicMessagesHeaderFromIR } from './input-from.ts'; +import { type RestoredAssistantTurn } from '../../../composition/reading.ts'; +import type { IRInputTurnWithCallIds } from '../../../composition/request/call-ids.ts'; +import { segmentTurns } from '../../../composition/request/segments.ts'; +import type { IRInputRequestHeader } from '../../../ir/input/types.ts'; +import type { TranslateOptions } from '../../../types.ts'; +import type { AnthropicMessagesMessage, AnthropicMessagesPayload, AnthropicMessagesTextBlockParam } from '@floway-dev/protocols/anthropic-messages'; + +/** + * Preserve restored native Messages turns verbatim and compute request-wide fields once. + * Apply cache breakpoints after complete assembly, leaving restored turns untouched. + */ +export const anthropicMessagesPayloadFromSegments = ( + request: IRInputRequestHeader, + turns: readonly (IRInputTurnWithCallIds | RestoredAssistantTurn)[], + options: Pick, +): AnthropicMessagesPayload => { + const system: AnthropicMessagesTextBlockParam[] = []; + const messages: AnthropicMessagesMessage[] = []; + const restored = new Set(); + for (const piece of segmentTurns(turns)) { + if (piece.kind === 'restored') { + messages.push(piece.turn); + restored.add(piece.turn); + continue; + } + // Only the first segment may project its instruction or leading + // system/developer messages into the top-level `system` slot. + const segment = anthropicMessagesConversationFromIR(piece.turns, piece.leading); + system.push(...segment.system); + messages.push(...segment.messages); + } + + const target: AnthropicMessagesPayload = { + ...anthropicMessagesHeaderFromIR(request, options), + messages, + ...(system.length === 0 ? {} : { system }), + }; + applyLastSystemCacheBreakpoint(system); + applyLastToolCacheBreakpoint(target.tools); + applyLastMessageCacheBreakpoint(messages.filter(message => !restored.has(message))); + return target; +}; diff --git a/packages/translate/src/protocols/anthropic-messages/as-target/tool-input-schema.ts b/packages/translate/src/protocols/anthropic-messages/as-target/tool-input-schema.ts deleted file mode 100644 index 1612c98007..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/as-target/tool-input-schema.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { AnthropicMessagesClientTool } from '@floway-dev/protocols/anthropic-messages'; - -export const anthropicMessagesToolInputSchema = (schema: Record): AnthropicMessagesClientTool['input_schema'] => { - if (schema.type !== 'object') throw new TranslateInputException('Anthropic Messages function tools require an object input schema.'); - return schema as AnthropicMessagesClientTool['input_schema']; -}; diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts deleted file mode 100644 index 2621b137c2..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/events.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; -import { readOpenAIChatCompletionsEvents } from '../../openai-chat-completions/as-target/events.ts'; -import { writeAnthropicMessagesEvents } from '../as-source/events.ts'; -import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; - -export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { - const stream = createRoundTripCoordinator('anthropicMessages', 'openaiChatCompletions', codec); - return writeAnthropicMessagesEvents(readOpenAIChatCompletionsEvents(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); -}; diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/request.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/request.ts deleted file mode 100644 index 1d82a504e3..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/request.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { klona } from 'klona/json'; - -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import { filterAnthropicMessagesClientTools } from '../as-source/client-tools.ts'; -import { partitionAnthropicMessagesTurns } from '../as-source/conversation-turns.ts'; -import { collectAnthropicMessagesReplayContents, findAnthropicMessagesSidecarOccurrences, verifyAnthropicMessagesTurnDigest } from '../as-source/round-trip.ts'; -import type { - AnthropicMessagesAssistantContentBlock, - AnthropicMessagesAssistantMessage, - AnthropicMessagesClientTool, - AnthropicMessagesPayload, - AnthropicMessagesServerToolUseBlockParam, - AnthropicMessagesSystemMessage, - AnthropicMessagesTextBlockParam, - AnthropicMessagesToolResultBlock, - AnthropicMessagesToolUseBlockParam, - AnthropicMessagesUserContentBlock, - AnthropicMessagesUserMessage, -} from '@floway-dev/protocols/anthropic-messages'; -import { FAST_SERVICE_TIER } from '@floway-dev/protocols/common'; -import type { OpenAIChatCompletionsStreamOptionsEx, OpenAIChatCompletionsTextPart, OpenAIChatCompletionsUserContentPart, OpenAIChatCompletionsPayload, OpenAIChatCompletionsMessage, OpenAIChatCompletionsTool, OpenAIChatCompletionsToolCall } from '@floway-dev/protocols/openai-chat-completions'; - -const flattenToolResultContent = (content: AnthropicMessagesToolResultBlock['content']): string => { - if (content === undefined) return ''; - if (typeof content === 'string') return content; - - const textBlocks = content.filter((block): block is AnthropicMessagesTextBlockParam => block.type === 'text'); - if (textBlocks.length === content.length) return textBlocks.map(block => block.text).join('\n\n'); - - return JSON.stringify(content); -}; - -// OpenAI strict-mode JSON Schema validators reject an object schema without an -// explicit `properties` field. Preserve the source schema while satisfying that -// target requirement. -// https://github.com/caozhiyuan/copilot-api/commit/ad57069826843c5d17d7b0e5ef2f75050128893c -const normalizeToolInputSchema = (schema: Record): Record => { - const target = klona(schema); - if (target.type !== 'object' || target.properties !== undefined) return target; - return { ...target, properties: {} }; -}; - -// The Chat Completions effort axis combines Anthropic's separate effort and -// thinking controls; token budgets remain a separate quantity. -const resolveReasoningEffort = (payload: AnthropicMessagesPayload): string | undefined => { - if (payload.output_config?.effort != null) return payload.output_config.effort; - if (payload.thinking?.type === 'disabled') return 'none'; - if (payload.thinking?.type === 'enabled' || payload.thinking?.type === 'adaptive') return 'medium'; - return undefined; -}; - -const toOpenAIChatCompletionsContent = (content: string | AnthropicMessagesUserContentBlock[] | AnthropicMessagesAssistantContentBlock[]): string | OpenAIChatCompletionsUserContentPart[] => { - if (typeof content === 'string') return content; - - if (!content.some(block => block.type === 'image')) { - return content - .filter((block): block is AnthropicMessagesTextBlockParam => block.type === 'text') - .map(block => block.text) - .join('\n\n'); - } - - const parts: OpenAIChatCompletionsUserContentPart[] = []; - - for (const block of content) { - if (block.type === 'text') { - parts.push({ type: 'text', text: block.text }); - continue; - } - - if (block.type === 'image') { - if (block.source.type === 'file') throw new TranslateInputException('Cannot translate file_id-only Anthropic image input to Chat Completions.'); - parts.push({ type: 'image_url', image_url: { url: block.source.type === 'url' ? block.source.url : `data:${block.source.media_type};base64,${block.source.data}` } }); - } - } - - return parts; -}; - -const toOpenAIChatCompletionsFunctionCall = (block: AnthropicMessagesToolUseBlockParam | AnthropicMessagesServerToolUseBlockParam): OpenAIChatCompletionsToolCall => ({ - id: block.id, - type: 'function', - function: { - name: block.name, - arguments: JSON.stringify(block.input), - }, -}); - -type PendingAssistantMessage = { - textParts: string[]; - toolCalls: OpenAIChatCompletionsToolCall[]; - reasoningText: string[]; -}; - -const flushPendingAssistantMessage = (messages: OpenAIChatCompletionsMessage[], pending: PendingAssistantMessage): void => { - if (pending.textParts.length === 0 && pending.toolCalls.length === 0 && pending.reasoningText.length === 0) { - return; - } - - messages.push({ - role: 'assistant', - content: pending.textParts.join('\n\n') || null, - ...(pending.toolCalls.length > 0 ? { tool_calls: [...pending.toolCalls] } : {}), - ...(pending.reasoningText.length > 0 ? { reasoning_text: pending.reasoningText.join('') } : {}), - }); - - pending.textParts.length = 0; - pending.toolCalls.length = 0; - pending.reasoningText.length = 0; -}; - -const translateAnthropicMessagesUser = (message: AnthropicMessagesUserMessage, messageIdx: number, legacyFunctionNames: Map): OpenAIChatCompletionsMessage[] => { - if (!Array.isArray(message.content)) { - return [ - { - role: 'user', - content: message.content, - }, - ]; - } - - const messages: OpenAIChatCompletionsMessage[] = []; - const pendingUserBlocks: Exclude[] = []; - - const flushPendingUserBlocks = () => { - if (pendingUserBlocks.length === 0) return; - messages.push({ - role: 'user', - content: toOpenAIChatCompletionsContent(pendingUserBlocks), - }); - - pendingUserBlocks.length = 0; - }; - - for (const [blockIdx, block] of message.content.entries()) { - if (block.type === 'tool_result') { - // Preserving source chronology matters more than keeping one OpenAI Chat Completions message, - // so interleaved user content and tool results become alternating messages. - flushPendingUserBlocks(); - const content = flattenToolResultContent(block.content); - const legacyName = legacyFunctionNames.get(block.tool_use_id); - legacyFunctionNames.delete(block.tool_use_id); - messages.push(legacyName === undefined - ? { role: 'tool', tool_call_id: block.tool_use_id, content } - : { role: 'function', name: legacyName, content }); - continue; - } - - if (block.type !== 'text' && block.type !== 'image') { - throw new TranslateInputException(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' content blocks are not supported on this model`); - } - - pendingUserBlocks.push(block); - } - - flushPendingUserBlocks(); - - return messages; -}; - -const translateAnthropicMessagesAssistant = (message: AnthropicMessagesAssistantMessage, messageIdx: number): OpenAIChatCompletionsMessage[] => { - if (!Array.isArray(message.content)) { - return [ - { - role: 'assistant', - content: message.content, - }, - ]; - } - - const messages: OpenAIChatCompletionsMessage[] = []; - const pending: PendingAssistantMessage = { - textParts: [], - toolCalls: [], - reasoningText: [], - }; - - for (const [blockIdx, block] of message.content.entries()) { - switch (block.type) { - case 'text': - pending.textParts.push(block.text); - break; - case 'thinking': - if (block.thinking !== '') pending.reasoningText.push(block.thinking); - break; - case 'redacted_thinking': - warnDroppedUnrecognizedAssistantReasoning('anthropicMessages', 'openaiChatCompletions'); - break; - case 'tool_use': - case 'server_tool_use': - pending.toolCalls.push(toOpenAIChatCompletionsFunctionCall(block)); - break; - case 'web_search_tool_result': - flushPendingAssistantMessage(messages, pending); - messages.push({ - role: 'tool', - tool_call_id: block.tool_use_id, - content: JSON.stringify(block.content), - }); - break; - default: - throw new TranslateInputException(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' assistant content blocks are not supported on this model`); - } - } - - flushPendingAssistantMessage(messages, pending); - return messages; -}; - -// Anthropic Messages system blocks are prompt boundaries; preserve each one -// as a separate OpenAI Chat Completions text part so a CC→Anthropic Messages→CC round trip -// does not silently merge them. Falls back to the simple string form when -// the source is already a single-string field. -const systemContentFromBlocks = (system: string | AnthropicMessagesTextBlockParam[]): string | OpenAIChatCompletionsTextPart[] => - typeof system === 'string' - ? system - : system.map(block => ({ type: 'text', text: block.text })); - -const translateAnthropicMessagesSystem = (message: AnthropicMessagesSystemMessage): OpenAIChatCompletionsMessage[] => [ - { - role: 'system', - content: typeof message.content === 'string' ? message.content : message.content.map(block => { - if (block.type !== 'text') throw new TranslateInputException('Only text is supported in Chat system messages.'); - return { type: 'text', text: block.text }; - }), - }, -]; - -const translateAnthropicMessagesTools = (tools?: AnthropicMessagesClientTool[]): OpenAIChatCompletionsTool[] | undefined => - // Do not hide target-side function-name constraints by renaming tools here; - // the Anthropic Messages source contract has no reverse mapping surface for that. - tools?.map(tool => ({ - type: 'function', - function: { - name: tool.name, - description: tool.description, - parameters: normalizeToolInputSchema(tool.input_schema), - ...(tool.strict !== undefined ? { strict: tool.strict } : {}), - }, - })); - -const translateAnthropicMessagesToolChoice = (toolChoice?: AnthropicMessagesPayload['tool_choice'], tools?: AnthropicMessagesClientTool[]): OpenAIChatCompletionsPayload['tool_choice'] => { - if (!toolChoice) return undefined; - if (!tools || tools.length === 0) { - if (toolChoice.type === 'tool') throw new TranslateInputException(`Anthropic Messages tool_choice names an undeclared tool: ${String(toolChoice.name)}`); - return undefined; - } - - switch (toolChoice.type) { - case 'auto': - return 'auto'; - case 'any': - return 'required'; - case 'tool': { - if (!toolChoice.name || !tools.some(tool => tool.name === toolChoice.name)) throw new TranslateInputException(`Anthropic Messages tool_choice names an undeclared tool: ${String(toolChoice.name)}`); - return { type: 'function', function: { name: toolChoice.name } }; - } - case 'none': - return 'none'; - } -}; - -export const buildTargetRequest = async ( - payload: AnthropicMessagesPayload, - codec: SidecarCodec, -): Promise => { - const clientTools = filterAnthropicMessagesClientTools(payload.tools); - const legacyFunctionNames = new Map(); - // Pass effort through verbatim; per-upstream enum acceptance (e.g. some - // backends rejecting `xhigh`/`max`) is the target interceptor's concern. - const reasoningEffort = resolveReasoningEffort(payload); - // Anthropic's structured-output schema is already strict. Supply the name - // required by OpenAI and let OpenAI report any schema constraint it rejects. - // https://platform.claude.com/docs/en/build-with-claude/structured-outputs - const format = payload.output_config?.format; - const jsonSchema = format?.type === 'json_schema' - ? { name: 'messages_response', strict: true as const, schema: klona(format.schema) } - : undefined; - const responseFormat = jsonSchema ? { type: 'json_schema' as const, json_schema: jsonSchema } : undefined; - - // Anthropic `speed: 'fast'` selects OpenAI's priority lane. Without an - // explicit speed, preserve Anthropic's service tier as an open string. - // https://docs.claude.com/en/build-with-claude/fast-mode - // https://platform.openai.com/docs/guides/fast-mode - const serviceTier = payload.speed === 'fast' ? FAST_SERVICE_TIER : payload.speed === undefined ? payload.service_tier : undefined; - const messages: OpenAIChatCompletionsMessage[] = payload.system == null || (typeof payload.system === 'string' ? payload.system === '' : payload.system.length === 0) - ? [] - : [{ role: 'system', content: systemContentFromBlocks(payload.system) }]; - let messageIndex = 0; - for (const turn of partitionAnthropicMessagesTurns(payload.messages)) { - if (turn.role === 'assistant') { - const roundTrip = await roundTripAssistantTurn('anthropicMessages', 'openaiChatCompletions', turn.items, codec, findAnthropicMessagesSidecarOccurrences, verifyAnthropicMessagesTurnDigest, collectAnthropicMessagesReplayContents); - if (roundTrip?.turn.function_call != null) { - const modernIds = new Set(roundTrip.turn.tool_calls?.map(call => call.id)); - for (const message of turn.items) if (Array.isArray(message.content)) for (const block of message.content) { - if ((block.type === 'tool_use' || block.type === 'server_tool_use') && !modernIds.has(block.id)) legacyFunctionNames.set(block.id, roundTrip.turn.function_call.name); - } - } - messages.push(...(roundTrip ? [roundTrip.turn] : turn.items.flatMap((message, index) => translateAnthropicMessagesAssistant(message, messageIndex + index)))); - } else for (const [index, message] of turn.items.entries()) { - switch (message.role) { - case 'user': messages.push(...translateAnthropicMessagesUser(message, messageIndex + index, legacyFunctionNames)); break; - case 'system': messages.push(...translateAnthropicMessagesSystem(message)); break; - default: throw new TranslateInputException(`messages.${messageIndex + index}.role: role '${(message as { role: string }).role}' is not supported on this model`); - } - } - messageIndex += turn.items.length; - } - - return { - model: payload.model, - messages, - ...(reasoningEffort !== undefined ? { reasoning_effort: reasoningEffort } : {}), - max_tokens: payload.max_tokens, - stop: klona(payload.stop_sequences), - stream: true, - // Ask the upstream for usage on every streaming chunk, not just the final - // one. `include_usage` is the OpenAI-standard flag; `continuous_usage_stats` - // is the vLLM/SGLang extension that lets this translation emit the real - // `input_tokens` in `message_start` (before any output exists) and - // cumulative `output_tokens` on intermediate `message_delta` events, which - // is the shape the Anthropic Messages SSE contract promises. Upstreams that - // do not implement the extension ignore it, and the translator falls back - // to the final usage-only chunk. Ref: - // https://github.com/vllm-project/vllm/blob/d5f0a6e829faa69d1db289bf62b14dae136c02b2/vllm/entrypoints/generate/base/protocol.py#L241-L243 - stream_options: { include_usage: true, continuous_usage_stats: true } as OpenAIChatCompletionsStreamOptionsEx, - temperature: payload.temperature, - top_p: payload.top_p, - tools: translateAnthropicMessagesTools(clientTools), - tool_choice: translateAnthropicMessagesToolChoice(payload.tool_choice, clientTools), - ...(responseFormat ? { response_format: responseFormat } : {}), - ...(serviceTier !== undefined ? { service_tier: serviceTier } : {}), - }; -}; diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/translate.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/translate.ts deleted file mode 100644 index 6bd9c0ade8..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-chat-completions/translate.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { translateToSourceEvents } from './events.ts'; -import { buildTargetRequest } from './request.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { Translator } from '../../../types.ts'; -import { restateAnthropicMessagesError } from '../as-source/error-body.ts'; -import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; - -export const translate: Translator< - AnthropicMessagesPayload, AnthropicMessagesStreamEventEx, OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent -> = async (src, ctx) => { - try { - const target = await buildTargetRequest(src, ctx.sidecarCodec); - return { - type: 'trip', - target, - events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), - error: restateAnthropicMessagesError, - }; - } catch (error) { - if (error instanceof TranslateInputException) return error.failure; - throw error; - } -}; diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts deleted file mode 100644 index a199b410ab..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/events.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; -import { readOpenAIResponsesEvents } from '../../openai-responses/as-target/events.ts'; -import { writeAnthropicMessagesEvents } from '../as-source/events.ts'; -import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; - -export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { - const stream = createRoundTripCoordinator('anthropicMessages', 'openaiResponses', codec); - return writeAnthropicMessagesEvents(readOpenAIResponsesEvents(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); -}; diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/reasoning.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/reasoning.ts deleted file mode 100644 index 5202730ab2..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/reasoning.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { AnthropicMessagesRedactedThinkingBlock, AnthropicMessagesThinkingBlock } from '@floway-dev/protocols/anthropic-messages'; -import type { OpenAIResponsesInputReasoning } from '@floway-dev/protocols/openai-responses'; - -type AnthropicMessagesReasoningBlock = AnthropicMessagesThinkingBlock | AnthropicMessagesRedactedThinkingBlock; - -export const anthropicMessagesReasoningBlockToOpenAIResponsesReasoning = (block: AnthropicMessagesReasoningBlock): OpenAIResponsesInputReasoning | null => { - if (block.type !== 'thinking' || block.thinking.length === 0) return null; - return { - type: 'reasoning', - summary: [{ type: 'summary_text', text: block.thinking }], - }; -}; diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/request.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/request.ts deleted file mode 100644 index 9bad07ebc0..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/request.ts +++ /dev/null @@ -1,325 +0,0 @@ -import { klona } from 'klona/json'; - -import { anthropicMessagesReasoningBlockToOpenAIResponsesReasoning } from './reasoning.ts'; -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import { filterAnthropicMessagesClientTools } from '../as-source/client-tools.ts'; -import { partitionAnthropicMessagesTurns } from '../as-source/conversation-turns.ts'; -import { collectAnthropicMessagesReplayContents, findAnthropicMessagesSidecarOccurrences, verifyAnthropicMessagesTurnDigest } from '../as-source/round-trip.ts'; -import { - type AnthropicMessagesAssistantMessage, - type AnthropicMessagesClientTool, - type AnthropicMessagesPayload, - type AnthropicMessagesMetadataEx, - type AnthropicMessagesServerToolUseBlockParam, - type AnthropicMessagesSystemMessage, - type AnthropicMessagesTextBlockParam, - type AnthropicMessagesToolResultBlock, - type AnthropicMessagesToolUseBlockParam, - type AnthropicMessagesUserContentBlock, - type AnthropicMessagesUserMessage, - type AnthropicMessagesWebSearchToolResultBlockParam, -} from '@floway-dev/protocols/anthropic-messages'; -import { FAST_SERVICE_TIER } from '@floway-dev/protocols/common'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesInputContent, CanonicalOpenAIResponsesInputItem, OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; - -const flattenToolResultContent = (content: AnthropicMessagesToolResultBlock['content']): string => { - if (content === undefined) return ''; - if (typeof content === 'string') return content; - - const textBlocks = content.filter((block): block is AnthropicMessagesTextBlockParam => block.type === 'text'); - if (textBlocks.length === content.length) return textBlocks.map(block => block.text).join('\n\n'); - - return JSON.stringify(content); -}; - -// OpenAI strict-mode JSON Schema validators reject an object schema without an -// explicit `properties` field. Preserve the source schema while satisfying that -// target requirement. -// https://github.com/caozhiyuan/copilot-api/commit/ad57069826843c5d17d7b0e5ef2f75050128893c -const normalizeToolInputSchema = (schema: Record): Record => { - const target = klona(schema); - if (target.type !== 'object' || target.properties !== undefined) return target; - return { ...target, properties: {} }; -}; - -// The Responses effort axis combines Anthropic's separate effort and thinking -// controls; token budgets remain a separate quantity. -const resolveReasoningEffort = (payload: AnthropicMessagesPayload): string | undefined => { - if (payload.output_config?.effort != null) return payload.output_config.effort; - if (payload.thinking?.type === 'disabled') return 'none'; - if (payload.thinking?.type === 'enabled' || payload.thinking?.type === 'adaptive') return 'medium'; - return undefined; -}; - -const flushPendingContent = (pending: OpenAIResponsesInputContent[], input: CanonicalOpenAIResponsesInputItem[], role: 'user' | 'assistant'): void => { - if (pending.length === 0) return; - input.push({ type: 'message', role, content: [...pending] }); - pending.length = 0; -}; - -const translateUserContentBlock = ( - block: Exclude, - messageIdx: number, - blockIdx: number, -): OpenAIResponsesInputContent => { - if (block.type === 'text') return { type: 'input_text', text: block.text }; - if (block.type === 'image') { - if (block.source.type === 'file') return { type: 'input_image', file_id: block.source.file_id }; - if (block.source.type === 'url') return { type: 'input_image', image_url: block.source.url }; - return { - type: 'input_image', - image_url: `data:${block.source.media_type};base64,${block.source.data}`, - }; - } - - throw new TranslateInputException(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' user content blocks are not supported on this model`); -}; - -const toOpenAIResponsesFunctionCall = (block: AnthropicMessagesToolUseBlockParam | AnthropicMessagesServerToolUseBlockParam): CanonicalOpenAIResponsesInputItem => ({ - type: 'function_call', - call_id: block.id, - name: block.name, - arguments: JSON.stringify(block.input), - status: 'completed', -}); - -const toOpenAIResponsesStructuredToolOutput = (block: AnthropicMessagesWebSearchToolResultBlockParam): Extract => ({ - type: 'function_call_output', - call_id: block.tool_use_id, - output: JSON.stringify(block.content), - status: Array.isArray(block.content) ? 'completed' : 'incomplete', -}); - -const translateUserMessage = (message: AnthropicMessagesUserMessage, messageIdx: number): CanonicalOpenAIResponsesInputItem[] => { - if (typeof message.content === 'string') { - return [{ type: 'message', role: 'user', content: message.content }]; - } - - const input: CanonicalOpenAIResponsesInputItem[] = []; - const pendingContent: OpenAIResponsesInputContent[] = []; - - for (const [blockIdx, block] of message.content.entries()) { - if (block.type === 'tool_result') { - // OpenAI Responses can represent alternating user content and tool outputs, so - // preserve Anthropic Messages block chronology instead of moving all tool results to - // the front of the turn. - flushPendingContent(pendingContent, input, 'user'); - input.push({ - type: 'function_call_output', - call_id: block.tool_use_id, - output: flattenToolResultContent(block.content), - status: block.is_error ? 'incomplete' : 'completed', - }); - continue; - } - - pendingContent.push(translateUserContentBlock(block, messageIdx, blockIdx)); - } - - flushPendingContent(pendingContent, input, 'user'); - return input; -}; - -const translateAssistantMessage = (message: AnthropicMessagesAssistantMessage, messageIdx: number): CanonicalOpenAIResponsesInputItem[] => { - if (typeof message.content === 'string') { - return [{ type: 'message', role: 'assistant', content: message.content }]; - } - - const input: CanonicalOpenAIResponsesInputItem[] = []; - const pendingContent: OpenAIResponsesInputContent[] = []; - - for (const [blockIdx, block] of message.content.entries()) { - if (block.type === 'tool_use' || block.type === 'server_tool_use') { - flushPendingContent(pendingContent, input, 'assistant'); - input.push(toOpenAIResponsesFunctionCall(block)); - continue; - } - - if (block.type === 'web_search_tool_result') { - flushPendingContent(pendingContent, input, 'assistant'); - input.push(toOpenAIResponsesStructuredToolOutput(block)); - continue; - } - - if (block.type === 'redacted_thinking') { - warnDroppedUnrecognizedAssistantReasoning('anthropicMessages', 'openaiResponses'); - continue; - } - - if (block.type === 'thinking') { - flushPendingContent(pendingContent, input, 'assistant'); - const reasoning = anthropicMessagesReasoningBlockToOpenAIResponsesReasoning(block); - if (reasoning !== null) input.push(reasoning); - continue; - } - - if (block.type === 'text') { - pendingContent.push({ type: 'output_text', text: block.text }); - continue; - } - - throw new TranslateInputException(`messages.${messageIdx}.content.${blockIdx}.type: '${(block as { type: string }).type}' assistant content blocks are not supported on this model`); - } - - flushPendingContent(pendingContent, input, 'assistant'); - return input; -}; - -// Preserve per-block boundaries when the source carries a -// AnthropicMessagesTextBlockParam[]; single-string source stays as string content. -const translateAnthropicMessagesSystem = (message: AnthropicMessagesSystemMessage): CanonicalOpenAIResponsesInputItem[] => [ - { - type: 'message', - role: 'system', - content: typeof message.content === 'string' - ? message.content - : message.content.map(block => { - if (block.type !== 'text') throw new TranslateInputException('Only text system content is supported by this Responses translation.'); - return { type: 'input_text', text: block.text }; - }), - }, -]; - -// OpenAI Responses' `instructions` field is `string | null` — it cannot carry -// multiple text blocks faithfully. When the source `AnthropicMessagesPayload.system` -// is a single string or a single block, the canonical `instructions` slot -// is the right home. When the source is a multi-block array, the canonical -// slot would silently merge the boundaries, so emit a leading input -// system message with multi-part `input_text` content instead and leave -// `instructions` null; both placements act as a system prompt prefix at -// the upstream and the multi-part form preserves the caller-visible block -// structure. -interface SystemPlacement { - readonly instructions: string | null; - readonly prependItems: readonly CanonicalOpenAIResponsesInputItem[]; -} - -const placeAnthropicMessagesSystem = (system: string | AnthropicMessagesTextBlockParam[] | undefined): SystemPlacement => { - if (typeof system === 'string') return { instructions: system, prependItems: [] }; - if (!system || system.length === 0) return { instructions: null, prependItems: [] }; - if (system.length === 1) return { instructions: system[0].text, prependItems: [] }; - return { - instructions: null, - prependItems: [{ - type: 'message', - role: 'system', - content: system.map(block => ({ type: 'input_text', text: block.text })), - }], - }; -}; - -const translateTools = (tools: AnthropicMessagesClientTool[] | undefined): OpenAIResponsesTool[] => { - if (!tools || tools.length === 0) return []; - - return tools.map(tool => ({ - type: 'function', - name: tool.name, - parameters: normalizeToolInputSchema(tool.input_schema), - // OpenAI Responses tools default stricter than Anthropic/OpenAI-Chat-Completions-style function tools, - // so omitted source strictness is made explicit as false. - strict: tool.strict ?? false, - ...(tool.description ? { description: tool.description } : {}), - })); -}; - -// OpenAI Responses upstreams disagree on an orphaned `tool_choice` — one sent without -// tools: OpenAI-backed models ignore it, while xAI-backed ones reject the -// request with `invalid-argument: A tool_choice was set on the request but no -// tools were specified`. Other gateways hit the same wall on both the native -// OpenAI Responses path and the OpenAI Responses → OpenAI Chat Completions path: -// https://github.com/Wei-Shaw/sub2api/issues/4819 -// https://github.com/jlcodes99/cockpit-tools/issues/1727 -const translateToolChoice = (toolChoice: AnthropicMessagesPayload['tool_choice'], tools?: AnthropicMessagesClientTool[]): OpenAIResponsesToolChoice | undefined => { - if (!toolChoice) return undefined; - if (!tools || tools.length === 0) { - if (toolChoice.type === 'tool') throw new TranslateInputException(`Anthropic Messages tool_choice names an undeclared tool: ${String(toolChoice.name)}`); - return undefined; - } - - const toolNames = new Set(tools.map(tool => tool.name)); - - switch (toolChoice.type) { - case 'auto': - return 'auto'; - case 'any': - return 'required'; - case 'tool': { - if (!toolChoice.name || !toolNames.has(toolChoice.name)) throw new TranslateInputException(`Anthropic Messages tool_choice names an undeclared tool: ${String(toolChoice.name)}`); - return { type: 'function', name: toolChoice.name }; - } - case 'none': - return 'none'; - } -}; - -const translateMetadata = (metadata: AnthropicMessagesMetadataEx): Record => Object.fromEntries(Object.entries(metadata).flatMap(([key, value]) => { - if (key === 'user_id' && value == null) return []; - if (typeof value !== 'string') throw new TranslateInputException(`Cannot translate Anthropic Messages metadata field '${key}' to a Responses string metadata value.`); - return [[key, value]]; -})); - -export const buildTargetRequest = async ( - payload: AnthropicMessagesPayload, - codec: SidecarCodec, -): Promise => { - // Preserve the source `output_config.effort` value as-is, even if the chosen - // OpenAI Responses upstream may reject it. Translation stays pairwise and leaves - // target-side validation to the selected upstream endpoint. - const effort = resolveReasoningEffort(payload); - const reasoning = effort !== undefined ? { effort } : undefined; - const clientTools = filterAnthropicMessagesClientTools(payload.tools); - const { instructions, prependItems } = placeAnthropicMessagesSystem(payload.system); - // Anthropic's structured-output schema is already strict. Supply the name - // required by OpenAI and let OpenAI report any schema constraint it rejects. - // https://platform.claude.com/docs/en/build-with-claude/structured-outputs - const format = payload.output_config?.format; - const jsonSchema = format?.type === 'json_schema' - ? { name: 'messages_response', strict: true as const, schema: klona(format.schema) } - : undefined; - const text = jsonSchema ? { format: { type: 'json_schema' as const, ...jsonSchema } } : undefined; - - // Anthropic `speed: 'fast'` selects OpenAI's priority lane. Without an - // explicit speed, preserve Anthropic's service tier as an open string. - // https://docs.claude.com/en/build-with-claude/fast-mode - // https://platform.openai.com/docs/guides/fast-mode - const serviceTier = payload.speed === 'fast' ? FAST_SERVICE_TIER : payload.speed === undefined ? payload.service_tier : undefined; - const toolChoice = translateToolChoice(payload.tool_choice, clientTools); - const input = [...prependItems]; - let messageIndex = 0; - for (const turn of partitionAnthropicMessagesTurns(payload.messages)) { - if (turn.role === 'assistant') { - const roundTrip = await roundTripAssistantTurn('anthropicMessages', 'openaiResponses', turn.items, codec, findAnthropicMessagesSidecarOccurrences, verifyAnthropicMessagesTurnDigest, collectAnthropicMessagesReplayContents); - input.push(...(roundTrip ? roundTrip.turn : turn.items.flatMap((message, index) => translateAssistantMessage(message, messageIndex + index)))); - } else for (const [index, message] of turn.items.entries()) { - switch (message.role) { - case 'user': input.push(...translateUserMessage(message, messageIndex + index)); break; - case 'system': input.push(...translateAnthropicMessagesSystem(message)); break; - default: throw new TranslateInputException(`messages.${messageIndex + index}.role: role '${(message as { role: string }).role}' is not supported on this model`); - } - } - messageIndex += turn.items.length; - } - - // Keep fallback semantics strict: do not synthesize `temperature: 1`, - // `store: false`, `parallel_tool_calls: true`, `reasoning.summary`, or - // `reasoning.context` when the Anthropic Messages source did not express those knobs. - // The source thinking block carries no reasoning-context mode, so we never - // invent `all_turns` (or any other value) on the target reasoning object. - return { - model: payload.model, - input, - ...(instructions !== null ? { instructions } : {}), - ...(payload.temperature !== undefined ? { temperature: payload.temperature } : {}), - ...(payload.top_p !== undefined ? { top_p: payload.top_p } : {}), - max_output_tokens: payload.max_tokens, - ...(payload.tools !== undefined ? { tools: translateTools(clientTools) } : {}), - ...(toolChoice !== undefined ? { tool_choice: toolChoice } : {}), - ...(payload.metadata ? { metadata: translateMetadata(payload.metadata as AnthropicMessagesMetadataEx) } : {}), - stream: true, - ...(reasoning ? { reasoning } : {}), - ...(text ? { text } : {}), - ...(serviceTier !== undefined ? { service_tier: serviceTier } : {}), - }; -}; diff --git a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/translate.ts b/packages/translate/src/protocols/anthropic-messages/via-openai-responses/translate.ts deleted file mode 100644 index 83581fc501..0000000000 --- a/packages/translate/src/protocols/anthropic-messages/via-openai-responses/translate.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { translateToSourceEvents } from './events.ts'; -import { buildTargetRequest } from './request.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { Translator } from '../../../types.ts'; -import { restateAnthropicMessagesError } from '../as-source/error-body.ts'; -import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; - -export const translate: Translator< - AnthropicMessagesPayload, AnthropicMessagesStreamEventEx, CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx -> = async (src, ctx) => { - try { - return { - type: 'trip', - target: await buildTargetRequest(src, ctx.sidecarCodec), - events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), - error: restateAnthropicMessagesError, - }; - } catch (error) { - if (error instanceof TranslateInputException) return error.failure; - throw error; - } -}; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source.ts b/packages/translate/src/protocols/gemini-generate-content/as-source.ts new file mode 100644 index 0000000000..47638e6540 --- /dev/null +++ b/packages/translate/src/protocols/gemini-generate-content/as-source.ts @@ -0,0 +1,23 @@ +import { restateGeminiGenerateContentError } from './as-source/error-body.ts'; +import { writeGeminiGenerateContentEvents } from './as-source/events.ts'; +import { readGeminiGenerateContentRequest } from './as-source/input-to.ts'; +import { collectGeminiGenerateContentReplayContents, findGeminiGenerateContentSidecarOccurrences, verifyGeminiGenerateContentTurnDigest } from './as-source/round-trip.ts'; +import type { SourceProtocol } from '../../composition/index.ts'; +import type { GeminiGenerateContentRequest, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; + +export const geminiGenerateContentSourceProtocol: SourceProtocol< + 'geminiGenerateContent', GeminiGenerateContentRequest, GeminiGenerateContentStreamEvent +> = { + name: 'geminiGenerateContent', + read: readGeminiGenerateContentRequest, + sidecarRecognition: { + inspect: findGeminiGenerateContentSidecarOccurrences, + verify: verifyGeminiGenerateContentTurnDigest, + collectCandidates: collectGeminiGenerateContentReplayContents, + }, + error: restateGeminiGenerateContentError, + writeEvents: (payload, records, roundTrip) => writeGeminiGenerateContentEvents(records, { + includeThoughts: payload.generationConfig?.thinkingConfig?.includeThoughts === true, + roundTrip, + }), +}; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/content.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/content.ts deleted file mode 100644 index f855b7b486..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/content.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { GeminiGenerateContentContent, GeminiGenerateContentPart } from '@floway-dev/protocols/gemini-generate-content'; - -const GEMINI_GENERATE_CONTENT_SUPPORTED_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] as const; - -// OpenAI Chat Completions accepts only WAV and MP3 input audio. -// https://github.com/openai/openai-node/blob/3f8753e0fc60f22c10c925b391810f45c3904af8/src/resources/chat/completions/completions.ts#L1591-L1621 -const GEMINI_GENERATE_CONTENT_AUDIO_FORMATS = { - 'audio/wav': 'wav', - 'audio/mp3': 'mp3', - 'audio/mpeg': 'mp3', -} as const; - -export type GeminiGenerateContentSupportedImageMimeType = (typeof GEMINI_GENERATE_CONTENT_SUPPORTED_IMAGE_MIME_TYPES)[number]; - -export type GeminiGenerateContentInlineData = - | { kind: 'image'; mimeType: GeminiGenerateContentSupportedImageMimeType; data: string } - | { kind: 'pdf'; mimeType: 'application/pdf'; data: string } - | { kind: 'audio'; mimeType: keyof typeof GEMINI_GENERATE_CONTENT_AUDIO_FORMATS; format: (typeof GEMINI_GENERATE_CONTENT_AUDIO_FORMATS)[keyof typeof GEMINI_GENERATE_CONTENT_AUDIO_FORMATS]; data: string } - | { kind: 'unsupported'; mimeType: string; data: string }; - -export const geminiGenerateContentPartText = (part: GeminiGenerateContentPart): string | null => (typeof part.text === 'string' ? part.text : null); - -export const geminiGenerateContentThoughtText = (part: GeminiGenerateContentPart): string | null => (part.thought === true && typeof part.text === 'string' ? part.text : null); - -export const geminiGenerateContentVisibleText = (part: GeminiGenerateContentPart): string | null => (part.thought === true ? null : geminiGenerateContentPartText(part)); - -export const geminiGenerateContentText = (content?: GeminiGenerateContentContent): string | null => { - const texts = content?.parts?.map(geminiGenerateContentPartText).filter((text): text is string => text !== null); - - return texts?.length ? texts.join('\n\n') : null; -}; - -export const geminiGenerateContentInlineData = (part: GeminiGenerateContentPart): GeminiGenerateContentInlineData | null => { - const inlineData = part.inlineData; - if (!inlineData) return null; - - if (GEMINI_GENERATE_CONTENT_SUPPORTED_IMAGE_MIME_TYPES.includes(inlineData.mimeType as GeminiGenerateContentSupportedImageMimeType)) { - return { - kind: 'image', - mimeType: inlineData.mimeType as GeminiGenerateContentSupportedImageMimeType, - data: inlineData.data, - }; - } - - if (inlineData.mimeType === 'application/pdf') { - return { kind: 'pdf', mimeType: inlineData.mimeType, data: inlineData.data }; - } - - const audioFormat = GEMINI_GENERATE_CONTENT_AUDIO_FORMATS[inlineData.mimeType as keyof typeof GEMINI_GENERATE_CONTENT_AUDIO_FORMATS]; - if (audioFormat !== undefined) { - return { - kind: 'audio', - mimeType: inlineData.mimeType as keyof typeof GEMINI_GENERATE_CONTENT_AUDIO_FORMATS, - format: audioFormat, - data: inlineData.data, - }; - } - - return { - kind: 'unsupported', - mimeType: inlineData.mimeType, - data: inlineData.data, - }; -}; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/conversation-turns.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/conversation-turns.ts index 5eb01fa6ec..a7bce50c7b 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/conversation-turns.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/conversation-turns.ts @@ -1,11 +1,11 @@ import type { GeminiGenerateContentContent } from '@floway-dev/protocols/gemini-generate-content'; -export type GeminiGenerateContentConversationTurn = { role: 'assistant' | 'bare'; items: GeminiGenerateContentContent[] }; +export type GeminiGenerateContentConversationTurn = { role: 'assistant' | 'client'; items: GeminiGenerateContentContent[] }; export const partitionGeminiGenerateContentTurns = (contents: readonly GeminiGenerateContentContent[]): GeminiGenerateContentConversationTurn[] => { const turns: GeminiGenerateContentConversationTurn[] = []; for (const content of contents) { - const role = content.role === 'model' ? 'assistant' : 'bare'; + const role = content.role === 'model' ? 'assistant' : 'client'; const previous = turns.at(-1); if (previous?.role === role) previous.items.push(content); else turns.push({ role, items: [content] }); diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts index 69591f76e0..c8a18146e1 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/events.ts @@ -49,6 +49,7 @@ interface TextSegment { } interface GeminiGenerateContentEventWriterOptions extends SourceEventWriterOptions<'geminiGenerateContent'> { + includeThoughts?: boolean; parseToolArguments?: (text: string) => IRJSONObject; } @@ -80,13 +81,21 @@ export const writeGeminiGenerateContentEvents = async function* ( const appendPendingContent = (content: EmittedFieldState, text: string, spans: readonly TextProvenanceSpan[]): void => replayTextProvenance(text, spans, (value, textFragmentIds) => appendEmittedField(options.roundTrip.builder, content, value, textFragmentIds)); + const frameOf = (event: GeminiGenerateContentResult): EventFrame => + eventFrame({ responseId: metadata.id, modelVersion: metadata.model, ...event }); const emit = (event: GeminiGenerateContentResult): EventFrame => { - const frame = eventFrame({ responseId: metadata.id, modelVersion: metadata.model, ...event }); + const frame = frameOf(event); nativeAccumulator.accept(frame.event); return frame; }; const emitPart = (choice: number, part: GeminiGenerateContentPart): EventFrame => emit({ candidates: [{ index: choice, content: { role: 'model', parts: [part] } }] }); + const renderPart = (choice: number, part: GeminiGenerateContentPart): EventFrame => { + if (part.thought !== true || options.includeThoughts === true) return emitPart(choice, part); + const full = frameOf({ candidates: [{ index: choice, content: { role: 'model', parts: [part] } }] }); + nativeAccumulator.accept(full.event); + return frameOf({ candidates: [{ index: choice, content: { role: 'model', parts: [{ text: '', thought: true }] } }] }); + }; const addUnit = (choice: number, unit: GenerateContentUnit): void => { let queue = queues.get(choice); if (queue === undefined) { queue = createIRUnitQueue(); queues.set(choice, queue); } @@ -284,8 +293,8 @@ export const writeGeminiGenerateContentEvents = async function* ( if (wait) break; queue.dropHead(); unit.opened = true; - if (part !== undefined) yield emitPart(choice, part); - for (const pendingPart of drainPendingText(choice, collector)) yield emitPart(choice, pendingPart); + if (part !== undefined) yield renderPart(choice, part); + for (const pendingPart of drainPendingText(choice, collector)) yield renderPart(choice, pendingPart); if (closed) units.delete(unit.path); } if (queue.head === undefined) queues.delete(choice); @@ -395,7 +404,7 @@ export const writeGeminiGenerateContentEvents = async function* ( }); } else { const part = appendText(choice, record.path, record.value, kind === 'thought', record.referencePieceIds); - if (part !== undefined) yield emitPart(choice, part); + if (part !== undefined) yield renderPart(choice, part); } } } diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts new file mode 100644 index 0000000000..22672a0366 --- /dev/null +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/input-to.ts @@ -0,0 +1,341 @@ +import { klona } from 'klona/json'; + +import { partitionGeminiGenerateContentTurns, type GeminiGenerateContentConversationTurn as GeminiGenerateContentTurn } from './conversation-turns.ts'; +import type { GeminiGenerateContentSourceAssistantTurn } from './round-trip.ts'; +import { geminiFunctionParameters, geminiResponseSchema } from './schema.ts'; +import type { SourceReading, SourceTurnSlot } from '../../../composition/reading.ts'; +import type { + IRInputAssistantContentPart, + IRInputRequestHeader, + IRInputSettings, + IRInputTool, + IRInputToolChoice, + IRInputOutputFormat, + IRInputAssistantItem, + IRInputTextPart, + IRInputTurn, + IRInputUserContentPart, + IRInputClientItem, + IRInputToolResultContentPart, +} from '../../../ir/input/types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import { geminiGenerateContentPartKind } from '@floway-dev/protocols/gemini-generate-content'; +import type { GeminiGenerateContentThinkingConfig, GeminiGenerateContentContent, GeminiGenerateContentPart, GeminiGenerateContentPayload, GeminiGenerateContentRequest } from '@floway-dev/protocols/gemini-generate-content'; + +// Translate Google's named thinking levels to target effort names; the default +// sentinel leaves effort unset. Preserve all other open-string values verbatim. +// Reference: https://ai.google.dev/api/generate-content#ThinkingLevel +const geminiGenerateContentReasoningEffortToIR = (thinkingConfig?: GeminiGenerateContentThinkingConfig): string | undefined => { + const level = thinkingConfig?.thinkingLevel ?? undefined; + switch (level) { + case 'LOW': return 'low'; + case 'MEDIUM': return 'medium'; + case 'HIGH': return 'high'; + case 'MINIMAL': return 'minimal'; + case 'THINKING_LEVEL_UNSPECIFIED': return undefined; + default: return level; + } +}; + +const inlinePart = (part: GeminiGenerateContentPart): IRInputUserContentPart => { + const inlineData = geminiGenerateContentInlineData(part); + if (inlineData === null) throw new TranslateInputException('Expected inline data.'); + const media = { source_kind: 'base64' as const, media_type: inlineData.mimeType, data: inlineData.data }; + switch (inlineData.kind) { + case 'image': return { type: 'image', image: media }; + case 'pdf': return { type: 'file', file: media }; + case 'audio': return { type: 'audio', audio: { media_type: inlineData.mimeType, data: inlineData.data } }; + // Inline data outside known image/audio kinds becomes a file with its original MIME type. + // The selected upstream owns acceptance. + case 'other': return { type: 'file', file: media }; + } +}; + +const functionResponsePart = ( + part: NonNullable['parts']>[number], +): IRInputToolResultContentPart => { + const media = { source_kind: 'base64' as const, media_type: part.inlineData.mimeType, data: part.inlineData.data }; + return geminiGenerateContentInlineData({ inlineData: part.inlineData })?.kind === 'image' + ? { type: 'image', image: media } + : { type: 'file', file: media }; +}; + +const userParts = (content: GeminiGenerateContentContent): IRInputClientItem[] => { + const items: IRInputClientItem[] = []; + const pending: IRInputUserContentPart[] = []; + const flush = (): void => { + if (pending.length === 0) return; + items.push({ type: 'message', role: 'user', content: pending.splice(0) }); + }; + + (content.parts ?? []).forEach(part => { + const kind = geminiGenerateContentPartKind(part); + switch (kind) { + case null: + return; + case 'function_response': { + const response = part.functionResponse!; + flush(); + items.push({ + type: 'tool_result', + // The target matches id-less results against both ordinary and native calls. + name: response.name, + ...(response.id === undefined ? {} : { call_id: response.id }), + output: response.parts === undefined + ? JSON.stringify(response.response) + : [{ type: 'text', text: JSON.stringify(response.response) }, ...response.parts.map(functionResponsePart)], + }); + return; + } + case 'text': { + const text = geminiGenerateContentPartText(part); + if (text !== null) pending.push({ type: 'text', text }); + return; + } + case 'inline_data': + pending.push(inlinePart(part)); + return; + default: + throw new TranslateInputException(`"${kind}" parts are not supported in user content.`); + } + }); + flush(); + return items; +}; + +const assistantItems = (content: GeminiGenerateContentContent, contentIndex: number): IRInputAssistantItem[] => { + const items: IRInputAssistantItem[] = []; + const pending: IRInputAssistantContentPart[] = []; + const flush = (): void => { + if (pending.length === 0) return; + items.push({ type: 'message', content: pending.splice(0) }); + }; + + const parts = (content.parts ?? []).flatMap(part => { + const kind = geminiGenerateContentPartKind(part); + return kind === null ? [] : [{ kind, part }]; + }); + + parts.forEach(({ kind, part }, partIndex) => { + switch (kind) { + case 'function_call': { + const call = part.functionCall!; + flush(); + // Omitted args leaves argument text absent; explicit null becomes the JSON text "null". + items.push({ type: 'function_call', ...(call.id === undefined ? {} : { call_id: call.id }), name: call.name, arguments: JSON.stringify(call.args) }); + return; + } + case 'text': { + const thoughtText = geminiGenerateContentThoughtText(part); + if (thoughtText !== null) { + flush(); + // Thought parts have no ID; their IR identity comes from their position. + items.push({ type: 'reasoning', id: `gemini_reasoning_${contentIndex}_${partIndex}`, summary: [thoughtText] }); + return; + } + const visible = geminiGenerateContentVisibleText(part); + if (visible !== null) pending.push({ type: 'text', text: visible }); + return; + } + case 'inline_data': + + throw new TranslateInputException('"inline_data" parts are not supported in model content.'); + default: + throw new TranslateInputException(`"${kind}" parts are not supported in model content.`); + } + }); + flush(); + return items; +}; + +const systemParts = (content: GeminiGenerateContentContent | undefined): IRInputTextPart[] => { + const text = geminiGenerateContentText(content); + return text === null ? [] : [{ type: 'text', text }]; +}; + +// One partitioned source turn becomes one IR turn, including consecutive same-role contents. +const turnToIRTurns = ( + turn: GeminiGenerateContentTurn, + contentIndex: number, +): IRInputTurn[] => turn.role === 'assistant' + ? [{ + kind: 'assistant', + items: turn.items.flatMap((content, index) => (content.parts?.length ?? 0) === 0 + ? [{ type: 'message' as const, content: [{ type: 'text' as const, text: '' }] }] + : assistantItems(content, contentIndex + index)), + }] + : [{ + kind: 'client', + items: turn.items.flatMap(content => { + if (content.role !== 'user' && content.role != null) { + throw new TranslateInputException(`"${(content as { role: string }).role}" is not a supported content role.`); + } + return (content.parts?.length ?? 0) === 0 + ? [{ type: 'message' as const, role: 'user' as const, content: [] }] + : userParts(content); + }), + }]; + +const geminiGenerateContentSystemToIR = (content: GeminiGenerateContentContent | undefined): IRInputTurn | undefined => { + const parts = systemParts(content); + return parts.length === 0 ? undefined : { kind: 'client', items: [{ type: 'instruction', content: parts }] }; +}; + +// GenerateContent's allowedFunctionNames becomes an IR allowed_tools choice while retaining all declarations. +// https://github.com/googleapis/googleapis/blob/bc7e3baa28fbb223fa93782e130260fab8205bfc/google/ai/generativelanguage/v1beta/content.proto +const toolsToIR = (payload: GeminiGenerateContentPayload): Pick => { + const declarations = payload.tools?.flatMap(group => group.functionDeclarations ?? []) ?? []; + const config = payload.toolConfig?.functionCallingConfig; + const allowedNames = config?.allowedFunctionNames ?? undefined; + const restrictable = config?.mode === 'ANY' || config?.mode === 'VALIDATED'; + const permitted = restrictable && allowedNames !== undefined && allowedNames.length > 0 ? new Set(allowedNames) : undefined; + + const tools: IRInputTool[] = declarations.map(declaration => { + const parameters = geminiFunctionParameters(declaration); + return { + type: 'function' as const, + name: declaration.name, + ...(declaration.description === undefined ? {} : { description: declaration.description }), + // GenerateContent states a declaration's schema either as its own dialect or + // as JSON Schema; both become the same neutral object here. + ...(parameters === undefined ? {} : { parameters }), + }; + }); + + let toolChoice: IRInputToolChoice | undefined; + if (config?.mode === 'NONE') toolChoice = 'none'; + else if (config?.mode === 'AUTO' || config?.mode === 'VALIDATED' || config?.mode === 'ANY') { + const mode = config.mode === 'ANY' ? 'required' : 'auto'; + toolChoice = permitted === undefined ? mode : { + type: 'allowed_tools', mode, + tools: [...permitted].map(name => ({ type: 'function', name })), + }; + } + + return { + ...(tools.length === 0 ? {} : { tools }), + ...(toolChoice === undefined || tools.length === 0 && toolChoice === 'required' ? {} : { tool_choice: toolChoice }), + }; +}; + +export const readGeminiGenerateContentRequest = (payload: GeminiGenerateContentRequest): SourceReading => { + let contentIndex = 0; + const instruction = geminiGenerateContentSystemToIR(payload.systemInstruction); + + const turnSlotFor = (turn: GeminiGenerateContentTurn): SourceTurnSlot => { + const at = contentIndex; + contentIndex += turn.items.length; + const toIRInputTurns = () => turnToIRTurns(turn, at); + if (turn.role !== 'assistant') return { kind: 'client', toIRInputTurns }; + return { kind: 'assistant', assistantTurn: turn.items, toIRInputTurns }; + }; + + return { + request: { + settings: geminiGenerateContentSettingsToIR(payload), + ...toolsToIR(payload), + }, + turns: [ + ...(instruction === undefined ? [] : [{ kind: 'client' as const, toIRInputTurns: () => [instruction] }]), + ...partitionGeminiGenerateContentTurns(payload.contents ?? []).map(turnSlotFor), + ], + }; +}; + +const geminiGenerateContentSettingsToIR = (payload: GeminiGenerateContentRequest): IRInputSettings => { + const config = payload.generationConfig ?? undefined; + const thinkingConfig = config?.thinkingConfig ?? undefined; + const budget = thinkingConfig?.thinkingBudget ?? undefined; + // A level is what the caller stated, including an empty one: it is an open + // string, and a target that accepts it forwards it verbatim. A budget is a + // different quantity the caller may state instead, so it travels on its own + // field and each target decides what it can do with it. + const statedLevel = geminiGenerateContentReasoningEffortToIR(thinkingConfig); + // Reject a simultaneous budget and non-empty thinking level. The empty string is exempt from this conflict check. + // https://github.com/cline/cline/issues/7957 + if (statedLevel !== undefined && statedLevel !== '' && budget !== undefined) { + throw new TranslateInputException('Unable to submit request because thinking_budget and thinking_level are not supported together.'); + } + // A zero thinking budget maps to disabled reasoning. + const effort = statedLevel ?? (budget === 0 ? 'none' : undefined); + const schema = config === undefined ? undefined : geminiResponseSchema(config); + const outputFormat: IRInputOutputFormat | undefined = schema !== undefined + ? { type: 'json_schema', schema: klona(schema), name: 'gemini_response' } + : config?.responseMimeType === 'application/json' ? { type: 'json_object' } : undefined; + + return { + model: payload.model, + stream: true, + ...(config?.temperature == null ? {} : { temperature: config.temperature }), + ...(config?.topP == null ? {} : { top_p: config.topP }), + ...(config?.topK == null ? {} : { top_k: config.topK }), + ...(config?.stopSequences == null ? {} : { stop: [...config.stopSequences] }), + ...(config?.seed == null ? {} : { seed: config.seed }), + ...(config?.maxOutputTokens == null ? {} : { max_output_tokens: config.maxOutputTokens }), + ...(outputFormat === undefined ? {} : { output_format: outputFormat }), + ...(config?.candidateCount == null ? {} : { choice_count: config.candidateCount }), + ...(config?.presencePenalty == null ? {} : { presence_penalty: config.presencePenalty }), + ...(config?.frequencyPenalty == null ? {} : { frequency_penalty: config.frequencyPenalty }), + ...(effort === undefined ? {} : { reasoning_effort: effort }), + // A negative budget asks the model to choose its own and states no number. + ...(budget === undefined || budget <= 0 ? {} : { reasoning_budget: budget }), + ...(budget === undefined || budget >= 0 ? {} : { reasoning_budget_dynamic: true }), + }; +}; + +const GEMINI_GENERATE_CONTENT_SUPPORTED_IMAGE_MIME_TYPES = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'] as const; + +const GEMINI_GENERATE_CONTENT_AUDIO_MIME_TYPES = ['audio/wav', 'audio/mp3', 'audio/mpeg'] as const; + +type GeminiGenerateContentAudioMimeType = (typeof GEMINI_GENERATE_CONTENT_AUDIO_MIME_TYPES)[number]; + +const isAudioMimeType = (value: string): value is GeminiGenerateContentAudioMimeType => + (GEMINI_GENERATE_CONTENT_AUDIO_MIME_TYPES as readonly string[]).includes(value); + +type GeminiGenerateContentSupportedImageMimeType = (typeof GEMINI_GENERATE_CONTENT_SUPPORTED_IMAGE_MIME_TYPES)[number]; + +type GeminiGenerateContentInlineData = + | { kind: 'image'; mimeType: GeminiGenerateContentSupportedImageMimeType; data: string } + | { kind: 'pdf'; mimeType: 'application/pdf'; data: string } + | { kind: 'audio'; mimeType: GeminiGenerateContentAudioMimeType; data: string } + // Any other MIME type: carried as a file and left for the target to judge. + | { kind: 'other'; mimeType: string; data: string }; + +const geminiGenerateContentPartText = (part: GeminiGenerateContentPart): string | null => (typeof part.text === 'string' ? part.text : null); + +const geminiGenerateContentThoughtText = (part: GeminiGenerateContentPart): string | null => (part.thought === true && typeof part.text === 'string' ? part.text : null); + +const geminiGenerateContentVisibleText = (part: GeminiGenerateContentPart): string | null => (part.thought === true ? null : geminiGenerateContentPartText(part)); + +const geminiGenerateContentText = (content?: GeminiGenerateContentContent): string | null => { + const texts = content?.parts?.map(geminiGenerateContentPartText).filter((text): text is string => text !== null); + + return texts?.length ? texts.join('') : null; +}; + +const geminiGenerateContentInlineData = (part: GeminiGenerateContentPart): GeminiGenerateContentInlineData | null => { + const inlineData = part.inlineData; + if (!inlineData) return null; + + if (GEMINI_GENERATE_CONTENT_SUPPORTED_IMAGE_MIME_TYPES.includes(inlineData.mimeType as GeminiGenerateContentSupportedImageMimeType)) { + return { + kind: 'image', + mimeType: inlineData.mimeType as GeminiGenerateContentSupportedImageMimeType, + data: inlineData.data, + }; + } + + if (inlineData.mimeType === 'application/pdf') { + return { kind: 'pdf', mimeType: inlineData.mimeType, data: inlineData.data }; + } + + if (isAudioMimeType(inlineData.mimeType)) { + return { kind: 'audio', mimeType: inlineData.mimeType, data: inlineData.data }; + } + + return { + kind: 'other', + mimeType: inlineData.mimeType, + data: inlineData.data, + }; +}; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/reasoning.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/reasoning.ts deleted file mode 100644 index b1d22619c7..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/reasoning.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { GeminiGenerateContentThinkingConfig } from '@floway-dev/protocols/gemini-generate-content'; - -// Translate Google's named thinking levels to target effort names; the default -// sentinel leaves effort unset. Preserve all other open-string values verbatim. -// Reference: https://ai.google.dev/api/generate-content#ThinkingLevel -export const geminiGenerateContentThinkingLevelEffort = (thinkingConfig?: GeminiGenerateContentThinkingConfig): string | undefined => { - switch (thinkingConfig?.thinkingLevel) { - case 'LOW': return 'low'; - case 'MEDIUM': return 'medium'; - case 'HIGH': return 'high'; - case 'MINIMAL': return 'minimal'; - case 'THINKING_LEVEL_UNSPECIFIED': return undefined; - default: return thinkingConfig?.thinkingLevel; - } -}; - -// Bucket Gemini generateContent's numeric `thinkingBudget` back onto the discrete -// `reasoning_effort` axis when the target protocol has no numeric budget -// slot (OpenAI Chat Completions, OpenAI Responses). Google publishes per-model -// budget ranges but not effort-name thresholds; the 2048 / 8192 bin -// edges below invert the community convention of using those same -// numbers as the DEFAULT budget per effort tier — treating "medium's -// default is 2048" as "budgets up to and including 2048 read as low -// tier", and "high's default is 8192" as "budgets up to 8192 read as -// medium tier". -// -// References: -// - Google Gemini thinking config: https://ai.google.dev/gemini-api/docs/thinking -// - AutoReview (community): `THINK_BUDGETS = { low: 512, medium: 2048, high: 8192 }` -// https://github.com/krzysztofdudek/AutoReview/blob/9bf2ede3a960f5215645bedba41c591829053f5c/scripts/lib/providers/google.mjs#L4 -// - LiteLLM (community): `DEFAULT_REASONING_EFFORT_MEDIUM_THINKING_BUDGET = 2048` -// https://github.com/BerriAI/litellm/blob/88e03e548716a45284597edf2b7f47a7e6a66d5f/litellm/constants.py#L184 -export const geminiGenerateContentReasoningEffort = (thinkingConfig?: GeminiGenerateContentThinkingConfig): string | null => { - if (!thinkingConfig) return null; - - if (thinkingConfig.thinkingBudget !== undefined) { - if (thinkingConfig.thinkingBudget === 0) return 'none'; - if (thinkingConfig.thinkingBudget < 0) return null; - if (thinkingConfig.thinkingBudget <= 2048) return 'low'; - if (thinkingConfig.thinkingBudget <= 8192) return 'medium'; - return 'high'; - } - - return geminiGenerateContentThinkingLevelEffort(thinkingConfig) ?? null; -}; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts index b45b63b855..6c7bb1e4fe 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/round-trip.ts @@ -12,9 +12,6 @@ const geminiVisibleTextGroup = (part: GeminiGenerateContentPart): TextContributi export type GeminiGenerateContentSourceAssistantTurn = GeminiGenerateContentContent[]; export type GeminiGenerateContentSidecarPart = GeminiGenerateContentPart & { thoughtSignature: string }; -export const hasGeminiGenerateContentThoughtSignature = (content: GeminiGenerateContentContent): boolean => - content.parts?.some(part => part.thoughtSignature !== undefined) ?? false; - const geminiTurnDigestMaterialV1 = (turn: GeminiGenerateContentSourceAssistantTurn): IRJSONObject => { const contents = turn.map(content => { const visible = collectTextGroups(content.parts ?? [], geminiVisibleTextGroup).get('visible'); diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/schema.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/schema.ts index a452afbfac..14b4e4e39d 100644 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/schema.ts +++ b/packages/translate/src/protocols/gemini-generate-content/as-source/schema.ts @@ -36,11 +36,11 @@ const jsonSchemaObject = (schema: unknown): Record => { }; export const geminiFunctionParameters = (declaration: GeminiGenerateContentFunctionDeclaration): Record | undefined => { - if (declaration.parameters !== undefined && declaration.parametersJsonSchema !== undefined) throw new TranslateInputException('Gemini parameters and parametersJsonSchema are mutually exclusive.'); - return declaration.parametersJsonSchema !== undefined ? jsonSchemaObject(declaration.parametersJsonSchema) : declaration.parameters === undefined ? undefined : geminiSchemaToJsonSchema(declaration.parameters); + if (declaration.parameters != null && declaration.parametersJsonSchema !== undefined) throw new TranslateInputException('Gemini parameters and parametersJsonSchema are mutually exclusive.'); + return declaration.parametersJsonSchema !== undefined ? jsonSchemaObject(declaration.parametersJsonSchema) : declaration.parameters == null ? undefined : geminiSchemaToJsonSchema(declaration.parameters); }; export const geminiResponseSchema = (config: GeminiGenerateContentGenerationConfig): Record | undefined => { - if (config.responseSchema !== undefined && config.responseJsonSchema !== undefined) throw new TranslateInputException('Gemini responseSchema and responseJsonSchema are mutually exclusive.'); - return config.responseJsonSchema !== undefined ? jsonSchemaObject(config.responseJsonSchema) : config.responseSchema === undefined ? undefined : geminiSchemaToJsonSchema(config.responseSchema); + if (config.responseSchema != null && config.responseJsonSchema !== undefined) throw new TranslateInputException('Gemini responseSchema and responseJsonSchema are mutually exclusive.'); + return config.responseJsonSchema !== undefined ? jsonSchemaObject(config.responseJsonSchema) : config.responseSchema == null ? undefined : geminiSchemaToJsonSchema(config.responseSchema); }; diff --git a/packages/translate/src/protocols/gemini-generate-content/as-source/tools.ts b/packages/translate/src/protocols/gemini-generate-content/as-source/tools.ts deleted file mode 100644 index 52f23425a5..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/as-source/tools.ts +++ /dev/null @@ -1,72 +0,0 @@ -const geminiGenerateContentToolCallId = (turnIndex: number, partIndex: number): string => `gemini_call_${turnIndex}_${partIndex}`; - -import type { GeminiGenerateContentFunctionCallingConfig, GeminiGenerateContentFunctionDeclaration, GeminiGenerateContentPayload, GeminiGenerateContentPart } from '@floway-dev/protocols/gemini-generate-content'; - -export type GeminiGenerateContentToolCallIds = Record; - -export type GeminiGenerateContentFunctionCall = NonNullable; -export type GeminiGenerateContentFunctionResponse = NonNullable; - -export type GeminiGenerateContentFunctionCallingIntent = { type: 'none' } | { type: 'auto' } | { type: 'any' } | { type: 'named'; name: string }; - -export interface GeminiGenerateContentFunctionCallPart { - call: GeminiGenerateContentFunctionCall; - id: string; -} - -export interface GeminiGenerateContentFunctionResponsePart { - response: GeminiGenerateContentFunctionResponse; - id: string; -} - -export const geminiGenerateContentFunctionCallPart = (part: GeminiGenerateContentPart, ids: GeminiGenerateContentToolCallIds, turnIndex: number, partIndex: number): GeminiGenerateContentFunctionCallPart | null => { - const call = part.functionCall; - if (!call) return null; - - const id = call.id ?? geminiGenerateContentToolCallId(turnIndex, partIndex); - ids[call.name] ??= []; - ids[call.name].push(id); - - return { call, id }; -}; - -export const geminiGenerateContentFunctionResponsePart = (part: GeminiGenerateContentPart, ids: GeminiGenerateContentToolCallIds, turnIndex: number, partIndex: number, remove: 'first' | 'last' = 'first'): GeminiGenerateContentFunctionResponsePart | null => { - const response = part.functionResponse; - if (!response) return null; - - const unmatched = ids[response.name]; - const id = response.id ?? geminiGenerateContentToolCallId(turnIndex, partIndex); - if (response.id !== undefined) { - const index = remove === 'first' ? unmatched?.indexOf(response.id) ?? -1 : unmatched?.lastIndexOf(response.id) ?? -1; - if (index >= 0) unmatched?.splice(index, 1); - return { response, id }; - } - - return { response, id: unmatched?.shift() ?? id }; -}; - -export const geminiGenerateContentFunctionDeclarations = (payload: GeminiGenerateContentPayload, allowedNameMode: 'any' | 'all' | 'none'): GeminiGenerateContentFunctionDeclaration[] => { - const config = payload.toolConfig?.functionCallingConfig; - const allowedFunctionNames = config?.allowedFunctionNames; - const allowedNames = allowedFunctionNames?.length && (allowedNameMode === 'all' || (allowedNameMode === 'any' && config?.mode === 'ANY')) ? new Set(allowedFunctionNames) : null; - - return payload.tools?.flatMap(toolGroup => toolGroup.functionDeclarations?.filter(declaration => allowedNames?.has(declaration.name) ?? true) ?? []) ?? []; -}; - -const geminiGenerateContentSingleAllowedFunctionName = (config?: GeminiGenerateContentFunctionCallingConfig): string | undefined => (config?.allowedFunctionNames?.length === 1 ? config.allowedFunctionNames[0] : undefined); - -export const geminiGenerateContentFunctionCallingIntent = (config?: GeminiGenerateContentFunctionCallingConfig): GeminiGenerateContentFunctionCallingIntent | undefined => { - switch (config?.mode) { - case 'NONE': - return { type: 'none' }; - case 'AUTO': - case 'VALIDATED': - return { type: 'auto' }; - case 'ANY': { - const name = geminiGenerateContentSingleAllowedFunctionName(config); - return name !== undefined ? { type: 'named', name } : { type: 'any' }; - } - default: - return undefined; - } -}; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts deleted file mode 100644 index 00b75e1095..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/events.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; -import { readAnthropicMessagesEvents } from '../../anthropic-messages/as-target/events.ts'; -import { writeGeminiGenerateContentEvents } from '../as-source/events.ts'; -import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; - -export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { - const stream = createRoundTripCoordinator('geminiGenerateContent', 'anthropicMessages', codec); - return writeGeminiGenerateContentEvents(readAnthropicMessagesEvents(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); -}; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/request.ts b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/request.ts deleted file mode 100644 index b99a9a6751..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/request.ts +++ /dev/null @@ -1,279 +0,0 @@ -import { klona } from 'klona/json'; - -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import { applyLastMessageCacheBreakpoint, applyLastSystemCacheBreakpoint, applyLastToolCacheBreakpoint } from '../../anthropic-messages/as-target/cache-breakpoints.ts'; -import { anthropicMessagesToolInputSchema } from '../../anthropic-messages/as-target/tool-input-schema.ts'; -import { geminiGenerateContentInlineData, geminiGenerateContentPartText, geminiGenerateContentText, geminiGenerateContentThoughtText, geminiGenerateContentVisibleText } from '../as-source/content.ts'; -import { partitionGeminiGenerateContentTurns } from '../as-source/conversation-turns.ts'; -import { geminiGenerateContentThinkingLevelEffort } from '../as-source/reasoning.ts'; -import { collectGeminiGenerateContentReplayContents, hasGeminiGenerateContentThoughtSignature, findGeminiGenerateContentSidecarOccurrences, verifyGeminiGenerateContentTurnDigest } from '../as-source/round-trip.ts'; -import { geminiFunctionParameters, geminiResponseSchema } from '../as-source/schema.ts'; -import { geminiGenerateContentFunctionCallingIntent, geminiGenerateContentFunctionCallPart, geminiGenerateContentFunctionDeclarations, geminiGenerateContentFunctionResponsePart, type GeminiGenerateContentToolCallIds } from '../as-source/tools.ts'; -import { - ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS, - type AnthropicMessagesAssistantInputContentBlock, - type AnthropicMessagesPayload, - type AnthropicMessagesTextBlockParam, - type AnthropicMessagesTool, - type AnthropicMessagesUserContentBlock, -} from '@floway-dev/protocols/anthropic-messages'; -import { geminiGenerateContentPartKind } from '@floway-dev/protocols/gemini-generate-content'; -import type { GeminiGenerateContentContent, GeminiGenerateContentPayload, GeminiGenerateContentGenerationConfig, GeminiGenerateContentPart, GeminiGenerateContentThinkingConfig } from '@floway-dev/protocols/gemini-generate-content'; - -const inlineDataToContentBlock = (part: GeminiGenerateContentPart): AnthropicMessagesUserContentBlock | null => { - const inlineData = geminiGenerateContentInlineData(part); - if (inlineData === null) return null; - - switch (inlineData.kind) { - case 'image': - return { - type: 'image', - source: { - type: 'base64', - media_type: inlineData.mimeType, - data: inlineData.data, - }, - }; - case 'pdf': - return { - type: 'document', - source: { - type: 'base64', - media_type: inlineData.mimeType, - data: inlineData.data, - }, - }; - case 'audio': - case 'unsupported': - throw new TranslateInputException(`Cannot translate Gemini generateContent inlineData MIME type '${inlineData.mimeType}' to Anthropic Messages.`); - } -}; - -const buildUserMessage = (content: GeminiGenerateContentContent, turnIndex: number, unmatchedToolCallIds: GeminiGenerateContentToolCallIds): AnthropicMessagesPayload['messages'][number] | null => { - const blocks: AnthropicMessagesUserContentBlock[] = []; - - (content.parts ?? []).forEach((part, partIndex) => { - const kind = geminiGenerateContentPartKind(part); - switch (kind) { - case null: - return; - case 'function_response': { - const { response, id } = geminiGenerateContentFunctionResponsePart(part, unmatchedToolCallIds, turnIndex, partIndex, 'last')!; - blocks.push({ - type: 'tool_result', - tool_use_id: id, - content: JSON.stringify(response.response), - }); - return; - } - case 'text': { - const text = geminiGenerateContentPartText(part); - if (text !== null) blocks.push({ type: 'text', text }); - return; - } - case 'inline_data': { - const inlineData = inlineDataToContentBlock(part); - if (inlineData) blocks.push(inlineData); - return; - } - default: - throw new TranslateInputException(`"${kind}" parts are not supported in user content.`); - } - }); - - return blocks.length ? { role: 'user', content: blocks } : (content.parts?.length ?? 0) === 0 ? { role: 'user', content: '' } : null; -}; - -const buildAssistantMessage = (content: GeminiGenerateContentContent, turnIndex: number, unmatchedToolCallIds: GeminiGenerateContentToolCallIds): AnthropicMessagesPayload['messages'][number] | null => { - if (hasGeminiGenerateContentThoughtSignature(content)) { - warnDroppedUnrecognizedAssistantReasoning('geminiGenerateContent', 'anthropicMessages'); - } - const blocks: AnthropicMessagesAssistantInputContentBlock[] = []; - const parts = (content.parts ?? []).flatMap(part => { - const kind = geminiGenerateContentPartKind(part); - return kind === null ? [] : [{ kind, part }]; - }); - - parts.forEach(({ kind, part }, partIndex) => { - switch (kind) { - case 'function_call': { - const { call, id } = geminiGenerateContentFunctionCallPart(part, unmatchedToolCallIds, turnIndex, partIndex)!; - blocks.push({ - type: 'tool_use', - id, - name: call.name, - input: klona(call.args), - }); - return; - } - case 'text': { - const thoughtText = geminiGenerateContentThoughtText(part); - if (thoughtText !== null) { - blocks.push({ signature: '', type: 'thinking', thinking: thoughtText }); - return; - } - const text = geminiGenerateContentVisibleText(part); - if (text !== null) { - blocks.push({ type: 'text', text }); - } - return; - } - default: - throw new TranslateInputException(`"${kind}" parts are not supported in model content.`); - } - }); - - return blocks.length ? { role: 'assistant', content: blocks } : (content.parts?.length ?? 0) === 0 ? { role: 'assistant', content: '' } : null; -}; - -interface ThinkingConfigFields { - thinking?: NonNullable; - outputConfig: NonNullable; -} - -const applyThinkingConfig = (thinkingConfig?: GeminiGenerateContentThinkingConfig): ThinkingConfigFields => { - if (!thinkingConfig) return { outputConfig: {} }; - - let thinking: ThinkingConfigFields['thinking']; - if (thinkingConfig.thinkingBudget === -1) { - thinking = { type: 'adaptive' }; - } else if (thinkingConfig.thinkingBudget !== undefined && thinkingConfig.thinkingBudget > 0) { - thinking = { - type: 'enabled', - budget_tokens: thinkingConfig.thinkingBudget, - }; - } else if (thinkingConfig.thinkingBudget === 0) { - thinking = { type: 'disabled' }; - } - - const effort = geminiGenerateContentThinkingLevelEffort(thinkingConfig); - return { - ...(thinking !== undefined ? { thinking } : {}), - outputConfig: effort !== undefined ? { effort } : {}, - }; -}; - -const applyGenerationConfig = (request: AnthropicMessagesPayload, generationConfig: GeminiGenerateContentGenerationConfig | undefined, fallbackMaxOutputTokens: number): NonNullable => { - request.max_tokens = generationConfig?.maxOutputTokens ?? fallbackMaxOutputTokens; - - if (!generationConfig) return {}; - - if (generationConfig.temperature !== undefined) { - request.temperature = generationConfig.temperature; - } - if (generationConfig.topP !== undefined) { - request.top_p = generationConfig.topP; - } - if (generationConfig.topK !== undefined) { - request.top_k = generationConfig.topK; - } - if (generationConfig.stopSequences !== undefined) { - request.stop_sequences = klona(generationConfig.stopSequences); - } - const schema = geminiResponseSchema(generationConfig); - return schema === undefined ? {} : { format: { type: 'json_schema', schema } }; -}; - -const buildTools = (payload: GeminiGenerateContentPayload): AnthropicMessagesTool[] | undefined => { - const tools = geminiGenerateContentFunctionDeclarations(payload, 'all').map(declaration => ({ - type: 'custom' as const, - name: declaration.name, - ...(declaration.description !== undefined ? { description: declaration.description } : {}), - input_schema: anthropicMessagesToolInputSchema(geminiFunctionParameters(declaration) ?? { type: 'object', properties: {} }), - })); - - return tools.length ? tools : undefined; -}; - -export const buildTargetRequest = async ( - payload: GeminiGenerateContentPayload, - model: string, - codec: SidecarCodec, - options: { fallbackMaxOutputTokens: number | undefined }, -): Promise => { - // Gemini generateContent can omit maxOutputTokens, but AnthropicMessagesPayload requires max_tokens. - // Prefer the model's advertised `/models` cap when one is known; otherwise - // fall back to the gateway policy default shared with the other - // `*-via-anthropic-messages` translators. - const fallbackMaxOutputTokens = options.fallbackMaxOutputTokens ?? ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS; - const request: AnthropicMessagesPayload = { - model, - stream: true, - max_tokens: fallbackMaxOutputTokens, - messages: [], - }; - const unmatchedToolCallIds: GeminiGenerateContentToolCallIds = Object.create(null); - - const system = geminiGenerateContentText(payload.systemInstruction); - if (system !== null) { - const systemBlocks: AnthropicMessagesTextBlockParam[] = [{ type: 'text', text: system }]; - applyLastSystemCacheBreakpoint(systemBlocks); - request.system = systemBlocks; - } - - const restoredMessages = new Set(); - let contentIndex = 0; - for (const turn of partitionGeminiGenerateContentTurns(payload.contents ?? [])) { - const roundTrip = turn.role === 'assistant' && await roundTripAssistantTurn('geminiGenerateContent', 'anthropicMessages', turn.items, codec, findGeminiGenerateContentSidecarOccurrences, verifyGeminiGenerateContentTurnDigest, collectGeminiGenerateContentReplayContents); - if (!roundTrip) { - if (turn.role === 'assistant') { - for (const [index, content] of turn.items.entries()) { - const message = buildAssistantMessage(content, contentIndex + index, unmatchedToolCallIds); - if (message) request.messages.push(message); - } - } else for (const [index, content] of turn.items.entries()) { - if (content.role !== 'user' && content.role !== undefined) throw new TranslateInputException(`"${(content as { role: string }).role}" is not a supported content role.`); - const message = buildUserMessage(content, contentIndex + index, unmatchedToolCallIds); - if (message) request.messages.push(message); - } - } else { - request.messages.push(roundTrip.turn); - restoredMessages.add(roundTrip.turn); - if (Array.isArray(roundTrip.turn.content)) for (const block of roundTrip.turn.content) { - if (block.type !== 'tool_use' && block.type !== 'server_tool_use') continue; - unmatchedToolCallIds[block.name] ??= []; - unmatchedToolCallIds[block.name].push(block.id); - } - } - contentIndex += turn.items.length; - } - - const generationOutputConfig = applyGenerationConfig(request, payload.generationConfig, fallbackMaxOutputTokens); - const { thinking, outputConfig: thinkingOutputConfig } = applyThinkingConfig(payload.generationConfig?.thinkingConfig); - const outputConfig = { ...generationOutputConfig, ...thinkingOutputConfig }; - const hasGenerationOutputConfig = Object.keys(generationOutputConfig).length > 0; - const attachOutputConfig = (): void => { - request.output_config = outputConfig; - }; - - // Preserve request-key insertion order: a structured-output format precedes - // `thinking`, while an effort-only `output_config` follows it. - if (hasGenerationOutputConfig) attachOutputConfig(); - if (thinking !== undefined) request.thinking = thinking; - if (!hasGenerationOutputConfig && Object.keys(outputConfig).length > 0) attachOutputConfig(); - - const tools = buildTools(payload); - if (tools) request.tools = tools; - applyLastToolCacheBreakpoint(request.tools); - applyLastMessageCacheBreakpoint(request.messages.filter(message => !restoredMessages.has(message))); - - const intent = geminiGenerateContentFunctionCallingIntent(payload.toolConfig?.functionCallingConfig); - switch (intent?.type) { - case 'none': - request.tool_choice = { type: 'none' }; - break; - case 'auto': - request.tool_choice = { type: 'auto' }; - break; - case 'any': - request.tool_choice = { type: 'any' }; - break; - case 'named': - request.tool_choice = { type: 'tool', name: intent.name }; - break; - } - - return request; -}; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/translate.ts b/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/translate.ts deleted file mode 100644 index c2fd71fb32..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/via-anthropic-messages/translate.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { translateToSourceEvents } from './events.ts'; -import { buildTargetRequest } from './request.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { Translator } from '../../../types.ts'; -import { restateGeminiGenerateContentError } from '../as-source/error-body.ts'; -import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import type { GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; - -export const translate: Translator< - GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent, AnthropicMessagesPayload, AnthropicMessagesStreamEventEx, - { fallbackMaxOutputTokens?: number } -> = async (src, ctx) => { - try { - return { - type: 'trip', - error: restateGeminiGenerateContentError, - target: await buildTargetRequest(src, ctx.model, ctx.sidecarCodec, { fallbackMaxOutputTokens: ctx.fallbackMaxOutputTokens }), - events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), - }; - } catch (error) { - if (error instanceof TranslateInputException) return error.failure; - throw error; - } -}; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts deleted file mode 100644 index 2c78e08e90..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/events.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; -import { readOpenAIChatCompletionsEvents } from '../../openai-chat-completions/as-target/events.ts'; -import { writeGeminiGenerateContentEvents } from '../as-source/events.ts'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; -import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; - -export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { - const stream = createRoundTripCoordinator('geminiGenerateContent', 'openaiChatCompletions', codec); - return writeGeminiGenerateContentEvents(readOpenAIChatCompletionsEvents(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); -}; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts deleted file mode 100644 index 86d06ab4a9..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/request.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { klona } from 'klona/json'; - -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import { geminiGenerateContentInlineData, geminiGenerateContentPartText, geminiGenerateContentText, geminiGenerateContentThoughtText, geminiGenerateContentVisibleText } from '../as-source/content.ts'; -import { partitionGeminiGenerateContentTurns } from '../as-source/conversation-turns.ts'; -import { geminiGenerateContentReasoningEffort } from '../as-source/reasoning.ts'; -import { collectGeminiGenerateContentReplayContents, hasGeminiGenerateContentThoughtSignature, findGeminiGenerateContentSidecarOccurrences, verifyGeminiGenerateContentTurnDigest } from '../as-source/round-trip.ts'; -import { geminiFunctionParameters, geminiResponseSchema } from '../as-source/schema.ts'; -import { geminiGenerateContentFunctionCallingIntent, geminiGenerateContentFunctionCallPart, geminiGenerateContentFunctionDeclarations, geminiGenerateContentFunctionResponsePart, type GeminiGenerateContentToolCallIds } from '../as-source/tools.ts'; -import { geminiGenerateContentPartKind } from '@floway-dev/protocols/gemini-generate-content'; -import type { GeminiGenerateContentContent, GeminiGenerateContentPayload, GeminiGenerateContentGenerationConfig, GeminiGenerateContentPart } from '@floway-dev/protocols/gemini-generate-content'; -import type { OpenAIChatCompletionsAssistantMessageEx, OpenAIChatCompletionsUserContentPart, OpenAIChatCompletionsPayload, OpenAIChatCompletionsMessage, OpenAIChatCompletionsTool, OpenAIChatCompletionsToolCall } from '@floway-dev/protocols/openai-chat-completions'; - -const inlineDataToContentPart = (part: GeminiGenerateContentPart): OpenAIChatCompletionsUserContentPart | null => { - const inlineData = geminiGenerateContentInlineData(part); - if (inlineData === null) return null; - - switch (inlineData.kind) { - case 'image': - return { - type: 'image_url', - image_url: { url: `data:${inlineData.mimeType};base64,${inlineData.data}` }, - }; - case 'pdf': - return { - type: 'file', - file: { file_data: `data:${inlineData.mimeType};base64,${inlineData.data}` }, - }; - case 'audio': - return { - type: 'input_audio', - input_audio: { data: inlineData.data, format: inlineData.format }, - }; - case 'unsupported': - throw new TranslateInputException(`Cannot translate Gemini generateContent inlineData MIME type '${inlineData.mimeType}' to OpenAI Chat Completions.`); - } -}; - -const textToContentPart = (text: string): OpenAIChatCompletionsUserContentPart => ({ - type: 'text', - text, -}); - -const contentFromParts = (parts: GeminiGenerateContentPart[]): string | OpenAIChatCompletionsUserContentPart[] | null => { - const textParts = parts.map(geminiGenerateContentPartText).filter((text): text is string => text !== null); - const mediaParts = parts.map(inlineDataToContentPart).filter((part): part is OpenAIChatCompletionsUserContentPart => part !== null); - - if (!textParts.length && !mediaParts.length) return null; - if (!mediaParts.length) return textParts.join('\n\n'); - - return parts.flatMap(part => { - const text = geminiGenerateContentPartText(part); - if (text !== null) return [textToContentPart(text)]; - - const media = inlineDataToContentPart(part); - return media ? [media] : []; - }); -}; - -const buildAssistantMessage = (content: GeminiGenerateContentContent, turnIndex: number, unmatchedToolCallIds: GeminiGenerateContentToolCallIds): OpenAIChatCompletionsMessage | null => { - if (hasGeminiGenerateContentThoughtSignature(content)) { - warnDroppedUnrecognizedAssistantReasoning('geminiGenerateContent', 'openaiChatCompletions'); - } - const visibleParts: GeminiGenerateContentPart[] = []; - const thoughtTexts: string[] = []; - const toolCalls: OpenAIChatCompletionsToolCall[] = []; - const parts = (content.parts ?? []).flatMap(part => { - const kind = geminiGenerateContentPartKind(part); - return kind === null ? [] : [{ kind, part }]; - }); - - parts.forEach(({ kind, part }, partIndex) => { - switch (kind) { - case 'function_call': { - const { call, id } = geminiGenerateContentFunctionCallPart(part, unmatchedToolCallIds, turnIndex, partIndex)!; - toolCalls.push({ - id, - type: 'function', - function: { - name: call.name, - arguments: JSON.stringify(call.args), - }, - }); - return; - } - case 'text': { - const thoughtText = geminiGenerateContentThoughtText(part); - if (thoughtText !== null) { - thoughtTexts.push(thoughtText); - return; - } - if (geminiGenerateContentVisibleText(part) !== null) visibleParts.push(part); - return; - } - case 'inline_data': - visibleParts.push(part); - return; - default: - throw new TranslateInputException(`"${kind}" parts are not supported in model content.`); - } - }); - - if (visibleParts.some(part => part.inlineData !== undefined)) throw new TranslateInputException('Cannot translate image content in a model turn to Chat assistant content.'); - const textParts = visibleParts.map(geminiGenerateContentPartText).filter((value): value is string => value !== null); - const message: OpenAIChatCompletionsAssistantMessageEx = { - role: 'assistant', - content: textParts.length > 0 ? textParts.join('\n\n') : null, - }; - - if (toolCalls.length) message.tool_calls = toolCalls; - if (thoughtTexts.length) message.reasoning_text = thoughtTexts.join('\n\n'); - - return message.content !== null || message.tool_calls?.length || message.reasoning_text !== undefined ? message : null; -}; - -const buildToolMessage = (part: GeminiGenerateContentPart, turnIndex: number, partIndex: number, unmatchedToolCallIds: GeminiGenerateContentToolCallIds, legacyFunctionNames: Map): OpenAIChatCompletionsMessage => { - const { response, id } = geminiGenerateContentFunctionResponsePart(part, unmatchedToolCallIds, turnIndex, partIndex)!; - const content = JSON.stringify(response.response); - const legacyName = legacyFunctionNames.get(id); - legacyFunctionNames.delete(id); - return legacyName === undefined - ? { role: 'tool', tool_call_id: id, content } - : { role: 'function', name: legacyName, content }; -}; - -const buildUserMessages = (content: GeminiGenerateContentContent, turnIndex: number, unmatchedToolCallIds: GeminiGenerateContentToolCallIds, legacyFunctionNames: Map): OpenAIChatCompletionsMessage[] => { - const messages: OpenAIChatCompletionsMessage[] = []; - let pendingParts: GeminiGenerateContentPart[] = []; - - const flushUserParts = (): void => { - const chatContent = contentFromParts(pendingParts); - pendingParts = []; - if (chatContent === null) return; - - messages.push({ role: 'user', content: chatContent }); - }; - - (content.parts ?? []).forEach((part, partIndex) => { - const kind = geminiGenerateContentPartKind(part); - switch (kind) { - case null: - return; - case 'function_response': - flushUserParts(); - messages.push(buildToolMessage(part, turnIndex, partIndex, unmatchedToolCallIds, legacyFunctionNames)); - return; - case 'text': - case 'inline_data': - pendingParts.push(part); - return; - default: - throw new TranslateInputException(`"${kind}" parts are not supported in user content.`); - } - }); - - flushUserParts(); - return messages; -}; - -const applyGenerationConfig = (request: OpenAIChatCompletionsPayload, generationConfig?: GeminiGenerateContentGenerationConfig): void => { - if (!generationConfig) return; - - if (generationConfig.maxOutputTokens !== undefined) { - request.max_tokens = generationConfig.maxOutputTokens; - } - if (generationConfig.temperature !== undefined) { - request.temperature = generationConfig.temperature; - } - if (generationConfig.topP !== undefined) { - request.top_p = generationConfig.topP; - } - if (generationConfig.stopSequences !== undefined) { - request.stop = klona(generationConfig.stopSequences); - } - if (generationConfig.candidateCount !== undefined) { - request.n = generationConfig.candidateCount; - } - if (generationConfig.presencePenalty !== undefined) { - request.presence_penalty = generationConfig.presencePenalty; - } - if (generationConfig.frequencyPenalty !== undefined) { - request.frequency_penalty = generationConfig.frequencyPenalty; - } - if (generationConfig.seed !== undefined) { - request.seed = generationConfig.seed; - } - - const schema = geminiResponseSchema(generationConfig); - if (schema !== undefined) { - request.response_format = { - type: 'json_schema', - json_schema: { - name: 'gemini_response', - schema, - }, - }; - } else if (generationConfig.responseMimeType === 'application/json') { - request.response_format = { type: 'json_object' }; - } - - const reasoningEffort = geminiGenerateContentReasoningEffort(generationConfig.thinkingConfig); - if (reasoningEffort !== null) request.reasoning_effort = reasoningEffort; -}; - -const buildTools = (payload: GeminiGenerateContentPayload): OpenAIChatCompletionsTool[] | undefined => { - const tools = geminiGenerateContentFunctionDeclarations(payload, 'any').map(declaration => { - const parameters = geminiFunctionParameters(declaration); - return { - type: 'function' as const, - function: { - name: declaration.name, - ...(declaration.description !== undefined ? { description: declaration.description } : {}), - ...(parameters !== undefined ? { parameters } : {}), - }, - }; - }); - - return tools.length ? tools : undefined; -}; - -export const buildTargetRequest = async ( - payload: GeminiGenerateContentPayload, - model: string, - codec: SidecarCodec, -): Promise => { - const request: OpenAIChatCompletionsPayload = { - model, - stream: true, - messages: [], - }; - const unmatchedToolCallIds: GeminiGenerateContentToolCallIds = Object.create(null); - const legacyFunctionNames = new Map(); - - const systemText = geminiGenerateContentText(payload.systemInstruction); - if (systemText !== null) { - request.messages.push({ role: 'system', content: systemText }); - } - - let contentIndex = 0; - for (const turn of partitionGeminiGenerateContentTurns(payload.contents ?? [])) { - const roundTrip = turn.role === 'assistant' && await roundTripAssistantTurn('geminiGenerateContent', 'openaiChatCompletions', turn.items, codec, findGeminiGenerateContentSidecarOccurrences, verifyGeminiGenerateContentTurnDigest, collectGeminiGenerateContentReplayContents); - if (!roundTrip) { - if (turn.role === 'assistant') { - for (const [index, content] of turn.items.entries()) { - if ((content.parts?.length ?? 0) === 0) { request.messages.push({ role: 'assistant', content: '' }); continue; } - const message = buildAssistantMessage(content, contentIndex + index, unmatchedToolCallIds); - if (message) request.messages.push(message); - } - } else for (const [index, content] of turn.items.entries()) { - if (content.role !== 'user' && content.role !== undefined) throw new TranslateInputException(`"${(content as { role: string }).role}" is not a supported content role.`); - if ((content.parts?.length ?? 0) === 0) { request.messages.push({ role: 'user', content: '' }); continue; } - request.messages.push(...buildUserMessages(content, contentIndex + index, unmatchedToolCallIds, legacyFunctionNames)); - } - } else { - request.messages.push(roundTrip.turn); - if (roundTrip.turn.function_call == null) { - for (const call of roundTrip.turn.tool_calls ?? []) { - const name = call.type === 'function' ? call.function.name : call.custom.name; - unmatchedToolCallIds[name] ??= []; - unmatchedToolCallIds[name].push(call.id); - } - } else { - const modernIds = new Set(roundTrip.turn.tool_calls?.map(call => call.id)); - for (const [index, content] of turn.items.entries()) for (const [partIndex, part] of (content.parts ?? []).entries()) { - const invocation = geminiGenerateContentFunctionCallPart(part, unmatchedToolCallIds, contentIndex + index, partIndex); - if (invocation !== null && !modernIds.has(invocation.id)) legacyFunctionNames.set(invocation.id, roundTrip.turn.function_call.name); - } - } - } - contentIndex += turn.items.length; - } - - applyGenerationConfig(request, payload.generationConfig); - - const tools = buildTools(payload); - if (tools) { - request.tools = tools; - - const intent = geminiGenerateContentFunctionCallingIntent(payload.toolConfig?.functionCallingConfig); - switch (intent?.type) { - case 'none': - request.tool_choice = 'none'; - break; - case 'auto': - request.tool_choice = 'auto'; - break; - case 'any': - request.tool_choice = 'required'; - break; - case 'named': - request.tool_choice = { - type: 'function', - function: { name: intent.name }, - }; - break; - } - } - - return request; -}; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/translate.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/translate.ts deleted file mode 100644 index 5772e145a0..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-chat-completions/translate.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { translateToSourceEvents } from './events.ts'; -import { buildTargetRequest } from './request.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { Translator } from '../../../types.ts'; -import { restateGeminiGenerateContentError } from '../as-source/error-body.ts'; -import type { GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; -import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; - -export const translate: Translator< - GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent, OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent -> = async (src, ctx) => { - try { - const target = await buildTargetRequest(src, ctx.model, ctx.sidecarCodec); - return { type: 'trip', error: restateGeminiGenerateContentError, target, events: frames => translateToSourceEvents(frames, ctx.sidecarCodec) }; - } catch (error) { - if (error instanceof TranslateInputException) return error.failure; - throw error; - } -}; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts deleted file mode 100644 index 2164c0de90..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/events.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; -import { readOpenAIResponsesEvents } from '../../openai-responses/as-target/events.ts'; -import { writeGeminiGenerateContentEvents } from '../as-source/events.ts'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; -import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; - -export const translateToSourceEvents = (frames: AsyncIterable>, codec: SidecarCodec): AsyncIterable> => { - const stream = createRoundTripCoordinator('geminiGenerateContent', 'openaiResponses', codec); - return writeGeminiGenerateContentEvents(readOpenAIResponsesEvents(frames, { roundTrip: stream.reader }), { roundTrip: stream.writer }); -}; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/request.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/request.ts deleted file mode 100644 index 72daa36fd5..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/request.ts +++ /dev/null @@ -1,244 +0,0 @@ -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import { geminiGenerateContentInlineData, geminiGenerateContentPartText, geminiGenerateContentText, geminiGenerateContentThoughtText, geminiGenerateContentVisibleText } from '../as-source/content.ts'; -import { partitionGeminiGenerateContentTurns } from '../as-source/conversation-turns.ts'; -import { geminiGenerateContentReasoningEffort } from '../as-source/reasoning.ts'; -import { collectGeminiGenerateContentReplayContents, hasGeminiGenerateContentThoughtSignature, findGeminiGenerateContentSidecarOccurrences, verifyGeminiGenerateContentTurnDigest } from '../as-source/round-trip.ts'; -import { geminiFunctionParameters, geminiResponseSchema } from '../as-source/schema.ts'; -import { geminiGenerateContentFunctionCallingIntent, geminiGenerateContentFunctionCallPart, geminiGenerateContentFunctionDeclarations, geminiGenerateContentFunctionResponsePart, type GeminiGenerateContentToolCallIds } from '../as-source/tools.ts'; -import { geminiGenerateContentPartKind } from '@floway-dev/protocols/gemini-generate-content'; -import type { GeminiGenerateContentContent, GeminiGenerateContentPayload, GeminiGenerateContentGenerationConfig, GeminiGenerateContentPart } from '@floway-dev/protocols/gemini-generate-content'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesInputContent, CanonicalOpenAIResponsesInputItem, OpenAIResponsesTool } from '@floway-dev/protocols/openai-responses'; - -const geminiGenerateContentReasoningId = (turnIndex: number, partIndex: number): string => `gemini_reasoning_${turnIndex}_${partIndex}`; - -const flushPendingContent = (input: CanonicalOpenAIResponsesInputItem[], pending: OpenAIResponsesInputContent[], role: 'user' | 'assistant'): void => { - if (pending.length === 0) return; - input.push({ type: 'message', role, content: [...pending] }); - pending.length = 0; -}; - -const inlineDataToInputContent = (part: GeminiGenerateContentPart): OpenAIResponsesInputContent | null => { - const inlineData = geminiGenerateContentInlineData(part); - if (inlineData === null) return null; - - switch (inlineData.kind) { - case 'image': - return { - type: 'input_image', - image_url: `data:${inlineData.mimeType};base64,${inlineData.data}`, - }; - case 'pdf': - return { - type: 'input_file', - file_data: `data:${inlineData.mimeType};base64,${inlineData.data}`, - }; - case 'audio': - case 'unsupported': - throw new TranslateInputException(`Cannot translate Gemini generateContent inlineData MIME type '${inlineData.mimeType}' to OpenAI Responses.`); - } -}; - -const buildUserInputItems = (content: GeminiGenerateContentContent, turnIndex: number, unmatchedToolCallIds: GeminiGenerateContentToolCallIds): CanonicalOpenAIResponsesInputItem[] => { - const input: CanonicalOpenAIResponsesInputItem[] = []; - const pendingContent: OpenAIResponsesInputContent[] = []; - - (content.parts ?? []).forEach((part, partIndex) => { - const kind = geminiGenerateContentPartKind(part); - switch (kind) { - case null: - return; - case 'function_response': { - const { response, id } = geminiGenerateContentFunctionResponsePart(part, unmatchedToolCallIds, turnIndex, partIndex)!; - flushPendingContent(input, pendingContent, 'user'); - input.push({ - type: 'function_call_output', - call_id: id, - output: JSON.stringify(response.response), - status: 'completed', - }); - return; - } - case 'text': { - const text = geminiGenerateContentPartText(part); - if (text !== null) pendingContent.push({ type: 'input_text', text }); - return; - } - case 'inline_data': { - const inlineData = inlineDataToInputContent(part); - if (inlineData) pendingContent.push(inlineData); - return; - } - default: - throw new TranslateInputException(`"${kind}" parts are not supported in user content.`); - } - }); - - flushPendingContent(input, pendingContent, 'user'); - return input; -}; - -const buildAssistantInputItems = (content: GeminiGenerateContentContent, turnIndex: number, unmatchedToolCallIds: GeminiGenerateContentToolCallIds): CanonicalOpenAIResponsesInputItem[] => { - if (hasGeminiGenerateContentThoughtSignature(content)) { - warnDroppedUnrecognizedAssistantReasoning('geminiGenerateContent', 'openaiResponses'); - } - const input: CanonicalOpenAIResponsesInputItem[] = []; - const pendingContent: OpenAIResponsesInputContent[] = []; - const parts = (content.parts ?? []).flatMap(part => { - const kind = geminiGenerateContentPartKind(part); - return kind === null ? [] : [{ kind, part }]; - }); - - parts.forEach(({ kind, part }, partIndex) => { - switch (kind) { - case 'function_call': { - const { call, id } = geminiGenerateContentFunctionCallPart(part, unmatchedToolCallIds, turnIndex, partIndex)!; - flushPendingContent(input, pendingContent, 'assistant'); - input.push({ - type: 'function_call', - call_id: id, - name: call.name, - arguments: JSON.stringify(call.args), - status: 'completed', - }); - return; - } - case 'text': { - const thoughtText = geminiGenerateContentThoughtText(part); - if (thoughtText !== null) { - flushPendingContent(input, pendingContent, 'assistant'); - input.push({ - type: 'reasoning', - id: geminiGenerateContentReasoningId(turnIndex, partIndex), - summary: [{ type: 'summary_text', text: thoughtText }], - }); - return; - } - const visible = geminiGenerateContentVisibleText(part); - if (visible !== null) pendingContent.push({ type: 'output_text', text: visible }); - return; - } - default: - throw new TranslateInputException(`"${kind}" parts are not supported in model content.`); - } - }); - - flushPendingContent(input, pendingContent, 'assistant'); - return input; -}; - -const applyGenerationConfig = (request: CanonicalOpenAIResponsesPayload, generationConfig?: GeminiGenerateContentGenerationConfig): void => { - if (!generationConfig) return; - - if (generationConfig.maxOutputTokens !== undefined) { - request.max_output_tokens = generationConfig.maxOutputTokens; - } - if (generationConfig.temperature !== undefined) { - request.temperature = generationConfig.temperature; - } - if (generationConfig.topP !== undefined) { - request.top_p = generationConfig.topP; - } - - const schema = geminiResponseSchema(generationConfig); - if (schema !== undefined) { - request.text = { - ...request.text, - format: { - type: 'json_schema', - name: 'gemini_response', - schema, - }, - }; - } else if (generationConfig.responseMimeType === 'application/json') { - request.text = { ...request.text, format: { type: 'json_object' } }; - } - - const effort = geminiGenerateContentReasoningEffort(generationConfig.thinkingConfig); - if (effort === null) return; - - request.reasoning = { - effort, - ...(effort !== 'none' && generationConfig.thinkingConfig?.includeThoughts === true ? { summary: 'detailed' as const } : {}), - }; -}; - -const buildTools = (payload: GeminiGenerateContentPayload): OpenAIResponsesTool[] | undefined => { - const tools = geminiGenerateContentFunctionDeclarations(payload, 'any').map(declaration => ({ - type: 'function' as const, - name: declaration.name, - ...(declaration.description !== undefined ? { description: declaration.description } : {}), - parameters: geminiFunctionParameters(declaration) ?? { type: 'object', properties: {} }, - strict: false, - })); - - return tools.length ? tools : undefined; -}; - -export const buildTargetRequest = async ( - payload: GeminiGenerateContentPayload, - model: string, - codec: SidecarCodec, -): Promise => { - const input: CanonicalOpenAIResponsesInputItem[] = []; - const request: CanonicalOpenAIResponsesPayload = { - model, - stream: true, - input, - }; - const unmatchedToolCallIds: GeminiGenerateContentToolCallIds = Object.create(null); - - const instructions = geminiGenerateContentText(payload.systemInstruction); - if (instructions !== null) request.instructions = instructions; - - let contentIndex = 0; - for (const turn of partitionGeminiGenerateContentTurns(payload.contents ?? [])) { - const roundTrip = turn.role === 'assistant' && await roundTripAssistantTurn('geminiGenerateContent', 'openaiResponses', turn.items, codec, findGeminiGenerateContentSidecarOccurrences, verifyGeminiGenerateContentTurnDigest, collectGeminiGenerateContentReplayContents); - if (!roundTrip) { - if (turn.role === 'assistant') { - for (const [index, content] of turn.items.entries()) { - if ((content.parts?.length ?? 0) === 0) { input.push({ type: 'message', role: 'assistant', content: '' }); continue; } - input.push(...buildAssistantInputItems(content, contentIndex + index, unmatchedToolCallIds)); - } - } else for (const [index, content] of turn.items.entries()) { - if (content.role !== 'user' && content.role !== undefined) throw new TranslateInputException(`"${(content as { role: string }).role}" is not a supported content role.`); - if ((content.parts?.length ?? 0) === 0) { input.push({ type: 'message', role: 'user', content: '' }); continue; } - input.push(...buildUserInputItems(content, contentIndex + index, unmatchedToolCallIds)); - } - } else { - input.push(...roundTrip.turn); - for (const item of roundTrip.turn) { - if (item.type !== 'function_call' && item.type !== 'custom_tool_call') continue; - unmatchedToolCallIds[item.name] ??= []; - unmatchedToolCallIds[item.name].push(item.call_id); - } - } - contentIndex += turn.items.length; - } - - applyGenerationConfig(request, payload.generationConfig); - - const tools = buildTools(payload); - if (tools) { - request.tools = tools; - - const intent = geminiGenerateContentFunctionCallingIntent(payload.toolConfig?.functionCallingConfig); - switch (intent?.type) { - case 'none': - request.tool_choice = 'none'; - break; - case 'auto': - request.tool_choice = 'auto'; - break; - case 'any': - request.tool_choice = 'required'; - break; - case 'named': - request.tool_choice = { type: 'function', name: intent.name }; - break; - } - } - - return request; -}; diff --git a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/translate.ts b/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/translate.ts deleted file mode 100644 index f483efe36f..0000000000 --- a/packages/translate/src/protocols/gemini-generate-content/via-openai-responses/translate.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { translateToSourceEvents } from './events.ts'; -import { buildTargetRequest } from './request.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { Translator } from '../../../types.ts'; -import { restateGeminiGenerateContentError } from '../as-source/error-body.ts'; -import type { GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent } from '@floway-dev/protocols/gemini-generate-content'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; - -export const translate: Translator< - GeminiGenerateContentPayload, GeminiGenerateContentStreamEvent, CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx -> = async (src, ctx) => { - try { - return { - type: 'trip', - error: restateGeminiGenerateContentError, - target: await buildTargetRequest(src, ctx.model, ctx.sidecarCodec), - events: frames => translateToSourceEvents(frames, ctx.sidecarCodec), - }; - } catch (error) { - if (error instanceof TranslateInputException) return error.failure; - throw error; - } -}; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source.ts b/packages/translate/src/protocols/openai-chat-completions/as-source.ts new file mode 100644 index 0000000000..e2dd886741 --- /dev/null +++ b/packages/translate/src/protocols/openai-chat-completions/as-source.ts @@ -0,0 +1,24 @@ +import { restateOpenAIChatCompletionsError } from './as-source/error-body.ts'; +import { writeOpenAIChatCompletionsEvents } from './as-source/events.ts'; +import { readOpenAIChatCompletionsRequest } from './as-source/input-to.ts'; +import { collectOpenAIChatCompletionsReplayContents, findOpenAIChatCompletionsSidecarOccurrences, verifyOpenAIChatCompletionsTurnDigest } from './as-source/round-trip.ts'; +import type { SourceProtocol } from '../../composition/index.ts'; +import type { OpenAIChatCompletionsPayloadEx, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; + +/** Chat sidecars authenticate one native assistant message. */ +export const openaiChatCompletionsSourceProtocol: SourceProtocol< + 'openaiChatCompletions', OpenAIChatCompletionsPayloadEx, OpenAIChatCompletionsStreamEvent +> = { + name: 'openaiChatCompletions', + read: readOpenAIChatCompletionsRequest, + sidecarRecognition: { + inspect: findOpenAIChatCompletionsSidecarOccurrences, + verify: verifyOpenAIChatCompletionsTurnDigest, + collectCandidates: collectOpenAIChatCompletionsReplayContents, + }, + error: restateOpenAIChatCompletionsError, + writeEvents: (payload, records, roundTrip) => writeOpenAIChatCompletionsEvents(records, { + continuousUsageStats: payload.stream_options?.include_usage === true && payload.stream_options?.continuous_usage_stats === true, + roundTrip, + }), +}; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/conversation-turns.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/conversation-turns.ts index b35746f262..0e02b560d4 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-source/conversation-turns.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/conversation-turns.ts @@ -3,8 +3,24 @@ import type { OpenAIChatCompletionsAssistantMessage, OpenAIChatCompletionsAssist export type OpenAIChatCompletionsConversationMessage = OpenAIChatCompletionsMessage | OpenAIChatCompletionsAssistantMessageEx; export type OpenAIChatCompletionsConversationTurn = | { role: 'assistant'; items: [OpenAIChatCompletionsAssistantMessage | OpenAIChatCompletionsAssistantMessageEx] } - | { role: 'bare'; items: [Exclude] }; - -export const partitionOpenAIChatCompletionsTurns = (messages: readonly OpenAIChatCompletionsConversationMessage[]): OpenAIChatCompletionsConversationTurn[] => messages.map(message => message.role === 'assistant' - ? { role: 'assistant', items: [message] } - : { role: 'bare', items: [message] }); + | { role: 'client'; items: Exclude[] }; +// Each assistant message forms its own turn; adjacent user/tool/function messages form one client run. +export const partitionOpenAIChatCompletionsTurns = (messages: readonly OpenAIChatCompletionsConversationMessage[]): OpenAIChatCompletionsConversationTurn[] => { + const turns: OpenAIChatCompletionsConversationTurn[] = []; + for (const message of messages) { + const previous = turns.at(-1); + // A system or developer message keeps its own boundary and role instead of + // joining the client content around it. + if (message.role === 'assistant') { + turns.push({ role: 'assistant', items: [message] }); + continue; + } + if (message.role === 'system' || message.role === 'developer') { + turns.push({ role: 'client', items: [message] }); + continue; + } + if (previous?.role === 'client' && previous.items.every(item => item.role !== 'system' && item.role !== 'developer')) previous.items.push(message); + else turns.push({ role: 'client', items: [message] }); + } + return turns; +}; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts new file mode 100644 index 0000000000..49a2f4766b --- /dev/null +++ b/packages/translate/src/protocols/openai-chat-completions/as-source/input-to.ts @@ -0,0 +1,326 @@ +import { klona } from 'klona/json'; + +import { partitionOpenAIChatCompletionsTurns, type OpenAIChatCompletionsConversationMessage, type OpenAIChatCompletionsConversationTurn } from './conversation-turns.ts'; +import type { OpenAIChatCompletionsSourceAssistantTurn } from './round-trip.ts'; +import type { SourceReading } from '../../../composition/reading.ts'; +import type { + IRInputAssistantContentPart, + IRInputAssistantItem, + IRInputRequestHeader, + IRInputSettings, + IRInputTool, + IRInputToolChoice, + IRInputToolSelector, + IRInputOutputFormat, + IRInputTextPart, + IRInputToolResultContentPart, + IRInputToolResultItem, + IRInputTurn, + IRInputUserContentPart, + IRInputClientItem, + +} from '../../../ir/input/types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import type { + OpenAIChatCompletionsNamedToolChoice, + OpenAIChatCompletionsPayload, + OpenAIChatCompletionsTool, + OpenAIChatCompletionsUserContentPart, +} from '@floway-dev/protocols/openai-chat-completions'; + +const AUDIO_MEDIA_TYPES: Readonly> = { wav: 'audio/wav', mp3: 'audio/mp3' }; + +// Explicit empty user and assistant messages retain their turn boundaries. +// A caller can explicitly keep or omit an empty text part. +const textPartsToIR = (value: string, options: { keepEmpty?: boolean } = {}): IRInputTextPart[] => value === '' && options.keepEmpty === false ? [] : [{ type: 'text', text: value }]; + +interface OpenAIChatCompletionsInputContext { + readonly callKinds: ReadonlyMap; + readonly legacyIds: readonly (string | undefined)[]; +} + +// A legacy call and result correlate by name and occurrence. Reserve modern +// ids before choosing their synthetic spelling, including ids later in history. +// This inventory also advances across assistant turns restored without the IR. +const openaiChatCompletionsInputContext = (payload: OpenAIChatCompletionsPayload): OpenAIChatCompletionsInputContext => { + const claimed = new Set(payload.messages.flatMap(message => message.role === 'assistant' ? (message.tool_calls ?? []).map(call => call.id) : [])); + const occurrences = new Map(); + const calls = new Map(); + const results = new Map(); + const legacyIds = payload.messages.map(message => { + const name = message.role === 'assistant' ? message.function_call?.name : message.role === 'function' ? message.name : undefined; + if (name === undefined) return undefined; + const counts = message.role === 'assistant' ? calls : results; + const occurrence = counts.get(name) ?? 0; + counts.set(name, occurrence + 1); + let ids = occurrences.get(name); + if (ids === undefined) { ids = []; occurrences.set(name, ids); } + let id = ids[occurrence]; + if (id === undefined) { + const base = `legacy__${name}__${occurrence}`; + id = base; + for (let suffix = 1; claimed.has(id); suffix++) id = `${base}_${suffix}`; + claimed.add(id); + ids[occurrence] = id; + } + return id; + }); + return { callKinds: openaiChatCompletionsCallKinds(payload), legacyIds }; +}; + +const userContentPart = (part: OpenAIChatCompletionsUserContentPart): IRInputUserContentPart => { + switch (part.type) { + case 'text': + return { type: 'text', text: part.text }; + case 'image_url': + return { + type: 'image', + image: { source_kind: 'url', url: part.image_url.url }, + ...(part.image_url.detail === undefined ? {} : { detail: part.image_url.detail }), + }; + case 'input_audio': + return { type: 'audio', audio: { media_type: AUDIO_MEDIA_TYPES[part.input_audio.format] ?? `audio/${part.input_audio.format}`, data: part.input_audio.data } }; + case 'file': { + const filename = part.file.filename === undefined ? {} : { filename: part.file.filename }; + if (part.file.file_id !== undefined) return { type: 'file', file: { source_kind: 'file', file_id: part.file.file_id }, ...filename }; + if (part.file.file_data !== undefined) { + return { + type: 'file', + file: { source_kind: 'data', data: part.file.file_data }, + ...filename, + }; + } + throw new TranslateInputException("A Chat Completions file part requires 'file_id' or 'file_data'."); + } + default: + throw new TranslateInputException(`Invalid '${(part as { type: string }).type}' content part. Only 'text', 'image_url', 'input_audio', and 'file' are supported.`); + } +}; + +// Chat's `tool` content array is transport fragments of one payload. The input IR +// keeps the parts so a target that accepts structured content can use them; a +// target that only carries strings decides how to flatten. +const toolResultContent = (content: string | readonly { text: string }[]): string | readonly IRInputToolResultContentPart[] => typeof content === 'string' + ? content + : content.map(part => ({ type: 'text' as const, text: part.text })); + +const assistantItems = (turn: Extract, context: OpenAIChatCompletionsInputContext, messageIndex: number): IRInputAssistantItem[] => { + const message = turn.items[0]; + + const content: IRInputAssistantContentPart[] = typeof message.content === 'string' + ? textPartsToIR(message.content) + : (message.content ?? []).map(part => part.type === 'text' + ? { type: 'text' as const, text: part.text } + : { type: 'refusal' as const, refusal: part.refusal }); + if (message.refusal !== undefined && message.refusal !== null) { + content.push({ type: 'refusal', refusal: message.refusal }); + } + + // A null content with nothing else to say is an explicitly empty assistant + // message; with tool calls or a refusal it is simply absent. + const legacyCall = message.function_call ?? undefined; + const emptyMessage = Array.isArray(message.content) && message.content.length === 0 + || message.content == null && message.refusal == null && (message.tool_calls?.length ?? 0) === 0 && legacyCall === undefined; + const items: IRInputAssistantItem[] = emptyMessage + ? [{ type: 'message', content: [{ type: 'text', text: '' }] }] + : content.length > 0 ? [{ type: 'message', content }] : []; + // The legacy single `function_call` field carries a call with no id. It is + // still input a client may send, so it becomes a call with a deterministic + // id shared with the following legacy result of the same name occurrence. + if (legacyCall !== undefined) { + items.push({ + type: 'function_call', + call_id: context.legacyIds[messageIndex]!, + name: legacyCall.name, + arguments: legacyCall.arguments, + }); + } + for (const call of message.tool_calls ?? []) { + items.push(call.type === 'custom' + ? { type: 'custom_tool_call', call_id: call.id, name: call.custom.name, input: call.custom.input } + : { type: 'function_call', call_id: call.id, name: call.function.name, arguments: call.function.arguments }); + } + return items; +}; + +/** + * Call kinds keyed by call ID. A Chat `tool` message does not state which kind + * it answers; only the preceding assistant message's `tool_calls` do. + */ +const openaiChatCompletionsCallKinds = (payload: OpenAIChatCompletionsPayload): ReadonlyMap => { + const kinds = new Map(); + for (const message of payload.messages) { + if (message.role !== 'assistant') continue; + for (const call of message.tool_calls ?? []) kinds.set(call.id, call.type === 'custom' ? 'custom' : 'function'); + } + return kinds; +}; + +const openaiChatCompletionsTurnToIR = ( + turn: OpenAIChatCompletionsConversationTurn, + context: OpenAIChatCompletionsInputContext, + messageIndex: number, +): IRInputTurn => { + if (turn.role === 'assistant') return { kind: 'assistant', items: assistantItems(turn, context, messageIndex) }; + + return { kind: 'client', items: turn.items.flatMap((message, index) => clientItems(message, context, messageIndex + index)) }; +}; + +const clientItems = (message: Exclude, context: OpenAIChatCompletionsInputContext, messageIndex: number): IRInputClientItem[] => { + switch (message.role) { + case 'system': + case 'developer': + return [{ + type: 'message', + role: message.role, + // A non-text part in system/developer content has no representation on + // any target, so it is rejected here rather than dropped silently. + content: typeof message.content === 'string' + ? textPartsToIR(message.content, { keepEmpty: false }) + : (message.content ?? []).map(part => { + if (part.type !== 'text') throw new TranslateInputException(`Invalid '${part.type}' content part in system or developer message. Only 'text' content parts are supported in system messages on this model.`); + return { type: 'text' as const, text: part.text }; + }), + }]; + case 'user': + return [{ + type: 'message', + role: 'user', + content: message.content == null ? [{ type: 'text', text: '' }] : typeof message.content === 'string' ? textPartsToIR(message.content) : message.content.map(userContentPart), + }]; + case 'tool': { + if (!message.tool_call_id) throw new TranslateInputException("Missing required field 'tool_call_id' on a 'tool' role message."); + const toolKind = context.callKinds.get(message.tool_call_id); + const result: IRInputToolResultItem = { + type: 'tool_result', + call_id: message.tool_call_id, + output: toolResultContent(message.content), + ...(toolKind === undefined ? {} : { tool_kind: toolKind }), + }; + return [result]; + } + case 'function': + return [{ type: 'tool_result', call_id: context.legacyIds[messageIndex]!, output: message.content == null ? '' : toolResultContent(message.content) }]; + default: + throw new TranslateInputException(`Invalid role '${(message as { role: string }).role}'.`); + } +}; + +const toolToIR = (tool: OpenAIChatCompletionsTool): IRInputTool => { + if (tool.type === 'custom') { + const format = tool.custom.format; + return { + type: 'custom', + name: tool.custom.name, + ...(tool.custom.description === undefined ? {} : { description: tool.custom.description }), + ...(format === undefined ? {} : { + format: format.type === 'text' + ? { type: 'text' } + : format.type === 'grammar' + ? { type: 'grammar', definition: format.grammar.definition, syntax: format.grammar.syntax } + : { type: 'unrecognized', value: format }, + }), + }; + } + return { + type: 'function', + name: tool.function.name, + ...(tool.function.description === undefined ? {} : { description: tool.function.description }), + ...(tool.function.parameters === undefined ? {} : { parameters: klona(tool.function.parameters) }), + ...(tool.function.strict == null ? {} : { strict: tool.function.strict }), + }; +}; + +const selectionToIR = (selection: OpenAIChatCompletionsNamedToolChoice): IRInputToolSelector => selection.type === 'custom' + ? { type: 'custom', name: selection.custom.name } + : { type: 'function', name: selection.function.name }; + +const toolChoiceToIR = (choice: NonNullable): IRInputToolChoice => { + if (typeof choice === 'string') return choice; + if (choice.type === 'allowed_tools') { + return { type: 'allowed_tools', mode: choice.allowed_tools.mode, tools: choice.allowed_tools.tools.map(selectionToIR) }; + } + return selectionToIR(choice); +}; + +const openaiChatCompletionsToolsToIR = (payload: OpenAIChatCompletionsPayload): Pick => { + const declarations = payload.tools ?? payload.functions?.map(fn => ({ type: 'function' as const, function: fn })); + const tools = declarations?.map(toolToIR); + const legacyChoice = payload.function_call; + const choice = payload.tool_choice ?? (typeof legacyChoice === 'string' + ? legacyChoice + : legacyChoice === undefined ? undefined : { type: 'function' as const, function: legacyChoice }); + return { + ...(tools === undefined || tools.length === 0 ? {} : { tools }), + ...(choice == null ? {} : { tool_choice: toolChoiceToIR(choice) }), + }; +}; + +const openaiChatCompletionsRequestToIR = (payload: OpenAIChatCompletionsPayload): IRInputRequestHeader => ({ + settings: openaiChatCompletionsSettingsToIR(payload), + ...openaiChatCompletionsToolsToIR(payload), +}); + +const openaiChatCompletionsSettingsToIR = (payload: OpenAIChatCompletionsPayload): IRInputSettings => { + const format = payload.response_format; + const outputFormat: IRInputOutputFormat | undefined = format === undefined ? undefined + : format.type === 'json_schema' + ? { + type: 'json_schema', + ...(format.json_schema.name === undefined ? {} : { name: format.json_schema.name }), + ...(format.json_schema.description === undefined ? {} : { description: format.json_schema.description }), + ...(format.json_schema.schema === undefined ? {} : { schema: klona(format.json_schema.schema) }), + // The tool-side and format-side `strict` share a spelling; an omitted + // format strictness stays absent so the target applies its own default. + ...(format.json_schema.strict === undefined ? {} : { strict: format.json_schema.strict }), + } + : { type: format.type }; + + return { + model: payload.model, + stream: payload.stream === true, + ...(payload.temperature === undefined ? {} : { temperature: payload.temperature }), + ...(payload.top_p === undefined ? {} : { top_p: payload.top_p }), + ...(payload.stop == null ? {} : { stop: typeof payload.stop === 'string' ? [payload.stop] : [...payload.stop] }), + ...(payload.seed == null ? {} : { seed: payload.seed }), + ...(payload.max_tokens === undefined ? {} : { max_visible_output_tokens: payload.max_tokens }), + ...(payload.max_completion_tokens === undefined ? {} : { max_output_tokens: payload.max_completion_tokens }), + ...(outputFormat === undefined ? {} : { output_format: outputFormat }), + ...(payload.metadata == null ? {} : { metadata: payload.metadata }), + ...(payload.store === undefined ? {} : { store: payload.store }), + ...(payload.service_tier == null ? {} : { service_tier: payload.service_tier }), + ...(payload.parallel_tool_calls === undefined ? {} : { parallel_tool_calls: payload.parallel_tool_calls }), + ...(payload.reasoning_effort == null ? {} : { reasoning_effort: payload.reasoning_effort }), + ...(payload.prompt_cache_key === undefined ? {} : { prompt_cache_key: payload.prompt_cache_key }), + ...(payload.prompt_cache_retention === undefined ? {} : { prompt_cache_retention: payload.prompt_cache_retention }), + ...(payload.prompt_cache_options === undefined ? {} : { prompt_cache_options: klona(payload.prompt_cache_options) }), + ...(payload.safety_identifier === undefined ? {} : { safety_identifier: payload.safety_identifier }), + ...(payload.user === undefined ? {} : { user: payload.user }), + ...(payload.logprobs === undefined ? {} : { logprobs: payload.logprobs }), + ...(payload.top_logprobs === undefined ? {} : { top_logprobs: payload.top_logprobs }), + ...(payload.verbosity == null ? {} : { verbosity: payload.verbosity }), + ...(payload.n == null ? {} : { choice_count: payload.n }), + ...(payload.presence_penalty === undefined ? {} : { presence_penalty: payload.presence_penalty }), + ...(payload.frequency_penalty === undefined ? {} : { frequency_penalty: payload.frequency_penalty }), + + }; +}; + +export const readOpenAIChatCompletionsRequest = (payload: OpenAIChatCompletionsPayload): SourceReading => { + const context = openaiChatCompletionsInputContext(payload); + let messageIndex = 0; + return { + request: openaiChatCompletionsRequestToIR(payload), + turns: partitionOpenAIChatCompletionsTurns(payload.messages).map(turn => { + const at = messageIndex; + messageIndex += turn.items.length; + return turn.role === 'assistant' + ? { + kind: 'assistant' as const, + assistantTurn: turn.items[0], + toIRInputTurns: () => [openaiChatCompletionsTurnToIR(turn, context, at)], + } + : { kind: 'client' as const, toIRInputTurns: () => [openaiChatCompletionsTurnToIR(turn, context, at)] }; + }), + }; +}; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts b/packages/translate/src/protocols/openai-chat-completions/as-source/round-trip.ts index 801eb84a42..682903fa6a 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 @@ -60,9 +60,6 @@ const reasoningTextMaterial = (turn: OpenAIChatCompletionsSourceAssistantTurn): return materials; }; -export const hasOpenAIChatCompletionsReasoning = (turn: OpenAIChatCompletionsSourceAssistantTurn): boolean => - reasoningFields.some(field => turn[field] != null) || reasoningDetails(turn).length > 0; - const chatTurnDigestMaterialV1 = (turn: OpenAIChatCompletionsSourceAssistantTurn): IRJSONObject => ({ ...(Object.hasOwn(turn, 'content') && turn.content !== undefined ? { content: messageContentMaterial(turn) } : {}), ...(Object.hasOwn(turn, 'refusal') && turn.refusal !== undefined ? { refusal: turn.refusal } : {}), diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target.ts b/packages/translate/src/protocols/openai-chat-completions/as-target.ts new file mode 100644 index 0000000000..10cc9dd4c5 --- /dev/null +++ b/packages/translate/src/protocols/openai-chat-completions/as-target.ts @@ -0,0 +1,51 @@ +import { readOpenAIChatCompletionsEvents } from './as-target/events.ts'; +import { chatLegacyCallsOf } from './as-target/legacy-calls.ts'; +import { openaiChatCompletionsPayloadFromSegments } from './as-target/segmented-request.ts'; +import type { TargetProtocol } from '../../composition/index.ts'; +import { assignCallIds, type PendingCall } from '../../composition/request/call-ids.ts'; +import { allocateToolNames, type ToolNameContext } from '../../composition/request/tool-names.ts'; +import type { InputAssistantTurnFor } from '../../round-trip/input-assistant-turn.ts'; +import type { OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; + +type ChatRestoredAssistantTurn = InputAssistantTurnFor<'openaiChatCompletions'>; + +const callsOf = ( + turn: ChatRestoredAssistantTurn, + legacyCallIds: ReadonlyMap, +): readonly PendingCall[] => { + // Legacy calls have no wire ID. Track their results with the resolver's pairing ID; + // the native function result still pairs by function name. + const legacyCall = turn.function_call; + const legacyId = legacyCallIds.get(turn); + const legacy = legacyCall == null || legacyId === undefined + ? [] + : [{ callId: legacyId, name: legacyCall.name }]; + return [ + ...legacy, + ...(turn.tool_calls ?? []).map(call => ({ callId: call.id, name: call.type === 'function' ? call.function.name : call.custom.name })), + ]; +}; + +/** Request name allocation records custom-call kinds and original logical identities for the event reader. */ +export const openaiChatCompletionsTargetProtocol: TargetProtocol< + 'openaiChatCompletions', OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent, ToolNameContext +> = { + name: 'openaiChatCompletions', + // This wire addresses a callable by one bare name, so every grouped + // declaration and call is stated under the flat spelling it computes. + write: (request, turns) => { + const legacy = chatLegacyCallsOf(turns); + const assigned = assignCallIds(turns, turn => callsOf(turn, legacy.callIds), { resultCallIds: legacy.results }); + const flattened = allocateToolNames(request, assigned); + return { + payload: openaiChatCompletionsPayloadFromSegments( + flattened.request, + flattened.turns, + legacy.names, + ), + context: flattened.context, + }; + }, + readEvents: (frames, context, roundTripReader) => + readOpenAIChatCompletionsEvents(frames, { ...context, roundTrip: roundTripReader }), +}; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/events.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/events.ts index cdfc556d6b..427cc283a3 100644 --- a/packages/translate/src/protocols/openai-chat-completions/as-target/events.ts +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/events.ts @@ -1,5 +1,6 @@ import { toOpenAIChatCompletionsInputAssistantTurn, type OpenAIChatCompletionsOutputAssistantTurnState } from './assistant-turn.ts'; import { usageToIR } from './usage.ts'; +import type { ToolNameContext } from '../../../composition/request/tool-names.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'; @@ -7,8 +8,8 @@ import type { IRRecord } from '../../../ir/output/records.ts'; import type { IROutputSourceCitation } from '../../../ir/output/types.ts'; import type { RoundTripReader } from '../../../round-trip/reader.ts'; import { appendReadField, createReadFieldState, type ReadFieldStates, type ReadFieldState, type TextFragmentId } from '../../../round-trip/reference/reference-builder.ts'; +import { unwrapCustomToolInput } from '../../../shared/custom-tool.ts'; import { TextCoordinateIndex } from '../../../shared/text-coordinate-index.ts'; -import { unwrapCustomToolInput } from '../../openai-responses/common/custom-tool-wrap.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; import { createOpenAIChatCompletionsEventAccumulator, @@ -119,11 +120,17 @@ interface ChatChoiceState { export const readOpenAIChatCompletionsEvents = async function* ( frames: AsyncIterable>, - options: { customToolNames?: ReadonlySet; roundTrip: RoundTripReader<'openaiChatCompletions'> }, + options: Partial & { + roundTrip: RoundTripReader<'openaiChatCompletions'>; + }, ): AsyncGenerator { const ir = createIRBuilder(); const choices = new Map(); const nativeAccumulators = new Map>(); + const namingOf = (flatName: string): { readonly name: string; readonly namespace?: string } => { + const identity = options.originalToolNames?.get(flatName); + return identity ?? { name: flatName }; + }; const nativeAccumulator = (choice: number): ReturnType => { let accumulator = nativeAccumulators.get(choice); if (accumulator === undefined) { @@ -415,19 +422,17 @@ export const readOpenAIChatCompletionsEvents = async function* ( } if (tool.item === undefined && tool.name !== '') { - // Waiting here delays no supported target: Messages and Responses need the name when - // the structure opens, while GenerateContent emits only complete calls. Planned - // Interactions support has the same step.start requirement: - // https://ai.google.dev/gemini-api/docs/streaming - // The official ChatCompletions SDK tolerates a late name: + // The name must precede any delta a client can act on, so it waits for the + // field that carries it. The official ChatCompletions SDK tolerates a late + // name: // https://github.com/openai/openai-node/blob/7423ac3e9351c46300cd094479cded5551a72eb4/src/lib/ChatCompletionStream.ts#L2059-L2107 // Clients such as the Vercel AI SDK reject a first tool delta without one: // https://github.com/vercel/ai/blob/ba05943b69ad070558bb5bc912da99ec66d7d263/packages/provider-utils/src/streaming-tool-call-tracker.ts#L356-L395 const custom = tool.custom || tool.wrapped; const callId = call.index === -1 ? IRAbsent : tool.id; tool.item = createItem(index, choice, custom - ? { type: 'custom_tool_call', ...(callId === undefined ? {} : { call_id: callId }), name: tool.name, input: '' } - : { type: 'function_call', ...(callId === undefined ? {} : { call_id: callId }), name: tool.name, arguments: '' }); + ? { type: 'custom_tool_call', ...(callId === undefined ? {} : { call_id: callId }), ...namingOf(tool.name), input: '' } + : { type: 'function_call', ...(callId === undefined ? {} : { call_id: callId }), ...namingOf(tool.name), arguments: '' }); tool.callIdAssigned = callId !== undefined; tool.emittedName = tool.name; if (!tool.wrapped && tool.pendingInput !== '') { @@ -437,14 +442,17 @@ export const readOpenAIChatCompletionsEvents = async function* ( tool.pendingTextFragmentIds = []; } } else if (tool.item !== undefined) { + if (tool.name !== tool.emittedName) { + // A rename updates both parts of the identity; null clears an earlier namespace. + const naming = namingOf(tool.name); + tool.item.assign('name', naming.name); + tool.item.assign('namespace', naming.namespace ?? null); + tool.emittedName = tool.name; + } if (tool.id !== undefined && !tool.callIdAssigned) { tool.item.assign('call_id', tool.id); tool.callIdAssigned = true; } - if (tool.name !== tool.emittedName) { - tool.item.assign('name', tool.name); - tool.emittedName = tool.name; - } if (fragment !== undefined && fragment !== '' && !tool.wrapped) tool.item.append(tool.custom ? 'input' : 'arguments', fragment, textFragmentId === undefined ? undefined : [textFragmentId]); if (fragment !== undefined && tool.wrapped) tool.pendingInput += fragment; } diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts new file mode 100644 index 0000000000..5baba9ec7b --- /dev/null +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/input-from.ts @@ -0,0 +1,323 @@ +import type { IRInputAssistantItemWithCallIds, IRInputTurnWithCallIds, IRInputClientItemWithCallIds } from '../../../composition/request/call-ids.ts'; +import { requireStreaming } from '../../../ir/input/streaming.ts'; +import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputDocument, IRInputMedia, IRInputToolResultContentPart, IRInputClientItem } from '../../../ir/input/types.ts'; +import { buildCustomToolInputSchema } from '../../../shared/custom-tool.ts'; +import { DOCUMENT_CLOSE_TAG, documentImage, documentOpening } from '../../../shared/document.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import { reasoningEffortForBudget } from '../../../shared/reasoning-budget.ts'; +import type { + OpenAIChatCompletionsPayload, + OpenAIChatCompletionsMessage, + OpenAIChatCompletionsStreamOptionsEx, + OpenAIChatCompletionsToolCall, + OpenAIChatCompletionsTool, + OpenAIChatCompletionsUserContentPart, +} from '@floway-dev/protocols/openai-chat-completions'; + +// Chat Completions names a container while the input IR keeps MIME types. +const audioFormat = (mediaType: string): string => { + if (mediaType === 'audio/wav') return 'wav'; + if (mediaType === 'audio/mpeg' || mediaType === 'audio/mp3') return 'mp3'; + throw new TranslateInputException(`Cannot translate audio MIME type '${mediaType}' to OpenAI Chat Completions.`); +}; + +const dataUrl = (media: Extract, requireMediaType: boolean): string => media.media_type === undefined && !requireMediaType + ? media.data + : `data:${media.media_type ?? 'application/octet-stream'};base64,${media.data}`; + +const imageUrlPart = (media: IRInputMedia, detail?: string | null): OpenAIChatCompletionsUserContentPart => ({ + type: 'image_url', + image_url: { + url: media.source_kind === 'url' ? media.url + : media.source_kind === 'data' ? media.data.startsWith('data:') ? media.data : `data:application/octet-stream;base64,${media.data}` + : media.source_kind === 'base64' ? dataUrl(media, true) + : (() => { throw new TranslateInputException('Cannot translate file_id-only image content to OpenAI Chat Completions.'); })(), + ...(detail == null ? {} : { detail }), + }, +}); + +const filePart = (media: IRInputMedia, filename?: string | null): OpenAIChatCompletionsUserContentPart => { + const name = filename == null ? {} : { filename }; + switch (media.source_kind) { + case 'file': return { type: 'file', file: { file_id: media.file_id, ...name } }; + case 'data': return { type: 'file', file: { file_data: media.data, ...name } }; + case 'base64': return { type: 'file', file: { file_data: dataUrl(media, false), ...name } }; + case 'url': throw new TranslateInputException('Cannot translate a file URL to OpenAI Chat Completions.'); + } +}; + +const documentParts = (document: IRInputDocument): OpenAIChatCompletionsUserContentPart[] => { + const source = document.source; + const content = source.source_kind === 'media' + ? [filePart(source.media)] + : source.source_kind === 'text' + ? [{ type: 'text' as const, text: source.text }] + : source.content.map(part => part.type === 'text' + ? { type: 'text' as const, text: part.text } + : imageUrlPart(documentImage(part), part.detail)); + return [{ type: 'text', text: documentOpening(document) }, ...content, { type: 'text', text: DOCUMENT_CLOSE_TAG }]; +}; + +const userContent = (item: Extract): string | OpenAIChatCompletionsUserContentPart[] => { + if (item.content.every(part => part.type === 'text')) return item.content.map(part => part.text).join(''); + return item.content.flatMap(part => { + switch (part.type) { + case 'text': return { type: 'text' as const, text: part.text }; + case 'image': return imageUrlPart(part.image, part.detail); + case 'file': return filePart(part.file, part.filename); + case 'document': return documentParts(part.document); + case 'audio': return { type: 'input_audio' as const, input_audio: { data: part.audio.data, format: audioFormat(part.audio.media_type) } }; + } + }); +}; + +/** + * A Chat `tool` message admits only text, while the input IR tool result may carry + * attachments. Image, file, and document parts are lifted into a user message + * that follows the result, so the target still receives them. + * + * https://github.com/openai/openai-node/blob/61539248cbe04665de68a71e6fd878127ae4db87/src/resources/chat/completions/completions.ts#L1893-L1908 + * https://github.com/vercel/ai/blob/c093ee7458ccd5dada05d8461041e47c24ee55c0/packages/google/src/convert-to-google-messages.ts#L137-L180 + */ +const openaiChatCompletionsToolResultContent = ( + callId: string, + output: string | readonly IRInputToolResultContentPart[], +): { readonly content: string; readonly lifted: OpenAIChatCompletionsUserContentPart[] } => { + if (typeof output === 'string') return { content: output, lifted: [] }; + const text = output.flatMap(part => part.type === 'text' ? [part.text] : []).join(''); + const lifted = output.flatMap(part => part.type === 'image' + ? [imageUrlPart(part.image, part.detail)] + : part.type === 'file' ? [filePart(part.file, part.filename)] + : part.type === 'document' ? documentParts(part.document) : []); + if (lifted.length === 0) return { content: text, lifted: [] }; + const kinds = new Set(output.flatMap(part => part.type === 'image' || part.type === 'file' || part.type === 'document' ? [part.type] : [])); + const noun = kinds.size > 1 ? 'Attachments' : kinds.has('image') ? 'Image output' : kinds.has('file') ? 'File output' : 'Document output'; + return { + content: text === '' ? `${noun} is attached in the following user message.` : text, + lifted: [{ type: 'text', text: `${noun} from tool call ${callId}:` }, ...lifted], + }; +}; + +const assistantMessage = (items: readonly IRInputAssistantItemWithCallIds[]): OpenAIChatCompletionsMessage[] => { + const text: string[] = []; + const refusals: string[] = []; + const reasoning: string[] = []; + const toolCalls: OpenAIChatCompletionsToolCall[] = []; + let hasMessage = false; + for (const item of items) { + switch (item.type) { + case 'message': + hasMessage = true; + for (const part of item.content) if (part.type === 'text') text.push(part.text); else refusals.push(part.refusal); + break; + case 'reasoning': + // We write readable reasoning as reasoning_text; input IR does not retain the source alias spelling. + if (item.summary !== undefined) reasoning.push(item.summary.join('')); + if (item.content !== undefined) reasoning.push(item.content.join('')); + break; + case 'function_call': + toolCalls.push({ + id: item.call_id, + type: 'function', + // Chat requires argument text; omitted source arguments use an empty JSON object. + function: { name: item.name, arguments: item.arguments ?? '{}' }, + }); + break; + case 'custom_tool_call': + // Freeform calls use the same function wrapper as their declarations; the response path restores the custom shape. + toolCalls.push({ id: item.call_id, type: 'function', function: { name: item.name, arguments: JSON.stringify({ input: item.input }) } }); + break; + } + } + if (!hasMessage && toolCalls.length === 0 && reasoning.length === 0) return []; + // A turn carrying reasoning or tool calls with no text spells its content as + // null; a turn with neither is an explicitly empty message and keeps ''. + return [{ + role: 'assistant', + content: text.length === 0 && (toolCalls.length > 0 || reasoning.length > 0) ? null : text.join(''), + ...(refusals.length === 0 ? {} : { refusal: refusals.join('') }), + ...(reasoning.length === 0 ? {} : { reasoning_text: reasoning.join('') }), + ...(toolCalls.length === 0 ? {} : { tool_calls: toolCalls }), + }]; +}; + +const clientMessages = ( + items: readonly IRInputClientItemWithCallIds[], + legacyCallNames: ReadonlyMap, +): OpenAIChatCompletionsMessage[] => { + // Attachments lifted out of consecutive tool results gather into one user message + // after the run, so a tool-call group stays contiguous. + const messages: OpenAIChatCompletionsMessage[] = []; + let pendingAttachments: OpenAIChatCompletionsUserContentPart[] = []; + const flushAttachments = (): void => { + if (pendingAttachments.length === 0) return; + messages.push({ role: 'user', content: pendingAttachments }); + pendingAttachments = []; + }; + for (const item of items) { + if (item.type !== 'tool_result') flushAttachments(); + messages.push(...openaiChatCompletionsClientItem(item, lifted => pendingAttachments.push(...lifted), legacyCallNames)); + } + flushAttachments(); + return messages; +}; + +const openaiChatCompletionsClientItem = ( + item: IRInputClientItemWithCallIds, + lift: (parts: readonly OpenAIChatCompletionsUserContentPart[]) => void, + legacyCallNames: ReadonlyMap, +): OpenAIChatCompletionsMessage[] => { + switch (item.type) { + case 'message': + case 'instruction': { + if (item.type === 'message' && item.role === 'user') return [{ role: 'user', content: userContent(item) }]; + return [{ role: item.type === 'instruction' ? 'system' : item.role, content: item.content.length === 1 ? item.content[0].text : item.content.length === 0 ? '' : item.content.map(part => ({ type: 'text' as const, text: part.text })) }]; + } + case 'tool_result': { + const projected = openaiChatCompletionsToolResultContent(item.call_id, item.output); + lift(projected.lifted); + // This wire pairs a legacy `function_call` with a `function` message by + // name, and a call that states an id with a `tool` message by that id. + // Answering a legacy call with a `tool` message would name an id no + // assistant turn ever stated. + const legacyCallName = legacyCallNames.get(item.call_id); + return [legacyCallName === undefined + ? { role: 'tool', tool_call_id: item.call_id, content: projected.content } + : { role: 'function', name: legacyCallName, content: projected.content }]; + } + } +}; + +export const openaiChatCompletionsConversationFromIR = ( + turns: readonly IRInputTurnWithCallIds[], + legacyCallNames: ReadonlyMap, +): OpenAIChatCompletionsMessage[] => turns.flatMap(turn => turn.kind === 'assistant' + ? assistantMessage(turn.items) + : clientMessages(turn.items, legacyCallNames)); + +// OpenAI's strict-mode validators reject an object schema without `properties`, +// which is a target-side constraint rather than something the IR states. +const normalizeParameters = (schema: Readonly>): Record => { + const target = { ...schema }; + return target.type === 'object' && target.properties === undefined ? { ...target, properties: {} } : target; +}; + +const toolFromIR = (tool: IRInputTool): OpenAIChatCompletionsTool => { + if (tool.type === 'custom') { + // Chat accepts a freeform declaration natively, but not every upstream that + // speaks the Chat wire accepts one, so a freeform tool is projected onto the + // function shape that states its single raw string. The caller records the + // name so returned function calls can be restored to freeform calls. + return { + type: 'function', + function: { + name: tool.name, + ...(tool.description === undefined ? {} : { description: tool.description }), + parameters: buildCustomToolInputSchema(tool.format), + strict: false, + }, + }; + } + return { + type: 'function', + function: { + name: tool.name, + ...(tool.description === undefined ? {} : { description: tool.description }), + ...(tool.parameters === undefined ? {} : { parameters: normalizeParameters(tool.parameters) }), + // Keep omitted strictness absent so the Chat target applies its own default. + ...(tool.strict === undefined ? {} : { strict: tool.strict }), + }, + }; +}; + +const toolChoiceFromIR = (choice: IRInputToolChoice): NonNullable => { + if (typeof choice === 'string') return choice; + if (choice.type === 'allowed_tools') return { + type: 'allowed_tools', + allowed_tools: { + mode: choice.mode, + tools: choice.tools.map(tool => ({ type: 'function', function: { name: tool.name } })), + }, + }; + // A freeform declaration is written as a function tool, so a selector naming + // it must name the same function. + return { type: 'function', function: { name: choice.name } }; +}; + +/** + * Everything a Chat Completions request states except its messages. + * + * The conversation is written separately because a trip interleaves it with the + * assistant turns a sidecar already resolved, so only this half is computed once + * per payload. + */ +export const openaiChatCompletionsHeaderFromIR = (request: IRInputRequestHeader): Omit => { + const tools = request.tools; + const choice = tools === undefined || tools.length === 0 ? request.tool_choice === 'required' ? 'required' : undefined : request.tool_choice; + return { + ...openaiChatCompletionsSettingsFromIR(request.settings), + model: request.settings.model, + // A trip guarantees a streaming request; a request that says otherwise + // cannot be carried by this side at all. + stream: requireStreaming(request.settings), + // Request final usage and the vLLM extension for cumulative usage on each chunk. + // https://github.com/vllm-project/vllm/blob/d5f0a6e829faa69d1db289bf62b14dae136c02b2/vllm/entrypoints/generate/base/protocol.py#L241-L243 + stream_options: { include_usage: true, continuous_usage_stats: true } as OpenAIChatCompletionsStreamOptionsEx, + ...(tools === undefined || tools.length === 0 ? {} : { tools: tools.map(toolFromIR) }), + ...(choice === undefined ? {} : { tool_choice: toolChoiceFromIR(choice) }), + }; +}; + +/** A numeric budget approximates effort only when the caller supplied no level. */ +const chatReasoningEffort = (settings: IRInputSettings): string | undefined => + settings.reasoning_effort ?? (settings.reasoning_budget === undefined ? undefined : reasoningEffortForBudget(settings.reasoning_budget)); + +export const openaiChatCompletionsSettingsFromIR = (settings: IRInputSettings): Partial => { + const ceiling = settings.max_output_tokens !== undefined ? settings.max_output_tokens : settings.max_visible_output_tokens; + const format = settings.output_format; + const reasoningEffort = chatReasoningEffort(settings); + + return { + // State both cap spellings. OpenAI uses the replacement + // `max_completion_tokens`, while compatible targets such as Ollama still + // read only `max_tokens`. + // https://github.com/openai/openai-node/blob/61539248cbe04665de68a71e6fd878127ae4db87/src/resources/chat/completions/completions.ts + // https://github.com/ollama/ollama/blob/c1db5f9/openai/openai.go + ...(ceiling === undefined ? {} : { max_tokens: ceiling, max_completion_tokens: ceiling }), + ...(settings.temperature === undefined ? {} : { temperature: settings.temperature }), + ...(settings.top_p === undefined ? {} : { top_p: settings.top_p }), + ...(settings.stop === undefined ? {} : { stop: [...settings.stop] }), + ...(settings.seed === undefined ? {} : { seed: settings.seed }), + ...(settings.metadata === undefined ? {} : { metadata: settings.metadata === null ? null : { ...settings.metadata } }), + ...(settings.store === undefined ? {} : { store: settings.store }), + ...(settings.service_tier === undefined ? {} : { service_tier: settings.service_tier }), + ...(settings.parallel_tool_calls == null ? {} : { parallel_tool_calls: settings.parallel_tool_calls }), + ...(reasoningEffort === undefined ? {} : { reasoning_effort: reasoningEffort }), + ...(settings.prompt_cache_key === undefined ? {} : { prompt_cache_key: settings.prompt_cache_key }), + ...(settings.prompt_cache_retention === undefined ? {} : { prompt_cache_retention: settings.prompt_cache_retention }), + ...(settings.prompt_cache_options === undefined ? {} : { prompt_cache_options: settings.prompt_cache_options === null ? null : { ...settings.prompt_cache_options } }), + ...(settings.safety_identifier === undefined ? {} : { safety_identifier: settings.safety_identifier }), + ...(settings.user === undefined ? {} : { user: settings.user }), + ...(settings.logprobs === undefined ? {} : { logprobs: settings.logprobs }), + ...(settings.top_logprobs === undefined ? {} : { top_logprobs: settings.top_logprobs }), + ...(settings.verbosity === undefined ? {} : { verbosity: settings.verbosity }), + ...(settings.choice_count === undefined ? {} : { n: settings.choice_count }), + ...(settings.presence_penalty === undefined ? {} : { presence_penalty: settings.presence_penalty }), + ...(settings.frequency_penalty === undefined ? {} : { frequency_penalty: settings.frequency_penalty }), + ...(format === undefined + ? {} + : format.type === 'json_schema' + ? { + response_format: { + type: 'json_schema' as const, + json_schema: { + name: format.name ?? 'response', + ...(format.description === undefined ? {} : { description: format.description }), + ...(format.schema === undefined ? {} : { schema: format.schema }), + ...(format.strict === undefined ? {} : { strict: format.strict }), + }, + }, + } + : { response_format: { type: format.type } }), + }; +}; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts new file mode 100644 index 0000000000..75a9756cf6 --- /dev/null +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/legacy-calls.ts @@ -0,0 +1,81 @@ +import type { RestoredAssistantTurn } from '../../../composition/reading.ts'; +import type { IRInputToolResultItem, IRInputTurn } from '../../../ir/input/types.ts'; +import type { InputAssistantTurnFor } from '../../../round-trip/input-assistant-turn.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; + +type ChatRestoredAssistantTurn = InputAssistantTurnFor<'openaiChatCompletions'>; + +interface ChatLegacyCalls { + /** + * The id the answer to each restored legacy call is written under, keyed by + * that restored turn. Two calls may share a name, so the turn identifies one. + */ + readonly callIds: ReadonlyMap; + /** The legacy call each of those ids answers, by that id. */ + readonly names: ReadonlyMap; + /** The selected id-less result for each legacy call, before modern matching. */ + readonly results: ReadonlyMap; +} + +/** Every call id this conversation states, which an internal legacy pairing id must avoid. */ +const statedCallIds = ( + turns: readonly (IRInputTurn | RestoredAssistantTurn)[], +): ReadonlySet => { + const ids = new Set(); + for (const piece of turns) { + if (piece.kind === 'restored') { + for (const call of piece.turn.tool_calls ?? []) ids.add(call.id); + continue; + } + for (const item of piece.items) { + if ((item.type === 'function_call' || item.type === 'custom_tool_call') && item.call_id !== undefined) ids.add(item.call_id); + } + } + return ids; +}; + +/** + * Native legacy function_call has no ID. It consumes the unique later id-less + * result that states the same function name before modern calls consume the + * remaining results. A tail call may remain pending. + * Internal pairing IDs never change the legacy wire's name-based result correlation. + */ +export const chatLegacyCallsOf = ( + turns: readonly (IRInputTurn | RestoredAssistantTurn)[], +): ChatLegacyCalls => { + const stated = statedCallIds(turns); + const claimed = new Set(stated); + const callIds = new Map(); + const names = new Map(); + const results = new Map(); + let allocated = 0; + for (const [index, piece] of turns.entries()) { + if (piece.kind !== 'restored') continue; + const name = piece.turn.function_call?.name; + if (name === undefined) continue; + const nextLegacyOffset = turns.slice(index + 1).findIndex(turn => turn.kind === 'restored' && turn.turn.function_call?.name === name); + const end = nextLegacyOffset < 0 ? turns.length : index + 1 + nextLegacyOffset; + const candidates = turns.slice(index + 1, end).flatMap(turn => turn.kind !== 'client' ? [] : turn.items.flatMap(item => + item.type === 'tool_result' && item.call_id === undefined && item.name === name ? [item] : [])); + if (candidates.length > 1) { + throw new TranslateInputException(`Cannot translate the answer to the legacy call '${name}': ${candidates.length} id-less results state that function name.`); + } + const [answer] = candidates; + if (answer === undefined) { + if (index < turns.length - 1) { + throw new TranslateInputException(`Cannot translate the legacy call '${name}': no unique id-less result states that function name.`); + } + continue; + } + let callId: string; + do { + allocated += 1; + callId = `legacy_call_${allocated}`; + } while (claimed.has(callId)); + claimed.add(callId); + callIds.set(piece.turn, callId); + names.set(callId, name); + results.set(answer, callId); + } + return { callIds, names, results }; +}; diff --git a/packages/translate/src/protocols/openai-chat-completions/as-target/segmented-request.ts b/packages/translate/src/protocols/openai-chat-completions/as-target/segmented-request.ts new file mode 100644 index 0000000000..d0de8ba332 --- /dev/null +++ b/packages/translate/src/protocols/openai-chat-completions/as-target/segmented-request.ts @@ -0,0 +1,26 @@ +import { openaiChatCompletionsConversationFromIR, openaiChatCompletionsHeaderFromIR } from './input-from.ts'; +import { type RestoredAssistantTurn } from '../../../composition/reading.ts'; +import type { IRInputTurnWithCallIds } from '../../../composition/request/call-ids.ts'; +import { segmentTurns } from '../../../composition/request/segments.ts'; +import type { IRInputRequestHeader } from '../../../ir/input/types.ts'; +import type { OpenAIChatCompletionsMessage, OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; + +/** + * Preserve restored native Chat turns verbatim and compute request-wide fields once. + * Chat has no top-level instruction slot, so instructions become inline messages. + */ +export const openaiChatCompletionsPayloadFromSegments = ( + request: IRInputRequestHeader, + turns: readonly (IRInputTurnWithCallIds | RestoredAssistantTurn)[], + // The legacy call each result answers, by the id that result carries. Only + // this writer reads it: a legacy call has no id to answer with, so its answer + // is stated in the `function` form this wire pairs with the field. + legacyCallNames: ReadonlyMap, +): OpenAIChatCompletionsPayload => { + const messages: OpenAIChatCompletionsMessage[] = []; + for (const piece of segmentTurns(turns)) { + if (piece.kind === 'restored') { messages.push(piece.turn); continue; } + messages.push(...openaiChatCompletionsConversationFromIR(piece.turns, legacyCallNames)); + } + return { ...openaiChatCompletionsHeaderFromIR(request), messages }; +}; diff --git a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts deleted file mode 100644 index 12b6d1a994..0000000000 --- a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/events.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; -import { readAnthropicMessagesEvents } from '../../anthropic-messages/as-target/events.ts'; -import { writeOpenAIChatCompletionsEvents } from '../as-source/events.ts'; -import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsStreamOptionsEx } from '@floway-dev/protocols/openai-chat-completions'; - -export const translateToSourceEvents = ( - frames: AsyncIterable>, - streamOptions: OpenAIChatCompletionsStreamOptionsEx, - codec: SidecarCodec, -): AsyncIterable> => { - const outputOptions = { continuousUsageStats: streamOptions.include_usage === true && streamOptions.continuous_usage_stats === true }; - const stream = createRoundTripCoordinator('openaiChatCompletions', 'anthropicMessages', codec); - return writeOpenAIChatCompletionsEvents(readAnthropicMessagesEvents(frames, { roundTrip: stream.reader }), { ...outputOptions, roundTrip: stream.writer }); -}; diff --git a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/request.ts b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/request.ts deleted file mode 100644 index f39d0f3f41..0000000000 --- a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/request.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { klona } from 'klona/json'; - -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { RemoteImageLoader } from '../../../types.ts'; -import { applyLastMessageCacheBreakpoint, applyLastSystemCacheBreakpoint, applyLastToolCacheBreakpoint } from '../../anthropic-messages/as-target/cache-breakpoints.ts'; -import { anthropicMessagesReasoningFieldsFromEffort } from '../../anthropic-messages/as-target/reasoning-effort.ts'; -import { resolveImageUrlToAnthropicMessagesImage } from '../../anthropic-messages/as-target/remote-images.ts'; -import { anthropicMessagesToolInputSchema } from '../../anthropic-messages/as-target/tool-input-schema.ts'; -import { partitionOpenAIChatCompletionsTurns } from '../as-source/conversation-turns.ts'; -import { collectOpenAIChatCompletionsReplayContents, hasOpenAIChatCompletionsReasoning, findOpenAIChatCompletionsSidecarOccurrences, verifyOpenAIChatCompletionsTurnDigest } from '../as-source/round-trip.ts'; -import { ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS, type AnthropicMessagesAssistantInputContentBlock, type AnthropicMessagesMessage, type AnthropicMessagesPayload, type AnthropicMessagesTextBlockParam, type AnthropicMessagesUserContentBlock } from '@floway-dev/protocols/anthropic-messages'; -import { isFastServiceTier } from '@floway-dev/protocols/common'; -import type { OpenAIChatCompletionsAssistantMessage, OpenAIChatCompletionsPayload, OpenAIChatCompletionsMessage, OpenAIChatCompletionsTool } from '@floway-dev/protocols/openai-chat-completions'; - -interface BuildTargetRequestOptions { - loadRemoteImage: RemoteImageLoader; - /** - * Preferred cap used when the source payload omits `max_tokens`. Callers in - * the data plane forward the model's advertised `/models` output cap so the - * translated Anthropic Messages request reflects the upstream-known limit rather than - * being silently capped by a target-side default later. - */ - fallbackMaxOutputTokens: number | undefined; -} - -const parseToolArgumentsObject = (argumentsJson: string): Record => { - try { - const parsed = JSON.parse(argumentsJson); - return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : { raw_arguments: argumentsJson }; - } catch { - return { raw_arguments: argumentsJson }; - } -}; - -const anthropicServiceTierFieldsFromOpenAI = (serviceTier: string | null | undefined): Partial => - isFastServiceTier(serviceTier) - ? { speed: 'fast' } - : serviceTier != null - ? { service_tier: serviceTier } - : {}; - -const buildAssistantBlocks = (message: OpenAIChatCompletionsAssistantMessage): AnthropicMessagesAssistantInputContentBlock[] => { - const blocks: AnthropicMessagesAssistantInputContentBlock[] = []; - if (typeof message.content === 'string') { - if (message.content) blocks.push({ type: 'text', text: message.content }); - } else if (Array.isArray(message.content)) { - for (const part of message.content) { - if (part.type === 'text') blocks.push({ type: 'text', text: part.text }); - else if (part.type === 'refusal') blocks.push({ type: 'text', text: part.refusal }); - } - } - - if (message.refusal) blocks.push({ type: 'text', text: message.refusal }); - - for (const toolCall of message.tool_calls ?? []) { - if (toolCall.type !== 'function') throw new TranslateInputException('Cannot translate a custom Chat tool call to Anthropic Messages.'); - blocks.push({ - type: 'tool_use', - id: toolCall.id, - name: toolCall.function.name, - input: parseToolArgumentsObject(toolCall.function.arguments), - }); - } - - return blocks.length > 0 ? blocks : [{ type: 'text', text: '' }]; -}; - -const appendUserBlocks = (messages: AnthropicMessagesMessage[], blocks: AnthropicMessagesUserContentBlock[]): void => { - const lastMessage = messages[messages.length - 1]; - - if (lastMessage?.role === 'user') { - const existing = Array.isArray(lastMessage.content) ? lastMessage.content : [{ type: 'text' as const, text: lastMessage.content }]; - - lastMessage.content = [...existing, ...blocks]; - return; - } - - messages.push({ - role: 'user', - content: blocks.length === 1 && blocks[0].type === 'text' ? blocks[0].text : blocks, - }); -}; - -const convertUserContent = async (message: OpenAIChatCompletionsMessage, loadRemoteImage: RemoteImageLoader): Promise => { - if (typeof message.content === 'string') { - return [{ type: 'text', text: message.content }]; - } - - if (!Array.isArray(message.content)) { - return [{ type: 'text', text: '' }]; - } - - const resolved = await Promise.all( - message.content.map(part => { - if (part.type === 'text') { - return Promise.resolve({ type: 'text', text: part.text } as AnthropicMessagesUserContentBlock); - } - - if (part.type === 'image_url') { - return resolveImageUrlToAnthropicMessagesImage(part.image_url.url, loadRemoteImage); - } - - throw new TranslateInputException(`Invalid '${(part as { type: string }).type}' content part. Only 'text' and 'image_url' are supported in user content.`); - }), - ); - - const blocks = resolved.filter((block): block is AnthropicMessagesUserContentBlock => block !== null); - - return blocks.length > 0 ? blocks : [{ type: 'text', text: '' }]; -}; - -// Anthropic's Anthropic Messages system field (top-level `AnthropicMessagesPayload.system` and -// inline `AnthropicMessagesSystemMessage.content`) accepts only text. Image parts in -// system / developer messages are rejected here at the translator boundary so -// the caller hits an explicit failure instead of having the image silently -// dropped on the wire. Returns blocks (possibly empty) so the hoist and -// inline call sites share one shape. -const convertSystemContent = (content: OpenAIChatCompletionsMessage['content']): AnthropicMessagesTextBlockParam[] => { - if (typeof content === 'string') { - return content ? [{ type: 'text', text: content }] : []; - } - if (!Array.isArray(content)) return []; - - const blocks: AnthropicMessagesTextBlockParam[] = []; - for (const part of content) { - if (part.type === 'image_url') { - throw new TranslateInputException("Invalid 'image_url' content part in system or developer message. Only 'text' content parts are supported in system messages on this model."); - } - if (part.type === 'text') { - blocks.push({ type: 'text', text: part.text }); - } - } - - return blocks; -}; - -const translateNonAssistantMessages = async ( - messages: Exclude[], - loadRemoteImage: RemoteImageLoader, - result: AnthropicMessagesMessage[], -): Promise => { - for (const message of messages) { - switch (message.role) { - case 'user': - appendUserBlocks(result, await convertUserContent(message, loadRemoteImage)); - break; - case 'tool': - if (!message.tool_call_id) { - throw new TranslateInputException("Missing required field 'tool_call_id' on a 'tool' role message."); - } - - appendUserBlocks(result, [ - { - type: 'tool_result', - tool_use_id: message.tool_call_id, - content: typeof message.content === 'string' ? message.content : '', - }, - ]); - break; - case 'system': - case 'developer': { - // Inline path for non-leading system / developer (the leading prefix - // was hoisted earlier). Anthropic upstreams diverge on inline - // role:'system' here (Bedrock accepts it under placement rules; - // Vertex rejects it outright), so the gateway's - // `rewrite-mid-conv-system-to-user` interceptor flag is the safety - // net for any inline system that would otherwise reach an upstream - // that does not accept it. - const blocks = convertSystemContent(message.content); - result.push({ - role: 'system', - content: blocks.length > 0 ? blocks : '', - }); - break; - } - default: - throw new TranslateInputException(`Invalid role '${message.role}'.`); - } - } -}; - -const translateOpenAIChatCompletionsTools = (tools: OpenAIChatCompletionsTool[]): AnthropicMessagesPayload['tools'] => - tools.map(tool => { - if (tool.type !== 'function') throw new TranslateInputException('Cannot translate custom Chat tools to Anthropic Messages.'); - return { - name: tool.function.name, - description: tool.function.description, - input_schema: anthropicMessagesToolInputSchema(klona(tool.function.parameters) ?? { type: 'object', properties: {} }), - ...(tool.function.strict != null ? { strict: tool.function.strict } : {}), - }; - }); - -const translateOpenAIChatCompletionsToolChoice = (toolChoice: NonNullable): AnthropicMessagesPayload['tool_choice'] => { - if (typeof toolChoice === 'string') return klona(CHAT_TOOL_CHOICES[toolChoice]); - - if (toolChoice.type === 'allowed_tools') return { type: toolChoice.allowed_tools.mode === 'required' ? 'any' : 'auto' }; - if (toolChoice.type !== 'function') throw new TranslateInputException(`Cannot translate ${toolChoice.type} Chat tool choice to Anthropic Messages.`); - return { type: 'tool', name: toolChoice.function.name }; -}; - -const CHAT_TOOL_CHOICES = { - auto: { type: 'auto' }, - none: { type: 'none' }, - required: { type: 'any' }, -} satisfies Record, AnthropicMessagesPayload['tool_choice']>; - -export const buildTargetRequest = async ( - payload: OpenAIChatCompletionsPayload, - codec: SidecarCodec, - options: BuildTargetRequestOptions, -): Promise => { - // Hoist the leading contiguous run of system/developer messages to - // AnthropicMessagesPayload.system, preserving each ContentPart text as its own - // AnthropicMessagesTextBlockParam so part boundaries survive the hoist. Non-leading - // system/developer messages stay inline as AnthropicMessagesSystemMessage at their - // chronological position. - const systemBlocks: AnthropicMessagesTextBlockParam[] = []; - let prefixEnd = 0; - for (const message of payload.messages) { - if (message.role !== 'system' && message.role !== 'developer') break; - systemBlocks.push(...convertSystemContent(message.content)); - prefixEnd++; - } - - const messages: AnthropicMessagesMessage[] = []; - const restoredMessages = new Set(); - for (const turn of partitionOpenAIChatCompletionsTurns(payload.messages.slice(prefixEnd))) { - const roundTrip = turn.role === 'assistant' && await roundTripAssistantTurn('openaiChatCompletions', 'anthropicMessages', turn.items[0], codec, findOpenAIChatCompletionsSidecarOccurrences, verifyOpenAIChatCompletionsTurnDigest, collectOpenAIChatCompletionsReplayContents); - if (!roundTrip) { - if (turn.role === 'assistant') { - const message = turn.items[0]; - if (hasOpenAIChatCompletionsReasoning(message)) warnDroppedUnrecognizedAssistantReasoning('openaiChatCompletions', 'anthropicMessages'); - messages.push({ role: 'assistant', content: buildAssistantBlocks(message) }); - } else await translateNonAssistantMessages(turn.items, options.loadRemoteImage, messages); - continue; - } - messages.push(roundTrip.turn); - restoredMessages.add(roundTrip.turn); - } - - const maxTokens = payload.max_tokens ?? options.fallbackMaxOutputTokens ?? ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS; - const allowedTools = typeof payload.tool_choice === 'object' && payload.tool_choice?.type === 'allowed_tools' - ? new Set(payload.tool_choice.allowed_tools.tools.map(tool => { - if (tool.type !== 'function') throw new TranslateInputException('Cannot translate custom Chat tools to Anthropic Messages.'); - return tool.function.name; - })) - : undefined; - const selectedTools = allowedTools === undefined ? payload.tools : payload.tools?.filter(tool => tool.type === 'function' && allowedTools.has(tool.function.name)); - const tools = selectedTools?.length ? translateOpenAIChatCompletionsTools(selectedTools) : undefined; - applyLastSystemCacheBreakpoint(systemBlocks); - applyLastToolCacheBreakpoint(tools); - applyLastMessageCacheBreakpoint(messages.filter(message => !restoredMessages.has(message))); - - // Merge OpenAI Chat Completions `reasoning_effort` + `response_format` into a single Anthropic Messages - // `output_config` so a chat-source structured-output request survives - // routing through an Anthropic Messages target. `reasoning_effort: 'none'` lands on - // `thinking` instead of `output_config`, and `format` still rides along. - // - // OpenAI Chat Completions nests json_schema details (`response_format = { type: 'json_schema', - // json_schema: { schema } }`); `json_object` / `text` / absent have no - // Anthropic Messages equivalent and drop. - const { thinking, effort: reasoningEffort } = anthropicMessagesReasoningFieldsFromEffort(payload.reasoning_effort); - const responseFormat = payload.response_format; - const jsonSchema = responseFormat?.type === 'json_schema' ? (responseFormat.json_schema as Record | undefined) : undefined; - const formatSchema = - jsonSchema?.schema && typeof jsonSchema.schema === 'object' && !Array.isArray(jsonSchema.schema) ? (jsonSchema.schema as Record) : undefined; - const outputConfig: NonNullable = {}; - if (reasoningEffort !== undefined) outputConfig.effort = reasoningEffort; - if (formatSchema) outputConfig.format = { type: 'json_schema', schema: klona(formatSchema) }; - const hasOutputConfig = Object.keys(outputConfig).length > 0; - - // OpenAI's accelerated request lane maps to Anthropic's `speed: 'fast'`; - // other service-tier values remain open strings. - // https://docs.claude.com/en/build-with-claude/fast-mode - // https://platform.openai.com/docs/guides/fast-mode - const serviceTierFields = anthropicServiceTierFieldsFromOpenAI(payload.service_tier); - - // OpenAI user and generic metadata are not interpreted as Messages user_id. - return { - model: payload.model, - messages, - max_tokens: maxTokens, - ...(systemBlocks.length > 0 ? { system: systemBlocks } : {}), - ...(payload.temperature != null ? { temperature: payload.temperature } : {}), - ...(payload.top_p != null ? { top_p: payload.top_p } : {}), - ...(payload.stop != null - ? { - stop_sequences: Array.isArray(payload.stop) ? klona(payload.stop) : [payload.stop], - } - : {}), - stream: true, - ...(tools ? { tools } : {}), - ...(payload.tool_choice != null ? { tool_choice: translateOpenAIChatCompletionsToolChoice(payload.tool_choice) } : {}), - ...(thinking ? { thinking } : {}), - ...(hasOutputConfig ? { output_config: outputConfig } : {}), - ...serviceTierFields, - }; -}; diff --git a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/translate.ts b/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/translate.ts deleted file mode 100644 index 864f6867c6..0000000000 --- a/packages/translate/src/protocols/openai-chat-completions/via-anthropic-messages/translate.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { translateToSourceEvents } from './events.ts'; -import { buildTargetRequest } from './request.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { RemoteImageLoader, Translator } from '../../../types.ts'; -import { restateOpenAIChatCompletionsError } from '../as-source/error-body.ts'; -import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; - -export const translate: Translator< - OpenAIChatCompletionsPayloadEx, OpenAIChatCompletionsStreamEvent, AnthropicMessagesPayload, AnthropicMessagesStreamEventEx, - { fallbackMaxOutputTokens?: number; loadRemoteImage: RemoteImageLoader } -> = async (src, ctx) => { - try { - const streamOptions = { ...src.stream_options }; - return { - type: 'trip', - error: restateOpenAIChatCompletionsError, - target: await buildTargetRequest(src, ctx.sidecarCodec, { - fallbackMaxOutputTokens: ctx.fallbackMaxOutputTokens, - loadRemoteImage: ctx.loadRemoteImage, - }), - events: frames => translateToSourceEvents(frames, streamOptions, ctx.sidecarCodec), - }; - } catch (error) { - if (error instanceof TranslateInputException) return error.failure; - throw error; - } -}; diff --git a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/content.ts b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/content.ts deleted file mode 100644 index b4ce07eb24..0000000000 --- a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/content.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { OpenAIChatCompletionsUserContentPart } from '@floway-dev/protocols/openai-chat-completions'; -import type { OpenAIResponsesInputContent } from '@floway-dev/protocols/openai-responses'; - -// OpenAI Chat Completions and OpenAI Responses text arrays are transport fragments of one message, not -// paragraph blocks. Preserve the existing no-separator flattening. -const contentPartText = (part: OpenAIChatCompletionsUserContentPart | OpenAIResponsesInputContent): string | null => (part.type === 'text' || part.type === 'input_text' || part.type === 'output_text' ? part.text : null); - -const contentPartsToText = (parts: readonly (OpenAIChatCompletionsUserContentPart | OpenAIResponsesInputContent)[]): string => - parts - .map(contentPartText) - .filter((text): text is string => text !== null) - .join(''); - -export const openaiChatCompletionsContentToText = (content: string | OpenAIChatCompletionsUserContentPart[] | null | undefined): string => (typeof content === 'string' ? content : Array.isArray(content) ? contentPartsToText(content) : ''); - -export const openaiChatCompletionsContentToOpenAIResponsesInputContent = (content: string | OpenAIChatCompletionsUserContentPart[] | null | undefined): string | OpenAIResponsesInputContent[] => { - if (typeof content === 'string') return content; - if (!Array.isArray(content) || content.length === 0) return ''; - - return content.map( - (part): OpenAIResponsesInputContent => { - switch (part.type) { - case 'text': - return { type: 'input_text', text: part.text }; - case 'input_audio': - throw new TranslateInputException('Cannot translate input_audio content to OpenAI Responses.'); - case 'file': - return { type: 'input_file', ...part.file }; - case 'image_url': - return { - type: 'input_image', - image_url: part.image_url.url, - ...(part.image_url.detail === undefined ? {} : { detail: part.image_url.detail }), - }; - } - }, - ); -}; diff --git a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts deleted file mode 100644 index 39e386f614..0000000000 --- a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/events.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; -import { readOpenAIResponsesEvents } from '../../openai-responses/as-target/events.ts'; -import { writeOpenAIChatCompletionsEvents } from '../as-source/events.ts'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsStreamOptionsEx } from '@floway-dev/protocols/openai-chat-completions'; -import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; - -export const translateToSourceEvents = ( - frames: AsyncIterable>, - streamOptions: OpenAIChatCompletionsStreamOptionsEx, - codec: SidecarCodec, -): AsyncIterable> => { - const outputOptions = { continuousUsageStats: streamOptions.include_usage === true && streamOptions.continuous_usage_stats === true }; - const stream = createRoundTripCoordinator('openaiChatCompletions', 'openaiResponses', codec); - return writeOpenAIChatCompletionsEvents(readOpenAIResponsesEvents(frames, { roundTrip: stream.reader }), { ...outputOptions, roundTrip: stream.writer }); -}; diff --git a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/request.ts b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/request.ts deleted file mode 100644 index b55fef0a4c..0000000000 --- a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/request.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { klona } from 'klona/json'; - -import { openaiChatCompletionsContentToOpenAIResponsesInputContent, openaiChatCompletionsContentToText } from './content.ts'; -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import { partitionOpenAIChatCompletionsTurns } from '../as-source/conversation-turns.ts'; -import { collectOpenAIChatCompletionsReplayContents, hasOpenAIChatCompletionsReasoning, findOpenAIChatCompletionsSidecarOccurrences, verifyOpenAIChatCompletionsTurnDigest } from '../as-source/round-trip.ts'; -import type { OpenAIChatCompletionsAssistantMessage, OpenAIChatCompletionsMessage, OpenAIChatCompletionsPayload, OpenAIChatCompletionsTool } from '@floway-dev/protocols/openai-chat-completions'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesAssistantInputContent, CanonicalOpenAIResponsesInputItem, OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; - -const translateChatTools = (tools?: OpenAIChatCompletionsTool[] | null): OpenAIResponsesTool[] => - tools?.length - ? tools.map(tool => { - if (tool.type === 'custom') return { type: 'custom', ...klona(tool.custom) }; - return { - type: 'function', - name: tool.function.name, - parameters: klona(tool.function.parameters) ?? { type: 'object', properties: {} }, - // OpenAI Chat Completions function tools are non-strict by default while OpenAI Responses function - // tools default strict; make omission explicit to preserve OpenAI Chat Completions semantics. - strict: tool.function.strict ?? false, - ...(tool.function.description ? { description: tool.function.description } : {}), - }; - }) - : []; - -const translateChatToolChoice = (choice: NonNullable): OpenAIResponsesToolChoice => { - if (typeof choice === 'string') return choice; - if (choice.type === 'function') return { type: 'function', name: choice.function.name }; - if (choice.type === 'custom') return { type: 'custom', name: choice.custom.name }; - return { - type: 'allowed_tools', mode: choice.allowed_tools.mode, tools: choice.allowed_tools.tools.map(tool => { - return tool.type === 'function' ? { type: 'function', name: tool.function.name } : { type: 'custom', name: tool.custom.name }; - }), - }; -}; - -const translateAssistantContent = (message: OpenAIChatCompletionsAssistantMessage): OpenAIResponsesAssistantInputContent[] => { - const content: OpenAIResponsesAssistantInputContent[] = []; - let hasRefusalPart = false; - - if (typeof message.content === 'string') { - if (message.content) content.push({ type: 'output_text', text: message.content }); - } else if (Array.isArray(message.content)) { - for (const part of message.content) { - if (part.type === 'text') content.push({ type: 'output_text', text: part.text }); - else if (part.type === 'refusal') { - content.push({ type: 'refusal', refusal: part.refusal }); - hasRefusalPart = true; - } - } - } - - if (message.refusal !== undefined && message.refusal !== null && !hasRefusalPart) { - content.push({ type: 'refusal', refusal: message.refusal }); - } - - return content; -}; - -export const buildTargetRequest = async ( - payload: OpenAIChatCompletionsPayload, - codec: SidecarCodec, -): Promise => { - const instructions: string[] = []; - const input: CanonicalOpenAIResponsesInputItem[] = []; - const customToolCallIds = new Set(); - let hoistSystemPrefix = true; - - const translateAssistantMessage = (message: OpenAIChatCompletionsAssistantMessage): void => { - hoistSystemPrefix = false; - if (hasOpenAIChatCompletionsReasoning(message)) { - warnDroppedUnrecognizedAssistantReasoning('openaiChatCompletions', 'openaiResponses'); - } - const assistantContent = translateAssistantContent(message); - if (message.tool_calls?.length) { - if (assistantContent.length > 0) { - input.push({ - type: 'message', - role: 'assistant', - content: assistantContent, - }); - } - - for (const toolCall of message.tool_calls) { - if (toolCall.type === 'custom') { - customToolCallIds.add(toolCall.id); - input.push({ type: 'custom_tool_call', call_id: toolCall.id, ...toolCall.custom, status: 'completed' }); - continue; - } - input.push({ - type: 'function_call', - call_id: toolCall.id, - name: toolCall.function.name, - arguments: toolCall.function.arguments, - status: 'completed', - }); - } - - return; - } - - input.push({ - type: 'message', - role: 'assistant', - content: assistantContent.length > 0 ? assistantContent : '', - }); - }; - - const translateNonAssistantMessages = (messages: Exclude[]): void => { - for (const message of messages) { - // Only the initial OpenAI Chat Completions `system` prefix maps cleanly to OpenAI Responses - // `instructions`; later `system` and `developer` turns are - // chronology-bearing input items. - if (hoistSystemPrefix && message.role === 'system') { - const text = openaiChatCompletionsContentToText(message.content); - if (text) instructions.push(text); - continue; - } - - hoistSystemPrefix = false; - - if (message.role === 'user') { - input.push({ - type: 'message', - role: 'user', - content: openaiChatCompletionsContentToOpenAIResponsesInputContent(message.content), - }); - continue; - } - - if (message.role === 'system' || message.role === 'developer') { - input.push({ - type: 'message', - role: message.role, - content: openaiChatCompletionsContentToOpenAIResponsesInputContent(message.content), - }); - continue; - } - - if (message.role !== 'tool') { - throw new TranslateInputException(`Invalid role '${(message as { role: string }).role}'.`); - } - - if (!message.tool_call_id) { - throw new TranslateInputException("Missing required field 'tool_call_id' on a 'tool' role message."); - } - - input.push({ - type: customToolCallIds.has(message.tool_call_id) ? 'custom_tool_call_output' : 'function_call_output', - call_id: message.tool_call_id, - output: typeof message.content === 'string' ? message.content : JSON.stringify(message.content), - }); - } - }; - - for (const turn of partitionOpenAIChatCompletionsTurns(payload.messages)) { - const roundTrip = turn.role === 'assistant' && await roundTripAssistantTurn('openaiChatCompletions', 'openaiResponses', turn.items[0], codec, findOpenAIChatCompletionsSidecarOccurrences, verifyOpenAIChatCompletionsTurnDigest, collectOpenAIChatCompletionsReplayContents); - if (!roundTrip) { - if (turn.role === 'assistant') translateAssistantMessage(turn.items[0]); - else translateNonAssistantMessages(turn.items); - continue; - } - hoistSystemPrefix = false; - input.push(...roundTrip.turn); - for (const item of roundTrip.turn) if (item.type === 'custom_tool_call') customToolCallIds.add(item.call_id); - } - - const format = klona(payload.response_format); - if (format?.type === 'json_schema' && format.json_schema.schema === undefined) throw new TranslateInputException('Cannot translate json_schema response format without a schema.'); - const responseTextConfig = format == null ? undefined : { - format: format.type === 'json_schema' - ? { type: 'json_schema' as const, ...format.json_schema, schema: format.json_schema.schema! } - : format, - }; - - const reasoningEffort = payload.reasoning_effort ?? undefined; - const reasoning = reasoningEffort !== undefined ? { effort: reasoningEffort } : undefined; - - return { - model: payload.model, - input, - ...(instructions.length > 0 ? { instructions: instructions.join('\n\n') } : {}), - ...(payload.temperature !== undefined ? { temperature: payload.temperature } : {}), - ...(payload.top_p !== undefined ? { top_p: payload.top_p } : {}), - ...(payload.max_tokens !== undefined ? { max_output_tokens: payload.max_tokens } : {}), - ...(payload.tools !== undefined ? { tools: translateChatTools(payload.tools) } : {}), - // Omit tool_choice without tools: xAI-backed upstreams reject this shape. - // https://github.com/Wei-Shaw/sub2api/issues/4819 - // https://github.com/jlcodes99/cockpit-tools/issues/1727 - ...(payload.tool_choice != null && payload.tools?.length ? { tool_choice: translateChatToolChoice(payload.tool_choice) } : {}), - // Same-purpose OpenAI fields are normal OpenAI Chat Completions/OpenAI Responses adapter surface; - // provider-specific policy filtering belongs at the target boundary, not in - // pairwise translation. - ...(payload.metadata !== undefined ? { metadata: klona(payload.metadata) } : {}), - stream: true, - // Omitted store inherits the target/account default; explicit false disables it. - // https://developers.openai.com/api/docs/guides/migrate-to-responses - ...(payload.store !== undefined ? { store: payload.store } : {}), - ...(payload.parallel_tool_calls !== undefined ? { parallel_tool_calls: payload.parallel_tool_calls } : {}), - ...(reasoning ? { reasoning } : {}), - ...(responseTextConfig !== undefined ? { text: responseTextConfig } : {}), - ...(payload.prompt_cache_key !== undefined ? { prompt_cache_key: payload.prompt_cache_key } : {}), - ...(payload.safety_identifier !== undefined ? { safety_identifier: payload.safety_identifier } : {}), - ...(payload.service_tier !== undefined ? { service_tier: payload.service_tier } : {}), - }; -}; diff --git a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/translate.ts b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/translate.ts deleted file mode 100644 index 27fbb53a80..0000000000 --- a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/translate.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { translateToSourceEvents } from './events.ts'; -import { buildTargetRequest } from './request.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { Translator } from '../../../types.ts'; -import { restateOpenAIChatCompletionsError } from '../as-source/error-body.ts'; -import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayloadEx } from '@floway-dev/protocols/openai-chat-completions'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; - -export const translate: Translator< - OpenAIChatCompletionsPayloadEx, OpenAIChatCompletionsStreamEvent, CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx -> = async (src, ctx) => { - try { - const streamOptions = { ...src.stream_options }; - return { - type: 'trip', - error: restateOpenAIChatCompletionsError, - target: await buildTargetRequest(src, ctx.sidecarCodec), - events: frames => translateToSourceEvents(frames, streamOptions, ctx.sidecarCodec), - }; - } catch (error) { - if (error instanceof TranslateInputException) return error.failure; - throw error; - } -}; diff --git a/packages/translate/src/protocols/openai-responses/as-source.ts b/packages/translate/src/protocols/openai-responses/as-source.ts new file mode 100644 index 0000000000..ad6c040463 --- /dev/null +++ b/packages/translate/src/protocols/openai-responses/as-source.ts @@ -0,0 +1,34 @@ +import { restateOpenAIResponsesError } from './as-source/error-body.ts'; +import { writeOpenAIResponsesEvents } from './as-source/events.ts'; +import { readOpenAIResponsesRequest } from './as-source/input-to.ts'; +import { createRandomOpenAIResponsesResponseId } from './as-source/response-id.ts'; +import { collectOpenAIResponsesReplayContents, findOpenAIResponsesSidecarOccurrences, verifyOpenAIResponsesTurnDigest } from './as-source/round-trip.ts'; +import type { SourceProtocol } from '../../composition/index.ts'; +import type { OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; + +/** Preserve grouped callable identity as namespace and name for source event restoration. */ +export const openaiResponsesSourceProtocol: SourceProtocol< + 'openaiResponses', OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx +> = { + name: 'openaiResponses', + read: readOpenAIResponsesRequest, + sidecarRecognition: { + inspect: findOpenAIResponsesSidecarOccurrences, + verify: verifyOpenAIResponsesTurnDigest, + collectCandidates: collectOpenAIResponsesReplayContents, + }, + error: restateOpenAIResponsesError, + writeEvents: (payload, records, roundTrip) => { + const responseId = createRandomOpenAIResponsesResponseId(); + // The response objects echo the client's own declarations, which only the + // payload states as the client wrote them: the IR holds a normalized form + // and the upstream states none of this at all. + const { tools, tool_choice: toolChoice } = payload; + return writeOpenAIResponsesEvents(records, { + id: responseId, + roundTrip, + ...(tools == null || tools.length === 0 ? {} : { echoedTools: tools }), + ...(toolChoice == null ? {} : { echoedToolChoice: toolChoice }), + }); + }, +}; diff --git a/packages/translate/src/protocols/openai-responses/as-source/allowed-tools.ts b/packages/translate/src/protocols/openai-responses/as-source/allowed-tools.ts index 62f7249ba1..3663de7967 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/allowed-tools.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/allowed-tools.ts @@ -1,77 +1,19 @@ import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; -const validateCallableKinds = (tools: OpenAIResponsesTool[] | null | undefined): void => { - const kinds = new Map(); - for (const tool of tools ?? []) { - if (tool.type !== 'function' && tool.type !== 'custom') continue; - if ('namespace' in tool && tool.namespace !== undefined) continue; - const prior = kinds.get(tool.name); - if (prior !== undefined && prior !== tool.type) { - throw new TranslateInputException(`Cannot translate distinct callable kinds sharing '${tool.name}'.`); - } - kinds.set(tool.name, tool.type); +/** + * The callable a selector names. + * + * `allowed_tools.tools` is the wire's own `Record` array, so + * this is where a member becomes a typed selector. A member that carries + * anything beyond the three fields a selector may state is not one. + */ +export const requireCallableSelector = (selector: unknown): { readonly type: 'function' | 'custom'; readonly name: string; readonly namespace?: string } => { + if (typeof selector !== 'object' || selector === null + || ((selector as { type?: unknown }).type !== 'function' && (selector as { type?: unknown }).type !== 'custom') + || typeof (selector as { name?: unknown }).name !== 'string' + || ('namespace' in selector && selector.namespace !== undefined && typeof selector.namespace !== 'string') + || Object.keys(selector).some(key => key !== 'type' && key !== 'name' && key !== 'namespace')) { + throw new TranslateInputException('Cannot translate an allowed_tools selector that is not a function or custom tool.'); } -}; - -// OpenAI Chat Completions and Anthropic Messages preserve flat function/custom -// subsets by filtering declarations and translating the mode separately. -// Namespace and hosted selectors must arrive already rewritten as flat -// callables; otherwise translation rejects them so the permitted set cannot -// silently widen. -// https://github.com/openai/openai-node/blob/61539248cbe04665de68a71e6fd878127ae4db87/src/resources/responses/responses.ts#L8246-L8279 -export const restrictAllowedTools = ( - tools: OpenAIResponsesTool[] | null | undefined, - choice: OpenAIResponsesToolChoice | null | undefined, -): { tools: OpenAIResponsesTool[] | null | undefined; choice: OpenAIResponsesToolChoice | null | undefined } => { - if (typeof choice !== 'object' || choice?.type !== 'allowed_tools') { - validateCallableKinds(tools); - return { tools, choice }; - } - if ((choice.mode !== 'auto' && choice.mode !== 'required') || !Array.isArray(choice.tools)) { - throw new TranslateInputException('Cannot translate malformed allowed_tools mode or tools array.'); - } - - const declared = new Map>(); - for (const tool of tools ?? []) { - if (tool.type !== 'function' && tool.type !== 'custom') continue; - if ('namespace' in tool && tool.namespace !== undefined) continue; - let names = declared.get(tool.type); - if (names === undefined) { - names = new Map(); - declared.set(tool.type, names); - } - const entries = names.get(tool.name) ?? []; - entries.push(tool); - names.set(tool.name, entries); - } - const selected = new Set(); - const selectedKinds = new Map(); - for (const selector of choice.tools) { - if (typeof selector !== 'object' || selector === null - || (selector.type !== 'function' && selector.type !== 'custom') - || typeof selector.name !== 'string' || selector.namespace !== undefined - || Object.keys(selector).some(key => key !== 'type' && key !== 'name' && key !== 'namespace')) { - throw new TranslateInputException('Cannot translate an allowed_tools selector that is not a flat function or custom tool.'); - } - const matches = declared.get(selector.type)?.get(selector.name); - if (matches === undefined) { - throw new TranslateInputException(`Cannot translate allowed_tools selector '${selector.name}' without a matching callable declaration.`); - } - const kind = selectedKinds.get(selector.name); - if (kind !== undefined && kind !== selector.type) { - throw new TranslateInputException(`Cannot translate distinct allowed_tools callable kinds sharing '${selector.name}'.`); - } - selectedKinds.set(selector.name, selector.type); - for (const tool of matches) selected.add(tool); - } - if (selected.size === 0 && choice.mode === 'required') { - throw new TranslateInputException('Cannot translate required allowed_tools with an empty callable subset.'); - } - const restricted = (tools ?? []).filter(tool => selected.has(tool)); - validateCallableKinds(restricted); - return { - tools: restricted, - choice: selected.size === 0 ? 'none' : choice.mode, - }; + return selector as { readonly type: 'function' | 'custom'; readonly name: string; readonly namespace?: string }; }; diff --git a/packages/translate/src/protocols/openai-responses/as-source/conversation-turns.ts b/packages/translate/src/protocols/openai-responses/as-source/conversation-turns.ts index 17723e3d86..1cbd36a4ba 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/conversation-turns.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/conversation-turns.ts @@ -1,14 +1,25 @@ import { isOpenAIResponsesAssistantInputItem, type CanonicalOpenAIResponsesInputItem } from '@floway-dev/protocols/openai-responses'; -export type OpenAIResponsesConversationTurn = { role: 'assistant' | 'bare'; items: CanonicalOpenAIResponsesInputItem[] }; +export type OpenAIResponsesConversationTurn = { + readonly role: 'assistant' | 'client'; + readonly items: CanonicalOpenAIResponsesInputItem[]; +}; + +// A system or developer message keeps its own boundary and role instead of +// joining the client content around it. +const isRoleMessageBoundary = (item: CanonicalOpenAIResponsesInputItem): boolean => + item.type === 'message' && (item.role === 'system' || item.role === 'developer'); export const partitionOpenAIResponsesTurns = (input: readonly CanonicalOpenAIResponsesInputItem[]): OpenAIResponsesConversationTurn[] => { - const turns: OpenAIResponsesConversationTurn[] = []; + const turns: { role: 'assistant' | 'client'; items: CanonicalOpenAIResponsesInputItem[] }[] = []; for (const item of input) { - const role = isOpenAIResponsesAssistantInputItem(item) ? 'assistant' : 'bare'; + const role = isOpenAIResponsesAssistantInputItem(item) ? 'assistant' : 'client'; const previous = turns.at(-1); - if (previous?.role === role) previous.items.push(item); - else turns.push({ role, items: [item] }); + if (isRoleMessageBoundary(item) || previous?.role !== role || previous.items.some(isRoleMessageBoundary)) { + turns.push({ role, items: [item] }); + continue; + } + previous.items.push(item); } return turns; }; diff --git a/packages/translate/src/protocols/openai-responses/as-source/events.ts b/packages/translate/src/protocols/openai-responses/as-source/events.ts index 73a5c06f87..b0f5ef16c2 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/events.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/events.ts @@ -1,5 +1,4 @@ import { openaiResponsesErrorEventOf } from './error.ts'; -import { restoreNamespaceOutputItem, type NamespaceToolNames } from './namespace-tools.ts'; import { createRandomOpenAIResponsesResponseId } from './response-id.ts'; import { collectOpenAIResponsesTextPlacements, computeOpenAIResponsesTurnDigest, createOpenAIResponsesSidecarItem } from './round-trip.ts'; import { usageFromIR } from './usage.ts'; @@ -23,6 +22,7 @@ import { isJsonObject } from '@floway-dev/protocols/common'; import { eventFrame, type ProtocolFrame } from '@floway-dev/protocols/common'; import { createRandomOpenAIResponsesItemId, + type CanonicalOpenAIResponsesPayload, type OpenAIResponsesAnnotation, type OpenAIResponsesOutputContentBlock, type OpenAIResponsesOutputItemEx, @@ -53,7 +53,11 @@ type ResponsesOutputUnit = ResponsesOutputUnitBase & ( | { readonly kind: 'function_call' | 'custom_tool_call'; toolContentState?: EmittedFieldState<'text'> } ); -type OpenAIResponsesEventWriterOptions = SourceEventWriterOptions<'openaiResponses'> & { namespaceToolNames: NamespaceToolNames }; +interface OpenAIResponsesEventWriterOptions extends SourceEventWriterOptions<'openaiResponses'> { + /** The declarations the client asked with, echoed back on every response object. */ + echoedTools?: NonNullable; + echoedToolChoice?: NonNullable; +} const IMAGE_OUTPUT_FORMATS = { 'image/png': 'png', 'image/jpeg': 'jpeg', 'image/webp': 'webp' } as const; @@ -182,6 +186,7 @@ export const writeOpenAIResponsesEvents = async function* ( records: AsyncIterable, options: OpenAIResponsesEventWriterOptions, ): AsyncGenerator> { + const { echoedTools, echoedToolChoice } = options; let metadata = { id: '', model: '', created: 0 }; let started = false; let sequence = 0; @@ -326,10 +331,18 @@ export const writeOpenAIResponsesEvents = async function* ( if (unit.callId === undefined) throw new TypeError('Responses tool unit has no call ID'); if (unit.kind === 'custom_tool_call') { if (item.type !== 'custom_tool_call') throw new TypeError('Responses tool unit has the wrong IR item'); - return { type: 'custom_tool_call', id, call_id: unit.callId, name: item.name, input: item.input, status }; + return { + type: 'custom_tool_call', id, call_id: unit.callId, name: item.name, + ...(item.namespace == null ? {} : { namespace: item.namespace }), + input: item.input, status, + }; } if (item.type !== 'function_call') throw new TypeError('Responses tool unit has the wrong IR item'); - return { type: 'function_call', id, call_id: unit.callId, name: item.name, arguments: item.arguments, status }; + return { + type: 'function_call', id, call_id: unit.callId, name: item.name, + ...(item.namespace == null ? {} : { namespace: item.namespace }), + arguments: item.arguments, status, + }; }; const response = (state: IROutput, status: OpenAIResponsesResultEx['status'], usage: OpenAIResponsesResultEx['usage']): OpenAIResponsesResultEx => ({ @@ -343,6 +356,12 @@ export const writeOpenAIResponsesEvents = async function* ( error: null, incomplete_details: status === 'incomplete' ? { reason: finishReason === 'content_filter' ? 'content_filter' : 'max_output_tokens' } : null, ...(state.service_tier === undefined ? {} : { service_tier: state.service_tier }), + // The response echoes the declarations the client asked with, because a + // client that reads this field expects to see its own groups rather than + // the flattened names the request placed on the wire. The upstream states + // none of this, so it comes from the request this trip was built from. + ...(echoedTools === undefined ? {} : { tools: echoedTools }), + ...(echoedToolChoice === undefined ? {} : { tool_choice: echoedToolChoice }), }); // Creates the output units that the IR structures initialized by a record will become. @@ -486,13 +505,22 @@ export const writeOpenAIResponsesEvents = async function* ( const resolvedCallId = callId === IRAbsent ? createRandomOpenAIResponsesItemId('function_call') : callId; unit.id = id; unit.callId = resolvedCallId; + // The announcement states the same pair the terminal snapshot does: a call + // the request declared grouped is named by its group here too. const added: OpenAIResponsesOutputItemEx = custom - ? { type: 'custom_tool_call', id, call_id: resolvedCallId, name: item.name, input: '', status: 'in_progress' } - : { type: 'function_call', id, call_id: resolvedCallId, name: item.name, arguments: '', status: 'in_progress' }; - const restored = restoreNamespaceOutputItem(added, options.namespaceToolNames, 'in_progress'); + ? { + type: 'custom_tool_call', id, call_id: resolvedCallId, name: item.name, + ...(item.namespace == null ? {} : { namespace: item.namespace }), + input: '', status: 'in_progress', + } + : { + type: 'function_call', id, call_id: resolvedCallId, name: item.name, + ...(item.namespace == null ? {} : { namespace: item.namespace }), + arguments: '', status: 'in_progress', + }; const content = createEmittedFieldState(options.roundTrip.builder, 'text'); unit.toolContentState = content; - nativeState[outputIndex] = restored.type === 'custom_tool_call' ? { input: content } : { arguments: content }; + nativeState[outputIndex] = added.type === 'custom_tool_call' ? { input: content } : { arguments: content }; const followUps: OpenAIResponsesStreamEventEx[] = []; appendPendingText(unit, [...unit.path, item.type === 'custom_tool_call' ? 'input' : 'arguments'], item.type === 'custom_tool_call' ? item.input : item.arguments); if (item.type === 'custom_tool_call') { @@ -805,7 +833,7 @@ export const writeOpenAIResponsesEvents = async function* ( return; } const status = record.status === 'completed' && finishReason === 'content_filter' ? 'incomplete' : record.status; - const nativeOutput = outputUnits.map(unit => restoreNamespaceOutputItem(outputItem(state, unit), options.namespaceToolNames, 'completed')); + const nativeOutput = outputUnits.map(unit => outputItem(state, unit)); nativeState.length = nativeOutput.length; const sourceTurnDigest = await computeOpenAIResponsesTurnDigest(nativeOutput); const input = toOpenAIResponsesInputAssistantTurn(nativeOutput, nativeState); diff --git a/packages/translate/src/protocols/openai-responses/as-source/input-to.ts b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts new file mode 100644 index 0000000000..37f21fa612 --- /dev/null +++ b/packages/translate/src/protocols/openai-responses/as-source/input-to.ts @@ -0,0 +1,386 @@ +import { klona } from 'klona/json'; + +import { requireCallableSelector } from './allowed-tools.ts'; +import { partitionOpenAIResponsesTurns, type OpenAIResponsesConversationTurn } from './conversation-turns.ts'; +import { rejectProgramCaller, rejectProgrammaticOpenAIResponsesPayload } from './programmatic-tooling.ts'; +import type { OpenAIResponsesSourceAssistantTurn } from './round-trip.ts'; +import type { SourceReading } from '../../../composition/reading.ts'; +import type { + IRInputAssistantContentPart, + IRInputAssistantItem, + IRInputRequestHeader, + IRInputSettings, + IRInputTool, + IRInputToolChoice, + IRInputToolSelector, + IRInputOutputFormat, + IRInputMedia, + IRInputTextPart, + IRInputToolResultContentPart, + IRInputToolResultItem, + IRInputTurn, + IRInputUserContentPart, + IRInputClientItem, +} from '../../../ir/input/types.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; +import type { + CanonicalOpenAIResponsesInputItem, + CanonicalOpenAIResponsesPayload, + OpenAIResponsesInputContent, + OpenAIResponsesInputMessageEx, + OpenAIResponsesRequestPayloadEx, + OpenAIResponsesTool, +} from '@floway-dev/protocols/openai-responses'; + +const imageMedia = (part: Extract): IRInputMedia => { + if (typeof part.image_url === 'string') return { source_kind: 'url', url: part.image_url }; + if (typeof part.file_id === 'string') return { source_kind: 'file', file_id: part.file_id }; + throw new TranslateInputException('An OpenAI Responses input_image requires image_url or file_id.'); +}; + +const fileMedia = (part: Extract): IRInputMedia => { + if (typeof part.file_id === 'string') return { source_kind: 'file', file_id: part.file_id }; + if (typeof part.file_url === 'string') return { source_kind: 'url', url: part.file_url }; + if (typeof part.file_data === 'string') { + return { source_kind: 'data', data: part.file_data }; + } + throw new TranslateInputException('An OpenAI Responses input_file requires file_id, file_url, or file_data.'); +}; + +const userContentPart = (part: OpenAIResponsesInputContent): IRInputUserContentPart => { + switch (part.type) { + case 'input_text': + case 'output_text': + return { type: 'text', text: part.text }; + case 'input_image': { + const image = imageMedia(part); + return { type: 'image', image, ...(part.detail == null ? {} : { detail: part.detail }) }; + } + case 'input_file': + return { type: 'file', file: fileMedia(part), ...(typeof part.filename === 'string' ? { filename: part.filename } : {}) }; + } +}; + +const userContent = (content: string | OpenAIResponsesInputContent[]): IRInputUserContentPart[] => typeof content === 'string' + ? content === '' ? [] : [{ type: 'text', text: content }] + : content.map(userContentPart); + +const textPartsToIR = (message: OpenAIResponsesInputMessageEx): IRInputTextPart[] => typeof message.content === 'string' + ? message.content === '' ? [] : [{ type: 'text', text: message.content }] + : message.content.map(part => { + if (part.type !== 'input_text' && part.type !== 'output_text') { + throw new TranslateInputException(`Invalid '${part.type}' content part in ${message.role} message. Only 'input_text' content parts are supported in ${message.role} messages on this model.`); + } + return { type: 'text', text: part.text }; + }); + +// String content becomes one text part, including an empty string. Arrays preserve their listed parts; +// an empty list still retains the enclosing assistant message and turn. +const assistantContent = (message: Extract): IRInputAssistantContentPart[] => typeof message.content === 'string' + ? [{ type: 'text', text: message.content }] + : message.content.map(part => { + if (part.type === 'input_text' || part.type === 'output_text') return { type: 'text' as const, text: part.text }; + // An assistant turn replays model output, so a refusal it produced is + // history like any other part. + if (part.type === 'refusal') return { type: 'refusal' as const, refusal: part.refusal }; + throw new TranslateInputException(`Cannot translate ${part.type} assistant content.`); + }); + +const toolResultContent = (output: string | readonly OpenAIResponsesInputContent[]): string | readonly IRInputToolResultContentPart[] => typeof output === 'string' + ? output + : output.map(part => { + if (part.type === 'input_image') return { type: 'image' as const, image: imageMedia(part), ...(part.detail == null ? {} : { detail: part.detail }) }; + if (part.type === 'input_file') return { type: 'file' as const, file: fileMedia(part), ...(typeof part.filename === 'string' ? { filename: part.filename } : {}) }; + return { type: 'text' as const, text: part.text }; + }); + +const unsupportedItem = (item: CanonicalOpenAIResponsesInputItem): never => { + throw new TranslateInputException(`Invalid input item: ${JSON.stringify(item)}`); +}; + +const assistantItems = (items: readonly CanonicalOpenAIResponsesInputItem[]): IRInputAssistantItem[] => items.map((item): IRInputAssistantItem | undefined => { + rejectProgramCaller(item); + switch (item.type) { + case 'message': { + if (item.role !== 'assistant') throw new TranslateInputException(`Invalid assistant message role '${item.role}'.`); + const content = assistantContent(item); + // An explicitly empty assistant message still occupies a turn: dropping it + // would put the client runs on either side next to each other, and every + // target that combines adjacent same-role messages would merge them. + return { type: 'message' as const, content }; + } + case 'reasoning': + { + const summary = item.summary.map(part => part.text); + const content = (item.content ?? []).map(part => part.text); + // No readable text means nothing any target could replay. + if (summary.length === 0 && content.length === 0) return undefined; + return { type: 'reasoning' as const, ...(summary.length === 0 ? {} : { summary }), ...(content.length === 0 ? {} : { content }) }; + } + case 'function_call': + return { type: 'function_call' as const, call_id: item.call_id, name: item.name, ...('namespace' in item && typeof item.namespace === 'string' ? { namespace: item.namespace } : {}), arguments: item.arguments }; + case 'custom_tool_call': + return { type: 'custom_tool_call' as const, call_id: item.call_id, name: item.name, ...('namespace' in item && typeof item.namespace === 'string' ? { namespace: item.namespace } : {}), input: item.input }; + case 'item_reference': + case 'web_search_call': + case 'image_generation_call': + return unsupportedItem(item); + default: + unsupportedItem(item); + } +}).filter((item): item is IRInputAssistantItem => item !== undefined); + +const clientItems = (items: readonly CanonicalOpenAIResponsesInputItem[]): IRInputClientItem[] => items.flatMap((item): IRInputClientItem[] => { + rejectProgramCaller(item); + switch (item.type) { + case 'message': + switch (item.role) { + case 'user': return [{ type: 'message' as const, role: 'user' as const, content: userContent(item.content) }]; + case 'system': + case 'developer': return [{ type: 'message' as const, role: item.role, content: textPartsToIR(item) }]; + default: throw new TranslateInputException(`Invalid role '${item.role}' in input message.`); + } + case 'function_call_output': { + const result: IRInputToolResultItem = { + type: 'tool_result', + call_id: item.call_id, + output: toolResultContent(item.output), + tool_kind: 'function', + ...(item.status === 'incomplete' ? { is_error: true } : {}), + }; + return [result]; + } + case 'custom_tool_call_output': + return [{ + type: 'tool_result' as const, + call_id: item.call_id, + output: toolResultContent(item.output), + tool_kind: 'custom' as const, + ...(item.status === 'incomplete' ? { is_error: true } : {}), + }]; + // Deferred inventories contribute declarations to the request header; they are not conversational model output. + case 'additional_tools': + case 'tool_search_output': + return []; + default: + return unsupportedItem(item); + } +}); + +const openaiResponsesInstructionsToIR = (instructions: string | null | undefined): IRInputTurn | undefined => instructions + ? { kind: 'client', items: [{ type: 'instruction', content: [{ type: 'text', text: instructions }] }] } + : undefined; + +const openaiResponsesTurnToIR = (turn: OpenAIResponsesConversationTurn): IRInputTurn[] => { + const items = turn.items; + if (turn.role !== 'assistant') return [{ kind: 'client', items: clientItems(items) }]; + // Keep an assistant turn even when all its items are dropped, preserving its boundary between client turns. + return [{ kind: 'assistant', items: assistantItems(items) }]; +}; + +const toolToIR = (tool: Extract, namespace?: string): IRInputTool => { + // Preserve namespace/name identity here; flat-name targets allocate wire names later. + const group = namespace ?? ('namespace' in tool && typeof tool.namespace === 'string' ? tool.namespace : undefined); + const identity = group === undefined ? {} : { namespace: group }; + if (tool.type === 'custom') { + const format = tool.format; + return { + type: 'custom', + name: tool.name, + ...identity, + ...(tool.description == null ? {} : { description: tool.description }), + ...(format === undefined ? {} : { + format: format.type === 'text' + ? { type: 'text' } + : format.type === 'grammar' && typeof format.definition === 'string' && typeof format.syntax === 'string' + ? { type: 'grammar', definition: format.definition, syntax: format.syntax } + : { type: 'unrecognized', value: format }, + }), + }; + } + return { + type: 'function', + name: tool.name, + ...identity, + ...(tool.description == null ? {} : { description: tool.description }), + ...(tool.parameters == null ? {} : { parameters: klona(tool.parameters) }), + ...(tool.strict == null ? {} : { strict: tool.strict }), + }; +}; + +const selectionToIR = (selection: IRInputToolSelector, declarations: readonly IRInputTool[]): IRInputToolSelector => { + if (selection.namespace !== undefined) return selection; + if (declarations.some(tool => tool.type === selection.type && tool.namespace === undefined && tool.name === selection.name)) return selection; + const qualified = declarations.filter(tool => tool.type === selection.type && tool.namespace !== undefined + && (selection.name === `${tool.namespace}.${tool.name}` || selection.name === `${tool.namespace}__${tool.name}`)); + const first = qualified[0]; + if (first === undefined) return selection; + if (qualified.some(tool => tool.namespace !== first.namespace || tool.name !== first.name)) { + throw new TranslateInputException(`Cannot translate ambiguous tool selector '${selection.name}'.`); + } + return { type: selection.type, name: first.name, namespace: first.namespace }; +}; + +// A selector may name a whole group, which selects each callable it holds. +const selectorOf = (payload: CanonicalOpenAIResponsesPayload, selector: Record, declarations: readonly IRInputTool[]): IRInputToolSelector[] => { + if (typeof selector !== 'object' || selector?.type !== 'namespace') return [selectionToIR(requireCallableSelector(selector), declarations)]; + if (typeof selector.name !== 'string' || Object.keys(selector).some(key => key !== 'type' && key !== 'name')) { + throw new TranslateInputException('Cannot translate namespace selector extensions.'); + } + const groups = [ + ...(payload.tools ?? []), + ...payload.input.flatMap(item => item.type === 'additional_tools' || item.type === 'tool_search_output' ? item.tools : []), + ].flatMap(tool => tool.type === 'namespace' && tool.name === selector.name ? [tool] : []); + if (groups.length === 0) throw new TranslateInputException(`Cannot translate group selector '${String(selector.name)}' without a matching declaration.`); + return groups.flatMap(group => group.tools.flatMap(child => child.type === 'function' || child.type === 'custom' + ? [{ type: child.type, name: child.name, namespace: group.name } as IRInputToolSelector] + : [])); +}; + +const toolChoiceToIR = ( + payload: CanonicalOpenAIResponsesPayload, + choice: NonNullable, + declarations: readonly IRInputTool[], +): IRInputToolChoice => { + if (typeof choice === 'string') { + if (choice === 'auto' || choice === 'none' || choice === 'required') return choice; + // Any other bare string is a hosted selector, which names a callable this + // IR does not carry. + throw new TranslateInputException(`Cannot translate OpenAI Responses tool choice '${choice}'.`); + } + if (choice.type === 'allowed_tools') { + if ((choice.mode !== 'auto' && choice.mode !== 'required') || !Array.isArray(choice.tools)) { + throw new TranslateInputException('Cannot translate malformed allowed_tools mode or tools array.'); + } + const tools = choice.tools.flatMap(selector => selectorOf(payload, selector, declarations)); + for (const selector of tools) { + if (!declarations.some(tool => tool.type === selector.type && tool.namespace === selector.namespace && tool.name === selector.name)) { + throw new TranslateInputException(`Cannot translate allowed_tools selector '${selector.name}' without a matching callable declaration.`); + } + } + if (tools.length === 0 && choice.mode === 'required') { + throw new TranslateInputException('Cannot translate required allowed_tools with an empty callable subset.'); + } + return { type: 'allowed_tools', mode: choice.mode, tools }; + } + if (choice.type === 'function' || choice.type === 'custom') return selectionToIR(choice, declarations); + // A hosted selector names a tool this IR does not carry. + throw new TranslateInputException(`Cannot translate OpenAI Responses tool choice type '${choice.type}'.`); +}; + +// A group is a declaration container, not a callable: this IR carries the +// callables it holds, each stating the group it belongs to, so a target that +// has no groups can still spell it out. +const toolGroupToIR = (tool: OpenAIResponsesTool): IRInputTool[] => { + if (tool.type === 'namespace') { + return tool.tools.flatMap(child => { + if (child.type !== 'function' && child.type !== 'custom') { + throw new TranslateInputException(`Cannot translate non-callable child in namespace '${tool.name}'.`); + } + // A group's own description qualifies each child's, the way one merged + // declaration states it. + const parentDescription = tool.description === '' ? undefined : tool.description; + const childDescription = child.description === '' ? undefined : child.description; + return [toolToIR( + parentDescription === undefined + ? child + : { ...child, description: childDescription === undefined ? parentDescription : `${parentDescription}\n\n${childDescription}` }, + tool.name, + )]; + }); + } + if (tool.type === 'function' || tool.type === 'custom') return [toolToIR(tool)]; + throw new TranslateInputException(`Cannot translate OpenAI Responses tool type '${tool.type}'.`); +}; + +// A request can defer declarations to an inventory item inside the +// conversation; those are tools like any other, not things the model said. +const deferredInventories = (payload: CanonicalOpenAIResponsesPayload): OpenAIResponsesTool[] => + payload.input.flatMap(item => item.type === 'additional_tools' || item.type === 'tool_search_output' ? item.tools : []); + +const openaiResponsesToolsToIR = (payload: CanonicalOpenAIResponsesPayload): Pick => { + const declared = payload.tools?.flatMap(toolGroupToIR) ?? []; + const deferred = deferredInventories(payload).flatMap(toolGroupToIR); + const tools = declared.length === 0 && deferred.length === 0 ? undefined : [...declared, ...deferred]; + const toolChoice = payload.tool_choice == null ? undefined : toolChoiceToIR(payload, payload.tool_choice, tools ?? []); + return { + ...(tools === undefined ? {} : { tools }), + ...(toolChoice === undefined ? {} : { tool_choice: toolChoice }), + }; +}; + +const openaiResponsesSettingsToIR = (payload: CanonicalOpenAIResponsesPayload): IRInputSettings => { + const format = payload.text?.format; + const outputFormat: IRInputOutputFormat | undefined = format === undefined || format === null ? undefined + : format.type === 'json_schema' + ? { + // A caller may hand the Chat-Completions-nested spelling to the + // Responses field; its details are still the same schema. + ...('json_schema' in format && typeof format.json_schema === 'object' && format.json_schema !== null + ? format.json_schema as Record + : {}), + type: 'json_schema', + ...('name' in format && typeof format.name === 'string' ? { name: format.name } : {}), + ...('description' in format && typeof format.description === 'string' ? { description: format.description } : {}), + ...('schema' in format && format.schema !== undefined && format.schema !== null && typeof format.schema === 'object' + ? { schema: klona(format.schema) as Record } + : {}), + ...('strict' in format && format.strict !== undefined ? { strict: format.strict } : {}), + } + : { type: format.type }; + + return { + model: payload.model, + stream: payload.stream === true, + ...(payload.temperature === undefined ? {} : { temperature: payload.temperature }), + ...(payload.top_p === undefined ? {} : { top_p: payload.top_p }), + ...(payload.presence_penalty === undefined ? {} : { presence_penalty: payload.presence_penalty }), + ...(payload.frequency_penalty === undefined ? {} : { frequency_penalty: payload.frequency_penalty }), + ...(payload.max_output_tokens === undefined ? {} : { max_output_tokens: payload.max_output_tokens }), + ...(outputFormat === undefined ? {} : { output_format: outputFormat }), + ...(payload.metadata === undefined ? {} : { metadata: payload.metadata }), + ...(payload.store === undefined ? {} : { store: payload.store }), + ...(payload.service_tier === undefined ? {} : { service_tier: payload.service_tier }), + ...(payload.parallel_tool_calls === undefined ? {} : { parallel_tool_calls: payload.parallel_tool_calls }), + ...(payload.reasoning?.effort == null ? {} : { reasoning_effort: payload.reasoning.effort }), + ...(payload.reasoning?.summary == null ? {} : { reasoning_summary: payload.reasoning.summary }), + ...(payload.prompt_cache_key === undefined ? {} : { prompt_cache_key: payload.prompt_cache_key }), + ...(payload.prompt_cache_retention === undefined ? {} : { prompt_cache_retention: payload.prompt_cache_retention }), + ...(payload.prompt_cache_options === undefined ? {} : { + prompt_cache_options: { + ...(payload.prompt_cache_options.mode === undefined ? {} : { mode: payload.prompt_cache_options.mode }), + ...(payload.prompt_cache_options.ttl === undefined ? {} : { ttl: payload.prompt_cache_options.ttl }), + }, + }), + ...(payload.safety_identifier === undefined ? {} : { safety_identifier: payload.safety_identifier }), + ...(payload.user === undefined ? {} : { user: payload.user }), + ...(payload.include === undefined ? {} : { logprobs: payload.include === null ? null : payload.include.includes('message.output_text.logprobs') }), + ...(payload.top_logprobs === undefined ? {} : { top_logprobs: payload.top_logprobs }), + ...(payload.text?.verbosity == null ? {} : { verbosity: payload.text.verbosity }), + }; +}; + +export const readOpenAIResponsesRequest = (source: OpenAIResponsesRequestPayloadEx): SourceReading => { + const canonical = canonicalizeOpenAIResponsesPayload(source); + if (!('input' in canonical)) throw new TranslateInputException(canonical.error.message, canonical.error); + rejectProgrammaticOpenAIResponsesPayload(canonical); + + const instruction = openaiResponsesInstructionsToIR(canonical.instructions); + return { + request: { + settings: openaiResponsesSettingsToIR(canonical), + ...openaiResponsesToolsToIR(canonical), + }, + turns: [ + ...(instruction === undefined ? [] : [{ kind: 'client' as const, toIRInputTurns: () => [instruction] }]), + ...partitionOpenAIResponsesTurns(canonical.input).map(turn => turn.role === 'assistant' + ? { + kind: 'assistant' as const, + assistantTurn: turn.items, + toIRInputTurns: () => openaiResponsesTurnToIR(turn), + } + : { kind: 'client' as const, toIRInputTurns: () => openaiResponsesTurnToIR(turn) }), + ], + }; +}; diff --git a/packages/translate/src/protocols/openai-responses/as-source/namespace-tools.ts b/packages/translate/src/protocols/openai-responses/as-source/namespace-tools.ts deleted file mode 100644 index 299cedf0af..0000000000 --- a/packages/translate/src/protocols/openai-responses/as-source/namespace-tools.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import { isOpenAIResponsesTerminalEvent, type CanonicalOpenAIResponsesPayload, type CanonicalOpenAIResponsesInputItem, type OpenAIResponsesOutputItemEx, type OpenAIResponsesResultEx, type OpenAIResponsesStreamEventEx, type OpenAIResponsesTool, type OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; - -export interface NamespaceToolNames { - sourceToTarget: Map; - targetToSource: Map; - sourceTools: CanonicalOpenAIResponsesPayload['tools']; - sourceToolChoice: CanonicalOpenAIResponsesPayload['tool_choice']; - toolsChanged: boolean; - toolChoiceChanged: boolean; -} - -interface CallableIdentity { - namespace: string; - name: string; - type: 'function_call' | 'custom_tool_call'; -} - -type ObjectToolChoice = Exclude; -type NamedToolChoice = Extract; - -const isNamedToolChoice = (value: Record): value is Record & NamedToolChoice => - (value.type === 'function' || value.type === 'custom') - && typeof value.name === 'string' - && (value.namespace === undefined || typeof value.namespace === 'string'); - -export const restoreNamespaceOutputItem = ( - item: OpenAIResponsesOutputItemEx, - names: Pick, - status: 'in_progress' | 'completed', -): OpenAIResponsesOutputItemEx => { - if ((item.type !== 'function_call' && item.type !== 'custom_tool_call') || item.namespace !== undefined) return item; - const identity = names.targetToSource.get(item.name); - if (identity === undefined) return item; - if (item.type === 'function_call') { - if (identity.type === 'function_call') return { ...item, name: identity.name, namespace: identity.namespace }; - const { type: _type, arguments: input, ...rest } = item; - return { ...rest, type: 'custom_tool_call', name: identity.name, namespace: identity.namespace, input }; - } - if (identity.type === 'custom_tool_call') return { ...item, name: identity.name, namespace: identity.namespace }; - const { type: _type, input: arguments_, ...rest } = item; - return { ...rest, type: 'function_call', name: identity.name, namespace: identity.namespace, arguments: arguments_, status: item.status ?? status }; -}; - -// Both translated targets require flat callable names. Reserve names from the -// full inventory and raw history before projecting declarations so an excluded -// historical call cannot acquire a different identity on a later turn. -// https://github.com/lidge-jun/opencodex/blob/e45692f8d8e4dedfb4e9b0217fc245080fb2fba8/src/responses/plaintext-v2-agent-messages.ts#L347-L427 -export const flattenNamespaceTools = (payload: CanonicalOpenAIResponsesPayload): { - payload: CanonicalOpenAIResponsesPayload; - names: NamespaceToolNames; - flattenInput: (input: readonly CanonicalOpenAIResponsesInputItem[]) => CanonicalOpenAIResponsesInputItem[]; -} => { - const inventories = [payload.tools ?? [], ...payload.input.flatMap(item => - item.type === 'additional_tools' || item.type === 'tool_search_output' ? [item.tools] : [])]; - const flatNames = new Set(inventories.flatMap(tools => tools.flatMap(tool => - tool.type === 'function' || tool.type === 'custom' ? [tool.name] : []))); - for (const item of payload.input) { - if ((item.type === 'function_call' || item.type === 'custom_tool_call') && item.namespace === undefined) flatNames.add(item.name); - } - const reserved = new Set(flatNames); - const names: NamespaceToolNames = { - sourceToTarget: new Map(), targetToSource: new Map(), - sourceTools: undefined, sourceToolChoice: undefined, - toolsChanged: false, toolChoiceChanged: false, - }; - const byNamespace = new Map>(); - const nextSuffixes = new Map(); - const allocate = (namespace: string, name: string, kind: 'function' | 'custom'): string => { - const type = kind === 'function' ? 'function_call' : 'custom_tool_call'; - const key = `${namespace}.${name}`; - const existing = names.sourceToTarget.get(key); - if (existing !== undefined) { - const identity = names.targetToSource.get(existing)!; - if (identity.namespace !== namespace || identity.name !== name || identity.type !== type) { - throw new TranslateInputException(`Cannot translate ambiguous namespace tool '${key}'.`); - } - return existing; - } - // OpenAI Chat Completions limits function names to 64 ASCII characters. - // https://github.com/openai/openai-node/blob/61539248cbe04665de68a71e6fd878127ae4db87/src/resources/chat/completions/completions.ts - // Bound sanitization work even for a long shared namespace. - const scope = `${namespace.slice(0, 64)}_`.slice(0, 64); - const base = `${scope}${name.slice(0, 64 - scope.length)}`.replaceAll(/[^a-zA-Z0-9_-]/g, '_'); - for (let suffix = 1; ; suffix++) { - const ending = suffix === 1 ? '' : `_${suffix}`; - const prefix = base.slice(0, 64 - ending.length); - if (suffix > 1) { - // Share cursors only when both the truncated prefix and suffix width match. - const key = `${ending.length}:${prefix}`; - const next = nextSuffixes.get(key); - if (next !== undefined && next > suffix) { suffix = next - 1; continue; } - nextSuffixes.set(key, suffix + 1); - } - const candidate = `${prefix}${ending}`; - if (reserved.has(candidate)) continue; - reserved.add(candidate); - names.sourceToTarget.set(key, candidate); - names.targetToSource.set(candidate, { namespace, name, type }); - return candidate; - } - }; - const tools: OpenAIResponsesTool[] = []; - for (const inventory of inventories) { - for (const tool of inventory) { - if (tool.type !== 'namespace') { - tools.push(tool); - continue; - } - const children = byNamespace.get(tool.name) ?? []; - byNamespace.set(tool.name, children); - for (const child of tool.tools) { - if (child.type !== 'function' && child.type !== 'custom') { - throw new TranslateInputException(`Cannot translate non-callable child in namespace '${tool.name}'.`); - } - const name = allocate(tool.name, child.name, child.type); - children.push({ type: child.type, name }); - tools.push({ - ...child, name, - ...(tool.description ? { description: child.description ? `${tool.description}\n\n${child.description}` : tool.description } : {}), - }); - } - } - } - const flattenInput = (input: readonly CanonicalOpenAIResponsesInputItem[]) => input.flatMap(item => { - if (item.type === 'additional_tools' || item.type === 'tool_search_output') return []; - if (item.type !== 'function_call' && item.type !== 'custom_tool_call') return [item]; - if (item.namespace === undefined) return [item]; - const { namespace, ...rest } = item; - return [{ ...rest, name: allocate(namespace, item.name, item.type === 'function_call' ? 'function' : 'custom') }]; - }); - const selector = (choice: ObjectToolChoice): ObjectToolChoice => { - if (choice.type !== 'function' && choice.type !== 'custom') return choice; - const namespace = choice.namespace; - const key = namespace === undefined ? choice.name : `${namespace}.${choice.name}`; - if (namespace === undefined && flatNames.has(choice.name)) return choice; - if (namespace !== undefined && !byNamespace.get(namespace)?.some(child => child.type === choice.type && names.targetToSource.get(child.name)?.name === choice.name)) { - throw new TranslateInputException(`Cannot translate tool_choice / allowed_tools selector for undeclared namespace tool '${key}'.`); - } - const qualified = namespace === undefined - ? [...names.targetToSource].filter(([, identity]) => `${identity.namespace}.${identity.name}` === key || `${identity.namespace}__${identity.name}` === key) - : []; - if (qualified.length > 1) throw new TranslateInputException(`Cannot select ambiguous qualified tool '${key}'.`); - const name = namespace === undefined ? qualified[0]?.[0] : names.sourceToTarget.get(key); - if (name === undefined) return choice; - const { namespace: _namespace, ...rest } = choice; - return { ...rest, name }; - }; - let choice = payload.tool_choice; - if (typeof choice === 'object' && choice !== null) { - if (choice.type === 'allowed_tools' && Array.isArray(choice.tools)) { - const original = choice; - choice = { - ...choice, - tools: choice.tools.flatMap(tool => { - if (typeof tool !== 'object' || tool === null) throw new TranslateInputException('Cannot translate malformed allowed_tools selector.'); - if (tool.type === 'namespace') { - const children = typeof tool.name === 'string' ? byNamespace.get(tool.name) : undefined; - if (children === undefined) throw new TranslateInputException(`Cannot select undeclared namespace '${String(tool.name)}'.`); - if (Object.keys(tool).some(key => key !== 'type' && key !== 'name')) throw new TranslateInputException('Cannot translate namespace selector extensions.'); - return children; - } - if (tool.type !== 'function' && tool.type !== 'custom') return [tool]; - if (!isNamedToolChoice(tool)) throw new TranslateInputException('Cannot translate malformed allowed_tools selector.'); - const selected = selector(tool); - return selected === tool ? [tool] : [{ ...selected }]; - }), - }; - if (choice.tools.length === original.tools.length && choice.tools.every((tool, index) => tool === original.tools[index])) choice = original; - } else choice = selector(choice); - } - names.toolsChanged = inventories.length > 1 || payload.tools?.some(tool => tool.type === 'namespace') === true; - names.toolChoiceChanged = choice !== payload.tool_choice; - names.sourceTools = names.toolsChanged ? payload.tools : undefined; - names.sourceToolChoice = names.toolChoiceChanged ? payload.tool_choice : undefined; - return { payload: { ...payload, ...(payload.tools == null && inventories.length === 1 ? {} : { tools }), ...(choice === undefined ? {} : { tool_choice: choice }) }, names, flattenInput }; -}; - -export const restoreNamespaceEvents = async function* ( - frames: AsyncIterable>, - names: NamespaceToolNames, -): AsyncGenerator> { - const { sourceTools, sourceToolChoice, toolsChanged, toolChoiceChanged } = names; - const items = new Map>(); - for await (const frame of frames) { - if (frame.type !== 'event') { yield frame; continue; } - const event = frame.event; - const identity = 'item_id' in event ? items.get(event.item_id) : undefined; - if (event.type === 'response.output_item.added' || event.type === 'response.output_item.done') { - const item = restoreNamespaceOutputItem(event.item, names, event.type === 'response.output_item.added' ? 'in_progress' : 'completed'); - if ((item.type === 'function_call' || item.type === 'custom_tool_call') && typeof item.id === 'string') items.set(item.id, { name: item.name, type: item.type }); - yield item === event.item ? frame : { ...frame, event: { ...event, item } }; - } else if (event.type === 'response.function_call_arguments.delta' && identity?.type === 'custom_tool_call') { - yield { ...frame, event: { ...event, type: 'response.custom_tool_call_input.delta' } }; - } else if (event.type === 'response.function_call_arguments.done' && identity !== undefined) { - // Function arguments.done requires a bare name, not a namespace. Custom - // input.done has neither field; changing families must not leak a wire name. - // https://github.com/openai/openai-node/blob/61539248cbe04665de68a71e6fd878127ae4db87/src/resources/responses/responses.ts - if (identity.type === 'function_call') { - if ('name' in event && event.name === identity.name) yield frame; - else { - const restoredEvent: OpenAIResponsesStreamEventEx = { ...event, name: identity.name }; - yield { ...frame, event: restoredEvent }; - } - } else { - const { arguments: input, name: _name, ...rest } = event; - yield { ...frame, event: { ...rest, type: 'response.custom_tool_call_input.done', input } }; - } - } else if (event.type === 'response.custom_tool_call_input.delta' && identity?.type === 'function_call') { - yield { ...frame, event: { ...event, type: 'response.function_call_arguments.delta' } }; - } else if (event.type === 'response.custom_tool_call_input.done' && identity?.type === 'function_call') { - const { input: args, ...rest } = event; - const restoredEvent: OpenAIResponsesStreamEventEx = { ...rest, type: 'response.function_call_arguments.done', arguments: args, name: identity.name }; - yield { ...frame, event: restoredEvent }; - } else if ('response' in event && Array.isArray(event.response?.output)) { - const output = event.response.output.map(item => restoreNamespaceOutputItem(item, names, isOpenAIResponsesTerminalEvent(event) ? 'completed' : 'in_progress')); - const outputChanged = output.some((item, index) => item !== event.response.output[index]); - const restoreTools = toolsChanged && event.response.tools !== undefined && event.response.tools !== sourceTools; - const restoreChoice = toolChoiceChanged && event.response.tool_choice !== undefined && event.response.tool_choice !== sourceToolChoice; - if (!outputChanged && !restoreTools && !restoreChoice) { - yield frame; - continue; - } - const response: OpenAIResponsesResultEx = { - ...event.response, - output: outputChanged ? output : event.response.output, - // Preserve absent echoes; only undo fields actually stated by the - // translated result. The outer shim must not observe invented tools. - ...(restoreChoice ? { tool_choice: sourceToolChoice } : {}), - }; - if (restoreTools) { - if (sourceTools == null) delete response.tools; - else response.tools = sourceTools; - } - const restoredEvent: OpenAIResponsesStreamEventEx = { ...event, response }; - yield { ...frame, event: restoredEvent }; - } else yield frame; - } -}; diff --git a/packages/translate/src/protocols/openai-responses/as-source/programmatic-tooling.ts b/packages/translate/src/protocols/openai-responses/as-source/programmatic-tooling.ts index 5e17e8a741..94ce4e05ab 100644 --- a/packages/translate/src/protocols/openai-responses/as-source/programmatic-tooling.ts +++ b/packages/translate/src/protocols/openai-responses/as-source/programmatic-tooling.ts @@ -1,13 +1,17 @@ import { TranslateInputException } from '../../../shared/input-error.ts'; import type { CanonicalOpenAIResponsesInputItem, OpenAIResponsesPayloadEx } from '@floway-dev/protocols/openai-responses'; -export const rejectProgrammaticOpenAIResponsesPayload = (payload: OpenAIResponsesPayloadEx, target: string): void => { +export const rejectProgrammaticOpenAIResponsesPayload = (payload: OpenAIResponsesPayloadEx): void => { const toolChoice = payload.tool_choice; - if (payload.tools?.some(hasProgrammaticCaller) === true || (toolChoice !== null && typeof toolChoice === 'object' && toolChoice.type === 'programmatic_tool_calling')) { - throw new TranslateInputException(`Programmatic OpenAI Responses tooling cannot be translated to ${target}.`); + const tools = [ + ...(payload.tools ?? []), + ...(Array.isArray(payload.input) ? payload.input.flatMap(item => item.type === 'additional_tools' || item.type === 'tool_search_output' ? item.tools : []) : []), + ]; + if (tools.some(hasProgrammaticCaller) || (toolChoice !== null && typeof toolChoice === 'object' && toolChoice.type === 'programmatic_tool_calling')) { + throw new TranslateInputException('Programmatic OpenAI Responses tooling cannot be translated.'); } - if (payload.tools?.some(hasDeferredTool) === true) { - throw new TranslateInputException(`Deferred OpenAI Responses tooling cannot be translated to ${target}.`); + if (tools.some(hasDeferredTool)) { + throw new TranslateInputException('Deferred OpenAI Responses tooling cannot be translated.'); } }; @@ -26,14 +30,10 @@ const hasDeferredTool = (tool: unknown): boolean => { return Array.isArray(record.tools) && record.tools.some(hasDeferredTool); }; -const isProgramCaller = (item: CanonicalOpenAIResponsesInputItem): item is CanonicalOpenAIResponsesInputItem & { call_id: string; caller: { type: 'program'; caller_id: string } } => { - if (!('caller' in item)) return false; - const caller = item.caller; - return typeof caller === 'object' && caller !== null && 'type' in caller && caller.type === 'program'; -}; - export const rejectProgramCaller = (item: CanonicalOpenAIResponsesInputItem): void => { - if (isProgramCaller(item)) { - throw new TranslateInputException(`Cannot translate ${item.type} '${item.call_id}' with a program caller.`); + if (!('caller' in item)) return; + const caller = item.caller; + if (typeof caller === 'object' && caller !== null && 'type' in caller && caller.type === 'program') { + throw new TranslateInputException(`Cannot translate ${item.type} '${Reflect.get(item, 'call_id')}' with a program caller.`); } }; diff --git a/packages/translate/src/protocols/openai-responses/as-target.ts b/packages/translate/src/protocols/openai-responses/as-target.ts new file mode 100644 index 0000000000..f09cf220e1 --- /dev/null +++ b/packages/translate/src/protocols/openai-responses/as-target.ts @@ -0,0 +1,28 @@ +import { readOpenAIResponsesEvents } from './as-target/events.ts'; +import { openaiResponsesPayloadFromSegments } from './as-target/segmented-request.ts'; +import type { TargetProtocol } from '../../composition/index.ts'; +import { assignCallIds, type PendingCall } from '../../composition/request/call-ids.ts'; +import type { CanonicalOpenAIResponsesPayload, CanonicalOpenAIResponsesInputItem, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; + +type ResponsesRestoredAssistantTurn = readonly CanonicalOpenAIResponsesInputItem[]; + +// Responses call and result IDs accept at most 64 characters. +// https://github.com/openresponses/openresponses/blob/92c12d96d7b61d6d15e2214daa5e9c6000ab6e1c/public/openapi/openapi.json +const RESPONSES_MAX_CALL_ID_LENGTH = 64; + +const callsOf = (turn: ResponsesRestoredAssistantTurn): readonly PendingCall[] => + turn.flatMap(item => item.type === 'function_call' || item.type === 'custom_tool_call' + ? [{ callId: item.call_id, name: item.name }] + : []); + +/** Restored Responses turns expose their own call IDs, and returned call items carry the IDs needed for event correlation. */ +export const openaiResponsesTargetProtocol: TargetProtocol< + 'openaiResponses', CanonicalOpenAIResponsesPayload, OpenAIResponsesStreamEventEx +> = { + name: 'openaiResponses', + write: (request, turns) => ({ + payload: openaiResponsesPayloadFromSegments(request, assignCallIds(turns, callsOf, { maxGeneratedCallIdLength: RESPONSES_MAX_CALL_ID_LENGTH })), + context: undefined, + }), + readEvents: (frames, _context, roundTripReader) => readOpenAIResponsesEvents(frames, { roundTrip: roundTripReader }), +}; diff --git a/packages/translate/src/protocols/openai-responses/as-target/events.ts b/packages/translate/src/protocols/openai-responses/as-target/events.ts index 24ca5559a4..c058a0f93b 100644 --- a/packages/translate/src/protocols/openai-responses/as-target/events.ts +++ b/packages/translate/src/protocols/openai-responses/as-target/events.ts @@ -56,6 +56,9 @@ type ResponsesPartState = ResponsesTextPartState | ResponsesImagePartState; interface ResponsesItemStateBase { readonly outputIndex: number; readonly id: string; + // The declaration group the item opened with, kept so a later snapshot cannot + // silently state a call from a different group under the same id. + readonly namespace?: string; item?: IRItemHandle; readonly parts: Map; readonly reasoning: Record<'summary' | 'content', ResponsesTextTarget[]>; @@ -128,9 +131,9 @@ const irItemSeed = (item: OpenAIResponsesOutputItemEx): Record case 'reasoning': return undefined; case 'function_call': - return { type: 'function_call', ...(item.call_id === undefined ? {} : { call_id: item.call_id }), name: item.name, arguments: '' }; + return { type: 'function_call', ...(item.call_id === undefined ? {} : { call_id: item.call_id }), name: item.name, ...(item.namespace === undefined ? {} : { namespace: item.namespace }), arguments: '' }; case 'custom_tool_call': - return { type: 'custom_tool_call', ...(item.call_id === undefined ? {} : { call_id: item.call_id }), name: item.name, input: '' }; + return { type: 'custom_tool_call', ...(item.call_id === undefined ? {} : { call_id: item.call_id }), name: item.name, ...(item.namespace === undefined ? {} : { namespace: item.namespace }), input: '' }; default: return undefined; } @@ -187,6 +190,9 @@ export const readOpenAIResponsesEvents = async function* ( const base: ResponsesItemStateBase = { outputIndex, id, + ...(item.type === 'function_call' || item.type === 'custom_tool_call' + ? item.namespace === undefined ? {} : { namespace: item.namespace } + : {}), item: handle, parts: new Map(), reasoning: { summary: [], content: [] }, @@ -216,6 +222,10 @@ export const readOpenAIResponsesEvents = async function* ( if (item.type !== state.type) throw new TypeError(`Responses output item type changed from ${state.type} to ${item.type}`); const id = itemId(item); if (id !== state.id) throw new TypeError(`Responses output item id changed from ${state.id} to ${String(id)}`); + // The group is part of what names the call, so it may not move between + // snapshots any more than the id may. + const namespace = item.type === 'function_call' || item.type === 'custom_tool_call' ? item.namespace : undefined; + if (namespace !== state.namespace) throw new TypeError(`Responses output item namespace changed from ${String(state.namespace)} to ${String(namespace)}`); }; const ensureToolText = (state: ResponsesItemState): ResponsesTextTarget => { diff --git a/packages/translate/src/protocols/openai-responses/as-target/input-from.ts b/packages/translate/src/protocols/openai-responses/as-target/input-from.ts new file mode 100644 index 0000000000..96e8df0403 --- /dev/null +++ b/packages/translate/src/protocols/openai-responses/as-target/input-from.ts @@ -0,0 +1,305 @@ +import type { IRInputAssistantItemWithCallIds, IRInputTurnWithCallIds, IRInputClientItemWithCallIds } from '../../../composition/request/call-ids.ts'; +import { requireStreaming } from '../../../ir/input/streaming.ts'; +import type { IRInputRequestHeader, IRInputSettings, IRInputTool, IRInputToolChoice, IRInputOutputFormat, IRInputCacheControl, IRInputDocument, IRInputMedia, IRInputToolResultContentPart, IRInputClientItem } from '../../../ir/input/types.ts'; +import { requireKnownCustomToolFormat } from '../../../shared/custom-tool.ts'; +import { DOCUMENT_CLOSE_TAG, documentImage, documentOpening } from '../../../shared/document.ts'; +import { TranslateInputException } from '../../../shared/input-error.ts'; +import { reasoningEffortForBudget } from '../../../shared/reasoning-budget.ts'; +import type { + CanonicalOpenAIResponsesInputItem, + CanonicalOpenAIResponsesPayload, + OpenAIResponsesInputContent, + OpenAIResponsesToolOutputContent, + OpenAIResponsesTool, +} from '@floway-dev/protocols/openai-responses'; + +const inputContent = (media: IRInputMedia, detail?: string | null): OpenAIResponsesInputContent => { + switch (media.source_kind) { + case 'url': + return { type: 'input_image', image_url: media.url, ...(detail == null ? {} : { detail }) }; + case 'file': + return { type: 'input_image', file_id: media.file_id, ...(detail == null ? {} : { detail }) }; + case 'data': + return { type: 'input_image', image_url: media.data.startsWith('data:') ? media.data : `data:application/octet-stream;base64,${media.data}`, ...(detail == null ? {} : { detail }) }; + case 'base64': + return { type: 'input_image', image_url: `data:${media.media_type ?? 'application/octet-stream'};base64,${media.data}`, ...(detail == null ? {} : { detail }) }; + } +}; + +const fileContent = (media: IRInputMedia, filename?: string | null): OpenAIResponsesInputContent => { + const name = filename == null ? {} : { filename }; + switch (media.source_kind) { + case 'url': + return { type: 'input_file', file_url: media.url, ...name }; + case 'file': + return { type: 'input_file', file_id: media.file_id, ...name }; + case 'data': + return { type: 'input_file', file_data: media.data, ...name }; + case 'base64': + // Responses accepts the bytes either raw or as a data URL; a MIME type the + // source stated is carried in the data URL so the target can act on it. + return { + type: 'input_file', + file_data: media.media_type === undefined ? media.data : `data:${media.media_type};base64,${media.data}`, + ...name, + }; + } +}; + +const withDocumentCache = (part: OpenAIResponsesInputContent, cache: IRInputCacheControl | null | undefined): OpenAIResponsesInputContent => + cache === undefined ? part : { ...part, prompt_cache_breakpoint: cache === null ? null : { mode: 'explicit' } }; + +const documentContent = (document: IRInputDocument): OpenAIResponsesInputContent[] => { + const source = document.source; + if (source.source_kind === 'media' && document.title == null && document.context == null) { + return [withDocumentCache(fileContent(source.media), document.cache_control)]; + } + const content: OpenAIResponsesInputContent[] = source.source_kind === 'media' + ? [fileContent(source.media)] + : source.source_kind === 'text' + ? [{ type: 'input_text', text: source.text }] + : source.content.map(part => withDocumentCache(part.type === 'text' + ? { type: 'input_text', text: part.text } + : inputContent(documentImage(part), part.detail), part.cache_control)); + return [ + { type: 'input_text', text: documentOpening(document) }, + ...content, + withDocumentCache({ type: 'input_text', text: DOCUMENT_CLOSE_TAG }, document.cache_control), + ]; +}; + +// Responses tool output admits text, images, and files, but not refusals, so it +// is projected from the tool-result parts rather than the message-part union. +const toolResultOutput = (output: string | readonly IRInputToolResultContentPart[]): string | OpenAIResponsesToolOutputContent[] => typeof output === 'string' + ? output + : [...output].flatMap(part => { + if (part.type === 'text') return { type: 'input_text' as const, text: part.text }; + if (part.type === 'image') return inputContent(part.image, part.detail); + if (part.type === 'file') return fileContent(part.file, part.filename); + return documentContent(part.document); + }); + +const userContent = (item: Extract): OpenAIResponsesInputContent[] => item.content.flatMap(part => { + switch (part.type) { + case 'text': return { type: 'input_text' as const, text: part.text }; + case 'image': return inputContent(part.image, part.detail); + case 'file': return fileContent(part.file, part.filename); + case 'document': return documentContent(part.document); + case 'audio': throw new TranslateInputException(`Cannot translate an audio part of MIME type '${part.audio.media_type}' to OpenAI Responses.`); + } +}); + +const assistantItems = (items: readonly IRInputAssistantItemWithCallIds[]): CanonicalOpenAIResponsesInputItem[] => items.map(item => { + switch (item.type) { + case 'message': + return { + type: 'message', + role: 'assistant', + content: item.content.every(part => part.type === 'text' && part.text === '') + ? '' + : item.content.map(part => part.type === 'text' + ? { type: 'output_text' as const, text: part.text } + : { type: 'refusal' as const, refusal: part.refusal }), + }; + case 'reasoning': + return { + type: 'reasoning', + ...(item.id === undefined ? {} : { id: item.id }), + summary: (item.summary ?? []).map(text => ({ type: 'summary_text' as const, text })), + ...(item.content === undefined ? {} : { content: item.content.map(text => ({ type: 'reasoning_text' as const, text })) }), + }; + case 'function_call': + return { + type: 'function_call', + call_id: item.call_id, + name: item.name, + // Responses requires argument text; omitted source arguments use an empty JSON object. + arguments: item.arguments ?? '{}', + status: 'completed', + }; + case 'custom_tool_call': + return { type: 'custom_tool_call', call_id: item.call_id, name: item.name, input: item.input, status: 'completed' }; + } +}); + +const clientItem = (item: IRInputClientItemWithCallIds): CanonicalOpenAIResponsesInputItem => { + switch (item.type) { + case 'instruction': + return { type: 'message', role: 'system', content: item.content.length === 1 ? item.content[0].text : item.content.map(part => ({ type: 'input_text' as const, text: part.text })) }; + case 'message': { + if (item.role !== 'user') { + const content = item.content.length === 1 + ? item.content[0].text + : item.content.map(part => ({ type: 'input_text' as const, text: part.text })); + return { type: 'message', role: item.role, content }; + } + if (item.content.length === 1 && item.content[0].type === 'text') { + return { type: 'message', role: 'user', content: item.content[0].text }; + } + return { type: 'message', role: 'user', content: item.content.length === 0 ? '' : userContent(item) }; + } + case 'tool_result': { + const output = toolResultOutput(item.output); + return item.tool_kind === 'custom' + ? { type: 'custom_tool_call_output', call_id: item.call_id, output, ...(item.is_error === undefined ? {} : { status: item.is_error ? 'incomplete' as const : 'completed' as const }) } + : { type: 'function_call_output', call_id: item.call_id, output, ...(item.is_error === true ? { status: 'incomplete' as const } : { status: 'completed' as const }) }; + } + } +}; + +export const openaiResponsesConversationFromIR = ( + turns: readonly IRInputTurnWithCallIds[], + leading: boolean, +): Pick => { + let instructions: string | undefined; + // Only the first segment can contain the request's top-level instruction. + // A later segment continues conversation history, so every item stays inline. + let hoisting = leading; + + const input = turns.flatMap(item => { + if (item.kind === 'assistant') { + hoisting = false; + return assistantItems(item.items); + } + return item.items.flatMap((entry): CanonicalOpenAIResponsesInputItem[] => { + // Only a top-level instruction is hoisted; historical system and developer messages stay in place. + if (hoisting && entry.type === 'instruction') { + instructions = entry.content.map(part => part.text).join(''); + hoisting = false; + return []; + } + hoisting = false; + return [clientItem(entry)]; + }); + }); + + return { instructions, input }; +}; + +const requireSchema = (format: IRInputOutputFormat): Readonly> => { + if (format.schema === undefined) throw new TranslateInputException('Cannot translate json_schema response format without a schema.'); + return format.schema; +}; + +// OpenAI's strict-mode validators reject an object schema that states no +// `properties`, which is a target-side constraint rather than something the IR +// states. +const normalizeParameters = (schema: Readonly> | undefined): Record => { + const target: Record = schema === undefined ? { type: 'object' } : { ...schema }; + return target.type === 'object' && target.properties === undefined ? { ...target, properties: {} } : target; +}; + +const toolFromIR = (tool: IRInputTool): OpenAIResponsesTool => { + if (tool.type === 'custom') { + const format = requireKnownCustomToolFormat(tool.format); + return { + type: 'custom', + name: tool.name, + ...(tool.description === undefined ? {} : { description: tool.description }), + ...(format === undefined ? {} : { + format: format.type === 'grammar' + ? { type: 'grammar', definition: format.definition, syntax: format.syntax } + : { type: 'text' }, + }), + }; + } + return { + type: 'function', + name: tool.name, + ...(tool.description === undefined ? {} : { description: tool.description }), + parameters: normalizeParameters(tool.parameters), + // A Responses function tool requires `strict`; an omitted value states the + // schema is not enforced rather than letting the field go missing. + strict: tool.strict ?? false, + }; +}; + +const toolChoiceFromIR = (choice: IRInputToolChoice): NonNullable => { + if (typeof choice === 'string') return choice; + if (choice.type === 'allowed_tools') { + return { + type: 'allowed_tools', + mode: choice.mode, + tools: choice.tools.map(selection => selection.type === 'custom' + ? { type: 'custom' as const, name: selection.name } + : { type: 'function' as const, name: selection.name }), + }; + } + return { type: choice.type, name: choice.name }; +}; + +/** + * Everything a Responses request states except its conversation. + * + * The conversation is written separately because a trip interleaves it with the + * assistant turns a sidecar already resolved, so only this half is computed once + * per payload. + */ +export const openaiResponsesHeaderFromIR = (request: IRInputRequestHeader): Omit => { + const { settings } = request; + // This wire states a permitted subset natively, so the declarations stay + // whole: narrowing them as well would also narrow what the model may see. + const tools = request.tools; + const choice = tools === undefined || tools.length === 0 ? undefined : request.tool_choice; + return { + ...openaiResponsesSettingsFromIR(settings), + model: settings.model, + // A trip guarantees a streaming request; a request that says otherwise + // cannot be carried by this side at all. + stream: requireStreaming(settings), + ...(tools === undefined || tools.length === 0 ? {} : { tools: tools.map(toolFromIR) }), + ...(choice === undefined ? {} : { tool_choice: toolChoiceFromIR(choice) }), + }; +}; + +export const openaiResponsesSettingsFromIR = (settings: IRInputSettings): Partial => { + const ceiling = settings.max_output_tokens !== undefined ? settings.max_output_tokens : settings.max_visible_output_tokens; + const format = settings.output_format; + const reasoningEffort = settings.reasoning_effort ?? (settings.reasoning_budget === undefined ? undefined : reasoningEffortForBudget(settings.reasoning_budget)); + + return { + ...(ceiling === undefined ? {} : { max_output_tokens: ceiling }), + ...(settings.temperature === undefined ? {} : { temperature: settings.temperature }), + ...(settings.top_p === undefined ? {} : { top_p: settings.top_p }), + ...(settings.presence_penalty === undefined ? {} : { presence_penalty: settings.presence_penalty }), + ...(settings.frequency_penalty === undefined ? {} : { frequency_penalty: settings.frequency_penalty }), + ...(settings.metadata === undefined ? {} : { metadata: settings.metadata === null ? null : { ...settings.metadata } }), + ...(settings.store === undefined ? {} : { store: settings.store }), + ...(settings.service_tier === undefined ? {} : { service_tier: settings.service_tier }), + ...(settings.parallel_tool_calls === undefined ? {} : { parallel_tool_calls: settings.parallel_tool_calls }), + ...(reasoningEffort === undefined && settings.reasoning_summary === undefined ? {} : { + reasoning: { + ...(reasoningEffort === undefined ? {} : { effort: reasoningEffort }), + ...(settings.reasoning_summary === undefined ? {} : { summary: settings.reasoning_summary }), + }, + }), + ...(settings.prompt_cache_key === undefined ? {} : { prompt_cache_key: settings.prompt_cache_key }), + ...(settings.prompt_cache_retention === undefined ? {} : { prompt_cache_retention: settings.prompt_cache_retention }), + ...(settings.prompt_cache_options == null ? {} : { prompt_cache_options: { ...settings.prompt_cache_options } }), + ...(settings.safety_identifier === undefined ? {} : { safety_identifier: settings.safety_identifier }), + ...(settings.user == null ? {} : { user: settings.user }), + // Responses requests token probabilities through its includable output data. + // https://github.com/openai/openai-node/blob/7423ac3e9351c46300cd094479cded5551a72eb4/src/resources/responses/responses.ts + ...(settings.logprobs === undefined ? {} : { include: settings.logprobs === null ? null : settings.logprobs ? ['message.output_text.logprobs'] : [] }), + ...(settings.top_logprobs === undefined ? {} : { top_logprobs: settings.top_logprobs }), + ...(format === undefined && settings.verbosity === undefined + ? {} + : { + text: { + ...(settings.verbosity === undefined ? {} : { verbosity: settings.verbosity }), + ...(format === undefined ? {} : { + format: format.type === 'json_schema' + ? { + type: 'json_schema' as const, + // Responses JSON Schema output requires a schema and a name; an omitted source name uses a stable fallback. + name: format.name ?? 'response', + schema: requireSchema(format), + ...(format.description === undefined ? {} : { description: format.description }), + ...(format.strict === undefined ? {} : { strict: format.strict }), + } + : { type: format.type }, + }), + }, + }), + }; +}; diff --git a/packages/translate/src/protocols/openai-responses/as-target/segmented-request.ts b/packages/translate/src/protocols/openai-responses/as-target/segmented-request.ts new file mode 100644 index 0000000000..0412a981e1 --- /dev/null +++ b/packages/translate/src/protocols/openai-responses/as-target/segmented-request.ts @@ -0,0 +1,30 @@ +import { openaiResponsesConversationFromIR, openaiResponsesHeaderFromIR } from './input-from.ts'; +import { type RestoredAssistantTurn } from '../../../composition/reading.ts'; +import type { IRInputTurnWithCallIds } from '../../../composition/request/call-ids.ts'; +import { segmentTurns } from '../../../composition/request/segments.ts'; +import type { IRInputRequestHeader } from '../../../ir/input/types.ts'; +import type { CanonicalOpenAIResponsesInputItem, CanonicalOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; + +/** + * Preserve restored native Responses turns verbatim and compute request-wide fields once. + * Only the first conversation segment may supply top-level instructions. + */ +export const openaiResponsesPayloadFromSegments = ( + request: IRInputRequestHeader, + turns: readonly (IRInputTurnWithCallIds | RestoredAssistantTurn)[], +): CanonicalOpenAIResponsesPayload => { + const input: CanonicalOpenAIResponsesInputItem[] = []; + let instructions: string | undefined; + for (const piece of segmentTurns(turns)) { + if (piece.kind === 'restored') { input.push(...piece.turn); continue; } + const segment = openaiResponsesConversationFromIR(piece.turns, piece.leading); + if (segment.instructions != null) instructions = segment.instructions; + input.push(...segment.input); + } + + return { + ...openaiResponsesHeaderFromIR(request), + input, + ...(instructions === undefined ? {} : { instructions }), + }; +}; diff --git a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts deleted file mode 100644 index 1e5b36763b..0000000000 --- a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/events.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; -import { readAnthropicMessagesEvents } from '../../anthropic-messages/as-target/events.ts'; -import { writeOpenAIResponsesEvents } from '../as-source/events.ts'; -import type { NamespaceToolNames } from '../as-source/namespace-tools.ts'; -import type { AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; - -export const translateToSourceEvents = (frames: AsyncIterable>, responseId: string, customToolNames: ReadonlySet, codec: SidecarCodec, namespaceToolNames: NamespaceToolNames): AsyncIterable> => { - const stream = createRoundTripCoordinator('openaiResponses', 'anthropicMessages', codec); - return writeOpenAIResponsesEvents(readAnthropicMessagesEvents(frames, { customToolNames, roundTrip: stream.reader }), { id: responseId, namespaceToolNames, roundTrip: stream.writer }); -}; diff --git a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/reasoning.ts b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/reasoning.ts deleted file mode 100644 index 61fb2eccd6..0000000000 --- a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/reasoning.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { AnthropicMessagesThinkingBlock } from '@floway-dev/protocols/anthropic-messages'; -import type { OpenAIResponsesReasoningItem } from '@floway-dev/protocols/openai-responses'; - -export const openaiResponsesReasoningToAnthropicMessagesUpstreamBlock = (item: OpenAIResponsesReasoningItem): AnthropicMessagesThinkingBlock | null => { - const thinking = item.summary?.map(part => part.text).join('') ?? ''; - return thinking.length > 0 ? { type: 'thinking', thinking, signature: '' } : null; -}; diff --git a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts deleted file mode 100644 index b2ca3da6ad..0000000000 --- a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts +++ /dev/null @@ -1,442 +0,0 @@ -import { klona } from 'klona/json'; - -import { openaiResponsesReasoningToAnthropicMessagesUpstreamBlock } from './reasoning.ts'; -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import { parseJSONObject } from '../../../shared/json/object.ts'; -import type { RemoteImageLoader } from '../../../types.ts'; -import { applyLastMessageCacheBreakpoint, applyLastSystemCacheBreakpoint, applyLastToolCacheBreakpoint } from '../../anthropic-messages/as-target/cache-breakpoints.ts'; -import { anthropicMessagesReasoningFieldsFromEffort } from '../../anthropic-messages/as-target/reasoning-effort.ts'; -import { resolveImageUrlToAnthropicMessagesImage } from '../../anthropic-messages/as-target/remote-images.ts'; -import { anthropicMessagesToolInputSchema } from '../../anthropic-messages/as-target/tool-input-schema.ts'; -import { restrictAllowedTools } from '../as-source/allowed-tools.ts'; -import { partitionOpenAIResponsesTurns } from '../as-source/conversation-turns.ts'; -import { flattenNamespaceTools, type NamespaceToolNames } from '../as-source/namespace-tools.ts'; -import { rejectProgramCaller, rejectProgrammaticOpenAIResponsesPayload } from '../as-source/programmatic-tooling.ts'; -import { collectOpenAIResponsesReplayContents, findOpenAIResponsesSidecarOccurrences, isOpenAIResponsesEncryptedReasoning, verifyOpenAIResponsesTurnDigest } from '../as-source/round-trip.ts'; -import { buildCustomToolInputSchema } from '../common/custom-tool-wrap.ts'; -import { - ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS, - type AnthropicMessagesAssistantContentBlock, - type AnthropicMessagesAssistantInputContentBlock, - type AnthropicMessagesAssistantMessage, - type AnthropicMessagesMessage, - type AnthropicMessagesPayload, - type AnthropicMessagesTextBlockParam, - type AnthropicMessagesTool, - type AnthropicMessagesToolResultBlock, - type AnthropicMessagesToolResultContentBlock, - type AnthropicMessagesUserContentBlock, - type AnthropicMessagesUserMessage, -} from '@floway-dev/protocols/anthropic-messages'; -import { isFastServiceTier } from '@floway-dev/protocols/common'; -import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; -import type { - OpenAIResponsesInputContent, - OpenAIResponsesInputImage, - CanonicalOpenAIResponsesInputItem, - OpenAIResponsesInputMessageEx, - CanonicalOpenAIResponsesText, - OpenAIResponsesRequestPayloadEx, - OpenAIResponsesTool, - OpenAIResponsesToolChoice, -} from '@floway-dev/protocols/openai-responses'; - -interface BuildTargetRequestOptions { - loadRemoteImage: RemoteImageLoader; - /** - * Preferred cap used when the source payload omits `max_output_tokens`. - * Callers in the data plane forward the model's advertised `/models` output - * cap so the translated Anthropic Messages request reflects the upstream-known limit - * rather than being silently capped by a target-side default later. - */ - fallbackMaxOutputTokens: number | undefined; -} - -const parseToolArgumentsObject = (argumentsJson: string): Record => { - try { - return parseJSONObject(argumentsJson); - } catch { - return { raw_arguments: argumentsJson }; - } -}; - -const anthropicServiceTierFieldsFromOpenAI = (serviceTier: string | null | undefined): Partial => - isFastServiceTier(serviceTier) - ? { speed: 'fast' } - : serviceTier != null - ? { service_tier: serviceTier } - : {}; - -export interface TargetRequestResult { - target: AnthropicMessagesPayload; - /** - * Names of OpenAI Responses `custom` tools the request translator wrapped as - * single-string function tools. Returned alongside the translated payload so - * the trip's events translator can project wrapped function calls back into - * `custom_tool_call` outputs. - */ - customToolNames: Set; - namespaceToolNames: NamespaceToolNames; -} - -const translateUserMessage = async (message: OpenAIResponsesInputMessageEx, loadRemoteImage: RemoteImageLoader): Promise => { - if (typeof message.content === 'string') { - return { role: 'user', content: message.content }; - } - - const content: AnthropicMessagesUserContentBlock[] = []; - - for (const block of message.content) { - if (block.type === 'input_text') { - content.push({ type: 'text', text: (block as CanonicalOpenAIResponsesText).text }); - continue; - } - - if (block.type === 'input_file') { - throw new TranslateInputException('Cannot translate input_file message content to Anthropic Messages.'); - } - - if (block.type === 'refusal') { - throw new TranslateInputException('Cannot translate refusal content in a user message to Anthropic Messages.'); - } - - if (block.type !== 'input_image') continue; - - const imageUrl = (block as OpenAIResponsesInputImage).image_url; - if (typeof imageUrl !== 'string') { - throw new TranslateInputException('Cannot translate file_id-only image content to Anthropic Messages.'); - } - const image = await resolveImageUrlToAnthropicMessagesImage(imageUrl, loadRemoteImage); - if (image) content.push(image); - } - - return { role: 'user', content: content.length > 0 ? content : '' }; -}; - -// Function and custom tool outputs both admit text, image, and file parts. -// Anthropic tool_result content natively carries images, so preserve them -// rather than flattening the output. -// https://github.com/openai/openai-node/blob/cf1b7e1cf7981ef79695c496caf14b6bb492f18e/src/resources/responses/responses.ts#L3610-L3621 -// https://github.com/openai/openai-node/blob/cf1b7e1cf7981ef79695c496caf14b6bb492f18e/src/resources/responses/responses.ts#L3157-L3169 -// https://github.com/anthropics/anthropic-sdk-typescript/blob/3c5d9c0c15bb847a628f3f2876ac09719abe3012/src/resources/messages/messages.ts#L3457-L3475 -const translateToolOutput = async (output: string | OpenAIResponsesInputContent[], loadRemoteImage: RemoteImageLoader): Promise => { - if (typeof output === 'string') return output; - - const blocks: AnthropicMessagesToolResultContentBlock[] = []; - for (const part of output) { - if (part.type === 'input_image') { - if (typeof part.image_url !== 'string') { - throw new TranslateInputException('Cannot translate file_id-only image tool output to Anthropic Messages.'); - } - const image = await resolveImageUrlToAnthropicMessagesImage(part.image_url, loadRemoteImage); - if (image === null) { - throw new TranslateInputException('Cannot translate unavailable or unsupported image tool output to Anthropic Messages.'); - } - blocks.push(image); - } else if (part.type === 'input_file') { - throw new TranslateInputException('Cannot translate input_file tool output to Anthropic Messages.'); - } else { - blocks.push({ type: 'text', text: part.text }); - } - } - - return blocks.length > 0 ? blocks : ''; -}; - -const translateAssistantMessage = (message: OpenAIResponsesInputMessageEx): AnthropicMessagesAssistantMessage => { - if (typeof message.content === 'string') { - return { role: 'assistant', content: message.content }; - } - - const content: AnthropicMessagesAssistantInputContentBlock[] = []; - - for (const block of message.content) { - if (block.type === 'input_image') { - throw new TranslateInputException('Cannot translate input_image assistant content to Anthropic Messages.'); - } - if (block.type === 'input_file') { - throw new TranslateInputException('Cannot translate input_file assistant content to Anthropic Messages.'); - } - if (block.type === 'refusal') { - content.push({ type: 'text', text: block.refusal }); - continue; - } - if (block.type === 'input_text' || block.type === 'output_text') { - content.push({ type: 'text', text: (block as CanonicalOpenAIResponsesText).text }); - } - } - - return { role: 'assistant', content: content.length > 0 ? content : '' }; -}; - -// Anthropic's Anthropic Messages system field (top-level `AnthropicMessagesPayload.system` and -// inline `AnthropicMessagesSystemMessage.content`) accepts only text. Image parts in -// system / developer OpenAI Responses input messages are rejected here at the -// translator boundary so the caller hits an explicit failure instead of -// having the image silently dropped on the wire. -const openaiResponsesSystemBlocks = (message: OpenAIResponsesInputMessageEx): AnthropicMessagesTextBlockParam[] => { - if (typeof message.content === 'string') { - return message.content ? [{ type: 'text', text: message.content }] : []; - } - - const blocks: AnthropicMessagesTextBlockParam[] = []; - for (const block of message.content) { - if (block.type === 'input_image') { - throw new TranslateInputException(`Invalid 'input_image' content part in ${message.role} message. Only 'input_text' content parts are supported in ${message.role} messages on this model.`); - } - if (block.type !== 'input_text' && block.type !== 'output_text') { - // Every non-text content variant must opt into translator behavior - // rather than be silently dropped from system content. - throw new TranslateInputException(`Invalid content block type '${(block as { type: string }).type}' in ${message.role} message.`); - } - blocks.push({ type: 'text', text: block.text }); - } - return blocks; -}; - -const appendAssistantBlock = (messages: AnthropicMessagesMessage[], block: AnthropicMessagesAssistantContentBlock): void => { - const lastMessage = messages[messages.length - 1]; - - if (lastMessage?.role === 'assistant' && Array.isArray(lastMessage.content)) { - lastMessage.content.push(block); - return; - } - - messages.push({ role: 'assistant', content: [block] }); -}; - -const appendUserBlock = (messages: AnthropicMessagesMessage[], block: AnthropicMessagesToolResultBlock): void => { - const lastMessage = messages[messages.length - 1]; - - if (lastMessage?.role === 'user' && Array.isArray(lastMessage.content)) { - lastMessage.content.push(block); - return; - } - - messages.push({ role: 'user', content: [block] }); -}; - -const translateTools = ( - tools: OpenAIResponsesTool[] | null | undefined, - customToolNames: Set, -): AnthropicMessagesTool[] | undefined => { - const out: AnthropicMessagesTool[] = []; - - for (const tool of tools ?? []) { - if (tool.type === 'function') { - out.push({ - name: tool.name, - // OpenAI Responses spells "this tool has no description" as an explicit - // `null`; Anthropic Messages has no such spelling, so the key is dropped. - ...(tool.description == null ? {} : { description: tool.description }), - // Anthropic Messages has no spelling for "no schema" either: `input_schema` is - // required, and forwarding `undefined` makes Anthropic reject the whole - // request with a 400. The empty object schema is Anthropic's own - // spelling for a tool that takes no arguments. - // https://github.com/anthropics/anthropic-sdk-typescript/blob/3b45cd3b69c956ac63384fdb09ce1d8109f3fa80/src/resources/messages/messages.ts#L1845-L1852 - // https://github.com/anthropics/anthropic-sdk-typescript/blob/3b45cd3b69c956ac63384fdb09ce1d8109f3fa80/examples/managed-agents-self-hosted-sandbox-worker.ts#L34-L41 - input_schema: anthropicMessagesToolInputSchema(klona(tool.parameters) ?? { type: 'object', properties: {} }), - ...(tool.strict == null ? {} : { strict: tool.strict }), - }); - continue; - } - if (tool.type === 'custom') { - customToolNames.add(tool.name); - out.push({ - name: tool.name, - description: tool.description, - input_schema: buildCustomToolInputSchema(tool.format), - }); - continue; - } - throw new TranslateInputException(`Cannot translate OpenAI Responses tool type '${tool.type}' to Anthropic Messages.`); - } - return out.length > 0 ? out : undefined; -}; - -const translateToolChoice = ( - toolChoice: OpenAIResponsesToolChoice | null | undefined, -): AnthropicMessagesPayload['tool_choice'] => { - if (!toolChoice) return undefined; - - if (typeof toolChoice === 'string') { - switch (toolChoice) { - case 'auto': - return { type: 'auto' }; - case 'none': - return { type: 'none' }; - case 'required': - return { type: 'any' }; - default: - throw new TranslateInputException(`Cannot translate OpenAI Responses tool choice '${toolChoice}' to Anthropic Messages.`); - } - } - - // Both function and wrapped custom tools land on the target as named tool - // choices since they share the function-tool wire shape after translation. - if (toolChoice.type === 'function' || toolChoice.type === 'custom') { - return toolChoice.name ? { type: 'tool', name: toolChoice.name } : undefined; - } - throw new TranslateInputException(`Cannot translate OpenAI Responses tool choice type '${toolChoice.type}' to Anthropic Messages.`); -}; - -export const buildTargetRequest = async ( - source: OpenAIResponsesRequestPayloadEx, - codec: SidecarCodec, - options: BuildTargetRequestOptions, -): Promise => { - const canonical = canonicalizeOpenAIResponsesPayload(source); - if (!('input' in canonical)) throw new TranslateInputException(canonical.error.message, canonical.error); - const { payload, names: namespaceToolNames, flattenInput } = flattenNamespaceTools(canonical); - rejectProgrammaticOpenAIResponsesPayload(payload, 'Anthropic Messages'); - const customToolNames = new Set(); - const allowed = restrictAllowedTools(payload.tools, payload.tool_choice); - const tools = translateTools(allowed.tools, customToolNames); - const hoistedSystemBlocks: AnthropicMessagesTextBlockParam[] = []; - const hoistedItems = new Set(); - for (const item of canonical.input) { - if (item.type === 'additional_tools' || item.type === 'tool_search_output') continue; - if (item.type !== 'message' || (item.role !== 'system' && item.role !== 'developer')) break; - hoistedSystemBlocks.push(...openaiResponsesSystemBlocks(item)); - hoistedItems.add(item); - } - const bareMessages = new Set(); - const translateAssistantTurn = (input: CanonicalOpenAIResponsesInputItem[]): AnthropicMessagesMessage[] => { - const messages: AnthropicMessagesMessage[] = []; - for (const item of flattenInput(input)) { - rejectProgramCaller(item); - switch (item.type) { - case 'message': - if (item.role !== 'assistant') throw new TranslateInputException(`Invalid assistant message role '${item.role}'.`); - messages.push(translateAssistantMessage(item)); - break; - case 'function_call': - appendAssistantBlock(messages, { type: 'tool_use', id: item.call_id, name: item.name, input: parseToolArgumentsObject(item.arguments) }); - break; - case 'custom_tool_call': - appendAssistantBlock(messages, { type: 'tool_use', id: item.call_id, name: item.name, input: { input: item.input } }); - break; - case 'reasoning': { - if (isOpenAIResponsesEncryptedReasoning(item)) { - warnDroppedUnrecognizedAssistantReasoning('openaiResponses', 'anthropicMessages'); - break; - } - const block = openaiResponsesReasoningToAnthropicMessagesUpstreamBlock(item); - if (block) appendAssistantBlock(messages, block); - break; - } - case 'item_reference': - case 'web_search_call': - case 'image_generation_call': - throw new TranslateInputException(`Invalid input item type '${item.type}'.`); - default: - throw new TranslateInputException(`Invalid input item: ${JSON.stringify(item)}`); - } - } - for (const message of messages) bareMessages.add(message); - return messages; - }; - - const translateNonAssistantInput = async (input: CanonicalOpenAIResponsesInputItem[], loadRemoteImage: RemoteImageLoader): Promise => { - const messages: AnthropicMessagesMessage[] = []; - for (const item of flattenInput(input).filter(item => !hoistedItems.has(item))) { - rejectProgramCaller(item); - switch (item.type) { - case 'message': - switch (item.role) { - case 'user': messages.push(await translateUserMessage(item, loadRemoteImage)); break; - case 'system': - case 'developer': { - const blocks = openaiResponsesSystemBlocks(item); - messages.push({ role: 'system', content: blocks.length > 0 ? blocks : '' }); - break; - } - default: throw new TranslateInputException(`Invalid role '${item.role}' in input message.`); - } - break; - case 'function_call_output': - case 'custom_tool_call_output': - appendUserBlock(messages, { - type: 'tool_result', tool_use_id: item.call_id, - content: await translateToolOutput(item.output, loadRemoteImage), - is_error: item.type === 'function_call_output' && item.status === 'incomplete' ? true : undefined, - }); - break; - case 'item_reference': - case 'web_search_call': - case 'image_generation_call': - throw new TranslateInputException(`Invalid input item type '${item.type}'.`); - default: - throw new TranslateInputException(`Invalid input item: ${JSON.stringify(item)}`); - } - } - for (const message of messages) bareMessages.add(message); - return messages; - }; - // `payload.instructions` is the OpenAI Responses canonical system field; leading - // system/developer input items contribute additional blocks immediately - // after it. Each source — the instructions field and each leading input - // message — is preserved as its own AnthropicMessagesTextBlockParam so the boundary - // between "canonical instructions" and "leading input system" survives - // and the downstream prompt cache sees stable per-source segments. - const systemBlocks: AnthropicMessagesTextBlockParam[] = [ - ...(payload.instructions ? [{ type: 'text' as const, text: payload.instructions }] : []), - ...hoistedSystemBlocks, - ]; - const effort = payload.reasoning?.effort; - const maxTokens = payload.max_output_tokens ?? options.fallbackMaxOutputTokens ?? ANTHROPIC_MESSAGES_FALLBACK_MAX_TOKENS; - applyLastSystemCacheBreakpoint(systemBlocks); - applyLastToolCacheBreakpoint(tools); - - // Merge reasoning effort + structured-output format into a single - // `output_config`. `effort === 'none'` still maps to `thinking: {type: - // 'disabled'}` (Anthropic's native disable shape), but `format` should - // still ride along when present. - // - // OpenAI Responses keeps json_schema details flat (`text.format = { type, schema }`); - // a `text` format or absent config has no Anthropic Messages equivalent and drops. - const openaiResponsesFormat = payload.text?.format; - const formatSchema = - openaiResponsesFormat?.type === 'json_schema' && openaiResponsesFormat.schema && typeof openaiResponsesFormat.schema === 'object' && !Array.isArray(openaiResponsesFormat.schema) - ? (openaiResponsesFormat.schema as Record) - : undefined; - const { thinking, effort: outputConfigEffort } = anthropicMessagesReasoningFieldsFromEffort(effort); - const outputConfig: NonNullable = {}; - if (outputConfigEffort !== undefined) outputConfig.effort = outputConfigEffort; - if (formatSchema) outputConfig.format = { type: 'json_schema', schema: klona(formatSchema) }; - const hasOutputConfig = Object.keys(outputConfig).length > 0; - - // OpenAI's accelerated request lane maps to Anthropic's `speed: 'fast'`; - // other service-tier values remain open strings. - // https://docs.claude.com/en/build-with-claude/fast-mode - // https://platform.openai.com/docs/guides/fast-mode - const serviceTierFields = anthropicServiceTierFieldsFromOpenAI(payload.service_tier); - - // OpenAI Responses `metadata` is intentionally omitted on the Anthropic Messages path; - // not coerced into Anthropic metadata.user_id, prompt-cache, or safety - // semantics. - const target: AnthropicMessagesPayload = { - model: payload.model, - messages: [], - max_tokens: maxTokens, - ...(systemBlocks.length > 0 ? { system: systemBlocks } : {}), - ...(payload.temperature != null ? { temperature: payload.temperature } : {}), - ...(payload.top_p != null ? { top_p: payload.top_p } : {}), - stream: true, - tools, - tool_choice: translateToolChoice(allowed.choice), - ...(thinking ? { thinking } : {}), - ...(hasOutputConfig ? { output_config: outputConfig } : {}), - ...serviceTierFields, - }; - - for (const turn of partitionOpenAIResponsesTurns(canonical.input)) { - if (turn.role !== 'assistant') { target.messages.push(...await translateNonAssistantInput(turn.items, options.loadRemoteImage)); continue; } - const roundTrip = await roundTripAssistantTurn('openaiResponses', 'anthropicMessages', turn.items, codec, findOpenAIResponsesSidecarOccurrences, verifyOpenAIResponsesTurnDigest, collectOpenAIResponsesReplayContents); - target.messages.push(...(roundTrip ? [roundTrip.turn] : translateAssistantTurn(turn.items))); - } - applyLastMessageCacheBreakpoint(target.messages.filter(message => bareMessages.has(message))); - return { target, customToolNames, namespaceToolNames }; -}; diff --git a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/translate.ts b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/translate.ts deleted file mode 100644 index 4c8618bc35..0000000000 --- a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/translate.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { translateToSourceEvents } from './events.ts'; -import { buildTargetRequest } from './request.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { RemoteImageLoader, Translator } from '../../../types.ts'; -import { restateOpenAIResponsesError } from '../as-source/error-body.ts'; -import { restoreNamespaceEvents } from '../as-source/namespace-tools.ts'; -import { createRandomOpenAIResponsesResponseId } from '../as-source/response-id.ts'; -import type { AnthropicMessagesPayload, AnthropicMessagesStreamEventEx } from '@floway-dev/protocols/anthropic-messages'; -import type { OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; - -export const translate: Translator< - OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx, AnthropicMessagesPayload, AnthropicMessagesStreamEventEx, - { fallbackMaxOutputTokens?: number; loadRemoteImage: RemoteImageLoader } -> = async (src, ctx) => { - try { - const responseId = createRandomOpenAIResponsesResponseId(); - // Tool-name maps are produced inside the request translator (it sees the - // tools first) and read by the events translator so wrapped custom calls and - // flattened namespace calls recover their source OpenAI Responses identities. - const { target, customToolNames, namespaceToolNames } = await buildTargetRequest(src, ctx.sidecarCodec, { - fallbackMaxOutputTokens: ctx.fallbackMaxOutputTokens, - loadRemoteImage: ctx.loadRemoteImage, - }); - - return { - type: 'trip', - error: restateOpenAIResponsesError, - target, - events: frames => restoreNamespaceEvents(translateToSourceEvents(frames, responseId, customToolNames, ctx.sidecarCodec, namespaceToolNames), namespaceToolNames), - }; - } catch (error) { - if (error instanceof TranslateInputException) return error.failure; - throw error; - } -}; diff --git a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/content.ts b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/content.ts deleted file mode 100644 index bb6b09a280..0000000000 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/content.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { OpenAIChatCompletionsUserContentPart } from '@floway-dev/protocols/openai-chat-completions'; -import type { OpenAIResponsesInputContent, OpenAIResponsesAssistantInputContent } from '@floway-dev/protocols/openai-responses'; - -// OpenAI Chat Completions and OpenAI Responses text arrays are transport fragments of one message, not -// paragraph blocks. Preserve the existing no-separator flattening. -const contentPartText = (part: OpenAIChatCompletionsUserContentPart | OpenAIResponsesAssistantInputContent): string | null => (part.type === 'text' || part.type === 'input_text' || part.type === 'output_text' ? part.text : null); - -const contentPartsToText = (parts: readonly (OpenAIChatCompletionsUserContentPart | OpenAIResponsesAssistantInputContent)[]): string => - parts - .map(contentPartText) - .filter((text): text is string => text !== null) - .join(''); - -export const openaiResponsesContentToText = (content: string | OpenAIResponsesAssistantInputContent[]): string => (typeof content === 'string' ? content : contentPartsToText(content)); - -export const openaiResponsesContentToOpenAIChatCompletionsContent = (content: string | OpenAIResponsesInputContent[]): string | OpenAIChatCompletionsUserContentPart[] => { - if (typeof content === 'string') return content; - if (!content.every((part): part is Exclude => part.type !== 'input_file')) { - throw new TranslateInputException('Cannot translate input_file content to OpenAI Chat Completions.'); - } - - return content.some(part => part.type === 'input_image') - ? content.map( - (part): OpenAIChatCompletionsUserContentPart => { - if (part.type === 'input_image') { - if (typeof part.image_url !== 'string') { - throw new TranslateInputException('Cannot translate file_id-only image content to OpenAI Chat Completions.'); - } - return { - type: 'image_url', - image_url: { - url: part.image_url, - // Both protocols read an absent `detail` as `auto`, and OpenAI Chat Completions - // Completions has no null member, so an absent or null value - // becomes an omitted key. Anything else is the upstream's to - // accept or reject. - // https://github.com/openai/openai-openapi/blob/db3e53198a66732cfe161339ea63bf36fc0137ad/openapi.yaml#L30795-L30803 - // https://github.com/openai/openai-openapi/blob/db3e53198a66732cfe161339ea63bf36fc0137ad/openapi.yaml#L67946-L67951 - ...(part.detail == null ? {} : { detail: part.detail }), - }, - }; - } - return { type: 'text', text: part.text }; - }, - ) - : contentPartsToText(content); -}; diff --git a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts deleted file mode 100644 index 30ea2a5d62..0000000000 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/events.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { createRoundTripCoordinator } from '../../../round-trip/coordinator.ts'; -import { readOpenAIChatCompletionsEvents } from '../../openai-chat-completions/as-target/events.ts'; -import { writeOpenAIResponsesEvents } from '../as-source/events.ts'; -import type { NamespaceToolNames } from '../as-source/namespace-tools.ts'; -import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { OpenAIChatCompletionsStreamEvent } from '@floway-dev/protocols/openai-chat-completions'; -import type { OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; - -export const translateToSourceEvents = (frames: AsyncIterable>, customToolNames: ReadonlySet, codec: SidecarCodec, namespaceToolNames: NamespaceToolNames): AsyncIterable> => { - const stream = createRoundTripCoordinator('openaiResponses', 'openaiChatCompletions', codec); - return writeOpenAIResponsesEvents(readOpenAIChatCompletionsEvents(frames, { customToolNames, roundTrip: stream.reader }), { namespaceToolNames, roundTrip: stream.writer }); -}; diff --git a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/request.ts b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/request.ts deleted file mode 100644 index d6fcd2dfed..0000000000 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/request.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { klona } from 'klona/json'; - -import { openaiResponsesContentToOpenAIChatCompletionsContent, openaiResponsesContentToText } from './content.ts'; -import type { SidecarCodec } from '../../../round-trip/codec.ts'; -import { roundTripAssistantTurn, warnDroppedUnrecognizedAssistantReasoning } from '../../../round-trip/resolve.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import { restrictAllowedTools } from '../as-source/allowed-tools.ts'; -import { partitionOpenAIResponsesTurns } from '../as-source/conversation-turns.ts'; -import { flattenNamespaceTools, type NamespaceToolNames } from '../as-source/namespace-tools.ts'; -import { rejectProgramCaller, rejectProgrammaticOpenAIResponsesPayload } from '../as-source/programmatic-tooling.ts'; -import { collectOpenAIResponsesReplayContents, findOpenAIResponsesSidecarOccurrences, isOpenAIResponsesEncryptedReasoning, verifyOpenAIResponsesTurnDigest } from '../as-source/round-trip.ts'; -import { buildCustomToolInputSchema } from '../common/custom-tool-wrap.ts'; -import type { OpenAIChatCompletionsAssistantMessage, OpenAIChatCompletionsResponseFormat, OpenAIChatCompletionsUserContentPart, OpenAIChatCompletionsPayload, OpenAIChatCompletionsMessage, OpenAIChatCompletionsTool, OpenAIChatCompletionsToolCall } from '@floway-dev/protocols/openai-chat-completions'; -import { canonicalizeOpenAIResponsesPayload } from '@floway-dev/protocols/openai-responses'; -import type { OpenAIResponsesCustomToolCallOutputItem, OpenAIResponsesFunctionCallOutputItem, OpenAIResponsesInputImage, CanonicalOpenAIResponsesText, CanonicalOpenAIResponsesInputItem, OpenAIResponsesPayloadEx, OpenAIResponsesRequestPayloadEx, OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; - -interface AssistantAccumulator { - message: OpenAIChatCompletionsAssistantMessage; - reasoningText?: string; -} - -const ensureAssistant = (assistant: AssistantAccumulator | null): AssistantAccumulator => - assistant ?? { - message: { role: 'assistant', content: null }, - }; - -const appendAssistantText = (assistant: AssistantAccumulator | null, text: string): AssistantAccumulator | null => { - if (!text) return assistant; - - const next = ensureAssistant(assistant); - next.message.content = typeof next.message.content === 'string' ? next.message.content + text : text; - return next; -}; - -const appendAssistantToolCall = ( - assistant: AssistantAccumulator | null, - call: { call_id: string; name: string; arguments: string }, -): AssistantAccumulator => { - const next = ensureAssistant(assistant); - next.message.tool_calls = [ - ...(next.message.tool_calls ?? []), - { - id: call.call_id, - type: 'function', - function: { - name: call.name, - arguments: call.arguments, - }, - } satisfies OpenAIChatCompletionsToolCall, - ]; - return next; -}; - -interface ToolCallOutputProjection { - toolContent: string; - liftedImageContent: OpenAIChatCompletionsUserContentPart[]; -} - -// OpenAI Chat Completions tool messages admit only strings or text parts, while OpenAI Responses tool -// output also admits images. Keep every tool result contiguous with its -// assistant tool-call group, then lift its images into one following user -// message so vision targets receive a legal, usable shape. -// https://github.com/openai/openai-node/blob/61539248cbe04665de68a71e6fd878127ae4db87/src/resources/chat/completions/completions.ts#L1893-L1908 -// https://github.com/vercel/ai/blob/c093ee7458ccd5dada05d8461041e47c24ee55c0/packages/google/src/convert-to-google-messages.ts#L137-L180 -const projectToolCallOutput = (item: OpenAIResponsesFunctionCallOutputItem | OpenAIResponsesCustomToolCallOutputItem): ToolCallOutputProjection => { - if (typeof item.output === 'string') return { toolContent: item.output, liftedImageContent: [] }; - if (item.output.some(part => part.type === 'input_file')) { - throw new TranslateInputException('Cannot translate input_file tool output to OpenAI Chat Completions.'); - } - - const images = item.output.filter((part): part is OpenAIResponsesInputImage => part.type === 'input_image'); - const textParts = item.output.filter((part): part is CanonicalOpenAIResponsesText => - part.type === 'input_text' || part.type === 'output_text'); - if (images.length === 0) { - return { toolContent: openaiResponsesContentToText(textParts), liftedImageContent: [] }; - } - - const lifted = openaiResponsesContentToOpenAIChatCompletionsContent([ - { type: 'input_text', text: `Image output from tool call ${item.call_id}:` }, - ...images, - ]); - if (typeof lifted === 'string') throw new Error('Image tool output projection lost its image content'); - return { - toolContent: openaiResponsesContentToText(textParts) || 'Image output is attached in the following user message.', - liftedImageContent: lifted as OpenAIChatCompletionsUserContentPart[], - }; -}; - -const translateOpenAIResponsesTools = (tools: OpenAIResponsesTool[] | null | undefined, customToolNames: Set): OpenAIChatCompletionsTool[] | undefined => { - // After allowed_tools selection, Chat Completions can represent only flat - // function and custom declarations. Custom tools are wrapped as functions - // and recorded so response events can restore their freeform shape. The - // server-tool shim rewrites hosted web_search declarations to ordinary - // function tools before this translation, so retained shim tools need no - // special handling here. - const out: OpenAIChatCompletionsTool[] = []; - - for (const tool of tools ?? []) { - if (tool.type === 'function') { - out.push({ - type: 'function', - function: { - name: tool.name, - // OpenAI Responses spells "unspecified" as an omitted key or an explicit - // `null`; OpenAI Chat Completions has only the omitted-key spelling. - ...(tool.parameters == null ? {} : { parameters: klona(tool.parameters) }), - ...(tool.strict == null ? {} : { strict: tool.strict }), - ...(tool.description ? { description: tool.description } : {}), - }, - }); - continue; - } - if (tool.type === 'custom') { - customToolNames.add(tool.name); - out.push({ - type: 'function', - function: { - name: tool.name, - parameters: buildCustomToolInputSchema(tool.format), - strict: false, - ...(tool.description ? { description: tool.description } : {}), - }, - }); - continue; - } - throw new TranslateInputException(`Cannot translate OpenAI Responses tool type '${tool.type}' to OpenAI Chat Completions.`); - } - - return out.length > 0 ? out : undefined; -}; - -const translateOpenAIResponsesToolChoice = (choice?: OpenAIResponsesToolChoice | null): OpenAIChatCompletionsPayload['tool_choice'] => { - if (choice == null) return undefined; - if (typeof choice === 'string') { - if (choice === 'auto' || choice === 'none' || choice === 'required') return choice; - throw new TranslateInputException(`Cannot translate OpenAI Responses tool choice '${choice}' to OpenAI Chat Completions.`); - } - // Both function and wrapped custom tools land on the target as named function - // choices since they share the function-tool wire shape after translation. - if (choice.type !== 'function' && choice.type !== 'custom') throw new TranslateInputException(`Cannot translate OpenAI Responses tool choice type '${choice.type}' to OpenAI Chat Completions.`); - return { type: 'function', function: { name: choice.name } }; -}; - -const buildOpenAIChatCompletionsResponseFormat = (text: OpenAIResponsesPayloadEx['text']): OpenAIChatCompletionsPayload['response_format'] | undefined => { - if (text === undefined) return undefined; - if (text === null) return undefined; - // `text: {}` means no explicit format. Keep it omitted instead of converting - // absence into an explicit OpenAI Chat Completions `response_format: null`. - const format = klona(text.format); - if (!Object.hasOwn(text, 'format') || format === undefined) return undefined; - if (format === null) return undefined; - // OpenAI Responses API uses a flat json_schema shape - // ({ type, name, strict, schema }), while OpenAI Chat Completions wraps the - // schema details under a nested `json_schema` field. Reshape only when - // needed; pass `text`/`json_object` and already-wrapped variants through. - // Without this, OpenAI Chat Completions upstreams reject the request with - // "When response_format type is 'json_schema', the 'json_schema' field - // must be provided". - // References: - // https://platform.openai.com/docs/api-reference/responses/create - // https://platform.openai.com/docs/api-reference/chat/create#chat-create-response_format - if (format.type === 'json_schema' && !('json_schema' in format)) { - const { type: _type, ...rest } = format; - return { type: 'json_schema', json_schema: rest as Extract['json_schema'] }; - } - return format as OpenAIChatCompletionsResponseFormat; -}; - -export interface TargetRequestResult { - target: OpenAIChatCompletionsPayload; - namespaceToolNames: NamespaceToolNames; - /** - * Names of OpenAI Responses `custom` tools the request translator wrapped as - * single-string function tools. Returned alongside the translated payload so - * the trip's events translator can project wrapped function calls back into - * `custom_tool_call` outputs. - */ - customToolNames: Set; -} - -export const buildTargetRequest = async (source: OpenAIResponsesRequestPayloadEx, codec: SidecarCodec): Promise => { - const canonical = canonicalizeOpenAIResponsesPayload(source); - if (!('input' in canonical)) throw new TranslateInputException(canonical.error.message, canonical.error); - const { payload, names: namespaceToolNames, flattenInput } = flattenNamespaceTools(canonical); - rejectProgrammaticOpenAIResponsesPayload(payload, 'OpenAI Chat Completions'); - const customToolNames = new Set(); - const legacyFunctionNames = new Map(); - const responseFormat = buildOpenAIChatCompletionsResponseFormat(payload.text); - - const translateAssistantTurn = (items: CanonicalOpenAIResponsesInputItem[]): OpenAIChatCompletionsMessage[] => { - let assistant: AssistantAccumulator | null = null; - for (const item of flattenInput(items)) { - rejectProgramCaller(item); - if (item.type === 'reasoning') { - if (isOpenAIResponsesEncryptedReasoning(item)) { warnDroppedUnrecognizedAssistantReasoning('openaiResponses', 'openaiChatCompletions'); continue; } - assistant = ensureAssistant(assistant); - const text = item.summary.map(part => part.text).join(''); - if (text) assistant.reasoningText = (assistant.reasoningText ?? '') + text; - } else if (item.type === 'function_call') assistant = appendAssistantToolCall(assistant, item); - else if (item.type === 'custom_tool_call') assistant = appendAssistantToolCall(assistant, { call_id: item.call_id, name: item.name, arguments: JSON.stringify({ input: item.input }) }); - else if (item.type === 'message' && item.role === 'assistant') { - if (Array.isArray(item.content)) { - const unsupported = item.content.find(part => part.type === 'input_file' || part.type === 'input_image'); - if (unsupported !== undefined) throw new TranslateInputException(`Cannot translate ${unsupported.type} assistant content to OpenAI Chat Completions.`); - } - assistant = appendAssistantText(assistant, openaiResponsesContentToText(item.content)); - } else throw new TranslateInputException(`Invalid input item type '${item.type}'.`); - } - return assistant === null ? [] : [{ ...assistant.message, ...(assistant.reasoningText === undefined ? {} : { reasoning_text: assistant.reasoningText }) }]; - }; - - const translateNonAssistantInput = (items: CanonicalOpenAIResponsesInputItem[]): OpenAIChatCompletionsMessage[] => { - const messages: OpenAIChatCompletionsMessage[] = []; - const pendingToolOutputImages: OpenAIChatCompletionsUserContentPart[] = []; - const flushToolOutputImages = (): void => { - if (pendingToolOutputImages.length === 0) return; - messages.push({ role: 'user', content: [...pendingToolOutputImages] }); - pendingToolOutputImages.length = 0; - }; - for (const item of flattenInput(items)) { - if (item.type !== 'function_call_output' && item.type !== 'custom_tool_call_output') flushToolOutputImages(); - rejectProgramCaller(item); - if (item.type === 'function_call_output' || item.type === 'custom_tool_call_output') { - const projected = projectToolCallOutput(item); - const legacyName = legacyFunctionNames.get(item.call_id); - legacyFunctionNames.delete(item.call_id); - messages.push(legacyName === undefined - ? { role: 'tool', tool_call_id: item.call_id, content: projected.toolContent } - : { role: 'function', name: legacyName, content: projected.toolContent }); - pendingToolOutputImages.push(...projected.liftedImageContent); - } else if (item.type === 'message' && item.role !== 'assistant') { - if (item.role === 'user') messages.push({ role: 'user', content: openaiResponsesContentToOpenAIChatCompletionsContent(item.content) as OpenAIChatCompletionsUserContentPart[] | string }); - else { - const content = typeof item.content === 'string' ? item.content : item.content.map(part => { - if (part.type !== 'input_text' && part.type !== 'output_text') throw new TranslateInputException('Only text content is supported in Chat system/developer messages.'); - return { type: 'text' as const, text: part.text }; - }); - messages.push({ role: item.role, content }); - } - } else throw new TranslateInputException(`Invalid input item type '${item.type}'.`); - } - flushToolOutputImages(); - return messages; - }; - - const allowed = restrictAllowedTools(payload.tools, payload.tool_choice); - const tools = translateOpenAIResponsesTools(allowed.tools, customToolNames); - // Same-purpose OpenAI fields pass through directly here, while broader - // OpenAI-Responses-only state such as `previous_response_id` remains native-only. - const target: OpenAIChatCompletionsPayload = { - model: payload.model, - messages: payload.instructions ? [{ role: 'system', content: payload.instructions }] : [], - ...(payload.max_output_tokens !== undefined ? { max_tokens: payload.max_output_tokens } : {}), - stream: true, - ...(payload.temperature !== undefined ? { temperature: payload.temperature } : {}), - ...(payload.top_p !== undefined ? { top_p: payload.top_p } : {}), - ...(payload.metadata !== undefined ? { metadata: klona(payload.metadata) } : {}), - ...(payload.store !== undefined ? { store: payload.store } : {}), - ...(payload.parallel_tool_calls != null ? { parallel_tool_calls: payload.parallel_tool_calls } : {}), - ...(responseFormat !== undefined ? { response_format: responseFormat } : {}), - ...(payload.prompt_cache_key !== undefined ? { prompt_cache_key: payload.prompt_cache_key } : {}), - ...(payload.safety_identifier !== undefined ? { safety_identifier: payload.safety_identifier } : {}), - ...(payload.reasoning?.effort != null ? { reasoning_effort: payload.reasoning.effort } : {}), - ...(payload.text?.verbosity != null ? { verbosity: payload.text.verbosity } : {}), - ...(payload.service_tier !== undefined ? { service_tier: payload.service_tier } : {}), - tools, - tool_choice: translateOpenAIResponsesToolChoice(allowed.choice), - }; - - for (const turn of partitionOpenAIResponsesTurns(canonical.input)) { - if (turn.role !== 'assistant') { target.messages.push(...translateNonAssistantInput(turn.items)); continue; } - const roundTrip = await roundTripAssistantTurn('openaiResponses', 'openaiChatCompletions', turn.items, codec, findOpenAIResponsesSidecarOccurrences, verifyOpenAIResponsesTurnDigest, collectOpenAIResponsesReplayContents); - if (roundTrip?.turn.function_call != null) { - const modernIds = new Set(roundTrip.turn.tool_calls?.map(call => call.id)); - for (const item of turn.items) { - if ((item.type === 'function_call' || item.type === 'custom_tool_call') && !modernIds.has(item.call_id)) legacyFunctionNames.set(item.call_id, roundTrip.turn.function_call.name); - } - } - target.messages.push(...(roundTrip ? [roundTrip.turn] : translateAssistantTurn(turn.items))); - } - return { target, customToolNames, namespaceToolNames }; -}; diff --git a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/translate.ts b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/translate.ts deleted file mode 100644 index b349d18130..0000000000 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/translate.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { translateToSourceEvents } from './events.ts'; -import { buildTargetRequest } from './request.ts'; -import { TranslateInputException } from '../../../shared/input-error.ts'; -import type { Translator } from '../../../types.ts'; -import { restateOpenAIResponsesError } from '../as-source/error-body.ts'; -import { restoreNamespaceEvents } from '../as-source/namespace-tools.ts'; -import type { OpenAIChatCompletionsStreamEvent, OpenAIChatCompletionsPayload } from '@floway-dev/protocols/openai-chat-completions'; -import type { OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx } from '@floway-dev/protocols/openai-responses'; - -export const translate: Translator< - OpenAIResponsesRequestPayloadEx, OpenAIResponsesStreamEventEx, OpenAIChatCompletionsPayload, OpenAIChatCompletionsStreamEvent -> = async (src, ctx) => { - try { - const { target, customToolNames, namespaceToolNames } = await buildTargetRequest(src, ctx.sidecarCodec); - return { - type: 'trip', - error: restateOpenAIResponsesError, - target, - events: frames => restoreNamespaceEvents( - translateToSourceEvents(frames, customToolNames, ctx.sidecarCodec, namespaceToolNames), - namespaceToolNames, - ), - }; - } catch (error) { - if (error instanceof TranslateInputException) return error.failure; - throw error; - } -}; diff --git a/packages/translate/src/protocols/routes.ts b/packages/translate/src/protocols/routes.ts index d547865e59..451b71d41f 100644 --- a/packages/translate/src/protocols/routes.ts +++ b/packages/translate/src/protocols/routes.ts @@ -1,9 +1,35 @@ -export { translate as routeAnthropicMessagesViaOpenAIChatCompletions } from './anthropic-messages/via-openai-chat-completions/translate.ts'; -export { translate as routeAnthropicMessagesViaOpenAIResponses } from './anthropic-messages/via-openai-responses/translate.ts'; -export { translate as routeGeminiGenerateContentViaAnthropicMessages } from './gemini-generate-content/via-anthropic-messages/translate.ts'; -export { translate as routeGeminiGenerateContentViaOpenAIChatCompletions } from './gemini-generate-content/via-openai-chat-completions/translate.ts'; -export { translate as routeGeminiGenerateContentViaOpenAIResponses } from './gemini-generate-content/via-openai-responses/translate.ts'; -export { translate as routeOpenAIChatCompletionsViaAnthropicMessages } from './openai-chat-completions/via-anthropic-messages/translate.ts'; -export { translate as routeOpenAIChatCompletionsViaOpenAIResponses } from './openai-chat-completions/via-openai-responses/translate.ts'; -export { translate as routeOpenAIResponsesViaAnthropicMessages } from './openai-responses/via-anthropic-messages/translate.ts'; -export { translate as routeOpenAIResponsesViaOpenAIChatCompletions } from './openai-responses/via-openai-chat-completions/translate.ts'; +import { anthropicMessagesSourceProtocol } from './anthropic-messages/as-source.ts'; +import { anthropicMessagesTargetProtocol } from './anthropic-messages/as-target.ts'; +import { composeTranslation } from '../composition/index.ts'; +import { geminiGenerateContentSourceProtocol } from './gemini-generate-content/as-source.ts'; +import { openaiChatCompletionsSourceProtocol } from './openai-chat-completions/as-source.ts'; +import { openaiChatCompletionsTargetProtocol } from './openai-chat-completions/as-target.ts'; +import { openaiResponsesSourceProtocol } from './openai-responses/as-source.ts'; +import { openaiResponsesTargetProtocol } from './openai-responses/as-target.ts'; + +export const routeAnthropicMessagesViaOpenAIChatCompletions = + composeTranslation(anthropicMessagesSourceProtocol, openaiChatCompletionsTargetProtocol); + +export const routeAnthropicMessagesViaOpenAIResponses = + composeTranslation(anthropicMessagesSourceProtocol, openaiResponsesTargetProtocol); + +export const routeGeminiGenerateContentViaAnthropicMessages = + composeTranslation(geminiGenerateContentSourceProtocol, anthropicMessagesTargetProtocol); + +export const routeGeminiGenerateContentViaOpenAIChatCompletions = + composeTranslation(geminiGenerateContentSourceProtocol, openaiChatCompletionsTargetProtocol); + +export const routeGeminiGenerateContentViaOpenAIResponses = + composeTranslation(geminiGenerateContentSourceProtocol, openaiResponsesTargetProtocol); + +export const routeOpenAIChatCompletionsViaAnthropicMessages = + composeTranslation(openaiChatCompletionsSourceProtocol, anthropicMessagesTargetProtocol); + +export const routeOpenAIChatCompletionsViaOpenAIResponses = + composeTranslation(openaiChatCompletionsSourceProtocol, openaiResponsesTargetProtocol); + +export const routeOpenAIResponsesViaAnthropicMessages = + composeTranslation(openaiResponsesSourceProtocol, anthropicMessagesTargetProtocol); + +export const routeOpenAIResponsesViaOpenAIChatCompletions = + composeTranslation(openaiResponsesSourceProtocol, openaiChatCompletionsTargetProtocol); diff --git a/packages/translate/src/round-trip/resolve.ts b/packages/translate/src/round-trip/resolve.ts index 8853c3bf33..b625dc1b63 100644 --- a/packages/translate/src/round-trip/resolve.ts +++ b/packages/translate/src/round-trip/resolve.ts @@ -10,35 +10,31 @@ export interface SidecarOccurrence { readonly withoutSidecar: () => Turn; } -export interface RoundTripAssistantTurn { - turn: InputAssistantTurnFor; +export interface SidecarRecognition { + readonly inspect: (turn: Turn) => SidecarOccurrence[]; + readonly verify: (turn: Turn, digest: RoundTripTurnDigest, version: number) => Promise; + readonly collectCandidates: (turn: Turn) => ReplayContent[]; } -export const warnDroppedUnrecognizedAssistantReasoning = ( - source: keyof InputAssistantTurnByProtocol, - target: keyof InputAssistantTurnByProtocol, -): void => { console.warn('Dropping reasoning from an unidentifiable foreign assistant turn', { source, target }); }; - export const roundTripAssistantTurn = async ( source: Source, target: Target, turn: SourceTurn, codec: SidecarCodec, - inspect: (turn: SourceTurn) => SidecarOccurrence[], - verify: (turn: SourceTurn, sourceTurnDigest: RoundTripTurnDigest, version: number) => Promise, - collectCandidates: (turn: SourceTurn) => ReplayContent[], -): Promise | undefined> => { - let selected: { candidate: SidecarOccurrence; sidecar: AnyRoundTripSidecar } | undefined; - for (const candidate of inspect(turn)) { - const sidecar = await decodeRoundTripSidecar(codec, source, candidate.data); + recognition: SidecarRecognition, +): Promise | undefined> => { + const { inspect, verify, collectCandidates } = recognition; + let selected: { carrier: SidecarOccurrence; sidecar: AnyRoundTripSidecar } | undefined; + for (const carrier of inspect(turn)) { + const sidecar = await decodeRoundTripSidecar(codec, source, carrier.data); if (sidecar === undefined) continue; if (selected !== undefined) throw new Error('Assistant turn has multiple recognized round-trip sidecars'); - selected = { candidate, sidecar }; + selected = { carrier, sidecar }; } if (selected === undefined || !hasRoundTripTarget(selected.sidecar, target)) return undefined; - const sourceTurn = selected.candidate.withoutSidecar(); + const sourceTurn = selected.carrier.withoutSidecar(); if (!(await verify(sourceTurn, selected.sidecar.sourceTurnDigest, selected.sidecar.v))) return undefined; const resolved = await restoreAssistantTurn(target, collectCandidates(sourceTurn), selected.sidecar.targetTurn, selected.sidecar.referencedContentHashes); if (!resolved.ok) throw new Error(`Authenticated assistant-turn sidecar restoration failed: ${resolved.reason}`); - return { turn: resolved.value }; + return resolved.value; }; diff --git a/packages/translate/src/round-trip/source-assistant-turn.ts b/packages/translate/src/round-trip/source-assistant-turn.ts new file mode 100644 index 0000000000..952ed62b72 --- /dev/null +++ b/packages/translate/src/round-trip/source-assistant-turn.ts @@ -0,0 +1,18 @@ +import type { AnthropicMessagesSourceAssistantTurn } from '../protocols/anthropic-messages/as-source/round-trip.ts'; +import type { GeminiGenerateContentSourceAssistantTurn } from '../protocols/gemini-generate-content/as-source/round-trip.ts'; +import type { OpenAIChatCompletionsSourceAssistantTurn } from '../protocols/openai-chat-completions/as-source/round-trip.ts'; +import type { OpenAIResponsesSourceAssistantTurn } from '../protocols/openai-responses/as-source/round-trip.ts'; + +/** + * Source sidecars authenticate the source's native assistant structure. + * Target replay uses InputAssistantTurnFor. + */ +interface SourceAssistantTurnByProtocol { + openaiChatCompletions: OpenAIChatCompletionsSourceAssistantTurn; + openaiResponses: OpenAIResponsesSourceAssistantTurn; + anthropicMessages: AnthropicMessagesSourceAssistantTurn; + geminiGenerateContent: GeminiGenerateContentSourceAssistantTurn; +} + +export type SourceAssistantTurnFor = + SourceAssistantTurnByProtocol[Protocol]; diff --git a/packages/translate/src/protocols/openai-responses/common/custom-tool-wrap.ts b/packages/translate/src/shared/custom-tool.ts similarity index 50% rename from packages/translate/src/protocols/openai-responses/common/custom-tool-wrap.ts rename to packages/translate/src/shared/custom-tool.ts index d822649d76..d5948f9f85 100644 --- a/packages/translate/src/protocols/openai-responses/common/custom-tool-wrap.ts +++ b/packages/translate/src/shared/custom-tool.ts @@ -1,10 +1,19 @@ +import { TranslateInputException } from './input-error.ts'; +import type { IRInputCustomToolFormat } from '../ir/input/types.ts'; + +export const requireKnownCustomToolFormat = (format: IRInputCustomToolFormat | undefined): Exclude | undefined => { + if (format?.type === 'unrecognized') throw new TranslateInputException('Cannot translate an unrecognized custom tool format.'); + return format; +}; + // Translation paths wrap Responses `custom` tools as single-string JSON-schema // function tools. Their grammar definition becomes a syntax-labeled prompt hint; // downstream enforcement remains provider-dependent. // https://github.com/openai/openai-node/blob/7423ac3e9351c46300cd094479cded5551a72eb4/src/resources/responses/responses.ts -export const buildCustomToolInputSchema = (format?: Record): Record & { type: 'object' } => { - const definition = typeof format?.definition === 'string' ? format.definition : undefined; - const sourceSyntax = typeof format?.syntax === 'string' && format.syntax !== '' ? format.syntax : undefined; +export const buildCustomToolInputSchema = (format?: IRInputCustomToolFormat): Record & { type: 'object' } => { + const known = requireKnownCustomToolFormat(format); + const definition = known?.type === 'grammar' ? known.definition : undefined; + const sourceSyntax = known?.type === 'grammar' && known.syntax !== '' ? known.syntax : undefined; const syntax = sourceSyntax === 'lark' ? 'Lark grammar' : sourceSyntax === 'regex' ? 'Regex' : sourceSyntax === undefined ? 'Grammar' : `Grammar (${sourceSyntax})`; return { type: 'object', @@ -19,8 +28,8 @@ export const buildCustomToolInputSchema = (format?: Record): Re }; }; -// Preserve malformed wrappers as freeform input so the client can decide how -// to handle the tool call without terminating the agent's generation stream. +// Extract custom input from the single-input function wrapper. Preserve malformed wrappers as raw +// freeform input so the client can handle the call without terminating generation. export const unwrapCustomToolInput = (wrappedArguments: string): string => { if (wrappedArguments.length === 0) return ''; let parsed: unknown; @@ -29,7 +38,7 @@ export const unwrapCustomToolInput = (wrappedArguments: string): string => { } catch { return wrappedArguments; } - if (typeof parsed === 'object' && parsed !== null && typeof (parsed as { input?: unknown }).input === 'string') return (parsed as { input: string }).input; + if (typeof parsed === 'object' && parsed !== null && 'input' in parsed && typeof parsed.input === 'string') return parsed.input; console.warn('Custom tool wrapper has no string input; returning the entire JSON value as freeform input.'); return wrappedArguments; }; diff --git a/packages/translate/src/shared/document.ts b/packages/translate/src/shared/document.ts new file mode 100644 index 0000000000..38d5144dae --- /dev/null +++ b/packages/translate/src/shared/document.ts @@ -0,0 +1,20 @@ +import { TranslateInputException } from './input-error.ts'; +import type { IRInputDocument, IRInputDocumentContentPart, IRInputMedia } from '../ir/input/types.ts'; + +export const DOCUMENT_CLOSE_TAG = ''; + +export const documentOpening = (document: IRInputDocument): string => { + const escape = (value: string): string => value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); + return [ + '', + ...(document.title == null ? [] : [`${escape(document.title)}`]), + ...(document.context == null ? [] : [`${escape(document.context)}`]), + ].join('\n'); +}; + +export const documentImage = (part: Extract): IRInputMedia => { + if (part.transformations?.oversized_image !== undefined) { + throw new TranslateInputException('Cannot translate document image transformations to this protocol.'); + } + return part.image; +}; diff --git a/packages/translate/src/shared/reasoning-budget.ts b/packages/translate/src/shared/reasoning-budget.ts new file mode 100644 index 0000000000..ef82d3cba3 --- /dev/null +++ b/packages/translate/src/shared/reasoning-budget.ts @@ -0,0 +1,14 @@ +/** + * Approximate numeric budgets using community effort-tier defaults as upper bounds. + * The 2048 and 8192 thresholds are our heuristic; zero maps to none. + * Google budget ranges: https://ai.google.dev/gemini-api/docs/thinking + * Community tier defaults: + * https://github.com/krzysztofdudek/AutoReview/blob/9bf2ede3a960f5215645bedba41c591829053f5c/scripts/lib/providers/google.mjs#L4 + * https://github.com/BerriAI/litellm/blob/88e03e548716a45284597edf2b7f47a7e6a66d5f/litellm/constants.py#L184 + */ +export const reasoningEffortForBudget = (budget: number): string => { + if (budget <= 0) return 'none'; + if (budget <= 2048) return 'low'; + if (budget <= 8192) return 'medium'; + return 'high'; +}; diff --git a/packages/translate/src/types.ts b/packages/translate/src/types.ts index 1f565db7c6..22cd767e74 100644 --- a/packages/translate/src/types.ts +++ b/packages/translate/src/types.ts @@ -1,18 +1,12 @@ import type { SidecarCodec } from './round-trip/codec.ts'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; -export interface RemoteImageData { - mediaType: string | null; - data: Uint8Array; -} - -export type RemoteImageLoader = (url: string) => Promise; - -/** Runtime dependencies and target-specific settings for one translation. */ -export type TranslateOptions = { - readonly model: string; +export interface TranslateOptions { readonly sidecarCodec: SidecarCodec; -} & TExtras; + // The upstream's own output limit, when the operator knows it. A target that + // requires a ceiling uses it when the source stated none. + readonly fallbackMaxOutputTokens?: number; +} export type ApiErrorTranslator = (status: number, headers: Headers, body: Uint8Array) => { readonly status: number; @@ -20,11 +14,6 @@ export type ApiErrorTranslator = (status: number, headers: Headers, body: Uint8A readonly body: unknown; } | undefined; -/** - * The target owns its mutable JSON objects and arrays. Providers can mutate it - * while the source request and other trips remain live, so builders clone values - * borrowed from their source. - */ export interface TranslateTrip { readonly type: 'trip'; readonly target: TargetPayload; @@ -39,7 +28,7 @@ export interface TranslateInputError { readonly code?: string; } -export type Translator = ( +export type Translator = ( payload: SourcePayload, - options: TranslateOptions, + options: TranslateOptions, ) => Promise | TranslateInputError>;