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, + }, + } + : {}), }; } }