Skip to content
Merged
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
25 changes: 24 additions & 1 deletion src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { buildBifrostSource, buildOpenAISource, buildProviderCatalog, catalogEntryAsProviderSettings, CliHelpError, CLI_HELP_TEXT, KEYLESS_API_KEY, loadConfig, providerCatalogToSettings, runtimeSettingsWithCatalog, SOURCE_MAX_TOKENS } from "./config/index.js";
import { buildBifrostSource, buildOpenAISource, buildXaiSource, buildProviderCatalog, catalogEntryAsProviderSettings, CliHelpError, CLI_HELP_TEXT, KEYLESS_API_KEY, loadConfig, providerCatalogToSettings, runtimeSettingsWithCatalog, SOURCE_MAX_TOKENS } from "./config/index.js";
import type { Config, UnconfiguredConfig } from "./config/index.js";
import { mergeProviderIntoSettings, type ResolvedProvider, type Settings } from "./config/settings.js";
import { OPENCODE_GO_BASE_URL } from "../packages/opencode-go/src/index.js";
Expand Down Expand Up @@ -677,6 +677,29 @@ describe("buildBifrostSource", () => {
});
});

describe("buildXaiSource", () => {
test("omits reasoning_effort when effort is absent", () => {
const source = buildXaiSource({ id: "xai/work", apiKey: "tok", model: "grok-4.6" });
expect(source.provider).toBe("grok-responses");
expect(source.defaults?.providerOptions).not.toHaveProperty("reasoning_effort");
});

test("sets providerOptions.reasoning_effort when effort is present", () => {
const source = buildXaiSource({
id: "xai/work",
apiKey: "tok",
model: "grok-4.6",
reasoningEffort: "low",
});
expect(source.defaults?.providerOptions).toMatchObject({ reasoning_effort: "low" });
});

test("does not invent high when effort is absent", () => {
const source = buildXaiSource({ id: "xai/work", apiKey: "tok", model: "grok-4.6" });
expect(source.defaults?.providerOptions?.["reasoning_effort"]).toBeUndefined();
});
});

