diff --git a/README.md b/README.md index 1e73b86..3b66f7f 100644 --- a/README.md +++ b/README.md @@ -309,14 +309,18 @@ for await (const chunk of converter.convertStream(responsesStream)) { | OpenAI Chat Completions | OpenAI Responses | |---|---| | `messages` | `input` (ResponseInputItem[]) | -| `system` / `developer` messages | `instructions` | -| `max_completion_tokens` | `max_output_tokens` | -| `tools` (function) | `tools` (function) | -| `tool_choice` | `tool_choice` | +| `system` / `developer` messages | `input` messages with the same role | +| `max_completion_tokens` / `max_tokens` | `max_output_tokens` | +| `tools` (function/custom), deprecated `functions` | `tools` (function/custom) | +| `tool_choice`, deprecated `function_call` | `tool_choice` | | `response_format` | `text.format` | +| `verbosity` | `text.verbosity` | | `reasoning_effort` | `reasoning.effort` | | `web_search_options` | `tools` (web_search) | | `parallel_tool_calls` | `parallel_tool_calls` | +| `logprobs` / `top_logprobs` | `include` / `top_logprobs` | +| `metadata`, `store`, `service_tier` | Same-name fields | +| `safety_identifier`, `user` | Same-name fields | | `prompt_cache_key` / `prompt_cache_retention` | `prompt_cache_key` / `prompt_cache_retention` | ### Chat Completions → Gemini diff --git a/package.json b/package.json index df91d75..f896dcb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@zenmux/rosetta-ai", - "version": "1.0.18", + "version": "1.0.19", "description": "Universal translator between AI provider protocols", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/chat-completions/__tests__/responses.test.ts b/src/chat-completions/__tests__/responses.test.ts index 1b6064d..2b39861 100644 --- a/src/chat-completions/__tests__/responses.test.ts +++ b/src/chat-completions/__tests__/responses.test.ts @@ -2,7 +2,11 @@ import type OpenAI from "openai"; import { ChatCompletionToResponsesConverter } from "../responses"; describe("ChatCompletionToResponsesConverter", () => { - const converter = new ChatCompletionToResponsesConverter(); + let converter: ChatCompletionToResponsesConverter; + + beforeEach(() => { + converter = new ChatCompletionToResponsesConverter(); + }); describe("convertRequest", () => { it("converts messages to input items", () => { @@ -69,6 +73,16 @@ describe("ChatCompletionToResponsesConverter", () => { expect(result.max_output_tokens).toBe(1000); }); + it("uses deprecated max_tokens when max_completion_tokens is absent", () => { + const result = converter.convertRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "Hi" }], + max_tokens: 500, + }); + + expect(result.max_output_tokens).toBe(500); + }); + it("maps temperature and top_p", () => { const result = converter.convertRequest({ model: "gpt-4o", @@ -110,10 +124,25 @@ describe("ChatCompletionToResponsesConverter", () => { const result = converter.convertRequest({ model: "gpt-4o", messages: [{ role: "user", content: "Search" }], - web_search_options: {}, + web_search_options: { + search_context_size: "high", + user_location: { + type: "approximate", + approximate: { city: "Paris", country: "FR", timezone: "Europe/Paris" }, + }, + }, } as any); - expect((result.tools as any[]).find((t: any) => t.type === "web_search")).toBeDefined(); + expect((result.tools as any[]).find((t: any) => t.type === "web_search")).toEqual({ + type: "web_search", + search_context_size: "high", + user_location: { + type: "approximate", + city: "Paris", + country: "FR", + timezone: "Europe/Paris", + }, + }); }); it("maps response_format to text.format", () => { @@ -133,6 +162,34 @@ describe("ChatCompletionToResponsesConverter", () => { }); }); + it("preserves structured output options and verbosity", () => { + const result = converter.convertRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "Hi" }], + response_format: { + type: "json_schema", + json_schema: { + name: "answer", + description: "A structured answer", + schema: { type: "object" }, + strict: true, + }, + }, + verbosity: "low", + }); + + expect(result.text).toEqual({ + format: { + type: "json_schema", + name: "answer", + description: "A structured answer", + schema: { type: "object" }, + strict: true, + }, + verbosity: "low", + }); + }); + it("maps tool_choice", () => { expect( converter.convertRequest({ @@ -171,6 +228,63 @@ describe("ChatCompletionToResponsesConverter", () => { expect(result.include).toContain("message.output_text.logprobs"); }); + it("preserves shared request controls", () => { + const result = converter.convertRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "Hi" }], + store: false, + safety_identifier: "user_hash", + user: "legacy_user", + stream: true, + stream_options: { include_usage: true, include_obfuscation: false }, + }); + + expect(result.store).toBe(false); + expect(result.safety_identifier).toBe("user_hash"); + expect(result.user).toBe("legacy_user"); + expect(result.stream_options).toEqual({ include_obfuscation: false }); + }); + + it("preserves metadata, prompt-cache controls, and service tier", () => { + const result = converter.convertRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "Hi" }], + metadata: { test: "responses" }, + prompt_cache_key: "cache-key", + prompt_cache_retention: "24h", + service_tier: "priority", + }); + + expect(result.metadata).toEqual({ test: "responses" }); + expect(result.prompt_cache_key).toBe("cache-key"); + expect(result.prompt_cache_retention).toBe("24h"); + expect(result.service_tier).toBe("priority"); + }); + + it("does not invent Responses mappings for Chat Completions-only controls", () => { + const result = converter.convertRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "Hi" }], + n: 2, + frequency_penalty: 0.2, + presence_penalty: 0.3, + logit_bias: { "42": 1 }, + seed: 123, + stop: ["END"], + modalities: ["text"], + prediction: { type: "content", content: "predicted" }, + }); + + expect(result).not.toHaveProperty("n"); + expect(result).not.toHaveProperty("frequency_penalty"); + expect(result).not.toHaveProperty("presence_penalty"); + expect(result).not.toHaveProperty("logit_bias"); + expect(result).not.toHaveProperty("seed"); + expect(result).not.toHaveProperty("stop"); + expect(result).not.toHaveProperty("modalities"); + expect(result).not.toHaveProperty("prediction"); + }); + it("passes through parallel_tool_calls", () => { const result = converter.convertRequest({ model: "gpt-4o", @@ -181,6 +295,16 @@ describe("ChatCompletionToResponsesConverter", () => { expect((result as any).parallel_tool_calls).toBe(false); }); + it("preserves an explicit stream false value", () => { + const result = converter.convertRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "Hi" }], + stream: false, + }); + + expect((result as any).stream).toBe(false); + }); + it("converts user multimodal content", () => { const result = converter.convertRequest({ model: "gpt-4o", @@ -204,6 +328,209 @@ describe("ChatCompletionToResponsesConverter", () => { detail: "auto", }); }); + + it("omits user content parts that have no standard Responses mapping", () => { + const result = converter.convertRequest({ + model: "gpt-4o", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Transcribe this" }, + { type: "input_audio", input_audio: { data: "base64", format: "wav" } }, + ], + }, + ], + }); + + expect((result.input as any[])[0].content).toEqual([ + { type: "input_text", text: "Transcribe this" }, + ]); + }); + + it("converts custom tools, custom calls, and custom tool outputs", () => { + const result = converter.convertRequest({ + model: "gpt-4o", + messages: [ + { role: "user", content: "Run code" }, + { + role: "assistant", + content: "I will run it.", + tool_calls: [ + { + id: "call_custom", + type: "custom", + custom: { name: "code_exec", input: "print(1)" }, + }, + ], + }, + { role: "tool", tool_call_id: "call_custom", content: "1" }, + ], + tools: [ + { + type: "custom", + custom: { + name: "code_exec", + description: "Execute code", + format: { + type: "grammar", + grammar: { syntax: "lark", definition: "start: /.+/" }, + }, + }, + }, + ], + tool_choice: { type: "custom", custom: { name: "code_exec" } }, + }); + + expect(result.tools).toEqual([ + { + type: "custom", + name: "code_exec", + description: "Execute code", + format: { type: "grammar", syntax: "lark", definition: "start: /.+/" }, + }, + ]); + expect(result.tool_choice).toEqual({ type: "custom", name: "code_exec" }); + expect(result.input).toEqual([ + { role: "user", type: "message", content: "Run code" }, + { + role: "assistant", + type: "message", + content: "I will run it.", + }, + { + type: "custom_tool_call", + call_id: "call_custom", + name: "code_exec", + input: "print(1)", + }, + { type: "custom_tool_call_output", call_id: "call_custom", output: "1" }, + ]); + }); + + it("converts allowed tool choices", () => { + const result = converter.convertRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "Use one tool" }], + tool_choice: { + type: "allowed_tools", + allowed_tools: { + mode: "required", + tools: [ + { type: "function", function: { name: "lookup" } }, + { type: "custom", custom: { name: "shell" } }, + ], + }, + }, + }); + + expect(result.tool_choice).toEqual({ + type: "allowed_tools", + mode: "required", + tools: [ + { type: "function", name: "lookup" }, + { type: "custom", name: "shell" }, + ], + }); + }); + + it("converts deprecated function definitions when tools are absent", () => { + const result = converter.convertRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "Weather?" }], + functions: [ + { + name: "weather", + description: "Get weather", + parameters: { type: "object" }, + }, + ], + function_call: { name: "weather" }, + }); + + expect(result.tools).toEqual([ + { + type: "function", + name: "weather", + description: "Get weather", + strict: null, + parameters: { type: "object" }, + }, + ]); + expect(result.tool_choice).toEqual({ type: "function", name: "weather" }); + }); + + it("converts deprecated function call history with matching synthetic call IDs", () => { + const result = converter.convertRequest({ + model: "gpt-4o", + messages: [ + { role: "user", content: "Weather?" }, + { + role: "assistant", + content: null, + function_call: { name: "weather", arguments: '{"city":"SF"}' }, + }, + { role: "function", name: "weather", content: "72F" }, + ], + functions: [{ name: "weather" }], + }); + + expect(result.input).toEqual([ + { role: "user", type: "message", content: "Weather?" }, + { + type: "function_call", + name: "weather", + call_id: "call_legacy_1", + arguments: '{"city":"SF"}', + }, + { + type: "function_call_output", + call_id: "call_legacy_1", + output: "72F", + }, + ]); + }); + + it("uses valid easy-input content for assistant history", () => { + const result = converter.convertRequest({ + model: "gpt-4o", + messages: [ + { + role: "assistant", + content: [ + { type: "text", text: "First" }, + { type: "refusal", refusal: "Cannot continue" }, + ], + }, + { role: "user", content: "Why?" }, + ], + }); + + expect((result.input as any[])[0]).toEqual({ + role: "assistant", + type: "message", + content: [ + { type: "output_text", text: "First" }, + { type: "refusal", refusal: "Cannot continue" }, + ], + }); + }); + + it("preserves a standalone assistant refusal as a refusal content part", () => { + const result = converter.convertRequest({ + model: "gpt-4o", + messages: [ + { role: "assistant", content: null, refusal: "Cannot continue" }, + { role: "user", content: "Why?" }, + ], + }); + + expect((result.input as any[])[0]).toEqual({ + role: "assistant", + type: "message", + content: [{ type: "refusal", refusal: "Cannot continue" }], + }); + }); }); // ===== convertResponse (Responses → CC, backward) ===== @@ -260,6 +587,12 @@ describe("ChatCompletionToResponsesConverter", () => { } as OpenAI.Responses.Response; } + it("omits usage when the Responses payload has no usage", () => { + const result = converter.convertResponse(makeResponse({ usage: undefined as any })); + + expect(result.usage).toBeUndefined(); + }); + it("converts a basic text response", () => { const result = converter.convertResponse(makeResponse()); @@ -268,6 +601,7 @@ describe("ChatCompletionToResponsesConverter", () => { expect(result.object).toBe("chat.completion"); expect(result.created).toBe(1700000000); expect(result.choices[0].message.content).toBe("Hello!"); + expect(result.choices[0].message.annotations).toEqual([]); expect(result.choices[0].finish_reason).toBe("stop"); expect(result.usage?.prompt_tokens).toBe(10); expect(result.usage?.completion_tokens).toBe(5); @@ -300,6 +634,167 @@ describe("ChatCompletionToResponsesConverter", () => { expect(result.choices[0].finish_reason).toBe("tool_calls"); }); + it("converts Responses function calls back to deprecated CC function_call mode", () => { + converter.convertRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "Weather?" }], + functions: [{ name: "get_weather" }], + function_call: "auto", + }); + + const result = converter.convertResponse( + makeResponse({ + output: [ + { + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "get_weather", + arguments: '{"city":"SF"}', + status: "completed", + } as any, + ], + }) + ); + + expect(result.choices[0]).toMatchObject({ + finish_reason: "function_call", + message: { + function_call: { name: "get_weather", arguments: '{"city":"SF"}' }, + }, + }); + expect(result.choices[0].message.tool_calls).toBeUndefined(); + }); + + it("keeps an incomplete status finish reason when a tool call is partial", () => { + const result = converter.convertResponse( + makeResponse({ + output: [ + { + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "get_weather", + arguments: '{"city":', + status: "incomplete", + } as any, + ], + status: "incomplete", + incomplete_details: { reason: "max_output_tokens" }, + }) + ); + + expect(result.choices[0].finish_reason).toBe("length"); + expect(result.choices[0].message.tool_calls).toHaveLength(1); + }); + + it("converts custom_tool_call output items to custom tool_calls", () => { + const result = converter.convertResponse( + makeResponse({ + output: [ + { + type: "custom_tool_call", + id: "ctc_1", + call_id: "call_1", + name: "code_exec", + input: "print(1)", + } as any, + ], + }) + ); + + expect(result.choices[0].message.tool_calls).toEqual([ + { + id: "call_1", + type: "custom", + custom: { name: "code_exec", input: "print(1)" }, + }, + ]); + expect(result.choices[0].finish_reason).toBe("tool_calls"); + }); + + it("combines standard message parts, reasoning, and tool calls into one choice", () => { + const result = converter.convertResponse( + makeResponse({ + output: [ + { + type: "message", + id: "msg_1", + role: "assistant", + status: "completed", + content: [ + { + type: "output_text", + text: "Hello ", + annotations: [], + logprobs: [{ token: "Hello", logprob: -0.1, bytes: [72], top_logprobs: [] }], + }, + { + type: "output_text", + text: "world", + annotations: [ + { + type: "url_citation", + title: "Example", + url: "https://example.com", + start_index: 0, + end_index: 5, + }, + ], + logprobs: [], + }, + ], + } as any, + { + type: "reasoning", + id: "r_1", + summary: [{ type: "summary_text", text: "Thinking..." }], + } as any, + { + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "lookup", + arguments: "{}", + status: "completed", + } as any, + ], + }) + ); + + expect(result.choices).toHaveLength(1); + expect(result.choices[0]).toMatchObject({ + index: 0, + finish_reason: "tool_calls", + message: { + content: "Hello world", + reasoning: "Thinking...", + annotations: [ + { + type: "url_citation", + url_citation: { + start_index: 6, + end_index: 11, + }, + }, + ], + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "lookup", arguments: "{}" }, + }, + ], + }, + }); + expect(result.choices[0].logprobs?.content?.[0]).toEqual({ + token: "Hello", + logprob: -0.1, + bytes: [72], + top_logprobs: [], + }); + }); + it('maps incomplete status with max_output_tokens to "length"', () => { const result = converter.convertResponse( makeResponse({ @@ -429,6 +924,92 @@ describe("ChatCompletionToResponsesConverter", () => { expect(result.choices[0].message.content).toBe("42"); expect((result.choices[0].message as any).reasoning).toBe("Thinking..."); }); + + it("preserves every reasoning item and summary part in output order", () => { + const result = converter.convertResponse( + makeResponse({ + output: [ + { + type: "reasoning", + id: "r_1", + summary: [ + { type: "summary_text", text: "First " }, + { type: "summary_text", text: "step. " }, + ], + encrypted_content: "encrypted-1", + } as any, + { + type: "reasoning", + id: "r_2", + summary: [{ type: "summary_text", text: "Second step." }], + encrypted_content: "encrypted-2", + } as any, + { + type: "message", + id: "msg_1", + role: "assistant", + status: "completed", + content: [{ type: "output_text", text: "42", annotations: [], logprobs: null }], + } as any, + ], + }) + ); + + expect((result.choices[0].message as any).reasoning).toBe("First step. Second step."); + expect((result.choices[0].message as any).reasoning_details).toEqual([ + { + index: "0", + format: "openai-responses-v1", + type: "reasoning.summary", + summary: "First ", + }, + { + index: "0", + format: "openai-responses-v1", + type: "reasoning.summary", + summary: "step. ", + }, + { + id: "r_1", + index: "0", + format: "openai-responses-v1", + type: "reasoning.encrypted", + data: "encrypted-1", + }, + { + index: "0", + format: "openai-responses-v1", + type: "reasoning.summary", + summary: "Second step.", + }, + { + id: "r_2", + index: "0", + format: "openai-responses-v1", + type: "reasoning.encrypted", + data: "encrypted-2", + }, + ]); + }); + + it("returns a valid empty choice when Responses has no output items", () => { + const result = converter.convertResponse( + makeResponse({ + output: [], + status: "incomplete", + incomplete_details: { reason: "content_filter" }, + }) + ); + + expect(result.choices).toEqual([ + { + index: 0, + finish_reason: "content_filter", + message: { role: "assistant", content: null, refusal: null }, + logprobs: null, + }, + ]); + }); }); // ===== convertStreamEvent (Responses → CC, backward) ===== @@ -460,6 +1041,7 @@ describe("ChatCompletionToResponsesConverter", () => { ); expect(result.choices[0].delta.role).toBe("assistant"); + expect(result.choices[0].logprobs).toBeNull(); expect(result.id).toBe("resp_1"); }); @@ -482,6 +1064,47 @@ describe("ChatCompletionToResponsesConverter", () => { expect(result.choices[0].delta.content).toBe("Hello"); }); + it("converts streaming text logprobs when requested", () => { + const c = new ChatCompletionToResponsesConverter(); + c.convertRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "Hi" }], + stream: true, + top_logprobs: 5, + } as any); + initStream(c); + + const result = first( + c.convertStreamEvent({ + type: "response.output_text.delta", + delta: "Hi", + item_id: "msg_1", + output_index: 0, + content_index: 0, + sequence_number: 1, + logprobs: [ + { + token: "Hi", + logprob: -0.1, + top_logprobs: [{ token: "Hello", logprob: -0.5 }], + }, + ], + } as any) + ); + + expect(result.choices[0].logprobs).toEqual({ + content: [ + { + token: "Hi", + logprob: -0.1, + bytes: null, + top_logprobs: [{ token: "Hello", logprob: -0.5, bytes: null }], + }, + ], + refusal: null, + }); + }); + it("emits tool_call start on response.output_item.added (function_call)", () => { const c = new ChatCompletionToResponsesConverter(); initStream(c); @@ -537,6 +1160,92 @@ describe("ChatCompletionToResponsesConverter", () => { expect(result.choices[0].delta.tool_calls![0].function!.arguments).toBe('{"city'); }); + it("streams deprecated function_call deltas and finish reason in legacy mode", () => { + const c = new ChatCompletionToResponsesConverter(); + c.convertRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "Weather?" }], + functions: [{ name: "weather" }], + function_call: "auto", + stream: true, + }); + initStream(c); + + const start = first( + c.convertStreamEvent({ + type: "response.output_item.added", + item: { + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "weather", + arguments: "", + status: "in_progress", + }, + output_index: 0, + sequence_number: 1, + } as any) + ); + const delta = first( + c.convertStreamEvent({ + type: "response.function_call_arguments.delta", + delta: '{"city', + item_id: "fc_1", + output_index: 0, + sequence_number: 2, + } as any) + ); + const done = first( + c.convertStreamEvent({ + type: "response.completed", + response: { id: "resp_1", status: "completed" } as any, + sequence_number: 3, + }) + ); + + expect(start.choices[0].delta.function_call).toEqual({ name: "weather", arguments: "" }); + expect(delta.choices[0].delta.function_call).toEqual({ arguments: '{"city' }); + expect(done.choices[0].finish_reason).toBe("function_call"); + }); + + it("emits custom tool call start and input delta", () => { + const c = new ChatCompletionToResponsesConverter(); + initStream(c); + + const start = first( + c.convertStreamEvent({ + type: "response.output_item.added", + item: { + type: "custom_tool_call", + id: "ctc_1", + call_id: "call_custom", + name: "code_exec", + input: "", + }, + output_index: 0, + sequence_number: 1, + } as any) + ); + const delta = first( + c.convertStreamEvent({ + type: "response.custom_tool_call_input.delta", + delta: "print(1)", + item_id: "ctc_1", + output_index: 0, + sequence_number: 2, + } as any) + ); + + expect((start.choices[0].delta.tool_calls?.[0] as any).custom).toEqual({ + name: "code_exec", + input: "", + }); + expect((delta.choices[0].delta.tool_calls?.[0] as any).custom).toEqual({ + input: "print(1)", + }); + expect(delta.choices[0].delta.tool_calls?.[0].index).toBe(0); + }); + it("emits tool_calls finish_reason when tool calls present", () => { const c = new ChatCompletionToResponsesConverter(); initStream(c); @@ -588,6 +1297,63 @@ describe("ChatCompletionToResponsesConverter", () => { expect(result.choices[0].finish_reason).toBe("stop"); }); + it("maps a streaming content-filtered incomplete response", () => { + const c = new ChatCompletionToResponsesConverter(); + initStream(c); + + const result = first( + c.convertStreamEvent({ + type: "response.incomplete", + response: { + id: "resp_1", + status: "incomplete", + incomplete_details: { reason: "content_filter" }, + } as any, + sequence_number: 10, + }) + ); + + expect(result.choices[0].finish_reason).toBe("content_filter"); + }); + + it("emits a trailing usage chunk when stream_options.include_usage is enabled", () => { + const c = new ChatCompletionToResponsesConverter(); + c.convertRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "Hi" }], + stream: true, + stream_options: { include_usage: true }, + }); + initStream(c); + + const result = c.convertStreamEvent({ + type: "response.completed", + response: { + id: "resp_1", + status: "completed", + usage: { + input_tokens: 10, + input_tokens_details: { cached_tokens: 2 }, + output_tokens: 5, + output_tokens_details: { reasoning_tokens: 1 }, + total_tokens: 15, + }, + } as any, + sequence_number: 10, + }); + + expect(Array.isArray(result)).toBe(true); + expect((result as OpenAI.ChatCompletionChunk[])[0].usage).toBeNull(); + expect((result as OpenAI.ChatCompletionChunk[])[1]).toMatchObject({ + choices: [], + usage: { + prompt_tokens: 10, + completion_tokens: 5, + total_tokens: 15, + }, + }); + }); + it("handles reasoning_summary_text.delta", () => { const c = new ChatCompletionToResponsesConverter(); initStream(c); @@ -608,6 +1374,12 @@ describe("ChatCompletionToResponsesConverter", () => { it("counts web search when output_item.done completes a search", () => { const c = new ChatCompletionToResponsesConverter(); + c.convertRequest({ + model: "gpt-4o", + messages: [{ role: "user", content: "Search" }], + stream: true, + stream_options: { include_usage: true }, + }); initStream(c); c.convertStreamEvent({ @@ -634,20 +1406,21 @@ describe("ChatCompletionToResponsesConverter", () => { sequence_number: 2, } as any); - const result = first( - c.convertStreamEvent({ - type: "response.completed", - response: { - id: "resp_1", - status: "completed", - usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, - } as any, - sequence_number: 10, - }) - ); + const result = c.convertStreamEvent({ + type: "response.completed", + response: { + id: "resp_1", + status: "completed", + usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, + } as any, + sequence_number: 10, + }); expect(c["streamState"].webSearchCount).toBe(1); - expect(result.choices[0].finish_reason).toBe("stop"); + expect(Array.isArray(result)).toBe(true); + if (!Array.isArray(result)) throw new Error("expected finish and usage chunks"); + expect(result[0].choices[0].finish_reason).toBe("stop"); + expect((result[1].usage?.prompt_tokens_details as any)?.web_search).toBe(1); }); it("collects annotations from output_text.annotation.added", () => { @@ -677,7 +1450,6 @@ describe("ChatCompletionToResponsesConverter", () => { { type: "url_citation", url_citation: { - type: "url_citation", url: "https://example.com", title: "Example", start_index: 0, @@ -687,6 +1459,25 @@ describe("ChatCompletionToResponsesConverter", () => { ]); }); + it("ignores streaming annotations without a CC URL citation mapping", () => { + const c = new ChatCompletionToResponsesConverter(); + initStream(c); + + c.convertStreamEvent({ + type: "response.output_text.annotation.added", + annotation: { type: "file_citation", file_id: "file_1", filename: "a.txt", index: 0 }, + } as any); + + const result = first( + c.convertStreamEvent({ + type: "response.completed", + response: { id: "resp_1", status: "completed" } as any, + sequence_number: 10, + }) + ); + expect((result.choices[0].delta as any).annotations).toBeUndefined(); + }); + it("returns null for unknown events", () => { const c = new ChatCompletionToResponsesConverter(); const result = c.convertStreamEvent({ diff --git a/src/chat-completions/responses.ts b/src/chat-completions/responses.ts index 315781e..4c43a24 100644 --- a/src/chat-completions/responses.ts +++ b/src/chat-completions/responses.ts @@ -13,13 +13,16 @@ interface StreamState { serviceTier: OpenAI.ChatCompletionChunk["service_tier"] | null; toolCallCounter: number; hasToolCall: boolean; + toolCallIndexes: Map; webSearchCount: number; annotations: OpenAI.ChatCompletionMessage.Annotation[]; includeUsage: boolean; + includeLogprobs: boolean; } export class ChatCompletionToResponsesConverter { private streamState: StreamState; + private legacyFunctionMode = false; constructor() { this.streamState = this.createStreamState(); @@ -28,6 +31,12 @@ export class ChatCompletionToResponsesConverter { // --- Request conversion (CC → Responses, forward) --- convertRequest(params: CCParams): OpenAI.Responses.ResponseCreateParams { + this.streamState = this.createStreamState(); + this.streamState.includeUsage = params.stream_options?.include_usage ?? false; + this.streamState.includeLogprobs = params.top_logprobs != null; + this.legacyFunctionMode = + params.function_call != null || (params.tools == null && params.functions != null); + const result: OpenAI.Responses.ResponseCreateParams = { model: params.model as string, input: this.convertMessages(params.messages), @@ -65,17 +74,40 @@ export class ChatCompletionToResponsesConverter { if (params.service_tier != null) { result.service_tier = params.service_tier; } - if (params.tools || params.web_search_options != null) { - result.tools = this.convertTools(params); + if (params.store != null) { + result.store = params.store; + } + if (params.safety_identifier != null) { + result.safety_identifier = params.safety_identifier; + } + if (params.user != null) { + result.user = params.user; + } + const tools = this.convertTools(params); + if (tools.length > 0) { + result.tools = tools; } if (params.tool_choice != null) { result.tool_choice = this.convertToolChoice(params.tool_choice); + } else if (params.function_call != null) { + result.tool_choice = this.convertLegacyFunctionChoice(params.function_call); } if (params.response_format) { result.text = this.convertResponseFormat(params.response_format); } - if (params.stream) { - (result as any).stream = true; + if (params.verbosity != null) { + result.text = { + ...(result.text ?? {}), + verbosity: params.verbosity, + }; + } + if (params.stream != null) { + (result as any).stream = params.stream; + } + if (params.stream_options?.include_obfuscation != null) { + result.stream_options = { + include_obfuscation: params.stream_options.include_obfuscation, + }; } return result; @@ -86,18 +118,15 @@ export class ChatCompletionToResponsesConverter { convertResponse(response: RespResponse): OpenAI.ChatCompletion { const choices: OpenAI.ChatCompletion.Choice[] = []; const toolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[] = []; - let reasoningItem: OpenAI.Responses.ResponseReasoningItem | null = null; + const messages: OpenAI.Responses.ResponseOutputMessage[] = []; + const reasoningItems = response.output.filter( + (item): item is OpenAI.Responses.ResponseReasoningItem => item.type === "reasoning" + ); let webSearchCount = 0; for (const item of response.output) { if (item.type === "message") { - choices.push( - this.convertOutputMessage( - item as OpenAI.Responses.ResponseOutputMessage, - reasoningItem, - response.incomplete_details - ) - ); + messages.push(item as OpenAI.Responses.ResponseOutputMessage); } else if (item.type === "function_call") { const fc = item as OpenAI.Responses.ResponseFunctionToolCall; toolCalls.push({ @@ -105,21 +134,13 @@ export class ChatCompletionToResponsesConverter { type: "function", function: { name: fc.name, arguments: fc.arguments }, }); - } else if (item.type === "reasoning") { - reasoningItem = item as OpenAI.Responses.ResponseReasoningItem; - if (response.output.length === 1) { - choices.push({ - index: 0, - finish_reason: null as any, - message: { - role: "assistant", - content: null, - refusal: null, - ...this.convertReasoning(reasoningItem), - }, - logprobs: null, - }); - } + } else if (item.type === "custom_tool_call") { + const custom = item as OpenAI.Responses.ResponseCustomToolCall; + toolCalls.push({ + id: custom.call_id, + type: "custom", + custom: { name: custom.name, input: custom.input }, + }); } else if ( item.type === "web_search_call" && item.status === "completed" && @@ -129,18 +150,47 @@ export class ChatCompletionToResponsesConverter { } } - if (toolCalls.length > 0) { + if (messages.length > 0) { + const choice = this.convertOutputMessages( + messages, + reasoningItems, + response.status, + response.incomplete_details + ); + this.applyToolCalls(choice, toolCalls, response.status); + choices.push(choice); + } else if (toolCalls.length > 0) { + const choice: OpenAI.ChatCompletion.Choice = { + index: 0, + finish_reason: this.mapStatus(response.status, response.incomplete_details), + message: { + role: "assistant", + content: null, + refusal: null, + }, + logprobs: null, + }; + this.applyToolCalls(choice, toolCalls, response.status); + choices.push(choice); + } else if (reasoningItems.length > 0) { choices.push({ index: 0, - finish_reason: "tool_calls", + finish_reason: this.mapStatus(response.status, response.incomplete_details), message: { role: "assistant", content: null, refusal: null, - tool_calls: toolCalls, + ...this.convertReasoning(reasoningItems), }, logprobs: null, }); + } else { + choices.push({ + index: 0, + finish_reason: this.mapStatus(response.status, response.incomplete_details), + message: { role: "assistant", content: null, refusal: null }, + logprobs: null, + }); } return { @@ -150,66 +200,64 @@ export class ChatCompletionToResponsesConverter { model: response.model as string, service_tier: response.service_tier ?? undefined, choices, - usage: this.convertUsage(response.usage!, webSearchCount), + ...(response.usage ? { usage: this.convertUsage(response.usage, webSearchCount) } : {}), }; } - private convertOutputMessage( - message: OpenAI.Responses.ResponseOutputMessage, - reasoning: OpenAI.Responses.ResponseReasoningItem | null, + private convertOutputMessages( + messages: OpenAI.Responses.ResponseOutputMessage[], + reasoningItems: OpenAI.Responses.ResponseReasoningItem[], + status: RespResponse["status"], incompleteDetails?: RespResponse["incomplete_details"] ): OpenAI.ChatCompletion.Choice { - const content = message.content[0]; - let finishReason = this.messageStatusToFinishReason(message.status); - if (finishReason === "length" && incompleteDetails?.reason === "content_filter") { - finishReason = "content_filter"; - } + const textParts: string[] = []; + const refusalParts: string[] = []; + const annotations: OpenAI.ChatCompletionMessage.Annotation[] = []; + const logprobs: OpenAI.Chat.Completions.ChatCompletionTokenLogprob[] = []; + let hasLogprobs = false; + let textOffset = 0; - if (content?.type === "refusal") { - return { - index: 0, - finish_reason: finishReason, - logprobs: null, - message: { - role: "assistant", - content: null, - refusal: content.refusal, - ...this.convertReasoning(reasoning), - }, - }; - } + for (const message of messages) { + for (const part of message.content) { + if (part.type === "refusal") { + refusalParts.push(part.refusal); + continue; + } - const textContent = content?.type === "output_text" ? content : null; - const annotations = (textContent?.annotations ?? []) - .filter((a: any) => a.type === "url_citation") - .map((a: any) => ({ - type: "url_citation" as const, - url_citation: { - title: a.title, - url: a.url, - start_index: a.start_index, - end_index: a.end_index, - }, - })); + for (const annotation of part.annotations ?? []) { + if (annotation.type !== "url_citation") continue; + annotations.push({ + type: "url_citation", + url_citation: { + title: annotation.title, + url: annotation.url, + start_index: textOffset + annotation.start_index, + end_index: textOffset + annotation.end_index, + }, + }); + } + if (part.logprobs != null) { + hasLogprobs = true; + logprobs.push(...part.logprobs.map(logprob => this.convertTokenLogprob(logprob))); + } + textParts.push(part.text); + textOffset += part.text.length; + } + } return { index: 0, - finish_reason: finishReason, + finish_reason: this.mapStatus(status, incompleteDetails), message: { role: "assistant", - content: textContent?.text ?? null, - refusal: null, - ...(annotations.length > 0 ? { annotations } : {}), - ...this.convertReasoning(reasoning), + content: textParts.length > 0 ? textParts.join("") : null, + refusal: refusalParts.length > 0 ? refusalParts.join("") : null, + annotations, + ...this.convertReasoning(reasoningItems), }, - logprobs: textContent?.logprobs + logprobs: hasLogprobs ? { - content: textContent.logprobs.map((l: any) => ({ - token: l.token, - logprob: l.logprob, - bytes: l.bytes, - top_logprobs: l.top_logprobs, - })), + content: logprobs, refusal: null, } : null, @@ -217,39 +265,41 @@ export class ChatCompletionToResponsesConverter { } private convertReasoning( - reasoning: OpenAI.Responses.ResponseReasoningItem | null + reasoningItems: OpenAI.Responses.ResponseReasoningItem[] ): Record { - if (!reasoning || !reasoning.summary || reasoning.summary.length === 0) return {}; - - const summary = reasoning.summary[0].text; - const details: any[] = [ - { - index: "0", - format: "openai-responses-v1", - type: "reasoning.summary", - summary, - }, - ]; - - if (reasoning.encrypted_content) { - details.push({ - id: reasoning.id, - index: "0", - format: "openai-responses-v1", - type: "reasoning.encrypted", - data: reasoning.encrypted_content, - }); - } + const summaries: string[] = []; + const details: any[] = []; - return { reasoning: summary, reasoning_details: details }; - } + for (const reasoning of reasoningItems) { + if (!reasoning.summary || reasoning.summary.length === 0) { + continue; + } - private messageStatusToFinishReason( - status: OpenAI.Responses.ResponseOutputMessage["status"] - ): OpenAI.ChatCompletion.Choice["finish_reason"] { - if (status === "completed") return "stop"; - if (status === "incomplete") return "length"; - return "stop"; + for (const part of reasoning.summary) { + summaries.push(part.text); + details.push({ + index: "0", + format: "openai-responses-v1", + type: "reasoning.summary", + summary: part.text, + }); + } + + if (reasoning.encrypted_content) { + details.push({ + id: reasoning.id, + index: "0", + format: "openai-responses-v1", + type: "reasoning.encrypted", + data: reasoning.encrypted_content, + }); + } + } + + if (summaries.length === 0) { + return {}; + } + return { reasoning: summaries.join(""), reasoning_details: details }; } // --- Stream conversion (Responses → CC, backward) --- @@ -281,6 +331,8 @@ export class ChatCompletionToResponsesConverter { return this.handleTextDelta(event); case "response.function_call_arguments.delta": return this.handleFunctionCallDelta(event); + case "response.custom_tool_call_input.delta": + return this.handleCustomToolCallDelta(event); case "response.refusal.delta": return this.handleRefusalDelta(event); case "response.reasoning_summary_text.delta": @@ -297,10 +349,7 @@ export class ChatCompletionToResponsesConverter { this.handleOutputItemDone(event as OpenAI.Responses.ResponseOutputItemDoneEvent); return null; case "response.output_text.annotation.added": - this.streamState.annotations.push({ - type: "url_citation", - url_citation: (event as any).annotation, - }); + this.recordStreamAnnotation(event.annotation); return null; default: return null; @@ -311,8 +360,10 @@ export class ChatCompletionToResponsesConverter { private convertMessages(messages: CCParams["messages"]): RespInputItem[] { const input: RespInputItem[] = []; + const toolCallTypes = new Map(); + const legacyCallIds = new Map(); - for (const msg of messages) { + for (const [messageIndex, msg] of messages.entries()) { if (msg.role === "system" || msg.role === "developer") { if (typeof msg.content === "string") { input.push({ role: msg.role, type: "message", content: msg.content }); @@ -330,81 +381,153 @@ export class ChatCompletionToResponsesConverter { input.push({ role: "user", type: "message", - content: msg.content.map(p => { - if (p.type === "text") return { type: "input_text", text: p.text }; - if (p.type === "image_url") - return { - type: "input_image", - image_url: p.image_url.url, - detail: p.image_url.detail ?? "auto", - }; - if (p.type === "file") - return { - type: "input_file", - file_data: p.file.file_data, - file_id: p.file.file_id, - filename: p.file.filename, - }; - return { type: "input_text", text: "" }; - }), + content: this.convertUserContent(msg.content), }); } } else if (msg.role === "assistant") { - if (msg.tool_calls && msg.tool_calls.length > 0) { - for (const tc of msg.tool_calls) { + const toolCalls = msg.tool_calls ?? []; + const hasToolCalls = toolCalls.length > 0; + const hasLegacyFunctionCall = msg.function_call != null; + if ( + (!hasToolCalls && !hasLegacyFunctionCall) || + msg.content != null || + msg.refusal != null + ) { + input.push(this.convertAssistantMessage(msg)); + } + + if (hasToolCalls) { + for (const tc of toolCalls) { if (tc.type === "function") { + toolCallTypes.set(tc.id, "function"); input.push({ type: "function_call", name: tc.function.name, call_id: tc.id, arguments: tc.function.arguments, }); + } else if (tc.type === "custom") { + toolCallTypes.set(tc.id, "custom"); + input.push({ + type: "custom_tool_call", + name: tc.custom.name, + call_id: tc.id, + input: tc.custom.input, + }); } } - } else { - const content = msg.content; - if (typeof content === "string") { - input.push({ - role: "assistant", - type: "message", - content: [{ type: "output_text", text: content }], - } as any); - } else if (content == null) { - input.push({ - role: "assistant", - type: "message", - content: content ?? "", - } as any); - } else { - input.push({ - role: "assistant", - type: "message", - content: content - .filter((p: any) => p.type === "text") - .map((p: any) => ({ type: "output_text", text: p.text })), - } as any); - } } - } else if (msg.role === "tool") { - if (typeof msg.content === "string") { - input.push({ - type: "function_call_output", - call_id: msg.tool_call_id, - output: msg.content, - }); - } else { + + if (msg.function_call != null) { + const callId = `call_legacy_${messageIndex}`; + const pendingIds = legacyCallIds.get(msg.function_call.name) ?? []; + pendingIds.push(callId); + legacyCallIds.set(msg.function_call.name, pendingIds); input.push({ - type: "function_call_output", - call_id: msg.tool_call_id, - output: msg.content.map((p: any) => ({ type: "input_text", text: p.text })), + type: "function_call", + name: msg.function_call.name, + call_id: callId, + arguments: msg.function_call.arguments, }); } + } else if (msg.role === "tool") { + const output = + typeof msg.content === "string" + ? msg.content + : msg.content.map(p => ({ type: "input_text" as const, text: p.text })); + input.push({ + type: + toolCallTypes.get(msg.tool_call_id) === "custom" + ? "custom_tool_call_output" + : "function_call_output", + call_id: msg.tool_call_id, + output, + } as RespInputItem); + } else if (msg.role === "function") { + const pendingIds = legacyCallIds.get(msg.name) ?? []; + const callId = pendingIds.shift() ?? `call_legacy_${messageIndex}`; + input.push({ + type: "function_call_output", + call_id: callId, + output: msg.content ?? "", + }); } } return input; } + private convertAssistantMessage( + message: OpenAI.Chat.Completions.ChatCompletionAssistantMessageParam + ): OpenAI.Responses.EasyInputMessage { + const content = message.content; + if (typeof content === "string") { + return { + role: "assistant", + type: "message", + content, + }; + } + + if (Array.isArray(content)) { + const parts = content.map(part => + part.type === "refusal" + ? { type: "refusal" as const, refusal: part.refusal } + : { type: "output_text" as const, text: part.text } + ); + if (message.refusal != null && !content.some(part => part.type === "refusal")) { + parts.push({ type: "refusal", refusal: message.refusal }); + } + return { + role: "assistant", + type: "message", + // The Responses API requires assistant history blocks to use output_text/refusal, + // while the current SDK types only expose ResponseInputContent for EasyInputMessage. + content: parts as any, + }; + } + + if (message.refusal != null) { + return { + role: "assistant", + type: "message", + // The Responses API models refusal as its own assistant content part. + content: [{ type: "refusal", refusal: message.refusal }] as any, + }; + } + + return { + role: "assistant", + type: "message", + content: "", + }; + } + + private convertUserContent( + parts: Exclude + ): OpenAI.Responses.ResponseInputContent[] { + const content: OpenAI.Responses.ResponseInputContent[] = []; + for (const part of parts) { + if (part.type === "text") { + content.push({ type: "input_text", text: part.text }); + } else if (part.type === "image_url") { + content.push({ + type: "input_image", + image_url: part.image_url.url, + detail: part.image_url.detail ?? "auto", + }); + } else if (part.type === "file") { + content.push({ + type: "input_file", + file_data: part.file.file_data, + file_id: part.file.file_id, + filename: part.file.filename, + }); + } + } + return content; + } + private convertTools(params: CCParams): RespTool[] { const tools: RespTool[] = []; @@ -418,12 +541,43 @@ export class ChatCompletionToResponsesConverter { strict: t.function.strict ?? null, parameters: t.function.parameters ?? null, }); + } else if (t.type === "custom") { + tools.push({ + type: "custom", + name: t.custom.name, + description: t.custom.description, + ...(t.custom.format ? { format: this.convertCustomToolFormat(t.custom.format) } : {}), + }); } } + } else if (params.functions) { + for (const fn of params.functions) { + tools.push({ + type: "function", + name: fn.name, + description: fn.description, + strict: null, + parameters: fn.parameters ?? null, + }); + } } if (params.web_search_options != null) { - tools.push({ type: "web_search" }); + const options = params.web_search_options; + tools.push({ + type: "web_search", + ...(options.search_context_size != null + ? { search_context_size: options.search_context_size } + : {}), + ...(options.user_location != null + ? { + user_location: { + type: "approximate", + ...options.user_location.approximate, + }, + } + : {}), + }); } return tools; @@ -441,10 +595,58 @@ export class ChatCompletionToResponsesConverter { if (c.type === "function" && c.function?.name) { return { type: "function", name: c.function.name }; } + if (c.type === "custom" && c.custom?.name) { + return { type: "custom", name: c.custom.name }; + } + if (c.type === "allowed_tools") { + return { + type: "allowed_tools", + mode: c.allowed_tools.mode, + tools: c.allowed_tools.tools.map(tool => this.convertAllowedTool(tool)), + }; + } } return "auto"; } + private convertLegacyFunctionChoice( + choice: NonNullable + ): OpenAI.Responses.ResponseCreateParams["tool_choice"] { + if (choice === "auto" || choice === "none") { + return choice; + } + return { type: "function", name: choice.name }; + } + + private convertAllowedTool(tool: Record): Record { + if (tool.type === "function" && tool.function && typeof tool.function === "object") { + const fn = tool.function as { name?: unknown }; + if (typeof fn.name === "string") { + return { type: "function", name: fn.name }; + } + } + if (tool.type === "custom" && tool.custom && typeof tool.custom === "object") { + const custom = tool.custom as { name?: unknown }; + if (typeof custom.name === "string") { + return { type: "custom", name: custom.name }; + } + } + return tool; + } + + private convertCustomToolFormat( + format: OpenAI.Chat.Completions.ChatCompletionCustomTool.Custom["format"] + ): OpenAI.Responses.CustomTool["format"] { + if (format?.type === "grammar") { + return { + type: "grammar", + definition: format.grammar.definition, + syntax: format.grammar.syntax, + }; + } + return { type: "text" }; + } + private convertResponseFormat( format: CCParams["response_format"] ): OpenAI.Responses.ResponseCreateParams["text"] { @@ -458,6 +660,10 @@ export class ChatCompletionToResponsesConverter { type: "json_schema", name: format.json_schema.name, schema: format.json_schema.schema ?? {}, + ...(format.json_schema.description != null + ? { description: format.json_schema.description } + : {}), + ...(format.json_schema.strict != null ? { strict: format.json_schema.strict } : {}), }, }; } @@ -483,6 +689,28 @@ export class ChatCompletionToResponsesConverter { } } + private applyToolCalls( + choice: OpenAI.ChatCompletion.Choice, + toolCalls: OpenAI.Chat.Completions.ChatCompletionMessageToolCall[], + responseStatus: RespResponse["status"] + ): void { + if (toolCalls.length === 0) return; + + const firstCall = toolCalls[0]; + if (this.legacyFunctionMode && firstCall.type === "function") { + choice.message.function_call = { ...firstCall.function }; + if (responseStatus === "completed") { + choice.finish_reason = "function_call"; + } + return; + } + + choice.message.tool_calls = toolCalls; + if (responseStatus === "completed") { + choice.finish_reason = "tool_calls"; + } + } + private convertUsage( usage: OpenAI.Responses.ResponseUsage, webSearchCount = 0 @@ -501,6 +729,33 @@ export class ChatCompletionToResponsesConverter { }; } + private convertTokenLogprob(logprob: { + token: string; + logprob: number; + bytes?: number[] | null; + top_logprobs?: Array<{ + token?: string; + logprob?: number; + bytes?: number[] | null; + }>; + }): OpenAI.Chat.Completions.ChatCompletionTokenLogprob { + return { + token: logprob.token, + logprob: logprob.logprob, + bytes: logprob.bytes ?? null, + top_logprobs: (logprob.top_logprobs ?? []) + .filter( + (top): top is { token: string; logprob: number; bytes?: number[] | null } => + typeof top.token === "string" && typeof top.logprob === "number" + ) + .map(top => ({ + token: top.token, + logprob: top.logprob, + bytes: top.bytes ?? null, + })), + }; + } + // --- Private: stream helpers --- private createStreamState(): StreamState { @@ -511,26 +766,41 @@ export class ChatCompletionToResponsesConverter { serviceTier: null, toolCallCounter: -1, hasToolCall: false, + toolCallIndexes: new Map(), webSearchCount: 0, annotations: [], includeUsage: false, + includeLogprobs: false, }; } private makeChunk( delta: OpenAI.ChatCompletionChunk.Choice.Delta, finish_reason: OpenAI.ChatCompletionChunk.Choice["finish_reason"] = null, - usage?: OpenAI.CompletionUsage + usage?: OpenAI.CompletionUsage, + logprobs?: OpenAI.ChatCompletionChunk.Choice.Logprobs | null ): OpenAI.ChatCompletionChunk { - return { + const chunk: OpenAI.ChatCompletionChunk = { id: this.streamState.id, object: "chat.completion.chunk", created: this.streamState.created, model: this.streamState.model, service_tier: this.streamState.serviceTier, - choices: [{ index: 0, delta, finish_reason }], - ...(usage ? { usage } : {}), + choices: [ + { + index: 0, + delta, + finish_reason, + logprobs: logprobs ?? null, + }, + ], }; + if (this.streamState.includeUsage) { + chunk.usage = usage ?? null; + } else if (usage != null) { + chunk.usage = usage; + } + return chunk; } private handleResponseCreated( @@ -550,22 +820,51 @@ export class ChatCompletionToResponsesConverter { event: OpenAI.Responses.ResponseOutputItemAddedEvent ): OpenAI.ChatCompletionChunk | null { const item = event.item; - if (item.type === "function_call") { + if (item.type === "function_call" || item.type === "custom_tool_call") { this.streamState.hasToolCall = true; this.streamState.toolCallCounter++; - const fc = item as OpenAI.Responses.ResponseFunctionToolCall; + const index = this.streamState.toolCallCounter; + this.streamState.toolCallIndexes.set(item.call_id, index); + if (item.id) { + this.streamState.toolCallIndexes.set(item.id, index); + } + + if (item.type === "function_call") { + const fc = item as OpenAI.Responses.ResponseFunctionToolCall; + if (this.legacyFunctionMode) { + return this.makeChunk({ + role: "assistant", + content: null, + function_call: { name: fc.name, arguments: "" }, + }); + } + return this.makeChunk({ + role: "assistant", + content: null, + tool_calls: [ + { + index, + id: fc.call_id, + type: "function", + function: { name: fc.name, arguments: "" }, + }, + ], + }); + } + + const custom = item as OpenAI.Responses.ResponseCustomToolCall; return this.makeChunk({ role: "assistant", content: null, tool_calls: [ { - index: this.streamState.toolCallCounter, - id: fc.call_id, - type: "function", - function: { name: fc.name, arguments: "" }, + index, + id: custom.call_id, + type: "custom", + custom: { name: custom.name, input: "" }, }, ], - }); + } as any); } return null; } @@ -582,18 +881,34 @@ export class ChatCompletionToResponsesConverter { private handleTextDelta( event: OpenAI.Responses.ResponseTextDeltaEvent ): OpenAI.ChatCompletionChunk { - return this.makeChunk({ role: "assistant", content: event.delta }); + const logprobs = this.streamState.includeLogprobs + ? { + content: event.logprobs.map(logprob => this.convertTokenLogprob(logprob)), + refusal: null, + } + : undefined; + return this.makeChunk({ role: "assistant", content: event.delta }, null, undefined, logprobs); } private handleFunctionCallDelta( event: OpenAI.Responses.ResponseFunctionCallArgumentsDeltaEvent ): OpenAI.ChatCompletionChunk { + const index = + this.streamState.toolCallIndexes.get(event.item_id) ?? this.streamState.toolCallCounter; + if (this.legacyFunctionMode) { + return this.makeChunk({ + role: "assistant", + content: "", + function_call: { arguments: event.delta }, + }); + } + return this.makeChunk({ role: "assistant", content: "", tool_calls: [ { - index: this.streamState.toolCallCounter, + index, type: "function", function: { arguments: event.delta }, }, @@ -601,6 +916,24 @@ export class ChatCompletionToResponsesConverter { }); } + private handleCustomToolCallDelta( + event: OpenAI.Responses.ResponseCustomToolCallInputDeltaEvent + ): OpenAI.ChatCompletionChunk { + const index = + this.streamState.toolCallIndexes.get(event.item_id) ?? this.streamState.toolCallCounter; + return this.makeChunk({ + role: "assistant", + content: "", + tool_calls: [ + { + index, + type: "custom", + custom: { input: event.delta }, + }, + ], + } as any); + } + private handleRefusalDelta( event: OpenAI.Responses.ResponseRefusalDeltaEvent ): OpenAI.ChatCompletionChunk { @@ -634,7 +967,9 @@ export class ChatCompletionToResponsesConverter { const state = this.streamState; const resp = event.response; const finishReason = state.hasToolCall - ? ("tool_calls" as const) + ? this.legacyFunctionMode + ? ("function_call" as const) + : ("tool_calls" as const) : this.mapStatus(resp.status, resp.incomplete_details); const delta: any = { content: null }; @@ -666,7 +1001,7 @@ export class ChatCompletionToResponsesConverter { ): OpenAI.ChatCompletionChunk | OpenAI.ChatCompletionChunk[] { const state = this.streamState; const resp = event.response; - const finishChunk = this.makeChunk({}, "length"); + const finishChunk = this.makeChunk({}, this.mapStatus(resp.status, resp.incomplete_details)); const chunks: OpenAI.ChatCompletionChunk[] = [finishChunk]; if (state.includeUsage && resp.usage) { @@ -688,4 +1023,33 @@ export class ChatCompletionToResponsesConverter { private handleFailed(): OpenAI.ChatCompletionChunk { return this.makeChunk({ content: "" }, "stop"); } + + private recordStreamAnnotation(annotation: unknown): void { + if ( + annotation == null || + typeof annotation !== "object" || + !("type" in annotation) || + annotation.type !== "url_citation" || + !("title" in annotation) || + typeof annotation.title !== "string" || + !("url" in annotation) || + typeof annotation.url !== "string" || + !("start_index" in annotation) || + typeof annotation.start_index !== "number" || + !("end_index" in annotation) || + typeof annotation.end_index !== "number" + ) { + return; + } + + this.streamState.annotations.push({ + type: "url_citation", + url_citation: { + title: annotation.title, + url: annotation.url, + start_index: annotation.start_index, + end_index: annotation.end_index, + }, + }); + } }