diff --git a/docs-site/src/content/docs/guides/grok-build.md b/docs-site/src/content/docs/guides/grok-build.md index 958b25a30b..75dfadf84e 100644 --- a/docs-site/src/content/docs/guides/grok-build.md +++ b/docs-site/src/content/docs/guides/grok-build.md @@ -61,6 +61,13 @@ in Codex. Models with an empty tier list keep no effort control, matching Codex behavior. Native GPT-5.6 entries are separate: they preserve and expose their pinned upstream reasoning ladders rather than provider-configured routed metadata. +Grok Build talks to opencodex over Chat Completions and sends `reasoning_effort` when +the ladder is advertised. The Chat Completions inbound translator defaults the internal +Responses `reasoning.summary` to `auto` in that case, so thinking traces reach Grok as +`delta.reasoning_content` instead of being hidden. Set `include_reasoning: false` (or +`reasoning.summary: "none"`) if a client wants the model to think without returning the +trace. An explicit `reasoning.summary` wins when both knobs are present. + ## Authentication note Grok Build requires a non-empty API key for custom models even on loopback. The injected diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index bd02453019..72bc565121 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -145,6 +145,14 @@ non-empty `messages` array. It translates system, user, assistant, and tool mess Responses items; translates function tools, tool choice, images, reasoning effort, and supported response formats; runs the normal Responses routing pipeline; then translates the result back. +Reasoning is part of that translation. `reasoning_effort` (or `reasoning.effort`) becomes +internal `reasoning.effort`. Because the Responses parser hides thinking unless +`reasoning.summary` is set and is not `none`, Chat Completions requests that ask for an +effort default to `reasoning.summary: "auto"` so thinking streams back as +`delta.reasoning_content`. Clients can still hide traces with `include_reasoning: false` or +`reasoning.summary: "none"`. An explicit `reasoning.summary` of `auto`, `concise`, +`detailed`, or `none` wins over `include_reasoning`. + Structured output is part of that translation: `response_format` with `json_object` or `json_schema` is forwarded to routed `openai-chat` models. On `POST /v1/responses` the equivalent request field is `text.format`: native Responses routes preserve it in the raw diff --git a/src/chat/inbound.ts b/src/chat/inbound.ts index f9abe383ad..ff3dba5e4b 100644 --- a/src/chat/inbound.ts +++ b/src/chat/inbound.ts @@ -14,6 +14,7 @@ function isRec(v: unknown): v is Rec { } const OUTPUT_CONFIG_EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]); +const OUTPUT_CONFIG_SUMMARIES = new Set(["auto", "concise", "detailed", "none"]); function contentToText(content: unknown): string { if (typeof content === "string") return content; @@ -205,6 +206,22 @@ function resolveReasoningEffort(raw: Rec): string | undefined { return undefined; } +/** + * Chat Completions clients (Grok Build, Copilot, OpenAI-compatible SDKs) expect + * `delta.reasoning_content` whenever the model thinks. The internal Responses + * parser hides thinking unless `reasoning.summary` is set and is not `"none"`. + * Map the common Chat Completions knobs onto that field. When the client only + * sent an effort, default the summary to `"auto"` so traces are not swallowed. + */ +function resolveReasoningSummary(raw: Rec): string | undefined { + if (isRec(raw.reasoning) && typeof raw.reasoning.summary === "string" && OUTPUT_CONFIG_SUMMARIES.has(raw.reasoning.summary)) { + return raw.reasoning.summary; + } + if (raw.include_reasoning === false) return "none"; + if (raw.include_reasoning === true) return "auto"; + return undefined; +} + /** * Translate an OpenAI Chat Completions request body into a /v1/responses request body. * Throws ChatCompletionsRequestError (-> 400) on malformed input. @@ -286,7 +303,13 @@ export function chatCompletionsToResponsesBody(raw: unknown): Rec { if (raw.metadata !== undefined) body.metadata = raw.metadata; const effort = resolveReasoningEffort(raw); - if (effort) body.reasoning = { effort }; + const summary = resolveReasoningSummary(raw); + if (effort || summary !== undefined) { + body.reasoning = { + ...(effort ? { effort } : {}), + summary: summary ?? "auto", + }; + } const text = responseFormatToText(raw.response_format); if (text) body.text = text; diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 34864ad834..1ed1d4e6d6 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -140,9 +140,11 @@ async function handleChatCompletionsWithBudget( logCtx.usageLogInputTokens = Math.max(1, estimateTokens(parts.join("\n"), requestedModel)); } if (internalBody.reasoning !== undefined) { - const { supportedLadderFor } = await import("./effort-policy"); + const { stripEmptyLadderEffort, supportedLadderFor } = await import("./effort-policy"); const ladder = supportedLadderFor({ provider: route.provider, modelId: route.modelId }); - if (ladder !== undefined && ladder.length === 0) delete internalBody.reasoning; + const next = stripEmptyLadderEffort(internalBody.reasoning, ladder); + if (next === undefined) delete internalBody.reasoning; + else internalBody.reasoning = next; } } catch (err) { if (err instanceof NoEligiblePolicyCandidateError) { diff --git a/src/server/effort-policy.ts b/src/server/effort-policy.ts index 57f8b99e00..2686b73460 100644 --- a/src/server/effort-policy.ts +++ b/src/server/effort-policy.ts @@ -98,6 +98,24 @@ export function effortCapAppliesTo( * validates against that backend. A custom responses provider (key mode) serving a * native-looking bare id must NOT inherit the unrelated native ladder. */ +/** + * Empty ladders mean "no effort control", not "this model cannot emit reasoning". + * Drop only `effort` so a Chat Completions `include_reasoning` / `reasoning.summary` + * request still reaches parseRequest and is not hidden by hideThinkingSummary. + */ +export function stripEmptyLadderEffort( + reasoning: unknown, + ladder: readonly string[] | undefined, +): unknown { + if (ladder === undefined || ladder.length > 0) return reasoning; + if (reasoning === undefined || reasoning === null || typeof reasoning !== "object" || Array.isArray(reasoning)) { + return reasoning; + } + const next = { ...(reasoning as Record) }; + delete next.effort; + return Object.keys(next).length > 0 ? next : undefined; +} + export function supportedLadderFor(route: { provider: OcxProviderConfig; modelId: string }): string[] | undefined { const { provider, modelId } = route; if (modelInList(provider.noReasoningModels, modelId)) return []; diff --git a/tests/chat-completions-endpoint.test.ts b/tests/chat-completions-endpoint.test.ts index eea0719eb3..a7ecca3993 100644 --- a/tests/chat-completions-endpoint.test.ts +++ b/tests/chat-completions-endpoint.test.ts @@ -1,5 +1,5 @@ import { waitForNativeMainStartupGate } from "../src/codex/native-profile-startup"; -import { afterEach, beforeEach, expect, test } from "bun:test"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -10,6 +10,7 @@ import type { OcxConfig, OcxProviderConfig } from "../src/types"; import { installIsolatedCodexHome, type IsolatedCodexHome } from "./helpers/isolated-codex-home"; import { chatCompletionsToResponsesBody, ChatCompletionsRequestError } from "../src/chat/inbound"; import { chatCompletionsUsage } from "../src/chat/outbound"; +import { parseRequest } from "../src/responses/parser"; import { createTestTranslatorBudget } from "./helpers/translator-budget"; import type { TranslatorBudget } from "../src/lib/translator-budget"; import { @@ -218,7 +219,7 @@ test("chatCompletionsToResponsesBody maps messages/tools/system", () => { expect(body.stream).toBe(true); expect(body.instructions).toBe("be brief"); expect(body.max_output_tokens).toBe(64); - expect(body.reasoning).toEqual({ effort: "high" }); + expect(body.reasoning).toEqual({ effort: "high", summary: "auto" }); expect(body.tool_choice).toBe("auto"); expect(Array.isArray(body.tools)).toBe(true); expect((body.tools as Array>)[0]).toMatchObject({ type: "function", name: "lookup" }); @@ -228,6 +229,81 @@ test("chatCompletionsToResponsesBody maps messages/tools/system", () => { expect(input.some(i => i.type === "function_call_output" && i.call_id === "call_1")).toBe(true); }); +describe("chatCompletionsToResponsesBody reasoning summary", () => { + test("defaults summary to auto when the client only sent reasoning_effort", () => { + const body = chatCompletionsToResponsesBody({ + model: "opencode-go/deepseek-v4-flash", + messages: [{ role: "user", content: "What is 17*19?" }], + reasoning_effort: "max", + }); + expect(body.reasoning).toEqual({ effort: "max", summary: "auto" }); + const parsed = parseRequest(body); + expect(parsed.options.hideThinkingSummary).not.toBe(true); + expect(parsed.options.reasoning).toBe("max"); + }); + + test("preserves an explicit reasoning.summary", () => { + const body = chatCompletionsToResponsesBody({ + model: "mock/test-model", + messages: [{ role: "user", content: "hi" }], + reasoning: { effort: "high", summary: "concise" }, + }); + expect(body.reasoning).toEqual({ effort: "high", summary: "concise" }); + }); + + test("include_reasoning false hides thinking even when effort is set", () => { + const body = chatCompletionsToResponsesBody({ + model: "mock/test-model", + messages: [{ role: "user", content: "hi" }], + reasoning_effort: "high", + include_reasoning: false, + }); + expect(body.reasoning).toEqual({ effort: "high", summary: "none" }); + expect(parseRequest(body).options.hideThinkingSummary).toBe(true); + }); + + test("include_reasoning true requests a visible summary without an effort", () => { + const body = chatCompletionsToResponsesBody({ + model: "mock/test-model", + messages: [{ role: "user", content: "hi" }], + include_reasoning: true, + }); + expect(body.reasoning).toEqual({ summary: "auto" }); + expect(parseRequest(body).options.hideThinkingSummary).not.toBe(true); + }); + + test("explicit reasoning.summary wins over include_reasoning true", () => { + const body = chatCompletionsToResponsesBody({ + model: "mock/test-model", + messages: [{ role: "user", content: "hi" }], + include_reasoning: true, + reasoning: { summary: "none" }, + }); + expect(body.reasoning).toEqual({ summary: "none" }); + expect(parseRequest(body).options.hideThinkingSummary).toBe(true); + }); + + test("explicit reasoning.summary wins over include_reasoning false", () => { + const body = chatCompletionsToResponsesBody({ + model: "mock/test-model", + messages: [{ role: "user", content: "hi" }], + include_reasoning: false, + reasoning: { effort: "high", summary: "auto" }, + }); + expect(body.reasoning).toEqual({ effort: "high", summary: "auto" }); + expect(parseRequest(body).options.hideThinkingSummary).not.toBe(true); + }); + + test("omits reasoning when the client sent no reasoning knobs", () => { + const body = chatCompletionsToResponsesBody({ + model: "mock/test-model", + messages: [{ role: "user", content: "hi" }], + }); + expect(body.reasoning).toBeUndefined(); + expect(parseRequest(body).options.hideThinkingSummary).toBe(true); + }); +}); + test("chatCompletionsToResponsesBody rejects missing model", () => { expect(() => chatCompletionsToResponsesBody({ messages: [{ role: "user", content: "x" }] })) .toThrow(ChatCompletionsRequestError); diff --git a/tests/effort-policy.test.ts b/tests/effort-policy.test.ts index 1c26b40dd8..4cd88d8039 100644 --- a/tests/effort-policy.test.ts +++ b/tests/effort-policy.test.ts @@ -7,7 +7,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { applyEffortCap, effortCapAppliesTo, effortCapFor, isThreadSpawnRequest, resolveCappedEffort, supportedLadderFor } from "../src/server/effort-policy"; +import { applyEffortCap, effortCapAppliesTo, effortCapFor, isThreadSpawnRequest, resolveCappedEffort, stripEmptyLadderEffort, supportedLadderFor } from "../src/server/effort-policy"; import { collabSurface } from "../src/server/responses"; import { handleManagementAPI } from "../src/server/management-api"; import { NoEnabledOpenAiProviderError, routeModel } from "../src/router"; @@ -173,6 +173,26 @@ describe("resolveCappedEffort (ladder-aware resolution)", () => { }); }); +describe("stripEmptyLadderEffort", () => { + test("keeps a summary-only object on an empty ladder", () => { + expect(stripEmptyLadderEffort({ summary: "auto" }, [])).toEqual({ summary: "auto" }); + }); + + test("strips effort but keeps summary on an empty ladder", () => { + expect(stripEmptyLadderEffort({ effort: "max", summary: "auto" }, [])).toEqual({ summary: "auto" }); + }); + + test("drops an effort-only object on an empty ladder", () => { + expect(stripEmptyLadderEffort({ effort: "high" }, [])).toBeUndefined(); + }); + + test("leaves reasoning untouched when the ladder is unknown or non-empty", () => { + const reasoning = { effort: "high", summary: "auto" }; + expect(stripEmptyLadderEffort(reasoning, undefined)).toBe(reasoning); + expect(stripEmptyLadderEffort(reasoning, ["high"])).toBe(reasoning); + }); +}); + describe("applyEffortCap strip paths", () => { test("no-effort model strips even a below-cap effort from BOTH shapes, keeping summary", () => { const config = makeConfig({ effortCap: "high" });