diff --git a/CHANGELOG.md b/CHANGELOG.md index febb33f..2e6e041 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - The terminal assistant lives in the panel bar beside the SSH terminal on Termix 26.10.3 and later, so it can be resized, moved to a side or the bottom, and ordered with the other panels - Panel beside the terminal host setting and panel settings for where it opens and how big it is +- OpenAI providers on api.openai.com use the Responses API, so reasoning models such as GPT-5.6 and GPT-6 keep their reasoning while calling tools. OpenAI compatible providers and custom base URLs still use Chat Completions ### Fixed diff --git a/src/backend/context-window.ts b/src/backend/context-window.ts index a72ccc4..6cf7d92 100644 --- a/src/backend/context-window.ts +++ b/src/backend/context-window.ts @@ -28,6 +28,11 @@ export const DEFAULT_CONTEXT_WINDOW = 128_000; const KNOWN_WINDOWS: Array<[RegExp, number]> = [ [/claude/i, 200_000], [/gpt-4\.1/i, 1_000_000], + // OpenAI's model pages: the chat snapshots hold 128k, the small 5.4 and + // cyber models 400k, GPT-5.4 and later and every GPT-6 1.05M. + [/gpt-5(\.\d+)?-chat/i, 128_000], + [/gpt-5\.4-(mini|nano)|gpt-5\.6-cyber/i, 400_000], + [/gpt-5\.([4-9]|\d{2,})|gpt-6/i, 1_050_000], [/gpt-5/i, 400_000], [/gpt-4o|gpt-4-turbo/i, 128_000], [/\bo[134](-|$)/i, 200_000], diff --git a/src/backend/ops/routes.ts b/src/backend/ops/routes.ts index 498b512..9b51991 100644 --- a/src/backend/ops/routes.ts +++ b/src/backend/ops/routes.ts @@ -166,6 +166,7 @@ export function registerOpsRoutes( ctx.fetch, await readPrivateAllowlist(ctx.settings), ), + log: ctx.log, }); /** The window and budget of a provider's model, from what it reports. */ diff --git a/src/backend/providers/openai-responses.ts b/src/backend/providers/openai-responses.ts new file mode 100644 index 0000000..608a814 --- /dev/null +++ b/src/backend/providers/openai-responses.ts @@ -0,0 +1,222 @@ +import { assertOk, readSseLines } from "./http.js"; +import type { + ChatChunk, + ChatRequest, + ProviderConfig, + ToolCall, +} from "./types.js"; +import { AiProviderError } from "./types.js"; + +// Some models and unverified organizations reject reasoning summaries. +const reasoningDisabled = new Set(); + +export function resetResponsesMemory(): void { + reasoningDisabled.clear(); +} + +export function usesResponsesApi(config: ProviderConfig): boolean { + if (config.providerType !== "openai") return false; + const base = config.baseUrl?.trim(); + if (!base) return true; + try { + return /(^|\.)api\.openai\.com$/.test(new URL(base).hostname); + } catch { + return false; + } +} + +function toResponsesInput(request: ChatRequest): unknown[] { + const input: unknown[] = []; + for (const message of request.messages) { + if (message.role === "tool") { + input.push({ + type: "function_call_output", + call_id: message.toolCallId, + output: message.content, + }); + continue; + } + + if (message.role === "assistant" && message.toolCalls?.length) { + for (const block of message.reasoningState ?? []) { + if (!block || typeof block !== "object" || Array.isArray(block)) { + continue; + } + const item = block as Record; + // With store:false, only encrypted reasoning can be replayed without ids. + if (item.type !== "reasoning" || !item.encrypted_content) continue; + const { id: _id, status: _status, ...reasoning } = item; + input.push(reasoning); + } + if (message.content) { + input.push({ role: "assistant", content: message.content }); + } + for (const call of message.toolCalls) { + input.push({ + type: "function_call", + call_id: call.id, + name: call.name, + arguments: JSON.stringify(call.arguments), + }); + } + continue; + } + + if (message.role !== "assistant" || message.content) { + input.push({ role: message.role, content: message.content }); + } + } + return input; +} + +export async function* streamResponses( + config: ProviderConfig, + request: ChatRequest, + url: string, + headers: Record, +): AsyncIterable { + const memoryKey = `${url}|${request.model}`; + let body: Record = { + model: request.model, + instructions: request.system, + input: toResponsesInput(request), + stream: true, + store: false, + ...(!reasoningDisabled.has(memoryKey) + ? { reasoning: { summary: "auto" } } + : {}), + ...(request.maxTokens !== undefined + ? { max_output_tokens: request.maxTokens } + : {}), + ...(request.tools.length + ? { + tools: request.tools.map((tool) => ({ + type: "function", + name: tool.name, + description: tool.description, + parameters: tool.parameters, + strict: false, + })), + } + : {}), + }; + const post = (payload: object) => + config.fetch(url, { + method: "POST", + headers, + signal: request.signal, + body: JSON.stringify(payload), + }); + + let response = await post(body); + if (response.status === 400) { + const detail = await response.text().catch(() => ""); + if (/reasoning|summar|encrypted/i.test(detail) && body.reasoning) { + config.log?.warn( + `OpenAI refused reasoning summaries for ${request.model}, continuing without them (no Thinking block): ${detail.slice(0, 300)}`, + ); + reasoningDisabled.add(memoryKey); + const { reasoning: _dropped, ...rest } = body; + body = rest; + response = await post(body); + } else { + response = new Response(detail, { + status: response.status, + statusText: response.statusText, + }); + } + } + await assertOk(response, "OpenAI"); + + const calls: ToolCall[] = []; + const reasoning: unknown[] = []; + let usage: { input_tokens?: number; output_tokens?: number } | null = null; + let stopReason: string | undefined; + let previousReasoningKey: string | undefined; + + for await (const data of readSseLines(response)) { + if (data === "[DONE]") break; + let frame: any; + try { + frame = JSON.parse(data); + } catch { + continue; + } + + switch (frame?.type) { + case "response.output_text.delta": + case "response.refusal.delta": + if (typeof frame.delta === "string" && frame.delta) { + yield { type: "text", text: frame.delta }; + } + break; + case "response.reasoning_summary_text.delta": + case "response.reasoning_text.delta": + if (typeof frame.delta === "string" && frame.delta) { + // Separate summaries and reasoning items only when they have text. + const key = `${frame.item_id}:${frame.summary_index}`; + if ( + previousReasoningKey !== undefined && + previousReasoningKey !== key + ) { + yield { type: "reasoning", text: "\n\n" }; + } + previousReasoningKey = key; + yield { type: "reasoning", text: frame.delta }; + } + break; + case "response.output_item.done": { + const item = frame.item; + if (item?.type === "reasoning") reasoning.push(item); + if (item?.type !== "function_call" || !item.name) break; + let args: Record = {}; + try { + const parsed = JSON.parse(item.arguments); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + args = parsed; + } + } catch { + // The tool's schema validation reports malformed arguments. + } + calls.push({ id: item.call_id, name: item.name, arguments: args }); + break; + } + case "response.completed": + case "response.incomplete": + if (frame.response?.usage) usage = frame.response.usage; + stopReason = + frame.type === "response.incomplete" + ? (frame.response?.incomplete_details?.reason ?? "incomplete") + : calls.length + ? "tool_calls" + : "stop"; + break; + case "response.failed": + throw new AiProviderError( + `OpenAI request failed: ${frame.response?.error?.message ?? "the response failed"}`, + ); + case "error": + throw new AiProviderError(`OpenAI request failed: ${frame.message}`); + } + } + + // A closed connection is only successful after a terminal event. + if (stopReason === undefined) { + throw new AiProviderError( + "OpenAI closed the stream before the response finished.", + ); + } + + for (const call of calls) yield { type: "tool_call", call }; + if (reasoning.length) yield { type: "reasoning_state", blocks: reasoning }; + if (usage) { + yield { + type: "usage", + usage: { + promptTokens: Number(usage.input_tokens) || 0, + completionTokens: Number(usage.output_tokens) || 0, + }, + }; + } + yield { type: "done", stopReason }; +} diff --git a/src/backend/providers/openai.ts b/src/backend/providers/openai.ts index a23fd5f..05bdeaa 100644 --- a/src/backend/providers/openai.ts +++ b/src/backend/providers/openai.ts @@ -1,4 +1,5 @@ import { assertOk, firstCount, joinUrl, readSseLines } from "./http.js"; +import { streamResponses, usesResponsesApi } from "./openai-responses.js"; import { createThinkSplitter } from "./think-tags.js"; import type { ChatChunk, @@ -80,6 +81,16 @@ export const openAiAdapter: ProviderAdapter = { }; if (config.apiKey) headers.Authorization = `Bearer ${config.apiKey}`; + if (usesResponsesApi(config)) { + yield* streamResponses( + config, + request, + joinUrl(baseFor(config), "responses"), + headers, + ); + return; + } + let body: Record = { model: request.model, messages: toOpenAiMessages(request), diff --git a/src/backend/providers/types.ts b/src/backend/providers/types.ts index 0149483..5d405e2 100644 --- a/src/backend/providers/types.ts +++ b/src/backend/providers/types.ts @@ -100,6 +100,8 @@ export interface ProviderConfig { baseUrl?: string | null; apiKey?: string | null; fetch: ProviderFetch; + /** Reports provider fallbacks to the server log. */ + log?: { warn(message: string): void }; } /** What a provider says about one of its models; what it leaves out is unknown. */ diff --git a/src/backend/routes.ts b/src/backend/routes.ts index 25afe9b..66d187e 100644 --- a/src/backend/routes.ts +++ b/src/backend/routes.ts @@ -119,6 +119,7 @@ export function registerAiRoutes( baseUrl: input.baseUrl, apiKey: input.apiKey, fetch: createProviderFetch(ctx.fetch, allowlist), + log: ctx.log, }; } diff --git a/src/backend/tasks/runner.ts b/src/backend/tasks/runner.ts index 1d352cb..b8504be 100644 --- a/src/backend/tasks/runner.ts +++ b/src/backend/tasks/runner.ts @@ -98,7 +98,7 @@ export function taskResult( } export async function providerConfig( - ctx: Pick, + ctx: Pick, provider: AiProviderWithSecret, ): Promise { return { @@ -109,6 +109,7 @@ export async function providerConfig( ctx.fetch, await readPrivateAllowlist(ctx.settings), ), + log: ctx.log, }; } diff --git a/tests/backend/assets/annotate.test.ts b/tests/backend/assets/annotate.test.ts index 23aa497..0b54a13 100644 --- a/tests/backend/assets/annotate.test.ts +++ b/tests/backend/assets/annotate.test.ts @@ -143,6 +143,8 @@ async function setup( await mock.actAs("user-1", () => repository.createProvider("user-1", { providerType: "openai", + // The mock speaks Chat Completions; official OpenAI uses the Responses API. + baseUrl: "https://llm.example/v1", label: "Work", apiKey: "sk-test", defaultModel: "gpt-5", diff --git a/tests/backend/compaction.test.ts b/tests/backend/compaction.test.ts index 91babc7..f07c7df 100644 --- a/tests/backend/compaction.test.ts +++ b/tests/backend/compaction.test.ts @@ -431,6 +431,19 @@ describe("context windows", () => { expect(defaultContextWindow("claude-opus-5")).toBe(200_000); expect(defaultContextWindow("gemini-2.5-pro")).toBe(1_000_000); expect(defaultContextWindow("gpt-4o-mini")).toBe(128_000); + expect(defaultContextWindow("gpt-5")).toBe(400_000); + expect(defaultContextWindow("gpt-5.2-codex")).toBe(400_000); + expect(defaultContextWindow("gpt-5-chat-latest")).toBe(128_000); + expect(defaultContextWindow("gpt-5.3-chat-latest")).toBe(128_000); + expect(defaultContextWindow("gpt-5.4-mini")).toBe(400_000); + expect(defaultContextWindow("gpt-5.6-cyber")).toBe(400_000); + expect(defaultContextWindow("gpt-5.4")).toBe(1_050_000); + expect(defaultContextWindow("gpt-5.5-pro")).toBe(1_050_000); + expect(defaultContextWindow("gpt-5.6-luna")).toBe(1_050_000); + expect(defaultContextWindow("gpt-5.6-terra")).toBe(1_050_000); + expect(defaultContextWindow("gpt-6-luna")).toBe(1_050_000); + expect(defaultContextWindow("gpt-6-astra")).toBe(1_050_000); + expect(defaultContextWindow("gpt-6.1-sol")).toBe(1_050_000); expect(defaultContextWindow("something-new")).toBe(128_000); expect(defaultContextWindow(null)).toBe(128_000); }); diff --git a/tests/backend/ops/assistant-routes.test.ts b/tests/backend/ops/assistant-routes.test.ts index 5868374..4c9d195 100644 --- a/tests/backend/ops/assistant-routes.test.ts +++ b/tests/backend/ops/assistant-routes.test.ts @@ -78,6 +78,8 @@ async function addProvider(target: TestServer): Promise { const created = await target.request("POST", "/providers", { body: { providerType: "openai", + // The mock speaks Chat Completions; official OpenAI uses the Responses API. + baseUrl: "https://llm.example/v1", label: "Work", apiKey: "sk-test", defaultModel: "gpt-5", diff --git a/tests/backend/ops/routes.test.ts b/tests/backend/ops/routes.test.ts index 86ef283..e7d4dd6 100644 --- a/tests/backend/ops/routes.test.ts +++ b/tests/backend/ops/routes.test.ts @@ -64,6 +64,8 @@ async function addProvider(target: TestServer): Promise { const created = await target.request("POST", "/providers", { body: { providerType: "openai", + // The mock speaks Chat Completions; official OpenAI uses the Responses API. + baseUrl: "https://llm.example/v1", label: "Work", apiKey: "sk-test", defaultModel: "gpt-5", diff --git a/tests/backend/ops/runs.test.ts b/tests/backend/ops/runs.test.ts index 41a7855..cdb3935 100644 --- a/tests/backend/ops/runs.test.ts +++ b/tests/backend/ops/runs.test.ts @@ -120,6 +120,8 @@ async function setup( const created = await server.request("POST", "/providers", { body: { providerType: "openai", + // The mock speaks Chat Completions; official OpenAI uses the Responses API. + baseUrl: "https://llm.example/v1", label: "Work", apiKey: "sk-test", defaultModel: "gpt-5", diff --git a/tests/backend/providers/openai-responses.test.ts b/tests/backend/providers/openai-responses.test.ts new file mode 100644 index 0000000..5515f25 --- /dev/null +++ b/tests/backend/providers/openai-responses.test.ts @@ -0,0 +1,791 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChatChunk } from "../../../src/backend/providers/types.js"; +import { AiProviderError } from "../../../src/backend/providers/types.js"; + +const providerFetch = vi.fn(); + +const { openAiAdapter } = + await import("../../../src/backend/providers/openai.js"); +const { resetResponsesMemory, usesResponsesApi } = + await import("../../../src/backend/providers/openai-responses.js"); + +function streamingResponse(lines: string[]): Response { + const encoder = new TextEncoder(); + return new Response( + new ReadableStream({ + start(controller) { + for (const line of lines) controller.enqueue(encoder.encode(line)); + controller.close(); + }, + }), + ); +} + +const sse = (payload: unknown) => `data: ${JSON.stringify(payload)}\n`; + +async function collect(iterable: AsyncIterable) { + const chunks: ChatChunk[] = []; + for await (const chunk of iterable) chunks.push(chunk); + return chunks; +} + +const REQUEST = { + model: "gpt-5", + system: "system", + messages: [{ role: "user" as const, content: "hi" }], + tools: [], +}; + +const OPENAI = { + fetch: providerFetch, + providerType: "openai" as const, +}; + +const COMPATIBLE = { + fetch: providerFetch, + providerType: "openai_compatible" as const, + baseUrl: "https://proxy.example/v1", +}; + +const RUN_TOOL = { + name: "run", + description: "Run a command", + parameters: { + type: "object", + properties: { cmd: { type: "string" } }, + required: ["cmd"], + }, +}; + +beforeEach(() => { + vi.resetAllMocks(); + resetResponsesMemory(); +}); + +describe("usesResponsesApi", () => { + it.each([ + { label: "OpenAI without a base URL", config: OPENAI, expected: true }, + { + label: "OpenAI at api.openai.com", + config: { ...OPENAI, baseUrl: "https://api.openai.com/v1" }, + expected: true, + }, + { + label: "OpenAI at a regional hostname", + config: { ...OPENAI, baseUrl: "https://eu.api.openai.com/v1" }, + expected: true, + }, + { + label: "OpenAI at a lookalike hostname", + config: { ...OPENAI, baseUrl: "https://notapi.openai.com/v1" }, + expected: false, + }, + { + label: "OpenAI at a proxy", + config: { ...OPENAI, baseUrl: "https://proxy.example/v1" }, + expected: false, + }, + { + label: "a compatible provider at api.openai.com", + config: { ...COMPATIBLE, baseUrl: "https://api.openai.com/v1" }, + expected: false, + }, + { + label: "an invalid URL", + config: { ...OPENAI, baseUrl: "not a URL" }, + expected: false, + }, + ])("routes $label", ({ config, expected }) => { + expect(usesResponsesApi(config)).toBe(expected); + expect(providerFetch).not.toHaveBeenCalled(); + }); +}); + +describe("openAiAdapter Responses requests", () => { + it("maps the system, token limit and tools without setting reasoning effort", async () => { + providerFetch.mockResolvedValueOnce( + streamingResponse([ + sse({ type: "response.completed" }), + "data: [DONE]\n", + ]), + ); + + const chunks = await collect( + openAiAdapter.streamChat( + { ...OPENAI, apiKey: "test-key" }, + { ...REQUEST, maxTokens: 512, tools: [RUN_TOOL] }, + ), + ); + + expect(providerFetch).toHaveBeenCalledTimes(1); + expect(providerFetch).toHaveBeenCalledWith( + "https://api.openai.com/v1/responses", + expect.objectContaining({ + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: "Bearer test-key", + }, + }), + ); + const body = JSON.parse(providerFetch.mock.calls[0][1].body); + expect(body).toEqual({ + model: REQUEST.model, + instructions: REQUEST.system, + input: REQUEST.messages, + store: false, + stream: true, + reasoning: { summary: "auto" }, + max_output_tokens: 512, + tools: [{ type: "function", ...RUN_TOOL, strict: false }], + }); + expect(body.reasoning).not.toHaveProperty("effort"); + expect(body).not.toHaveProperty("reasoning_effort"); + expect(chunks).toEqual([{ type: "done", stopReason: "stop" }]); + }); +}); + +describe("openAiAdapter Responses streaming", () => { + it("yields text deltas, completed usage and a stop reason", async () => { + providerFetch.mockResolvedValueOnce( + streamingResponse([ + sse({ type: "response.output_text.delta", delta: "Hello " }), + sse({ type: "response.output_text.delta", delta: "world." }), + sse({ + type: "response.completed", + response: { usage: { input_tokens: 42, output_tokens: 7 } }, + }), + "data: [DONE]\n", + ]), + ); + + const chunks = await collect(openAiAdapter.streamChat(OPENAI, REQUEST)); + + const body = JSON.parse(providerFetch.mock.calls[0][1].body); + expect(body.input).toEqual(REQUEST.messages); + expect(body.stream).toBe(true); + expect(chunks).toEqual([ + { type: "text", text: "Hello " }, + { type: "text", text: "world." }, + { type: "usage", usage: { promptTokens: 42, completionTokens: 7 } }, + { type: "done", stopReason: "stop" }, + ]); + }); + + it("separates nonempty reasoning deltas across items and summaries", async () => { + providerFetch.mockResolvedValueOnce( + streamingResponse([ + sse({ + type: "response.reasoning_summary_part.added", + item_id: "rs_1", + summary_index: 0, + }), + sse({ + type: "response.reasoning_summary_text.delta", + item_id: "rs_1", + summary_index: 0, + delta: "Check ", + }), + sse({ + type: "response.reasoning_summary_part.added", + item_id: "rs_1", + summary_index: 1, + }), + sse({ + type: "response.reasoning_summary_text.delta", + item_id: "rs_1", + summary_index: 1, + delta: "", + }), + sse({ + type: "response.reasoning_summary_text.delta", + item_id: "rs_1", + summary_index: 0, + delta: "the host.", + }), + sse({ + type: "response.reasoning_summary_text.delta", + item_id: "rs_1", + summary_index: 1, + delta: "Then answer.", + }), + sse({ + type: "response.reasoning_summary_text.delta", + item_id: "rs_2", + summary_index: 0, + delta: "Confirm", + }), + sse({ + type: "response.reasoning_text.delta", + item_id: "rs_2", + summary_index: 0, + delta: " the result.", + }), + sse({ type: "response.completed" }), + ]), + ); + + const chunks = await collect(openAiAdapter.streamChat(OPENAI, REQUEST)); + + const body = JSON.parse(providerFetch.mock.calls[0][1].body); + expect(body.reasoning).toEqual({ summary: "auto" }); + expect(chunks).toEqual([ + { type: "reasoning", text: "Check " }, + { type: "reasoning", text: "the host." }, + { type: "reasoning", text: "\n\n" }, + { type: "reasoning", text: "Then answer." }, + { type: "reasoning", text: "\n\n" }, + { type: "reasoning", text: "Confirm" }, + { type: "reasoning", text: " the result." }, + { type: "done", stopReason: "stop" }, + ]); + }); + + it("yields refusal deltas as text", async () => { + providerFetch.mockResolvedValueOnce( + streamingResponse([ + sse({ type: "response.refusal.delta", delta: "I cannot " }), + sse({ type: "response.refusal.delta", delta: "help with that." }), + sse({ type: "response.completed" }), + ]), + ); + + expect(await collect(openAiAdapter.streamChat(OPENAI, REQUEST))).toEqual([ + { type: "text", text: "I cannot " }, + { type: "text", text: "help with that." }, + { type: "done", stopReason: "stop" }, + ]); + }); + + it.each([ + { + label: "valid", + arguments: '{"cmd":"uptime"}', + expected: { cmd: "uptime" }, + }, + { label: "malformed", arguments: "{broken", expected: {} }, + ])( + "parses $label function call arguments from output_item.done", + async (call) => { + providerFetch.mockResolvedValueOnce( + streamingResponse([ + sse({ + type: "response.output_item.done", + item: { + type: "function_call", + call_id: "call_1", + name: "run", + arguments: call.arguments, + }, + }), + sse({ type: "response.completed" }), + ]), + ); + + const chunks = await collect( + openAiAdapter.streamChat(OPENAI, { ...REQUEST, tools: [RUN_TOOL] }), + ); + + const body = JSON.parse(providerFetch.mock.calls[0][1].body); + expect(body.tools).toEqual([ + { type: "function", ...RUN_TOOL, strict: false }, + ]); + expect(chunks).toEqual([ + { + type: "tool_call", + call: { id: "call_1", name: "run", arguments: call.expected }, + }, + { type: "done", stopReason: "tool_calls" }, + ]); + }, + ); + + it("uses the incomplete details as the stop reason", async () => { + providerFetch.mockResolvedValueOnce( + streamingResponse([ + sse({ + type: "response.incomplete", + response: { incomplete_details: { reason: "max_output_tokens" } }, + }), + ]), + ); + + const chunks = await collect( + openAiAdapter.streamChat(OPENAI, { ...REQUEST, maxTokens: 16 }), + ); + + const body = JSON.parse(providerFetch.mock.calls[0][1].body); + expect(body.max_output_tokens).toBe(16); + expect(chunks).toEqual([{ type: "done", stopReason: "max_output_tokens" }]); + }); + + it("round trips streamed reasoning and tool calls without stored item ids", async () => { + const reasoningItem = { + id: "rs_1", + type: "reasoning", + status: "completed", + summary: [], + encrypted_content: "enc", + }; + const toolCall = { + id: "call_1", + name: "run", + arguments: { cmd: "uptime" }, + }; + providerFetch + .mockResolvedValueOnce( + streamingResponse([ + sse({ type: "response.output_text.delta", delta: "Checking" }), + sse({ type: "response.output_item.done", item: reasoningItem }), + sse({ + type: "response.output_item.done", + item: { + type: "function_call", + call_id: toolCall.id, + name: toolCall.name, + arguments: JSON.stringify(toolCall.arguments), + }, + }), + sse({ type: "response.completed" }), + ]), + ) + .mockResolvedValueOnce( + streamingResponse([ + sse({ type: "response.output_text.delta", delta: "Up 3 days." }), + sse({ type: "response.completed" }), + ]), + ); + + const first = await collect( + openAiAdapter.streamChat(OPENAI, { ...REQUEST, tools: [RUN_TOOL] }), + ); + expect(first).toEqual([ + { type: "text", text: "Checking" }, + { type: "tool_call", call: toolCall }, + { type: "reasoning_state", blocks: [reasoningItem] }, + { type: "done", stopReason: "tool_calls" }, + ]); + + const second = await collect( + openAiAdapter.streamChat(OPENAI, { + ...REQUEST, + tools: [RUN_TOOL], + messages: [ + ...REQUEST.messages, + { + role: "assistant", + content: first + .flatMap((chunk) => (chunk.type === "text" ? [chunk.text] : [])) + .join(""), + toolCalls: first.flatMap((chunk) => + chunk.type === "tool_call" ? [chunk.call] : [], + ), + reasoningState: first.flatMap((chunk) => + chunk.type === "reasoning_state" ? chunk.blocks : [], + ), + }, + { role: "tool", toolCallId: "call_1", content: "up 3 days" }, + ], + }), + ); + + expect(providerFetch).toHaveBeenCalledTimes(2); + const firstBody = JSON.parse(providerFetch.mock.calls[0][1].body); + expect(firstBody.input).toEqual(REQUEST.messages); + const secondBody = JSON.parse(providerFetch.mock.calls[1][1].body); + expect(secondBody.input).toEqual([ + { role: "user", content: "hi" }, + { type: "reasoning", summary: [], encrypted_content: "enc" }, + { role: "assistant", content: "Checking" }, + { + type: "function_call", + call_id: "call_1", + name: "run", + arguments: '{"cmd":"uptime"}', + }, + { type: "function_call_output", call_id: "call_1", output: "up 3 days" }, + ]); + expect(second).toEqual([ + { type: "text", text: "Up 3 days." }, + { type: "done", stopReason: "stop" }, + ]); + }); + + it("omits reasoning without encrypted content from the next request", async () => { + providerFetch.mockResolvedValueOnce( + streamingResponse([sse({ type: "response.completed" })]), + ); + + await collect( + openAiAdapter.streamChat(OPENAI, { + ...REQUEST, + messages: [ + ...REQUEST.messages, + { + role: "assistant", + content: "", + toolCalls: [ + { id: "call_1", name: "run", arguments: { cmd: "uptime" } }, + ], + reasoningState: [ + { + type: "reasoning", + summary: [{ type: "summary_text", text: "Check uptime." }], + }, + ], + }, + { role: "tool", toolCallId: "call_1", content: "up 3 days" }, + ], + }), + ); + + expect(JSON.parse(providerFetch.mock.calls[0][1].body).input).toEqual([ + ...REQUEST.messages, + { + type: "function_call", + call_id: "call_1", + name: "run", + arguments: '{"cmd":"uptime"}', + }, + { type: "function_call_output", call_id: "call_1", output: "up 3 days" }, + ]); + }); +}); + +describe("openAiAdapter Responses retries and errors", () => { + it.each([ + { + model: "gpt-5", + message: + "Your organization must be verified to generate reasoning summaries.", + }, + { model: "gpt-4.1", message: "Encrypted content is not supported" }, + { model: "gpt-4.1", message: "reasoning.summary is not supported" }, + ])( + "remembers the reasoning fallback for $model after $message", + async ({ model, message }) => { + providerFetch + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message } }), { status: 400 }), + ) + .mockResolvedValueOnce( + streamingResponse([ + sse({ type: "response.output_text.delta", delta: "ok" }), + sse({ type: "response.completed" }), + ]), + ) + .mockResolvedValueOnce( + streamingResponse([sse({ type: "response.completed" })]), + ); + + const request = { ...REQUEST, model }; + const chunks = await collect(openAiAdapter.streamChat(OPENAI, request)); + await collect(openAiAdapter.streamChat(OPENAI, request)); + + expect(providerFetch).toHaveBeenCalledTimes(3); + expect(providerFetch.mock.calls.map(([url]) => url)).toEqual([ + "https://api.openai.com/v1/responses", + "https://api.openai.com/v1/responses", + "https://api.openai.com/v1/responses", + ]); + const initial = JSON.parse(providerFetch.mock.calls[0][1].body); + const retry = JSON.parse(providerFetch.mock.calls[1][1].body); + expect(initial.reasoning).toEqual({ summary: "auto" }); + const { reasoning, ...withoutReasoning } = initial; + expect(retry).toEqual(withoutReasoning); + expect(retry).not.toHaveProperty("reasoning"); + expect(JSON.parse(providerFetch.mock.calls[2][1].body)).toEqual(retry); + expect(chunks).toEqual([ + { type: "text", text: "ok" }, + { type: "done", stopReason: "stop" }, + ]); + }, + ); + + it("warns once when reasoning summaries are refused and remembers the fallback", async () => { + const warn = vi.fn(); + const config = { ...OPENAI, log: { warn } }; + const detail = "reasoning.summary is not supported for this model"; + providerFetch + .mockResolvedValueOnce(new Response(detail, { status: 400 })) + .mockResolvedValueOnce( + streamingResponse([sse({ type: "response.completed" })]), + ) + .mockResolvedValueOnce( + streamingResponse([sse({ type: "response.completed" })]), + ); + + await collect(openAiAdapter.streamChat(config, REQUEST)); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith( + `OpenAI refused reasoning summaries for ${REQUEST.model}, continuing without them (no Thinking block): ${detail}`, + ); + + await collect(openAiAdapter.streamChat(config, REQUEST)); + + expect(warn).toHaveBeenCalledTimes(1); + expect(providerFetch).toHaveBeenCalledTimes(3); + expect( + providerFetch.mock.calls.map( + ([, options]) => JSON.parse(options.body).reasoning, + ), + ).toEqual([{ summary: "auto" }, undefined, undefined]); + }); + + it("keeps reasoning fallback memory separate by base URL and model", async () => { + providerFetch + .mockResolvedValueOnce( + new Response("reasoning is unsupported", { status: 400 }), + ) + .mockImplementation(async () => + streamingResponse([sse({ type: "response.completed" })]), + ); + + await collect(openAiAdapter.streamChat(OPENAI, REQUEST)); + await collect( + openAiAdapter.streamChat(OPENAI, { ...REQUEST, model: "gpt-4.1" }), + ); + await collect( + openAiAdapter.streamChat( + { ...OPENAI, baseUrl: "https://eu.api.openai.com/v1" }, + REQUEST, + ), + ); + await collect(openAiAdapter.streamChat(OPENAI, REQUEST)); + + expect(providerFetch).toHaveBeenCalledTimes(5); + expect( + providerFetch.mock.calls.map( + ([, options]) => JSON.parse(options.body).reasoning, + ), + ).toEqual([ + { summary: "auto" }, + undefined, + { summary: "auto" }, + { summary: "auto" }, + undefined, + ]); + }); + + it("throws an unrelated 400 without retrying", async () => { + providerFetch.mockResolvedValueOnce( + new Response(JSON.stringify({ error: { message: "Invalid model" } }), { + status: 400, + }), + ); + + const result = collect(openAiAdapter.streamChat(OPENAI, REQUEST)); + + await expect(result).rejects.toBeInstanceOf(AiProviderError); + await expect(result).rejects.toMatchObject({ + status: 400, + message: "OpenAI request failed (400): Invalid model", + }); + expect(providerFetch).toHaveBeenCalledTimes(1); + const body = JSON.parse(providerFetch.mock.calls[0][1].body); + expect(body.reasoning).toEqual({ summary: "auto" }); + }); + + it.each([404, 405, 422, 500])( + "throws a Responses %i without falling back", + async (status) => { + providerFetch.mockResolvedValueOnce( + new Response("reasoning unavailable", { status }), + ); + + const result = collect(openAiAdapter.streamChat(OPENAI, REQUEST)); + + await expect(result).rejects.toBeInstanceOf(AiProviderError); + await expect(result).rejects.toMatchObject({ + status, + message: `OpenAI request failed (${status}): reasoning unavailable`, + }); + expect(providerFetch).toHaveBeenCalledTimes(1); + expect(providerFetch.mock.calls[0][0]).toBe( + "https://api.openai.com/v1/responses", + ); + const body = JSON.parse(providerFetch.mock.calls[0][1].body); + expect(body.input).toEqual(REQUEST.messages); + }, + ); + + it("retries a rejected reasoning option only once", async () => { + providerFetch.mockImplementation( + async () => new Response("reasoning unavailable", { status: 400 }), + ); + + await expect( + collect(openAiAdapter.streamChat(OPENAI, REQUEST)), + ).rejects.toMatchObject({ + status: 400, + message: "OpenAI request failed (400): reasoning unavailable", + }); + expect(providerFetch).toHaveBeenCalledTimes(2); + expect(JSON.parse(providerFetch.mock.calls[1][1].body)).not.toHaveProperty( + "reasoning", + ); + }); + + it.each([ + { label: "EOF", ending: [] }, + { label: "[DONE]", ending: ["data: [DONE]\n"] }, + ])("rejects $label before a terminal response event", async ({ ending }) => { + providerFetch.mockResolvedValueOnce( + streamingResponse([ + sse({ type: "response.output_text.delta", delta: "partial" }), + ...ending, + ]), + ); + + const result = collect(openAiAdapter.streamChat(OPENAI, REQUEST)); + + await expect(result).rejects.toBeInstanceOf(AiProviderError); + await expect(result).rejects.toThrow( + "OpenAI closed the stream before the response finished.", + ); + }); + + it.each([ + { + type: "response.failed", + response: { error: { message: "Generation failed" } }, + expected: "Generation failed", + }, + { type: "error", message: "Stream failed", expected: "Stream failed" }, + ])( + "rejects a $type event with its message", + async ({ expected, ...frame }) => { + providerFetch.mockResolvedValueOnce(streamingResponse([sse(frame)])); + + await expect( + collect(openAiAdapter.streamChat(OPENAI, REQUEST)), + ).rejects.toThrow(`OpenAI request failed: ${expected}`); + + expect(providerFetch).toHaveBeenCalledTimes(1); + const body = JSON.parse(providerFetch.mock.calls[0][1].body); + expect(body.input).toEqual(REQUEST.messages); + }, + ); +}); + +describe("openAiAdapter compatible chat requests", () => { + it("uses chat completions for a compatible provider at the OpenAI base URL", async () => { + providerFetch.mockResolvedValueOnce( + streamingResponse([ + sse({ choices: [{ delta: { content: "ok" }, finish_reason: "stop" }] }), + "data: [DONE]\n", + ]), + ); + + const chunks = await collect( + openAiAdapter.streamChat( + { ...COMPATIBLE, baseUrl: "https://api.openai.com/v1" }, + { ...REQUEST, maxTokens: 64 }, + ), + ); + + expect(providerFetch).toHaveBeenCalledTimes(1); + expect(providerFetch.mock.calls[0][0]).toBe( + "https://api.openai.com/v1/chat/completions", + ); + const body = JSON.parse(providerFetch.mock.calls[0][1].body); + expect(body).toEqual({ + model: REQUEST.model, + messages: [ + { role: "system", content: REQUEST.system }, + ...REQUEST.messages, + ], + stream: true, + stream_options: { include_usage: true }, + max_tokens: 64, + }); + expect(chunks).toEqual([ + { type: "text", text: "ok" }, + { type: "done", stopReason: "stop" }, + ]); + }); + + it.each([ + { + label: "includes the first reasoning-state block", + reasoningState: [ + { + id: "rs_1", + type: "reasoning", + status: "completed", + summary: [], + encrypted_content: "enc", + }, + ], + expected: { + id: "rs_1", + type: "reasoning", + status: "completed", + summary: [], + encrypted_content: "enc", + }, + }, + { + label: "includes compatible reasoning content", + reasoningState: [{ reasoning_content: "x" }], + expected: { reasoning_content: "x" }, + }, + ])( + "$label in assistant chat messages", + async ({ reasoningState, expected }) => { + providerFetch.mockResolvedValueOnce( + streamingResponse([ + sse({ + choices: [{ delta: { content: "done" }, finish_reason: "stop" }], + }), + ]), + ); + + const chunks = await collect( + openAiAdapter.streamChat(COMPATIBLE, { + ...REQUEST, + messages: [ + ...REQUEST.messages, + { + role: "assistant", + content: "Checking", + toolCalls: [ + { id: "call_1", name: "run", arguments: { cmd: "uptime" } }, + ], + reasoningState, + }, + { role: "tool", toolCallId: "call_1", content: "up 3 days" }, + ], + }), + ); + + expect(providerFetch).toHaveBeenCalledTimes(1); + expect(providerFetch.mock.calls[0][0]).toBe( + "https://proxy.example/v1/chat/completions", + ); + const body = JSON.parse(providerFetch.mock.calls[0][1].body); + expect(body.messages).toEqual([ + { role: "system", content: REQUEST.system }, + ...REQUEST.messages, + { + role: "assistant", + content: "Checking", + ...expected, + tool_calls: [ + { + id: "call_1", + type: "function", + function: { name: "run", arguments: '{"cmd":"uptime"}' }, + }, + ], + }, + { role: "tool", tool_call_id: "call_1", content: "up 3 days" }, + ]); + expect(chunks).toEqual([ + { type: "text", text: "done" }, + { type: "done", stopReason: "stop" }, + ]); + }, + ); +}); diff --git a/tests/backend/providers/providers.test.ts b/tests/backend/providers/providers.test.ts index 793b9cb..eadf8dd 100644 --- a/tests/backend/providers/providers.test.ts +++ b/tests/backend/providers/providers.test.ts @@ -64,7 +64,12 @@ describe("openAiAdapter", () => { const chunks = await collect( openAiAdapter.streamChat( - { fetch: providerFetch, providerType: "openai" }, + { + fetch: providerFetch, + providerType: "openai", + // The mock speaks Chat Completions; official OpenAI uses Responses API. + baseUrl: "https://llm.example/v1", + }, REQUEST, ), ); @@ -87,7 +92,12 @@ describe("openAiAdapter", () => { const chunks = await collect( openAiAdapter.streamChat( - { fetch: providerFetch, providerType: "openai" }, + { + fetch: providerFetch, + providerType: "openai", + // The mock speaks Chat Completions; official OpenAI uses Responses API. + baseUrl: "https://llm.example/v1", + }, REQUEST, ), ); @@ -111,7 +121,12 @@ describe("openAiAdapter", () => { const chunks = await collect( openAiAdapter.streamChat( - { fetch: providerFetch, providerType: "openai" }, + { + fetch: providerFetch, + providerType: "openai", + // The mock speaks Chat Completions; official OpenAI uses Responses API. + baseUrl: "https://llm.example/v1", + }, REQUEST, ), ); @@ -305,7 +320,12 @@ describe("assertOk error messages", () => { await expect( collect( openAiAdapter.streamChat( - { fetch: providerFetch, providerType: "openai" }, + { + fetch: providerFetch, + providerType: "openai", + // The mock speaks Chat Completions; official OpenAI uses Responses API. + baseUrl: "https://llm.example/v1", + }, REQUEST, ), ), @@ -323,7 +343,12 @@ describe("assertOk error messages", () => { await expect( collect( openAiAdapter.streamChat( - { fetch: providerFetch, providerType: "openai" }, + { + fetch: providerFetch, + providerType: "openai", + // The mock speaks Chat Completions; official OpenAI uses Responses API. + baseUrl: "https://llm.example/v1", + }, REQUEST, ), ), @@ -338,7 +363,12 @@ describe("assertOk error messages", () => { await expect( collect( openAiAdapter.streamChat( - { fetch: providerFetch, providerType: "openai" }, + { + fetch: providerFetch, + providerType: "openai", + // The mock speaks Chat Completions; official OpenAI uses Responses API. + baseUrl: "https://llm.example/v1", + }, REQUEST, ), ), @@ -351,7 +381,12 @@ describe("assertOk error messages", () => { await expect( collect( openAiAdapter.streamChat( - { fetch: providerFetch, providerType: "openai" }, + { + fetch: providerFetch, + providerType: "openai", + // The mock speaks Chat Completions; official OpenAI uses Responses API. + baseUrl: "https://llm.example/v1", + }, REQUEST, ), ), @@ -386,7 +421,12 @@ describe("the reply ceiling", () => { providerFetch.mockResolvedValue(streamingResponse(["data: [DONE]\n\n"])); const openai = await body( openAiAdapter.streamChat( - { fetch: providerFetch, providerType: "openai" }, + { + fetch: providerFetch, + providerType: "openai", + // The mock speaks Chat Completions; official OpenAI uses Responses API. + baseUrl: "https://llm.example/v1", + }, capped, ), ); diff --git a/tests/backend/providers/reasoning-usage.test.ts b/tests/backend/providers/reasoning-usage.test.ts index 51fa2e2..61bd5bf 100644 --- a/tests/backend/providers/reasoning-usage.test.ts +++ b/tests/backend/providers/reasoning-usage.test.ts @@ -74,7 +74,12 @@ describe("openAiAdapter reasoning effort", () => { ); const chunks = await collect( openAiAdapter.streamChat( - { fetch: providerFetch, providerType: "openai" }, + { + fetch: providerFetch, + providerType: "openai", + // The mock speaks Chat Completions; official OpenAI uses Responses API. + baseUrl: "https://llm.example/v1", + }, { ...REQUEST, model: "gpt-5.6-terra", diff --git a/tests/backend/routes.test.ts b/tests/backend/routes.test.ts index 82710ea..a13dc54 100644 --- a/tests/backend/routes.test.ts +++ b/tests/backend/routes.test.ts @@ -194,6 +194,8 @@ describe("chat stream", () => { const created = await server.request("POST", "/providers", { body: { providerType: "openai", + // The mock speaks Chat Completions; official OpenAI uses the Responses API. + baseUrl: "https://llm.example/v1", label: "Work", apiKey: "sk-test", defaultModel: "gpt-5", diff --git a/tests/backend/tasks/fixtures.ts b/tests/backend/tasks/fixtures.ts index bc229d3..ff1fd76 100644 --- a/tests/backend/tasks/fixtures.ts +++ b/tests/backend/tasks/fixtures.ts @@ -112,6 +112,8 @@ export async function prepare(server: TestServer, user = "user-1") { user, body: { providerType: "openai", + // The mock speaks Chat Completions; official OpenAI uses the Responses API. + baseUrl: "https://llm.example/v1", label: "Work", apiKey: "sk-test", defaultModel: "gpt-5",