From 01d526e21b1f0eca1417935780128929f0b14d83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Po=C5=99=C3=ADzek?= <33246262+Rhiz3K@users.noreply.github.com> Date: Thu, 22 Oct 2026 14:25:39 +0200 Subject: [PATCH 1/3] feat: use the OpenAI Responses API so tools keep reasoning Official OpenAI providers (no base URL or an api.openai.com host) now stream from /v1/responses statelessly (store: false). Encrypted reasoning items are replayed with function_call and function_call_output items within a run, so models such as gpt-6-luna and gpt-5.6 can call tools without turning reasoning off. Reasoning summaries are requested and dropped (and remembered per model) when the org or model refuses them. OpenAI compatible providers and custom base URLs keep Chat Completions, including the reasoning_effort "none" retry from 4672df0. Refs #8 --- CHANGELOG.md | 1 + src/backend/providers/openai-responses.ts | 219 +++++ src/backend/providers/openai.ts | 11 + tests/backend/assets/annotate.test.ts | 2 + tests/backend/ops/assistant-routes.test.ts | 2 + tests/backend/ops/routes.test.ts | 2 + tests/backend/ops/runs.test.ts | 2 + .../providers/openai-responses.test.ts | 760 ++++++++++++++++++ tests/backend/providers/providers.test.ts | 56 +- .../backend/providers/reasoning-usage.test.ts | 7 +- tests/backend/routes.test.ts | 2 + tests/backend/tasks/fixtures.ts | 2 + 12 files changed, 1057 insertions(+), 9 deletions(-) create mode 100644 src/backend/providers/openai-responses.ts create mode 100644 tests/backend/providers/openai-responses.test.ts 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/providers/openai-responses.ts b/src/backend/providers/openai-responses.ts new file mode 100644 index 0000000..f8b3904 --- /dev/null +++ b/src/backend/providers/openai-responses.ts @@ -0,0 +1,219 @@ +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) { + 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/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/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..215a81c --- /dev/null +++ b/tests/backend/providers/openai-responses.test.ts @@ -0,0 +1,760 @@ +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("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", From 735e0bafd06a3b0c3da3d8bbeab789f5a6a0d66e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Po=C5=99=C3=ADzek?= <33246262+Rhiz3K@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:09:49 +0200 Subject: [PATCH 2/3] fix: know the context window of current OpenAI models GPT-5.4 and later and every GPT-6 model hold 1,050,000 tokens, the GPT-5 chat snapshots 128,000 and GPT-5.4 mini/nano and GPT-5.6 cyber 400,000, per OpenAI's model pages. gpt-6-luna used to fall back to the 128k default. --- src/backend/context-window.ts | 5 +++++ tests/backend/compaction.test.ts | 13 +++++++++++++ 2 files changed, 18 insertions(+) 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/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); }); From cde46b2c3bd242a01ba2e799dc2dbf389c194884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Petr=20Po=C5=99=C3=ADzek?= <33246262+Rhiz3K@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:14:36 +0200 Subject: [PATCH 3/3] feat: log when OpenAI refuses reasoning summaries The retry without reasoning summaries was silent, so a missing Thinking block could not be told apart from a model that wrote no summary. Provider configs now carry the plugin log and the retry warns once per model with OpenAI's reason. --- src/backend/ops/routes.ts | 1 + src/backend/providers/openai-responses.ts | 3 ++ src/backend/providers/types.ts | 2 ++ src/backend/routes.ts | 1 + src/backend/tasks/runner.ts | 3 +- .../providers/openai-responses.test.ts | 31 +++++++++++++++++++ 6 files changed, 40 insertions(+), 1 deletion(-) 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 index f8b3904..608a814 100644 --- a/src/backend/providers/openai-responses.ts +++ b/src/backend/providers/openai-responses.ts @@ -112,6 +112,9 @@ export async function* streamResponses( 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; 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/providers/openai-responses.test.ts b/tests/backend/providers/openai-responses.test.ts index 215a81c..5515f25 100644 --- a/tests/backend/providers/openai-responses.test.ts +++ b/tests/backend/providers/openai-responses.test.ts @@ -502,6 +502,37 @@ describe("openAiAdapter Responses retries and errors", () => { }, ); + 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(