Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/guides/grok-build.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs-site/src/content/docs/reference/proxy-formats.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 24 additions & 1 deletion src/chat/inbound.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
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.
Expand Down Expand Up @@ -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",
Comment thread
ahamkara-Nh marked this conversation as resolved.
};
}

const text = responseFormatToText(raw.response_format);
if (text) body.text = text;
Expand Down
6 changes: 4 additions & 2 deletions src/server/chat-completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
18 changes: 18 additions & 0 deletions src/server/effort-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) };
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 [];
Expand Down
80 changes: 78 additions & 2 deletions tests/chat-completions-endpoint.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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 {
Expand Down Expand Up @@ -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<Record<string, unknown>>)[0]).toMatchObject({ type: "function", name: "lookup" });
Expand All @@ -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);
Expand Down
22 changes: 21 additions & 1 deletion tests/effort-policy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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" });
Expand Down
Loading