Skip to content

Commit 03d164b

Browse files
Merge pull request #493 from corbitsdev/cl-6572-show-a-default-reasoning-effort-in-the-prompt-when-the-model
Show default reasoning effort in the prompt for the live model
2 parents cb8e27c + 647edf8 commit 03d164b

10 files changed

Lines changed: 321 additions & 16 deletions

src/config.test.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
33
import { tmpdir } from "node:os";
44
import { join } from "node:path";
55

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

680+
describe("buildXaiSource", () => {
681+
test("omits reasoning_effort when effort is absent", () => {
682+
const source = buildXaiSource({ id: "xai/work", apiKey: "tok", model: "grok-4.6" });
683+
expect(source.provider).toBe("grok-responses");
684+
expect(source.defaults?.providerOptions).not.toHaveProperty("reasoning_effort");
685+
});
686+
687+
test("sets providerOptions.reasoning_effort when effort is present", () => {
688+
const source = buildXaiSource({
689+
id: "xai/work",
690+
apiKey: "tok",
691+
model: "grok-4.6",
692+
reasoningEffort: "low",
693+
});
694+
expect(source.defaults?.providerOptions).toMatchObject({ reasoning_effort: "low" });
695+
});
696+
697+
test("does not invent high when effort is absent", () => {
698+
const source = buildXaiSource({ id: "xai/work", apiKey: "tok", model: "grok-4.6" });
699+
expect(source.defaults?.providerOptions?.["reasoning_effort"]).toBeUndefined();
700+
});
701+
});
702+
680703
describe("buildProviderCatalog", () => {
681704
const resolved: ResolvedProvider = {
682705
providerName: "fp",

src/config/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,10 +168,12 @@ export function buildXaiSource(fields: {
168168
id: string;
169169
apiKey: string;
170170
model: string;
171+
reasoningEffort?: ReasoningEffort;
171172
}): InferenceSource {
172173
const userId = xaiUserIdFromAccessToken(fields.apiKey);
173174
const providerOptions: Record<string, unknown> = {};
174175
if (userId !== undefined) providerOptions[GROK_USER_ID_OPTION] = userId;
176+
if (fields.reasoningEffort !== undefined) providerOptions["reasoning_effort"] = fields.reasoningEffort;
175177
return {
176178
id: fields.id,
177179
provider: GROK_RESPONSES_PROVIDER,

src/config/inference-sources.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
type ProviderCatalogEntry,
1111
} from "./index.js";
1212
import type { Settings } from "./settings.js";
13-
import type { ReasoningEffort } from "../provider/reasoning-effort.js";
13+
import { resolveSessionEffort, type ReasoningEffort } from "../provider/reasoning-effort.js";
1414
import { SOURCE_MAX_TOKENS } from "./index.js";
1515
import { isOpenCodeGoProvider } from "../../packages/opencode-go/src/index.js";
1616
import { resolveDefaultModel } from "./providers.js";
@@ -77,7 +77,11 @@ export function buildInferenceSourceForRef(
7777
if (baseURL === undefined) return null;
7878

7979
const maxTokens = maxTokensFor(settings, ref.provider, ref.model);
80-
const effort = ref.reasoningEffort ?? ctx.reasoningEffort;
80+
const configured = ref.reasoningEffort ?? ctx.reasoningEffort;
81+
const effort =
82+
configured !== undefined
83+
? resolveSessionEffort(ref.model, configured, entry?.codexProfile !== undefined)
84+
: undefined;
8185

8286
if (entry?.codexProfile !== undefined) {
8387
return buildCodexSource({
@@ -94,6 +98,7 @@ export function buildInferenceSourceForRef(
9498
id: ref.provider,
9599
apiKey: entry.apiKey ?? "",
96100
model: ref.model,
101+
...(effort !== undefined ? { reasoningEffort: effort } : {}),
97102
});
98103
}
99104
if (

src/exec/runner.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,9 @@ export async function runExec(config: Config): Promise<ExecResult> {
507507
id: config.providerName,
508508
apiKey: config.apiKey,
509509
model: config.model,
510+
...(config.reasoningEffort !== undefined
511+
? { reasoningEffort: config.reasoningEffort }
512+
: {}),
510513
})
511514
: buildOpenAICompatibleInitialSource();
512515

src/provider/grok-responses-adapter.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,43 @@ describe("createGrokResponsesAdapter", () => {
7575
expect(body.include).toEqual(["reasoning.encrypted_content"]);
7676
expect(body.reasoning).toEqual({ summary: "detailed" });
7777
});
78+
79+
test("forwards providerOptions.reasoning_effort onto reasoning.effort", () => {
80+
const adapter = createGrokResponsesAdapter(source);
81+
const turns: ConversationTurn[] = [
82+
{
83+
role: "user",
84+
timestamp: 0,
85+
content: [{ type: "text", text: "hello" }],
86+
},
87+
];
88+
89+
const request = adapter.buildRequest(turns, "grok-4.6", {
90+
providerOptions: { reasoning_effort: "low" },
91+
});
92+
const body = JSON.parse(request.body) as {
93+
reasoning?: { effort?: string; summary?: string };
94+
};
95+
96+
expect(body.reasoning).toEqual({ effort: "low", summary: "detailed" });
97+
});
98+
99+
test("does not invent high when no reasoning_effort is set", () => {
100+
const adapter = createGrokResponsesAdapter(source);
101+
const turns: ConversationTurn[] = [
102+
{
103+
role: "user",
104+
timestamp: 0,
105+
content: [{ type: "text", text: "hello" }],
106+
},
107+
];
108+
109+
const request = adapter.buildRequest(turns, "grok-4.6", {});
110+
const body = JSON.parse(request.body) as {
111+
reasoning?: { effort?: string; summary?: string };
112+
};
113+
114+
expect(body.reasoning).toEqual({ summary: "detailed" });
115+
expect(body.reasoning?.effort).toBeUndefined();
116+
});
78117
});

src/provider/grok-responses-adapter.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,10 @@ function buildRequest(
152152
const input = systemMessage !== undefined ? [systemMessage, ...conversation] : conversation;
153153
const tools = toResponsesTools(options);
154154

155+
const reasoning: { summary: "detailed"; effort?: string } = { summary: "detailed" };
156+
const effort = optionString(options, "reasoning_effort");
157+
if (effort !== undefined) reasoning.effort = effort;
158+
155159
const body: Record<string, unknown> = {
156160
model,
157161
input,
@@ -160,8 +164,9 @@ function buildRequest(
160164
include: ["reasoning.encrypted_content"],
161165
// "detailed" streams denser summary deltas than "auto". Grok bills full
162166
// thinking tokens but only returns summarized text; sparse auto summaries
163-
// left the stall/activity clocks quiet for 60–120s mid-think.
164-
reasoning: { summary: "detailed" },
167+
// left the stall/activity clocks quiet for 60–120s mid-think. Effort is
168+
// forwarded when the source set it — this adapter does not invent a default.
169+
reasoning,
165170
};
166171
if (tools !== undefined) {
167172
body["tools"] = tools;

src/provider/reasoning-effort.test.ts

Lines changed: 117 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import {
1111
clampEffort,
1212
pickEffortFromCascade,
1313
resolveEffortForRole,
14+
defaultEffortForModel,
15+
resolveSessionEffort,
1416
} from "./reasoning-effort.js";
17+
import { composePromptActionBarModelLabel } from "../tui/components/prompt-action-bar-label.js";
1518

1619
describe("REASONING_EFFORTS", () => {
1720
test("is ordered from least to most effort", () => {
@@ -120,15 +123,41 @@ describe("cycleReasoningEffort", () => {
120123
afterEach(() => setModelReasoningCapabilities({}));
121124

122125
test("walks the gpt-5 ladder and wraps", () => {
123-
expect(cycleReasoningEffort("gpt-5", undefined)).toBe("minimal");
124126
expect(cycleReasoningEffort("gpt-5", "minimal")).toBe("low");
125127
expect(cycleReasoningEffort("gpt-5", "low")).toBe("medium");
126128
expect(cycleReasoningEffort("gpt-5", "medium")).toBe("high");
127129
expect(cycleReasoningEffort("gpt-5", "high")).toBe("minimal");
128130
});
129131

130-
test("starts at the first supported level when current is unsupported", () => {
131-
expect(cycleReasoningEffort("gpt-5", "xhigh")).toBe("minimal");
132+
test("unset gpt-5 cycles from the implicit medium default to high", () => {
133+
expect(cycleReasoningEffort("gpt-5", undefined)).toBe("high");
134+
});
135+
136+
test("unset grok cycles from implicit high, matching an explicit high", () => {
137+
expect(cycleReasoningEffort("grok-4.6", undefined)).toBe(
138+
cycleReasoningEffort("grok-4.6", "high"),
139+
);
140+
expect(cycleReasoningEffort("grok-4.6", "high")).toBe("low");
141+
});
142+
143+
test("unset gpt-5.1 chat cycles from implicit none to minimal", () => {
144+
expect(cycleReasoningEffort("gpt-5.1", undefined)).toBe("minimal");
145+
});
146+
147+
test("leftover unsupported effort cycles from the family default", () => {
148+
expect(cycleReasoningEffort("gpt-5", "xhigh")).toBe("high");
149+
expect(cycleReasoningEffort("gpt-5", "xhigh")).toBe(cycleReasoningEffort("gpt-5", "medium"));
150+
});
151+
152+
test("grok leftover minimal cycles the same as unset / high", () => {
153+
expect(cycleReasoningEffort("grok-4.6", "minimal")).toBe(cycleReasoningEffort("grok-4.6", undefined));
154+
expect(cycleReasoningEffort("grok-4.6", "minimal")).toBe(cycleReasoningEffort("grok-4.6", "high"));
155+
expect(cycleReasoningEffort("grok-4.6", "minimal")).toBe("low");
156+
});
157+
158+
test("unknown models with rungs still start at supported[0] when no default exists", () => {
159+
expect(defaultEffortForModel("some-random-model")).toBeUndefined();
160+
expect(cycleReasoningEffort("some-random-model", undefined)).toBe("low");
132161
});
133162

134163
test("returns undefined for a non-reasoning model", () => {
@@ -334,3 +363,88 @@ describe("resolveEffortForRole", () => {
334363
).toBe("high");
335364
});
336365
});
366+
367+
describe("defaultEffortForModel", () => {
368+
afterEach(() => setModelReasoningCapabilities({}));
369+
370+
test("grok family defaults to high", () => {
371+
expect(defaultEffortForModel("grok-4.6")).toBe("high");
372+
expect(defaultEffortForModel("grok-4.5")).toBe("high");
373+
});
374+
375+
test("gpt-5 and o-series default to medium", () => {
376+
expect(defaultEffortForModel("gpt-5")).toBe("medium");
377+
expect(defaultEffortForModel("o1")).toBe("medium");
378+
expect(defaultEffortForModel("o3-mini")).toBe("medium");
379+
expect(defaultEffortForModel("o4-mini")).toBe("medium");
380+
});
381+
382+
test("gpt-5.1 chat defaults to none when none is on the ladder", () => {
383+
expect(supportedEfforts("gpt-5.1").includes("none")).toBe(true);
384+
expect(defaultEffortForModel("gpt-5.1")).toBe("none");
385+
expect(defaultEffortForModel("gpt-5.1", false)).toBe("none");
386+
});
387+
388+
test("Codex defaults to medium", () => {
389+
expect(defaultEffortForModel("gpt-5.6-sol", true)).toBe("medium");
390+
expect(defaultEffortForModel("gpt-5.1-codex", true)).toBe("medium");
391+
});
392+
393+
test("empty ladder yields undefined", () => {
394+
setModelReasoningCapabilities({ "chat-only-model": false });
395+
expect(defaultEffortForModel("chat-only-model")).toBeUndefined();
396+
});
397+
398+
test("unknown models with rungs have no family default", () => {
399+
expect(supportedEfforts("some-random-model").length).toBeGreaterThan(0);
400+
expect(defaultEffortForModel("some-random-model")).toBeUndefined();
401+
});
402+
});
403+
404+
describe("resolveSessionEffort", () => {
405+
afterEach(() => setModelReasoningCapabilities({}));
406+
407+
test("empty ladder yields undefined even when configured", () => {
408+
setModelReasoningCapabilities({ "chat-only-model": false });
409+
expect(resolveSessionEffort("chat-only-model", "high")).toBeUndefined();
410+
});
411+
412+
test("keeps a supported configured level", () => {
413+
expect(resolveSessionEffort("gpt-5", "low")).toBe("low");
414+
expect(resolveSessionEffort("grok-4.6", "low")).toBe("low");
415+
});
416+
417+
test("falls back to the family default when unset or unsupported", () => {
418+
expect(resolveSessionEffort("gpt-5", undefined)).toBe("medium");
419+
expect(resolveSessionEffort("gpt-5", "xhigh")).toBe("medium");
420+
expect(resolveSessionEffort("grok-4.6", undefined)).toBe("high");
421+
expect(resolveSessionEffort("gpt-5.1", undefined)).toBe("none");
422+
expect(resolveSessionEffort("gpt-5.6-sol", undefined, true)).toBe("medium");
423+
expect(resolveSessionEffort("some-random-model", undefined)).toBeUndefined();
424+
});
425+
});
426+
427+
describe("prompt action bar effort label", () => {
428+
test("joiner stays a dumb concatenation of the resolved session effort", () => {
429+
const effort = resolveSessionEffort("grok-4.6", undefined);
430+
expect(effort).toBe("high");
431+
expect(
432+
composePromptActionBarModelLabel({
433+
profile: "xai/work",
434+
model: "grok-4.6",
435+
...(effort !== undefined ? { effort } : {}),
436+
}),
437+
).toBe("xai/work · grok-4.6 · high");
438+
});
439+
440+
test("shows gpt-5 medium without seeding a configured effort", () => {
441+
const effort = resolveSessionEffort("gpt-5", undefined);
442+
expect(effort).toBe("medium");
443+
expect(
444+
composePromptActionBarModelLabel({
445+
model: "gpt-5",
446+
...(effort !== undefined ? { effort } : {}),
447+
}),
448+
).toBe("gpt-5 · medium");
449+
});
450+
});

src/provider/reasoning-effort.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ export function validateEffort(
107107
* Next effort on the model's supported ladder (wraps around). Returns undefined
108108
* when the model supports no reasoning effort — callers flash a status and leave
109109
* the session config alone.
110+
*
111+
* Walks from resolveSessionEffort: unset and leftover unsupported current sit
112+
* on the family default, then the next rung. When there is no family default
113+
* but the ladder is non-empty, start at supported[0].
110114
*/
111115
export function cycleReasoningEffort(
112116
model: string,
@@ -115,13 +119,53 @@ export function cycleReasoningEffort(
115119
): ReasoningEffort | undefined {
116120
const supported = supportedEfforts(model, undefined, isCodex);
117121
if (supported.length === 0) return undefined;
118-
if (current === undefined || !supported.includes(current)) {
122+
const implicit = resolveSessionEffort(model, current, isCodex);
123+
if (implicit === undefined || !supported.includes(implicit)) {
119124
return supported[0];
120125
}
121-
const idx = supported.indexOf(current);
126+
const idx = supported.indexOf(implicit);
122127
return supported[(idx + 1) % supported.length];
123128
}
124129

130+
/**
131+
* Product default effort for a live session model. Distinct from role defaults
132+
* (`defaultEffortForDirector`): this is what the prompt shows and what Shift+Tab
133+
* advances from when the operator has not picked a level.
134+
*
135+
* Family table: grok* → high; Codex → medium; gpt-5.1 chat (`none` on the
136+
* ladder, not Codex) → none; gpt-5/o1/o3/o4 → medium. Unknown models with a
137+
* conservative rung set stay undefined so we do not invent a family default.
138+
*/
139+
export function defaultEffortForModel(
140+
model: string,
141+
isCodex = false,
142+
): ReasoningEffort | undefined {
143+
const supported = supportedEfforts(model, undefined, isCodex);
144+
if (supported.length === 0) return undefined;
145+
const pick = (desired: ReasoningEffort): ReasoningEffort | undefined =>
146+
supported.includes(desired) ? desired : undefined;
147+
if (model.startsWith("grok")) return pick("high");
148+
if (!isCodex && supported.includes("none")) return "none";
149+
if (isCodex || isKnownOpenAIReasoningModel(model)) return pick("medium");
150+
return undefined;
151+
}
152+
153+
/**
154+
* Effort the session is currently on: a configured level when the model accepts
155+
* it, otherwise the family default. Empty ladders stay undefined. Does not
156+
* write back into session config — display and request wiring read this.
157+
*/
158+
export function resolveSessionEffort(
159+
model: string,
160+
configured: ReasoningEffort | undefined,
161+
isCodex = false,
162+
): ReasoningEffort | undefined {
163+
const supported = supportedEfforts(model, undefined, isCodex);
164+
if (supported.length === 0) return undefined;
165+
if (configured !== undefined && supported.includes(configured)) return configured;
166+
return defaultEffortForModel(model, isCodex);
167+
}
168+
125169
// ---------------------------------------------------------------------------
126170
// Role-based product defaults (CL-5162)
127171
//

0 commit comments

Comments
 (0)