describe("buildProviderCatalog", () => {
const resolved: ResolvedProvider = {
providerName: "fp",
Expand Down
2 changes: 2 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,10 +168,12 @@ export function buildXaiSource(fields: {
id: string;
apiKey: string;
model: string;
reasoningEffort?: ReasoningEffort;
}): InferenceSource {
const userId = xaiUserIdFromAccessToken(fields.apiKey);
const providerOptions: Record<string, unknown> = {};
if (userId !== undefined) providerOptions[GROK_USER_ID_OPTION] = userId;
if (fields.reasoningEffort !== undefined) providerOptions["reasoning_effort"] = fields.reasoningEffort;
return {
id: fields.id,
provider: GROK_RESPONSES_PROVIDER,
Expand Down
9 changes: 7 additions & 2 deletions src/config/inference-sources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
type ProviderCatalogEntry,
} from "./index.js";
import type { Settings } from "./settings.js";
import type { ReasoningEffort } from "../provider/reasoning-effort.js";
import { resolveSessionEffort, type ReasoningEffort } from "../provider/reasoning-effort.js";
import { SOURCE_MAX_TOKENS } from "./index.js";
import { isOpenCodeGoProvider } from "../../packages/opencode-go/src/index.js";
import { resolveDefaultModel } from "./providers.js";
Expand Down Expand Up @@ -77,7 +77,11 @@ export function buildInferenceSourceForRef(
if (baseURL === undefined) return null;

const maxTokens = maxTokensFor(settings, ref.provider, ref.model);
const effort = ref.reasoningEffort ?? ctx.reasoningEffort;
const configured = ref.reasoningEffort ?? ctx.reasoningEffort;
const effort =
configured !== undefined
? resolveSessionEffort(ref.model, configured, entry?.codexProfile !== undefined)
: undefined;

if (entry?.codexProfile !== undefined) {
return buildCodexSource({
Expand All @@ -94,6 +98,7 @@ export function buildInferenceSourceForRef(
id: ref.provider,
apiKey: entry.apiKey ?? "",
model: ref.model,
...(effort !== undefined ? { reasoningEffort: effort } : {}),
});
}
if (
Expand Down
3 changes: 3 additions & 0 deletions src/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,6 +507,9 @@ export async function runExec(config: Config): Promise<ExecResult> {
id: config.providerName,
apiKey: config.apiKey,
model: config.model,
...(config.reasoningEffort !== undefined
? { reasoningEffort: config.reasoningEffort }
: {}),
})
: buildOpenAICompatibleInitialSource();

Expand Down
39 changes: 39 additions & 0 deletions src/provider/grok-responses-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,43 @@ describe("createGrokResponsesAdapter", () => {
expect(body.include).toEqual(["reasoning.encrypted_content"]);
expect(body.reasoning).toEqual({ summary: "detailed" });
});

test("forwards providerOptions.reasoning_effort onto reasoning.effort", () => {
const adapter = createGrokResponsesAdapter(source);
const turns: ConversationTurn[] = [
{
role: "user",
timestamp: 0,
content: [{ type: "text", text: "hello" }],
},
];

const request = adapter.buildRequest(turns, "grok-4.6", {
providerOptions: { reasoning_effort: "low" },
});
const body = JSON.parse(request.body) as {
reasoning?: { effort?: string; summary?: string };
};

expect(body.reasoning).toEqual({ effort: "low", summary: "detailed" });
});

test("does not invent high when no reasoning_effort is set", () => {
const adapter = createGrokResponsesAdapter(source);
const turns: ConversationTurn[] = [
{
role: "user",
timestamp: 0,
content: [{ type: "text", text: "hello" }],
},
];

const request = adapter.buildRequest(turns, "grok-4.6", {});
const body = JSON.parse(request.body) as {
reasoning?: { effort?: string; summary?: string };
};

expect(body.reasoning).toEqual({ summary: "detailed" });
expect(body.reasoning?.effort).toBeUndefined();
});
});
9 changes: 7 additions & 2 deletions src/provider/grok-responses-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,10 @@ function buildRequest(
const input = systemMessage !== undefined ? [systemMessage, ...conversation] : conversation;
const tools = toResponsesTools(options);

const reasoning: { summary: "detailed"; effort?: string } = { summary: "detailed" };
const effort = optionString(options, "reasoning_effort");
if (effort !== undefined) reasoning.effort = effort;

const body: Record<string, unknown> = {
model,
input,
Expand All @@ -160,8 +164,9 @@ function buildRequest(
include: ["reasoning.encrypted_content"],
// "detailed" streams denser summary deltas than "auto". Grok bills full
// thinking tokens but only returns summarized text; sparse auto summaries
// left the stall/activity clocks quiet for 60–120s mid-think.
reasoning: { summary: "detailed" },
// left the stall/activity clocks quiet for 60–120s mid-think. Effort is
// forwarded when the source set it — this adapter does not invent a default.
reasoning,
};
if (tools !== undefined) {
body["tools"] = tools;
Expand Down
120 changes: 117 additions & 3 deletions src/provider/reasoning-effort.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import {
clampEffort,
pickEffortFromCascade,
resolveEffortForRole,
defaultEffortForModel,
resolveSessionEffort,
} from "./reasoning-effort.js";
import { composePromptActionBarModelLabel } from "../tui/components/prompt-action-bar-label.js";

describe("REASONING_EFFORTS", () => {
test("is ordered from least to most effort", () => {
Expand Down Expand Up @@ -103,15 +106,41 @@ describe("cycleReasoningEffort", () => {
afterEach(() => setModelReasoningCapabilities({}));

test("walks the gpt-5 ladder and wraps", () => {
expect(cycleReasoningEffort("gpt-5", undefined)).toBe("minimal");
expect(cycleReasoningEffort("gpt-5", "minimal")).toBe("low");
expect(cycleReasoningEffort("gpt-5", "low")).toBe("medium");
expect(cycleReasoningEffort("gpt-5", "medium")).toBe("high");
expect(cycleReasoningEffort("gpt-5", "high")).toBe("minimal");
});

test("starts at the first supported level when current is unsupported", () => {
expect(cycleReasoningEffort("gpt-5", "xhigh")).toBe("minimal");
test("unset gpt-5 cycles from the implicit medium default to high", () => {
expect(cycleReasoningEffort("gpt-5", undefined)).toBe("high");
});

test("unset grok cycles from implicit high, matching an explicit high", () => {
expect(cycleReasoningEffort("grok-4.6", undefined)).toBe(
cycleReasoningEffort("grok-4.6", "high"),
);
expect(cycleReasoningEffort("grok-4.6", "high")).toBe("low");
});

test("unset gpt-5.1 chat cycles from implicit none to minimal", () => {
expect(cycleReasoningEffort("gpt-5.1", undefined)).toBe("minimal");
});

test("leftover unsupported effort cycles from the family default", () => {
expect(cycleReasoningEffort("gpt-5", "xhigh")).toBe("high");
expect(cycleReasoningEffort("gpt-5", "xhigh")).toBe(cycleReasoningEffort("gpt-5", "medium"));
});

test("grok leftover minimal cycles the same as unset / high", () => {
expect(cycleReasoningEffort("grok-4.6", "minimal")).toBe(cycleReasoningEffort("grok-4.6", undefined));
expect(cycleReasoningEffort("grok-4.6", "minimal")).toBe(cycleReasoningEffort("grok-4.6", "high"));
expect(cycleReasoningEffort("grok-4.6", "minimal")).toBe("low");
});

test("unknown models with rungs still start at supported[0] when no default exists", () => {
expect(defaultEffortForModel("some-random-model")).toBeUndefined();
expect(cycleReasoningEffort("some-random-model", undefined)).toBe("low");
});

test("returns undefined for a non-reasoning model", () => {
Expand Down Expand Up @@ -312,3 +341,88 @@ describe("resolveEffortForRole", () => {
).toBe("high");
});
});

describe("defaultEffortForModel", () => {
afterEach(() => setModelReasoningCapabilities({}));

test("grok family defaults to high", () => {
expect(defaultEffortForModel("grok-4.6")).toBe("high");
expect(defaultEffortForModel("grok-4.5")).toBe("high");
});

test("gpt-5 and o-series default to medium", () => {
expect(defaultEffortForModel("gpt-5")).toBe("medium");
expect(defaultEffortForModel("o1")).toBe("medium");
expect(defaultEffortForModel("o3-mini")).toBe("medium");
expect(defaultEffortForModel("o4-mini")).toBe("medium");
});

test("gpt-5.1 chat defaults to none when none is on the ladder", () => {
expect(supportedEfforts("gpt-5.1").includes("none")).toBe(true);
expect(defaultEffortForModel("gpt-5.1")).toBe("none");
expect(defaultEffortForModel("gpt-5.1", false)).toBe("none");
});

test("Codex defaults to medium", () => {
expect(defaultEffortForModel("gpt-5.6-sol", true)).toBe("medium");
expect(defaultEffortForModel("gpt-5.1-codex", true)).toBe("medium");
});

test("empty ladder yields undefined", () => {
setModelReasoningCapabilities({ "chat-only-model": false });
expect(defaultEffortForModel("chat-only-model")).toBeUndefined();
});

test("unknown models with rungs have no family default", () => {
expect(supportedEfforts("some-random-model").length).toBeGreaterThan(0);
expect(defaultEffortForModel("some-random-model")).toBeUndefined();
});
});

describe("resolveSessionEffort", () => {
afterEach(() => setModelReasoningCapabilities({}));

test("empty ladder yields undefined even when configured", () => {
setModelReasoningCapabilities({ "chat-only-model": false });
expect(resolveSessionEffort("chat-only-model", "high")).toBeUndefined();
});

test("keeps a supported configured level", () => {
expect(resolveSessionEffort("gpt-5", "low")).toBe("low");
expect(resolveSessionEffort("grok-4.6", "low")).toBe("low");
});

test("falls back to the family default when unset or unsupported", () => {
expect(resolveSessionEffort("gpt-5", undefined)).toBe("medium");
expect(resolveSessionEffort("gpt-5", "xhigh")).toBe("medium");
expect(resolveSessionEffort("grok-4.6", undefined)).toBe("high");
expect(resolveSessionEffort("gpt-5.1", undefined)).toBe("none");
expect(resolveSessionEffort("gpt-5.6-sol", undefined, true)).toBe("medium");
expect(resolveSessionEffort("some-random-model", undefined)).toBeUndefined();
});
});

describe("prompt action bar effort label", () => {
test("joiner stays a dumb concatenation of the resolved session effort", () => {
const effort = resolveSessionEffort("grok-4.6", undefined);
expect(effort).toBe("high");
expect(
composePromptActionBarModelLabel({
profile: "xai/work",
model: "grok-4.6",
...(effort !== undefined ? { effort } : {}),
}),
).toBe("xai/work · grok-4.6 · high");
});

test("shows gpt-5 medium without seeding a configured effort", () => {
const effort = resolveSessionEffort("gpt-5", undefined);
expect(effort).toBe("medium");
expect(
composePromptActionBarModelLabel({
model: "gpt-5",
...(effort !== undefined ? { effort } : {}),
}),
).toBe("gpt-5 · medium");
});
});
48 changes: 46 additions & 2 deletions src/provider/reasoning-effort.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ export function validateEffort(
* Next effort on the model's supported ladder (wraps around). Returns undefined
* when the model supports no reasoning effort — callers flash a status and leave
* the session config alone.
*
* Walks from resolveSessionEffort: unset and leftover unsupported current sit
* on the family default, then the next rung. When there is no family default
* but the ladder is non-empty, start at supported[0].
*/
export function cycleReasoningEffort(
model: string,
Expand All @@ -108,13 +112,53 @@ export function cycleReasoningEffort(
): ReasoningEffort | undefined {
const supported = supportedEfforts(model, undefined, isCodex);
if (supported.length === 0) return undefined;
if (current === undefined || !supported.includes(current)) {
const implicit = resolveSessionEffort(model, current, isCodex);
if (implicit === undefined || !supported.includes(implicit)) {
return supported[0];
}
const idx = supported.indexOf(current);
const idx = supported.indexOf(implicit);
return supported[(idx + 1) % supported.length];
}

/**
* Product default effort for a live session model. Distinct from role defaults
* (`defaultEffortForDirector`): this is what the prompt shows and what Shift+Tab
* advances from when the operator has not picked a level.
*
* Family table: grok* → high; Codex → medium; gpt-5.1 chat (`none` on the
* ladder, not Codex) → none; gpt-5/o1/o3/o4 → medium. Unknown models with a
* conservative rung set stay undefined so we do not invent a family default.
*/
export function defaultEffortForModel(
model: string,
isCodex = false,
): ReasoningEffort | undefined {
const supported = supportedEfforts(model, undefined, isCodex);
if (supported.length === 0) return undefined;
const pick = (desired: ReasoningEffort): ReasoningEffort | undefined =>
supported.includes(desired) ? desired : undefined;
if (model.startsWith("grok")) return pick("high");
if (!isCodex && supported.includes("none")) return "none";
if (isCodex || isKnownOpenAIReasoningModel(model)) return pick("medium");
return undefined;
}

/**
* Effort the session is currently on: a configured level when the model accepts
* it, otherwise the family default. Empty ladders stay undefined. Does not
* write back into session config — display and request wiring read this.
*/
export function resolveSessionEffort(
model: string,
configured: ReasoningEffort | undefined,
isCodex = false,
): ReasoningEffort | undefined {
const supported = supportedEfforts(model, undefined, isCodex);
if (supported.length === 0) return undefined;
if (configured !== undefined && supported.includes(configured)) return configured;
return defaultEffortForModel(model, isCodex);
}

// ---------------------------------------------------------------------------
// Role-based product defaults (CL-5162)
//
Expand Down
Loading
Loading