From d87b8204f365054e09fc08717c850cc303be8934 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 6 Oct 2026 19:26:28 +0200 Subject: [PATCH] feat(ai): send reasoning on the Codex wire and hand its items back between tool rounds A call that carries a reasoning level sends reasoning {effort, summary} with include [reasoning.encrypted_content]; off is effort none. Summary titles are reported as soon as the bold first line closes, the text when the part is done. Reasoning items come back as provider state and are re-inserted before the next round's message and calls, only for the slug that made them. A 400 that names a reasoning parameter is answered with the lesser request the backend lists (no summary, the nearest supported effort, no reasoning), remembered per slug. Calls without a level keep reasoning null and include []. --- src/lib/ai/__tests__/codex-reasoning.test.ts | 404 ++++++++++++++++++ .../fixtures/reasoning/codex-after-tool.sse | 51 +++ .../reasoning/codex-effort-rejected.json | 8 + .../reasoning/codex-summary-rejected.json | 8 + .../fixtures/reasoning/codex-summary.sse | 81 ++++ .../fixtures/reasoning/codex-tool-round.sse | 39 ++ src/lib/ai/codex-client.ts | 337 ++++++++++++++- 7 files changed, 906 insertions(+), 22 deletions(-) create mode 100644 src/lib/ai/__tests__/codex-reasoning.test.ts create mode 100644 src/lib/ai/__tests__/fixtures/reasoning/codex-after-tool.sse create mode 100644 src/lib/ai/__tests__/fixtures/reasoning/codex-effort-rejected.json create mode 100644 src/lib/ai/__tests__/fixtures/reasoning/codex-summary-rejected.json create mode 100644 src/lib/ai/__tests__/fixtures/reasoning/codex-summary.sse create mode 100644 src/lib/ai/__tests__/fixtures/reasoning/codex-tool-round.sse diff --git a/src/lib/ai/__tests__/codex-reasoning.test.ts b/src/lib/ai/__tests__/codex-reasoning.test.ts new file mode 100644 index 000000000..4b640e5f0 --- /dev/null +++ b/src/lib/ai/__tests__/codex-reasoning.test.ts @@ -0,0 +1,404 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { CodexClient } from "../codex-client"; +import { clearCodexSlugCache } from "../codex-slug-cache"; +import { resetReasoningDialectCache } from "../reasoning/dialect-cache"; +import type { + AiMessage, + AiReasoningEvent, + CompletionParams, + CompletionResult, +} from "../types"; + +/** + * v1.41 — Codex reasoning at the wire. The SSE fixtures were recorded against + * the live backend on 2026-10-06 with throwaway prompts (no health data) and + * sanitised: encrypted reasoning content replaced by placeholders, account + * identifiers and echoed instructions removed. The 400 bodies are the + * backend's own answers to an unsupported effort and summary value. + */ +const FIXTURES = join(__dirname, "fixtures/reasoning"); +const fixture = (name: string) => readFileSync(join(FIXTURES, name), "utf8"); + +function sseResponse(body: string): Response { + const encoder = new TextEncoder(); + // Split into uneven chunks so event boundaries fall mid-chunk, as on a + // real connection. + const chunks: string[] = []; + for (let i = 0; i < body.length; i += 173) + chunks.push(body.slice(i, i + 173)); + const stream = new ReadableStream({ + start(controller) { + for (const c of chunks) controller.enqueue(encoder.encode(c)); + controller.close(); + }, + }); + return new Response(stream, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); +} + +function errorResponse(status: number, body: string): Response { + return new Response(body, { status }); +} + +function client() { + return new CodexClient({ + accessToken: "test-token", + accountId: "acct-test", + onTokenRefresh: vi + .fn() + .mockResolvedValue({ accessToken: "x", accountId: "acct-test" }), + slugChain: ["gpt-5.5"], + }); +} + +function params(extra: Partial = {}): CompletionParams { + return { + system: "You are a terse assistant.", + messages: [{ role: "user", content: "How long was the train moving?" }], + ...extra, + }; +} + +function sentBody(mock: ReturnType, call = 0) { + return JSON.parse(mock.mock.calls[call][1].body); +} + +beforeEach(() => { + vi.restoreAllMocks(); + clearCodexSlugCache(); + resetReasoningDialectCache(); +}); + +describe("Codex reasoning request", () => { + it("sends today's wire when the call asks for no reasoning", async () => { + const mock = vi + .fn() + .mockImplementation(async () => + sseResponse(fixture("codex-summary.sse")), + ); + vi.stubGlobal("fetch", mock); + const onReasoning = vi.fn(); + const result = await client().generateCompletion(params({ onReasoning })); + const body = sentBody(mock); + expect(body.reasoning).toBeNull(); + expect(body.include).toEqual([]); + // No reasoning asked → no events, no reasoning block, no state. + expect(onReasoning).not.toHaveBeenCalled(); + expect(result.reasoning).toBeUndefined(); + expect(result.providerState).toBeUndefined(); + }); + + it("asks for effort, a summary and the encrypted items", async () => { + const mock = vi + .fn() + .mockImplementation(async () => + sseResponse(fixture("codex-summary.sse")), + ); + vi.stubGlobal("fetch", mock); + await client().generateCompletion( + params({ reasoning: { effort: "medium", summaries: true } }), + ); + const body = sentBody(mock); + expect(body.reasoning).toEqual({ effort: "medium", summary: "auto" }); + expect(body.include).toEqual(["reasoning.encrypted_content"]); + expect(body.store).toBe(false); + expect(body.stream).toBe(true); + }); + + it("leaves the summary out when none is wanted", async () => { + const mock = vi + .fn() + .mockImplementation(async () => + sseResponse(fixture("codex-summary.sse")), + ); + vi.stubGlobal("fetch", mock); + await client().generateCompletion( + params({ reasoning: { effort: "high", summaries: false } }), + ); + expect(sentBody(mock).reasoning).toEqual({ effort: "high" }); + }); + + it("switches reasoning off with effort none, without encrypted items", async () => { + const mock = vi + .fn() + .mockImplementation(async () => + sseResponse(fixture("codex-after-tool.sse")), + ); + vi.stubGlobal("fetch", mock); + const result = await client().generateCompletion( + params({ reasoning: { effort: "off", summaries: true } }), + ); + const body = sentBody(mock); + expect(body.reasoning).toEqual({ effort: "none" }); + expect(body.include).toEqual([]); + expect(result.reasoning?.downgradedTo).toBeUndefined(); + }); +}); + +describe("Codex reasoning stream", () => { + it("reports the title live, then the text, then done", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockImplementation(async () => + sseResponse(fixture("codex-summary.sse")), + ), + ); + const events: AiReasoningEvent[] = []; + const result = await client().generateCompletion( + params({ + reasoning: { effort: "medium", summaries: true }, + onReasoning: (e) => events.push(e), + }), + ); + expect(events).toEqual([ + { kind: "title", text: "Calculating elapsed time minus offset" }, + { kind: "text", text: "**Calculating elapsed time minus offset**" }, + { kind: "done", text: "" }, + ]); + expect(result.content).toBe( + "The train was moving for 3 hours and 10 minutes.", + ); + expect(result.reasoning).toEqual({ + summary: ["**Calculating elapsed time minus offset**"], + tokens: 33, + }); + // Reasoning tokens are inside the billed total. + expect(result.tokensUsed).toBe(101); + }); + + it("keeps the reasoning items of a tool round as provider state", async () => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockImplementation(async () => + sseResponse(fixture("codex-tool-round.sse")), + ), + ); + const result = await client().generateCompletion( + params({ reasoning: { effort: "low", summaries: true } }), + ); + expect(result.finishReason).toBe("tool_calls"); + expect(result.toolCalls).toEqual([ + { + id: "call_edjM3le4GcXAsF7vywK4kQgY", + name: "get_weather", + arguments: '{"city":"Paris"}', + }, + ]); + expect(result.reasoning).toEqual({ summary: [], tokens: 16 }); + expect(result.providerState?.providerType).toBe("codex"); + expect(result.providerState?.model).toBe("gpt-5.5"); + expect(result.providerState?.items).toHaveLength(1); + expect(result.providerState?.items[0]).toMatchObject({ + type: "reasoning", + id: "rs_03743ca81429e023016ac52522f51c87d297ef373ae4b945c9", + encrypted_content: expect.stringMatching(/^enc-fixture-/), + }); + }); +}); + +describe("Codex reasoning round trip", () => { + async function firstRound(): Promise { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockImplementation(async () => + sseResponse(fixture("codex-tool-round.sse")), + ), + ); + return client().generateCompletion( + params({ reasoning: { effort: "low", summaries: true } }), + ); + } + + function secondRoundMessages(first: CompletionResult): AiMessage[] { + return [ + { role: "user", content: "What is the weather in the capital?" }, + { + role: "assistant", + content: first.content, + toolCalls: first.toolCalls, + providerState: first.providerState, + }, + { + role: "tool", + toolCallId: first.toolCalls![0].id, + content: '{"tempC":18,"sky":"cloudy"}', + }, + ]; + } + + it("hands the reasoning item back unchanged before the call it led to", async () => { + const first = await firstRound(); + const mock = vi + .fn() + .mockImplementation(async () => + sseResponse(fixture("codex-after-tool.sse")), + ); + vi.stubGlobal("fetch", mock); + const second = await client().generateCompletion( + params({ + messages: secondRoundMessages(first), + reasoning: { effort: "low", summaries: true }, + }), + ); + const input = sentBody(mock).input as Array>; + expect(input.map((i) => i.type)).toEqual([ + "message", + "reasoning", + "message", + "function_call", + "function_call_output", + ]); + expect(input[1]).toEqual(first.providerState!.items[0]); + expect(second.content).toBe("Paris weather: 18°C, cloudy."); + }); + + it("drops state made for another slug or by another provider", async () => { + const first = await firstRound(); + const mock = vi + .fn() + .mockImplementation(async () => + sseResponse(fixture("codex-after-tool.sse")), + ); + vi.stubGlobal("fetch", mock); + const messages = secondRoundMessages(first); + messages[1] = { + ...messages[1], + providerState: { ...first.providerState!, model: "gpt-5.4" }, + }; + await client().generateCompletion(params({ messages })); + messages[1] = { + ...messages[1], + providerState: { ...first.providerState!, providerType: "anthropic" }, + }; + await client().generateCompletion(params({ messages })); + for (const call of [0, 1]) { + const types = (sentBody(mock, call).input as Array<{ type: string }>).map( + (i) => i.type, + ); + expect(types).not.toContain("reasoning"); + } + }); +}); + +describe("Codex reasoning refusals", () => { + it("retries without the summary the backend refused, and remembers", async () => { + const mock = vi + .fn() + .mockResolvedValueOnce( + errorResponse(400, fixture("codex-summary-rejected.json")), + ) + .mockImplementation(async () => + sseResponse(fixture("codex-summary.sse")), + ); + vi.stubGlobal("fetch", mock); + const c = client(); + const result = await c.generateCompletion( + params({ reasoning: { effort: "medium", summaries: true } }), + ); + expect(result.content).toBe( + "The train was moving for 3 hours and 10 minutes.", + ); + expect(sentBody(mock, 0).reasoning).toEqual({ + effort: "medium", + summary: "auto", + }); + expect(sentBody(mock, 1).reasoning).toEqual({ effort: "medium" }); + // Same level, only the summary went: not a downgrade. + expect(result.reasoning?.downgradedTo).toBeUndefined(); + + await c.generateCompletion( + params({ reasoning: { effort: "medium", summaries: true } }), + ); + expect(mock).toHaveBeenCalledTimes(3); + expect(sentBody(mock, 2).reasoning).toEqual({ effort: "medium" }); + }); + + it("steps down to the nearest effort the slug names, and remembers", async () => { + // The recorded refusal, with the slug's list narrowed to below "high". + const refusal = fixture("codex-effort-rejected.json") + .replace("'minimal'", "'high'") + .replace( + "'none', 'low', 'medium', 'high', and 'xhigh'", + "'none', 'low', and 'medium'", + ); + const mock = vi + .fn() + .mockResolvedValueOnce(errorResponse(400, refusal)) + .mockImplementation(async () => + sseResponse(fixture("codex-summary.sse")), + ); + vi.stubGlobal("fetch", mock); + const c = client(); + const result = await c.generateCompletion( + params({ reasoning: { effort: "high", summaries: true } }), + ); + expect(sentBody(mock, 1).reasoning).toEqual({ + effort: "medium", + summary: "auto", + }); + expect(result.reasoning?.downgradedTo).toBe("medium"); + + const again = await c.generateCompletion( + params({ reasoning: { effort: "high", summaries: true } }), + ); + expect(mock).toHaveBeenCalledTimes(3); + expect(sentBody(mock, 2).reasoning.effort).toBe("medium"); + expect(again.reasoning?.downgradedTo).toBe("medium"); + }); + + it("drops reasoning entirely when the refusal names no alternative", async () => { + const mock = vi + .fn() + .mockResolvedValueOnce( + errorResponse( + 400, + JSON.stringify({ + error: { + message: "Reasoning is not supported for this model.", + type: "invalid_request_error", + param: "reasoning", + }, + }), + ), + ) + .mockImplementation(async () => + sseResponse(fixture("codex-after-tool.sse")), + ); + vi.stubGlobal("fetch", mock); + const result = await client().generateCompletion( + params({ reasoning: { effort: "low", summaries: false } }), + ); + expect(sentBody(mock, 1).reasoning).toBeNull(); + expect(sentBody(mock, 1).include).toEqual([]); + expect(result.reasoning?.downgradedTo).toBe("off"); + expect(result.content).toBe("Paris weather: 18°C, cloudy."); + }); + + it("does not learn from a 400 that is not about reasoning", async () => { + const mock = vi.fn().mockResolvedValue( + errorResponse( + 400, + JSON.stringify({ + error: { message: "Invalid schema for function 'get_weather'." }, + }), + ), + ); + vi.stubGlobal("fetch", mock); + await expect( + client().generateCompletion( + params({ reasoning: { effort: "low", summaries: true } }), + ), + ).rejects.toMatchObject({ httpStatus: 400 }); + expect(mock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/ai/__tests__/fixtures/reasoning/codex-after-tool.sse b/src/lib/ai/__tests__/fixtures/reasoning/codex-after-tool.sse new file mode 100644 index 000000000..391ec2cd8 --- /dev/null +++ b/src/lib/ai/__tests__/fixtures/reasoning/codex-after-tool.sse @@ -0,0 +1,51 @@ +event: response.created +data: {"type":"response.created","response":{"id":"resp_03743ca81429e023016ac52524c4c487d284b7ab8375c47930","object":"response","created_at":1791304996,"status":"in_progress","access_programs":null,"background":false,"completed_at":null,"error":null,"frequency_penalty":0,"incomplete_details":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.5","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0,"previous_response_id":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"low","mode":"standard","summary":"detailed"},"service_tier":"auto","store":false,"temperature":1,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_03743ca81429e023016ac52524c4c487d284b7ab8375c47930","object":"response","created_at":1791304996,"status":"in_progress","access_programs":null,"background":false,"completed_at":null,"error":null,"frequency_penalty":0,"incomplete_details":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.5","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0,"previous_response_id":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"low","mode":"standard","summary":"detailed"},"service_tier":"auto","store":false,"temperature":1,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","type":"message","status":"in_progress","content":[],"phase":"final_answer","role":"assistant"},"output_index":0,"sequence_number":2} + +event: response.content_part.added +data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":""},"sequence_number":3} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"Paris","item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","logprobs":[],"output_index":0,"sequence_number":4} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" weather","item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","logprobs":[],"output_index":0,"sequence_number":5} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":":","item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","logprobs":[],"output_index":0,"sequence_number":6} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" ","item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","logprobs":[],"output_index":0,"sequence_number":7} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"18","item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","logprobs":[],"output_index":0,"sequence_number":8} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"°C","item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","logprobs":[],"output_index":0,"sequence_number":9} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","logprobs":[],"output_index":0,"sequence_number":10} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":" cloudy","item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","logprobs":[],"output_index":0,"sequence_number":11} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":".","item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","logprobs":[],"output_index":0,"sequence_number":12} + +event: response.output_text.done +data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","logprobs":[],"output_index":0,"sequence_number":13,"text":"Paris weather: 18°C, cloudy."} + +event: response.content_part.done +data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","output_index":0,"part":{"type":"output_text","annotations":[],"logprobs":[],"text":"Paris weather: 18°C, cloudy."},"sequence_number":14} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"msg_03743ca81429e023016ac52525a89c87d280694ca111c7d02e","type":"message","status":"completed","content":[{"type":"output_text","annotations":[],"logprobs":[],"text":"Paris weather: 18°C, cloudy."}],"phase":"final_answer","role":"assistant"},"output_index":0,"sequence_number":15} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_03743ca81429e023016ac52524c4c487d284b7ab8375c47930","object":"response","created_at":1791304996,"status":"completed","access_programs":null,"background":false,"completed_at":1791304997,"error":null,"frequency_penalty":0,"incomplete_details":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.5","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0,"previous_response_id":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"low","mode":"standard","summary":"detailed"},"service_tier":"default","store":false,"temperature":1,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":205,"input_tokens_details":{"cache_write_tokens":0,"cached_tokens":0},"output_tokens":13,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":218},"user":null,"metadata":{}},"sequence_number":16} + diff --git a/src/lib/ai/__tests__/fixtures/reasoning/codex-effort-rejected.json b/src/lib/ai/__tests__/fixtures/reasoning/codex-effort-rejected.json new file mode 100644 index 000000000..f000a5253 --- /dev/null +++ b/src/lib/ai/__tests__/fixtures/reasoning/codex-effort-rejected.json @@ -0,0 +1,8 @@ +{ + "error": { + "message": "Unsupported value: 'minimal' is not supported with the 'gpt-5.5' model. Supported values are: 'none', 'low', 'medium', 'high', and 'xhigh'.", + "type": "invalid_request_error", + "param": "reasoning.effort", + "code": "unsupported_value" + } +} diff --git a/src/lib/ai/__tests__/fixtures/reasoning/codex-summary-rejected.json b/src/lib/ai/__tests__/fixtures/reasoning/codex-summary-rejected.json new file mode 100644 index 000000000..4b15d9294 --- /dev/null +++ b/src/lib/ai/__tests__/fixtures/reasoning/codex-summary-rejected.json @@ -0,0 +1,8 @@ +{ + "error": { + "message": "Invalid value: 'verbose'. Supported values are: 'concise', 'detailed', and 'auto'.", + "type": "invalid_request_error", + "param": "reasoning.summary", + "code": "invalid_value" + } +} diff --git a/src/lib/ai/__tests__/fixtures/reasoning/codex-summary.sse b/src/lib/ai/__tests__/fixtures/reasoning/codex-summary.sse new file mode 100644 index 000000000..6e9c6c616 --- /dev/null +++ b/src/lib/ai/__tests__/fixtures/reasoning/codex-summary.sse @@ -0,0 +1,81 @@ +event: response.created +data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_01241f364fbd794f016ac5253e2ce08191afe0c9a23644957f","object":"response","created_at":1791305022,"completed_at":null,"status":"in_progress","background":false,"frequency_penalty":0,"presence_penalty":0,"moderation":null,"model":"gpt-5.5","access_programs":null,"previous_response_id":null,"output":[],"error":null,"incomplete_details":null,"tool_choice":"auto","parallel_tool_calls":false,"prompt_cache_retention":"24h","text":{"format":{"type":"text"},"verbosity":"medium"},"top_p":0.98,"top_logprobs":0,"temperature":1,"reasoning":{"effort":"medium","summary":"detailed","context":"current_turn","mode":"standard"},"user":null,"usage":null,"store":false,"metadata":{},"max_output_tokens":null,"max_tool_calls":null,"service_tier":"auto","truncation":"disabled"}} + +event: response.in_progress +data: {"type":"response.in_progress","sequence_number":1,"response":{"id":"resp_01241f364fbd794f016ac5253e2ce08191afe0c9a23644957f","object":"response","created_at":1791305022,"completed_at":null,"status":"in_progress","background":false,"frequency_penalty":0,"presence_penalty":0,"moderation":null,"model":"gpt-5.5","access_programs":null,"previous_response_id":null,"output":[],"error":null,"incomplete_details":null,"tool_choice":"auto","parallel_tool_calls":false,"prompt_cache_retention":"24h","text":{"format":{"type":"text"},"verbosity":"medium"},"top_p":0.98,"top_logprobs":0,"temperature":1,"reasoning":{"effort":"medium","summary":"detailed","context":"current_turn","mode":"standard"},"user":null,"usage":null,"store":false,"metadata":{},"max_output_tokens":null,"max_tool_calls":null,"service_tier":"auto","truncation":"disabled"}} + +event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"type":"reasoning","id":"rs_01241f364fbd794f016ac5253e96a081919fae3ca9ea0bea08","summary":[],"content":[],"encrypted_content":"enc-fixture-1"}} + +event: response.reasoning_summary_part.added +data: {"type":"response.reasoning_summary_part.added","sequence_number":3,"item_id":"rs_01241f364fbd794f016ac5253e96a081919fae3ca9ea0bea08","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":""}} + +event: response.reasoning_summary_text.delta +data: {"type":"response.reasoning_summary_text.delta","sequence_number":4,"item_id":"rs_01241f364fbd794f016ac5253e96a081919fae3ca9ea0bea08","output_index":0,"summary_index":0,"delta":"**Calculating elapsed time minus offset**"} + +event: response.reasoning_summary_text.done +data: {"type":"response.reasoning_summary_text.done","sequence_number":5,"item_id":"rs_01241f364fbd794f016ac5253e96a081919fae3ca9ea0bea08","output_index":0,"summary_index":0,"text":"**Calculating elapsed time minus offset**"} + +event: response.reasoning_summary_part.done +data: {"type":"response.reasoning_summary_part.done","sequence_number":6,"item_id":"rs_01241f364fbd794f016ac5253e96a081919fae3ca9ea0bea08","output_index":0,"summary_index":0,"part":{"type":"summary_text","text":"**Calculating elapsed time minus offset**"}} + +event: response.output_item.done +data: {"type":"response.output_item.done","sequence_number":7,"output_index":0,"item":{"type":"reasoning","id":"rs_01241f364fbd794f016ac5253e96a081919fae3ca9ea0bea08","summary":[{"type":"summary_text","text":"**Calculating elapsed time minus offset**"}],"content":[],"encrypted_content":"enc-fixture-2"}} + +event: response.output_item.added +data: {"type":"response.output_item.added","sequence_number":8,"output_index":1,"item":{"type":"message","id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","role":"assistant","status":"in_progress","content":[],"phase":"final_answer"}} + +event: response.content_part.added +data: {"type":"response.content_part.added","sequence_number":9,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"part":{"type":"output_text","text":"","annotations":[],"logprobs":[]}} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":10,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":"The","logprobs":[]} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":11,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":" train","logprobs":[]} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":12,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":" was","logprobs":[]} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":13,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":" moving","logprobs":[]} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":14,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":" for","logprobs":[]} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":15,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":" ","logprobs":[]} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":16,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":"3","logprobs":[]} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":17,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":" hours","logprobs":[]} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":18,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":" and","logprobs":[]} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":19,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":" ","logprobs":[]} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":20,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":"10","logprobs":[]} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":21,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":" minutes","logprobs":[]} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","sequence_number":22,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"delta":".","logprobs":[]} + +event: response.output_text.done +data: {"type":"response.output_text.done","sequence_number":23,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"text":"The train was moving for 3 hours and 10 minutes.","logprobs":[]} + +event: response.content_part.done +data: {"type":"response.content_part.done","sequence_number":24,"item_id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","output_index":1,"content_index":0,"part":{"type":"output_text","text":"The train was moving for 3 hours and 10 minutes.","annotations":[],"logprobs":[]}} + +event: response.output_item.done +data: {"type":"response.output_item.done","sequence_number":25,"output_index":1,"item":{"type":"message","id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","role":"assistant","status":"completed","content":[{"type":"output_text","text":"The train was moving for 3 hours and 10 minutes.","annotations":[],"logprobs":[]}],"phase":"final_answer"}} + +event: response.completed +data: {"type":"response.completed","sequence_number":26,"response":{"id":"resp_01241f364fbd794f016ac5253e2ce08191afe0c9a23644957f","object":"response","created_at":1791305022,"completed_at":1791305023,"status":"completed","background":false,"frequency_penalty":0,"presence_penalty":0,"moderation":null,"model":"gpt-5.5","access_programs":null,"previous_response_id":null,"output":[{"type":"reasoning","id":"rs_01241f364fbd794f016ac5253e96a081919fae3ca9ea0bea08","summary":[{"type":"summary_text","text":"**Calculating elapsed time minus offset**"}],"content":[],"encrypted_content":"enc-fixture-3"},{"type":"message","id":"msg_01241f364fbd794f016ac5253f35e4819197bcd3f7451f26aa","role":"assistant","status":"completed","content":[{"type":"output_text","text":"The train was moving for 3 hours and 10 minutes.","annotations":[],"logprobs":[]}],"phase":"final_answer"}],"error":null,"incomplete_details":null,"tool_choice":"auto","parallel_tool_calls":false,"prompt_cache_retention":"24h","text":{"format":{"type":"text"},"verbosity":"medium"},"top_p":0.98,"top_logprobs":0,"temperature":1,"reasoning":{"effort":"medium","summary":"detailed","context":"current_turn","mode":"standard"},"user":null,"usage":{"input_tokens":49,"input_tokens_details":{"cached_tokens":0,"cache_write_tokens":0},"output_tokens":52,"output_tokens_details":{"reasoning_tokens":33},"total_tokens":101},"store":false,"metadata":{},"max_output_tokens":null,"max_tool_calls":null,"service_tier":"default","truncation":"disabled"}} + diff --git a/src/lib/ai/__tests__/fixtures/reasoning/codex-tool-round.sse b/src/lib/ai/__tests__/fixtures/reasoning/codex-tool-round.sse new file mode 100644 index 000000000..5b723cd08 --- /dev/null +++ b/src/lib/ai/__tests__/fixtures/reasoning/codex-tool-round.sse @@ -0,0 +1,39 @@ +event: response.created +data: {"type":"response.created","response":{"id":"resp_03743ca81429e023016ac5252240a887d29b3b3e712af57529","object":"response","created_at":1791304994,"status":"in_progress","access_programs":null,"background":false,"completed_at":null,"error":null,"frequency_penalty":0,"incomplete_details":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.5","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0,"previous_response_id":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"low","mode":"standard","summary":"detailed"},"service_tier":"auto","store":false,"temperature":1,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"id":"resp_03743ca81429e023016ac5252240a887d29b3b3e712af57529","object":"response","created_at":1791304994,"status":"in_progress","access_programs":null,"background":false,"completed_at":null,"error":null,"frequency_penalty":0,"incomplete_details":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.5","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0,"previous_response_id":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"low","mode":"standard","summary":"detailed"},"service_tier":"auto","store":false,"temperature":1,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":null,"user":null,"metadata":{}},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"rs_03743ca81429e023016ac52522f51c87d297ef373ae4b945c9","type":"reasoning","content":[],"encrypted_content":"enc-fixture-4","summary":[]},"output_index":0,"sequence_number":2} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"rs_03743ca81429e023016ac52522f51c87d297ef373ae4b945c9","type":"reasoning","content":[],"encrypted_content":"enc-fixture-5","summary":[]},"output_index":0,"sequence_number":3} + +event: response.output_item.added +data: {"type":"response.output_item.added","item":{"id":"fc_03743ca81429e023016ac52523984487d2b91da4a24fe34ece","type":"function_call","status":"in_progress","arguments":"","call_id":"call_edjM3le4GcXAsF7vywK4kQgY","name":"get_weather"},"output_index":1,"sequence_number":4} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"{\"","item_id":"fc_03743ca81429e023016ac52523984487d2b91da4a24fe34ece","output_index":1,"sequence_number":5} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"city","item_id":"fc_03743ca81429e023016ac52523984487d2b91da4a24fe34ece","output_index":1,"sequence_number":6} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"\":\"","item_id":"fc_03743ca81429e023016ac52523984487d2b91da4a24fe34ece","output_index":1,"sequence_number":7} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"Paris","item_id":"fc_03743ca81429e023016ac52523984487d2b91da4a24fe34ece","output_index":1,"sequence_number":8} + +event: response.function_call_arguments.delta +data: {"type":"response.function_call_arguments.delta","delta":"\"}","item_id":"fc_03743ca81429e023016ac52523984487d2b91da4a24fe34ece","output_index":1,"sequence_number":9} + +event: response.function_call_arguments.done +data: {"type":"response.function_call_arguments.done","arguments":"{\"city\":\"Paris\"}","item_id":"fc_03743ca81429e023016ac52523984487d2b91da4a24fe34ece","output_index":1,"sequence_number":10} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"id":"fc_03743ca81429e023016ac52523984487d2b91da4a24fe34ece","type":"function_call","status":"completed","arguments":"{\"city\":\"Paris\"}","call_id":"call_edjM3le4GcXAsF7vywK4kQgY","name":"get_weather"},"output_index":1,"sequence_number":11} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp_03743ca81429e023016ac5252240a887d29b3b3e712af57529","object":"response","created_at":1791304994,"status":"completed","access_programs":null,"background":false,"completed_at":1791304995,"error":null,"frequency_penalty":0,"incomplete_details":null,"max_output_tokens":null,"max_tool_calls":null,"model":"gpt-5.5","moderation":null,"output":[],"parallel_tool_calls":false,"presence_penalty":0,"previous_response_id":null,"prompt_cache_retention":"24h","reasoning":{"context":"current_turn","effort":"low","mode":"standard","summary":"detailed"},"service_tier":"default","store":false,"temperature":1,"text":{"format":{"type":"text"},"verbosity":"medium"},"tool_choice":"auto","top_logprobs":0,"top_p":0.98,"truncation":"disabled","usage":{"input_tokens":147,"input_tokens_details":{"cache_write_tokens":0,"cached_tokens":0},"output_tokens":36,"output_tokens_details":{"reasoning_tokens":16},"total_tokens":183},"user":null,"metadata":{}},"sequence_number":12} + diff --git a/src/lib/ai/codex-client.ts b/src/lib/ai/codex-client.ts index 5cba79686..863ad36ac 100644 --- a/src/lib/ai/codex-client.ts +++ b/src/lib/ai/codex-client.ts @@ -5,11 +5,19 @@ import type { AIProvider, AiContentPart, AiMessage, + AiProviderState, AiToolCall, AiToolDef, CompletionParams, CompletionResult, } from "./types"; +import { + annotateReasoningDowngrade, + learnedReasoningDialect, + rememberReasoningDialect, +} from "./reasoning/dialect-cache"; +import type { ReasoningLevel } from "./reasoning/levels"; +import { levelOfWireEffort, reasoningTitleOf } from "./reasoning/support"; import { getCachedCodexSlug, invalidateCachedCodexSlug, @@ -140,7 +148,10 @@ type CodexInputItem = type: "function_call_output"; call_id: string; output: string; - }; + } + // v1.41 — a reasoning item from an earlier round of the same turn, handed + // back exactly as the stream delivered it (encrypted content included). + | Record; /** * Map an `AiContentPart[]` body into Responses input content blocks. Text parts @@ -175,7 +186,10 @@ function mapCodexParts( * top-level items the Responses API expects; everything else is a `message`. * A text-only single user turn is byte-identical to the pre-refactor wire. */ -function buildCodexInput(messages: AiMessage[]): CodexInputItem[] { +function buildCodexInput( + messages: AiMessage[], + slug: string, +): CodexInputItem[] { const items: CodexInputItem[] = []; for (const m of messages) { if (m.role === "tool") { @@ -198,6 +212,18 @@ function buildCodexInput(messages: AiMessage[]): CodexInputItem[] { typeof m.content === "string" ? [{ type: textType, text: m.content }] : mapCodexParts(m.content, m.role); + // v1.41 — the reasoning items this round produced go back before its + // message and function calls, unchanged: with `store: false` the server + // keeps nothing, and a tool round that loses its reasoning items reasons + // from scratch (https://developers.openai.com/cookbook/examples/responses_api/reasoning_items). + // Only state this client produced for this slug is re-inserted; anything + // else (another provider's state, a slug the chain walked away from) is + // dropped, because an encrypted item is only readable where it was made. + if (m.role === "assistant") { + for (const item of codexStateItems(m.providerState, slug)) { + items.push(item); + } + } items.push({ type: "message", role: m.role, content }); if (m.role === "assistant" && m.toolCalls) { for (const tc of m.toolCalls) { @@ -213,6 +239,149 @@ function buildCodexInput(messages: AiMessage[]): CodexInputItem[] { return items; } +/** The reasoning items of `state` when this client made it for `slug`. */ +function codexStateItems( + state: AiProviderState | undefined, + slug: string, +): Record[] { + if (!state || state.providerType !== "codex" || state.model !== slug) { + return []; + } + return state.items.filter( + (item): item is Record => + item !== null && + typeof item === "object" && + (item as { type?: unknown }).type === "reasoning", + ); +} + +// ── v1.41 reasoning wire ──────────────────────────────────────────────────── + +/** + * The Responses-API efforts in ascending order. The Codex backend names the + * values a slug accepts in its 400 body ("Supported values are: 'none', + * 'low', …"), which is how a slug's ladder is learned. + */ +const CODEX_EFFORT_ORDER = [ + "none", + "minimal", + "low", + "medium", + "high", + "xhigh", +] as const; + +/** What one request sends: `null` is today's `reasoning: null`. */ +type CodexReasoningWire = { effort: string; summary: boolean } | null; + +/** What a slug was learned to refuse (see `reasoning/dialect-cache.ts`). */ +interface CodexReasoningDialect { + /** The slug refused `reasoning` altogether. */ + disabled?: true; + /** The slug refused `reasoning.summary`. */ + noSummary?: true; + /** The efforts the slug named as supported when it refused one. */ + efforts?: string[]; +} + +function dialectKey(slug: string) { + return { provider: "codex", endpoint: CODEX_ENDPOINT, model: slug }; +} + +function readCodexDialect(slug: string): CodexReasoningDialect { + const raw = learnedReasoningDialect(dialectKey(slug)); + if (!raw) return {}; + try { + return JSON.parse(raw) as CodexReasoningDialect; + } catch { + return {}; + } +} + +function wantedEffort(level: ReasoningLevel): string { + // "Off" is a real off on the current slugs: a 2026-10-06 probe answered + // `effort: "none"` on gpt-5.5 with a non-reasoning reply. A slug that + // refuses it learns its lowest supported value instead. + return level === "off" ? "none" : level; +} + +/** The supported effort closest to `wanted` from below, else the lowest. */ +function nearestEffort(wanted: string, supported: string[]): string | null { + const rank = (e: string) => + (CODEX_EFFORT_ORDER as readonly string[]).indexOf(e); + const known = supported + .filter((e) => rank(e) >= 0) + .sort((a, b) => rank(a) - rank(b)); + if (known.length === 0) return null; + const below = known.filter((e) => rank(e) <= rank(wanted)); + return below.length > 0 ? below[below.length - 1] : known[0]; +} + +function planCodexReasoning( + reasoning: CompletionParams["reasoning"], + slug: string, +): CodexReasoningWire { + if (!reasoning) return null; + const dialect = readCodexDialect(slug); + if (dialect.disabled) return null; + let effort: string | null = wantedEffort(reasoning.effort); + if (dialect.efforts && !dialect.efforts.includes(effort)) { + effort = nearestEffort(effort, dialect.efforts); + } + if (!effort) return null; + return { + effort, + summary: reasoning.summaries && effort !== "none" && !dialect.noSummary, + }; +} + +/** + * Read a 400 that refuses a reasoning parameter and learn what the slug + * accepts instead. Returns the lesser wire to retry with, or `undefined` + * when the body is not about reasoning (the caller's error path takes it). + */ +function learnFromReasoningRejection( + slug: string, + wire: Exclude, + status: number, + body: string, +): CodexReasoningWire | undefined { + // Only a body that names the reasoning parameter counts; a 400 about the + // prompt or a tool must not teach the slug anything. + if (status !== 400 || !/reasoning/i.test(body)) { + return undefined; + } + const dialect = readCodexDialect(slug); + let next: CodexReasoningWire; + if (wire.summary && /reasoning\.summary|summary/i.test(body)) { + dialect.noSummary = true; + next = { effort: wire.effort, summary: false }; + } else { + const listed = /supported values are:([^"\n]*)/i.exec(body); + const efforts = listed + ? Array.from(listed[1].matchAll(/'([a-z]+)'/g), (m) => m[1]) + : []; + const nearest = + efforts.length > 0 && !efforts.includes(wire.effort) + ? nearestEffort(wire.effort, efforts) + : null; + if (nearest) { + dialect.efforts = efforts; + next = { effort: nearest, summary: wire.summary && nearest !== "none" }; + } else { + dialect.disabled = true; + next = null; + } + } + rememberReasoningDialect(dialectKey(slug), JSON.stringify(dialect)); + annotateReasoningDowngrade( + "codex", + wire.effort, + next ? next.effort : "default", + ); + return next; +} + /** * Map tool defs into the Responses `function` tool shape. * @@ -311,7 +480,8 @@ export class CodexClient implements AIProvider { for (const slug of order) { attempted.push(slug); - const firstAttempt = await this.doRequest(params, slug); + let wire = planCodexReasoning(params.reasoning, slug); + const firstAttempt = await this.doRequest(params, slug, wire); if (firstAttempt.status === 401) { // Token-refresh path — don't walk the chain, this is auth-state @@ -319,7 +489,7 @@ export class CodexClient implements AIProvider { const fresh = await this.onTokenRefresh(); this.accessToken = fresh.accessToken; this.accountId = fresh.accountId; - const retryAfterRefresh = await this.doRequest(params, slug); + const retryAfterRefresh = await this.doRequest(params, slug, wire); if (retryAfterRefresh.ok) { this.lastDiagnostics = { attempted, @@ -327,7 +497,7 @@ export class CodexClient implements AIProvider { workingSlug: slug, }; setCachedCodexSlug(slug); - return this.parseResponse(retryAfterRefresh, slug); + return this.parseResponse(retryAfterRefresh, slug, params, wire); } // Even after refresh — surface the auth failure verbatim, do // NOT walk the chain (auth issues don't get fixed by changing @@ -339,30 +509,51 @@ export class CodexClient implements AIProvider { ); } - if (firstAttempt.ok) { + let attempt = firstAttempt; + let rawBody = ""; + // v1.41 — a slug that refuses a reasoning parameter is asked again with + // the lesser request it names (no summary, a lower effort, no reasoning + // at all), and the slug remembers it. The answer is never lost to a + // reasoning refusal. Bounded: each step strictly reduces the request. + for (let step = 0; !attempt.ok && step < 3; step += 1) { + rawBody = await attempt.text().catch(() => ""); + if (!wire) break; + const next = learnFromReasoningRejection( + slug, + wire, + attempt.status, + redactBody(rawBody), + ); + if (next === undefined) break; + wire = next; + rawBody = ""; + attempt = await this.doRequest(params, slug, wire); + } + + if (attempt.ok) { this.lastDiagnostics = { attempted, cacheState, workingSlug: slug, }; setCachedCodexSlug(slug); - return this.parseResponse(firstAttempt, slug); + return this.parseResponse(attempt, slug, params, wire); } // Capture the body for the slug-rejection check. - const rawBody = await firstAttempt.text().catch(() => ""); + if (!rawBody) rawBody = await attempt.text().catch(() => ""); const bodyExcerpt = redactBody(rawBody); - if (isSlugRejection(firstAttempt.status, bodyExcerpt)) { + if (isSlugRejection(attempt.status, bodyExcerpt)) { // Slug rejected — drop the cache (in case it pointed here), // record the failure for diagnostics, and walk to the next // chain slot. invalidateCachedCodexSlug(); const slugErr = new Error( - `Codex slug "${slug}" rejected (${firstAttempt.status})`, + `Codex slug "${slug}" rejected (${attempt.status})`, ); Object.assign(slugErr, { - httpStatus: firstAttempt.status, + httpStatus: attempt.status, upstream: "codex", model: slug, bodyExcerpt, @@ -382,11 +573,11 @@ export class CodexClient implements AIProvider { // un-diagnosable. The body is already redacted of bearer/sk- secrets. const err = new Error( bodyExcerpt - ? `Codex request failed (${firstAttempt.status}): ${bodyExcerpt}` - : `Codex request failed (${firstAttempt.status})`, + ? `Codex request failed (${attempt.status}): ${bodyExcerpt}` + : `Codex request failed (${attempt.status})`, ); Object.assign(err, { - httpStatus: firstAttempt.status, + httpStatus: attempt.status, upstream: "codex", model: slug, bodyExcerpt, @@ -447,6 +638,7 @@ export class CodexClient implements AIProvider { private async doRequest( params: CompletionParams, slug: string, + wire: CodexReasoningWire, ): Promise { const sessionId = randomUUID(); const threadId = randomUUID(); @@ -466,7 +658,7 @@ export class CodexClient implements AIProvider { // v1.20.0 — `input` is now built from the full message array (multi-turn + // tool-result turns), with `tools` mapped to the Responses `function` shape // (the wire already declared `tools`/`tool_choice` — now they carry defs). - const input = buildCodexInput(params.messages); + const input = buildCodexInput(params.messages, slug); const tools = params.tools && params.tools.length > 0 ? buildCodexTools(params.tools) @@ -496,15 +688,28 @@ export class CodexClient implements AIProvider { tools, tool_choice: toolChoice, parallel_tool_calls: false, - // Reasoning is required-but-nullable on the wire. We don't ask - // for reasoning summaries — emit `null` so the JSON has the - // field present and the server stops complaining about a - // missing key (it returned 400 in earlier iterations). - reasoning: null, + // Reasoning is required-but-nullable on the wire. Without a + // requested level (every caller before v1.41, and every caller + // that does not opt in) it stays `null`, the field present so the + // server stops complaining about a missing key (it returned 400 in + // earlier iterations). With one, the official client's shape: + // `{ effort, summary }`, `summary` only when summaries are wanted. + // https://developers.openai.com/api/docs/guides/reasoning + reasoning: wire + ? { + effort: wire.effort, + ...(wire.summary ? { summary: "auto" } : {}), + } + : null, store: false, stream: true, - // Required field; empty array when not asking for reasoning. - include: [], + // Required field; empty array when not asking for reasoning. With + // reasoning on, the encrypted items come back so the next tool + // round can hand them over statelessly (`store` must stay false). + include: + wire && wire.effort !== "none" + ? ["reasoning.encrypted_content"] + : [], }), // SSE streaming completion — match the 60 s budget the other AI // clients use so a long generation is not clipped by the 15 s @@ -540,6 +745,8 @@ export class CodexClient implements AIProvider { private async parseResponse( res: Response, requestedSlug: string, + params: CompletionParams, + wire: CodexReasoningWire, ): Promise { if (!res.body) { // v1.20.1 — sentinel httpStatus + kind so the chain classifier can tell @@ -563,6 +770,31 @@ export class CodexClient implements AIProvider { let serverModel: string | null = null; const toolCalls: AiToolCall[] = []; + // v1.41 — reasoning. Events only reach `onReasoning` when the caller asked + // for reasoning (the contract in `types.ts`); summaries are model text and + // the receiver screens them. + const emit = params.reasoning ? params.onReasoning : undefined; + const reasoningItems: Record[] = []; + const summaries: string[] = []; + let reasoningTokens: number | null = null; + const summaryBuffers = new Map(); + const titled = new Set(); + const texted = new Set(); + const emitTitle = (key: string, text: string) => { + if (!emit || titled.has(key)) return; + const title = reasoningTitleOf(text); + if (title) { + titled.add(key); + emit({ kind: "title", text: title }); + } + }; + const emitText = (key: string, text: string) => { + if (!emit || texted.has(key) || !text.trim()) return; + emitTitle(key, text); + texted.add(key); + emit({ kind: "text", text }); + }; + // The server reports the actual routed model in the OpenAI-Model // response header — useful when safety-routing kicks in. const headerModel = res.headers.get("OpenAI-Model"); @@ -589,10 +821,14 @@ export class CodexClient implements AIProvider { let parsed: { type?: string; delta?: string; + text?: string; + item_id?: string; + summary_index?: number; item?: { type?: string; role?: string; content?: Array<{ type?: string; text?: string }>; + summary?: Array<{ type?: string; text?: string }>; id?: string; call_id?: string; name?: string; @@ -602,6 +838,7 @@ export class CodexClient implements AIProvider { usage?: { total_tokens?: number; input_tokens_details?: { cached_tokens?: number }; + output_tokens_details?: { reasoning_tokens?: number }; }; error?: { code?: string; message?: string }; incomplete_details?: { reason?: string }; @@ -619,6 +856,22 @@ export class CodexClient implements AIProvider { if (parsed.delta) deltaText += parsed.delta; break; } + // v1.41 — live reasoning summaries. A summary's first bold line + // is its title; it goes out as soon as it has closed, the full + // text when the part is done. + case "response.reasoning_summary_text.delta": { + const key = `${parsed.item_id ?? ""}:${parsed.summary_index ?? 0}`; + const buffered = + (summaryBuffers.get(key) ?? "") + (parsed.delta ?? ""); + summaryBuffers.set(key, buffered); + emitTitle(key, buffered); + break; + } + case "response.reasoning_summary_text.done": { + const key = `${parsed.item_id ?? ""}:${parsed.summary_index ?? 0}`; + emitText(key, parsed.text ?? summaryBuffers.get(key) ?? ""); + break; + } case "response.output_item.done": { const item = parsed.item; if (item?.type === "message" && item.role === "assistant") { @@ -627,6 +880,16 @@ export class CodexClient implements AIProvider { .map((c) => c.text!) .join(""); if (text) assembledText += text; + } else if (item?.type === "reasoning" && params.reasoning) { + // The whole item, untouched: the next round hands it back. + reasoningItems.push(item as Record); + (item.summary ?? []).forEach((part, index) => { + if (typeof part.text !== "string" || !part.text) return; + summaries.push(part.text); + // A part whose `.done` never arrived still reaches the + // receiver, once. + emitText(`${item.id ?? ""}:${index}`, part.text); + }); } else if (item?.type === "function_call") { // v1.20.0 — the model asked to call a tool. Surface it for // F1's loop; the id is the call_id the function_call_output @@ -644,6 +907,11 @@ export class CodexClient implements AIProvider { cachedInputTokens = parsed.response?.usage?.input_tokens_details?.cached_tokens ?? null; + // Reasoning tokens are billed as output and already inside + // `total_tokens`; reported separately for the trail and budget. + reasoningTokens = + parsed.response?.usage?.output_tokens_details + ?.reasoning_tokens ?? null; break; } case "response.failed": { @@ -713,6 +981,19 @@ export class CodexClient implements AIProvider { throw err; } + if (emit) emit({ kind: "done", text: "" }); + + const sentLevel = wire ? levelOfWireEffort(wire.effort) : null; + const reasoning: CompletionResult["reasoning"] = params.reasoning + ? { + summary: summaries, + tokens: reasoningTokens, + ...(sentLevel !== params.reasoning.effort + ? { downgradedTo: sentLevel ?? "off" } + : {}), + } + : undefined; + return { content, tokensUsed, @@ -721,6 +1002,18 @@ export class CodexClient implements AIProvider { providerType: "codex", ...(toolCalls.length > 0 ? { toolCalls } : {}), finishReason: toolCalls.length > 0 ? "tool_calls" : "stop", + ...(reasoning ? { reasoning } : {}), + // Keyed on the slug that was asked, not the routed model header: the + // next round re-inserts the items only when it asks the same slug. + ...(reasoningItems.length > 0 + ? { + providerState: { + providerType: "codex" as const, + model: requestedSlug, + items: reasoningItems, + }, + } + : {}), }; } }