diff --git a/src/config.test.ts b/src/config.test.ts index e2c1b9b7f..41efccfe4 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -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"; @@ -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", diff --git a/src/config/index.ts b/src/config/index.ts index aec733045..56fd40a80 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -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 = {}; 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, diff --git a/src/config/inference-sources.ts b/src/config/inference-sources.ts index 9a378d29d..390742042 100644 --- a/src/config/inference-sources.ts +++ b/src/config/inference-sources.ts @@ -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"; @@ -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({ @@ -94,6 +98,7 @@ export function buildInferenceSourceForRef( id: ref.provider, apiKey: entry.apiKey ?? "", model: ref.model, + ...(effort !== undefined ? { reasoningEffort: effort } : {}), }); } if ( diff --git a/src/exec/runner.ts b/src/exec/runner.ts index cd6fbaa5c..5597bb483 100644 --- a/src/exec/runner.ts +++ b/src/exec/runner.ts @@ -507,6 +507,9 @@ export async function runExec(config: Config): Promise { id: config.providerName, apiKey: config.apiKey, model: config.model, + ...(config.reasoningEffort !== undefined + ? { reasoningEffort: config.reasoningEffort } + : {}), }) : buildOpenAICompatibleInitialSource(); diff --git a/src/provider/grok-responses-adapter.test.ts b/src/provider/grok-responses-adapter.test.ts index e98b70d86..0617a8e84 100644 --- a/src/provider/grok-responses-adapter.test.ts +++ b/src/provider/grok-responses-adapter.test.ts @@ -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(); + }); }); diff --git a/src/provider/grok-responses-adapter.ts b/src/provider/grok-responses-adapter.ts index cd367fca3..b3066d4a7 100644 --- a/src/provider/grok-responses-adapter.ts +++ b/src/provider/grok-responses-adapter.ts @@ -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 = { model, input, @@ -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; diff --git a/src/provider/reasoning-effort.test.ts b/src/provider/reasoning-effort.test.ts index 297fc871f..ea5a75570 100644 --- a/src/provider/reasoning-effort.test.ts +++ b/src/provider/reasoning-effort.test.ts @@ -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", () => { @@ -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", () => { @@ -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"); + }); +}); diff --git a/src/provider/reasoning-effort.ts b/src/provider/reasoning-effort.ts index 32894980c..49c5b052e 100644 --- a/src/provider/reasoning-effort.ts +++ b/src/provider/reasoning-effort.ts @@ -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, @@ -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) // diff --git a/src/tui/runner.ts b/src/tui/runner.ts index 9bffe0a9a..e60db4131 100644 --- a/src/tui/runner.ts +++ b/src/tui/runner.ts @@ -53,7 +53,7 @@ import { xaiProfileFromProviderName } from "../config/xai-providers.js"; import type { PluginsAdmin, PluginDescriptor } from "../plugins/admin.js"; import type { PluginManifest } from "../plugins/manifest.js"; import { createInferenceDependencies } from "../provider/inference-dependencies.js"; -import { cycleReasoningEffort } from "../provider/reasoning-effort.js"; +import { cycleReasoningEffort, resolveSessionEffort } from "../provider/reasoning-effort.js"; import { getValidCodexToken } from "../auth/codex/session.js"; import { getValidXaiToken } from "../auth/xai/session.js"; import { refreshCodexInstructions } from "../auth/codex/instructions.js"; @@ -1386,6 +1386,7 @@ export async function runTUI(initialConfig: Config): Promise { id: config.providerName, apiKey: config.apiKey, model: config.model, + ...(config.reasoningEffort !== undefined ? { reasoningEffort: config.reasoningEffort } : {}), }) : buildOpenAICompatibleInitialSource(); } @@ -2146,11 +2147,18 @@ export async function runTUI(initialConfig: Config): Promise { }); }); }, - modelLabel: () => ({ - profile: config.providerName, - model: config.model, - ...(config.reasoningEffort !== undefined ? { effort: config.reasoningEffort } : {}), - }), + modelLabel: () => { + const effort = resolveSessionEffort( + config.model, + config.reasoningEffort, + isCodexProviderName(config.providerName), + ); + return { + profile: config.providerName, + model: config.model, + ...(effort !== undefined ? { effort } : {}), + }; + }, activeModel: () => ({ provider: config.providerName, model: config.model }), readCostSummary: () => commandContext.getCostSummary?.(), showPromptCost: () => liveShowPromptCost, diff --git a/tests/unit/inference-sources.test.ts b/tests/unit/inference-sources.test.ts index c4974f793..0ac66a755 100644 --- a/tests/unit/inference-sources.test.ts +++ b/tests/unit/inference-sources.test.ts @@ -57,6 +57,68 @@ test("buildInferenceSourceForRef applies leg reasoning effort", () => { expect(source?.defaults?.providerOptions).toEqual({ reasoning_effort: "high" }); }); +test("leftover xhigh on gpt-5 inference source sends medium, not xhigh", () => { + const settings: Settings = { + providers: { + openai: { baseURL: "https://api.openai.com/v1", apiKey: "k", models: ["gpt-5"] }, + }, + }; + const source = buildInferenceSourceForRef( + { provider: "openai", model: "gpt-5", reasoningEffort: "xhigh" }, + { sessionId: "s1", catalog: [...catalog] }, + settings, + ); + expect(source?.defaults?.providerOptions).toEqual({ reasoning_effort: "medium" }); +}); + +test("unset still omits reasoning_effort", () => { + const settings: Settings = { + providers: { + openai: { baseURL: "https://api.openai.com/v1", apiKey: "k", models: ["gpt-5"] }, + }, + }; + const source = buildInferenceSourceForRef( + { provider: "openai", model: "gpt-5" }, + { sessionId: "s1", catalog: [...catalog] }, + settings, + ); + expect(source?.defaults?.providerOptions).not.toHaveProperty("reasoning_effort"); +}); + +test("buildInferenceSourceForRef forwards reasoning effort on xAI sources", () => { + const xaiCatalog: ProviderCatalogEntry[] = [ + { + name: "xai/work", + baseURL: "https://api.x.ai/v1", + apiKey: "tok", + models: ["grok-4.6"], + defaultModel: "grok-4.6", + xaiProfile: "work", + }, + ]; + const withLeg = buildInferenceSourceForRef( + { provider: "xai/work", model: "grok-4.6", reasoningEffort: "low" }, + { sessionId: "s1", catalog: xaiCatalog }, + undefined, + ); + expect(withLeg?.provider).toBe("grok-responses"); + expect(withLeg?.defaults?.providerOptions).toMatchObject({ reasoning_effort: "low" }); + + const withCtx = buildInferenceSourceForRef( + { provider: "xai/work", model: "grok-4.6" }, + { sessionId: "s1", catalog: xaiCatalog, reasoningEffort: "medium" }, + undefined, + ); + expect(withCtx?.defaults?.providerOptions).toMatchObject({ reasoning_effort: "medium" }); + + const unset = buildInferenceSourceForRef( + { provider: "xai/work", model: "grok-4.6" }, + { sessionId: "s1", catalog: xaiCatalog }, + undefined, + ); + expect(unset?.defaults?.providerOptions).not.toHaveProperty("reasoning_effort"); +}); + test("buildMainSessionSources backs the active head with other configured providers", () => { const settings: Settings = { providers: {