Skip to content

Commit 48d59a8

Browse files
authored
Merge pull request #853 from n3wr1ch/feat/grok-reasoning-effort-list
feat(server): advertise reasoning-effort ladders on the raw /v1/models list
2 parents 6ac6bea + dd813da commit 48d59a8

5 files changed

Lines changed: 229 additions & 15 deletions

File tree

docs-site/src/content/docs/guides/grok-build.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,21 @@ grok -m ocx-anthropic-claude-opus-4-8 -p "hello"
4646
# or in the TUI: /model ocx-anthropic-claude-opus-4-8
4747
```
4848

49+
## Reasoning effort
50+
51+
Grok Build's `/effort` (and `--effort`) only works for models whose catalog entry
52+
advertises the ladder: its model list fetch reads the raw `GET /v1/models` response, and
53+
entries there must carry `supports_reasoning_effort` plus `reasoning_efforts` menu
54+
options. For routed model entries, opencodex mirrors the configured provider tiers
55+
(`reasoningEfforts` / `modelReasoningEfforts`, and the default from
56+
`modelDefaultReasoningEfforts`) onto that response. This metadata describes the
57+
proxy-configured routed ladder — it does not claim native upstream reasoning support,
58+
and adapters may emulate reasoning or map levels onto provider-specific fields. Routed
59+
models with a configured ladder show the effort control in Grok Build just like they do
60+
in Codex. Models with an empty tier list keep no effort control, matching Codex
61+
behavior. Native GPT-5.6 entries are separate: they preserve and expose their pinned
62+
upstream reasoning ladders rather than provider-configured routed metadata.
63+
4964
## Authentication note
5065

5166
Grok Build requires a non-empty API key for custom models even on loopback. The injected

src/codex/catalog.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
// Public surface preserved exactly; importers keep using "src/codex/catalog".
33
export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, readCatalog, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing";
44
export type { CatalogModel, MultiAgentMode } from "./catalog/parsing";
5-
export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs } from "./catalog/metadata";
5+
export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort } from "./catalog/metadata";
66
export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled";
77
export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort";
88
export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch";

src/codex/catalog/metadata.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ import upstreamModelsSnapshot from "../data/upstream-models.json";
3434
import { filterSupportedNativeSlugs } from "./parsing";
3535
import type { RawEntry } from "./parsing";
3636
import { readCurrentCatalogOrCache, unique } from "./bundled";
37-
import { ensureGpt56ReasoningLevels, isGpt56NativeSlug } from "./effort";
3837

3938
export const NATIVE_OPENAI_MODELS = [
4039
"gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark",
@@ -88,21 +87,20 @@ export function nativeReasoningEfforts(slug: string): string[] {
8887
? upstream!.supported_reasoning_levels as Array<{ effort?: string }>
8988
: [];
9089
if (levels.length > 0) {
91-
const efforts = levels.flatMap(l => typeof l.effort === "string" ? [l.effort] : []);
92-
// gpt-5.6 natives get max+ultra restored (ensureGpt56ReasoningLevels catalog path does
93-
// the same); older natives (gpt-5.5/5.4/5.4-mini/5.3-codex-spark) stop at xhigh per
94-
// upstream snapshot.
95-
if (isGpt56NativeSlug(slug)) {
96-
const set = new Set(efforts);
97-
for (const e of ["max", "ultra"]) set.add(e);
98-
return [...set];
99-
}
100-
return efforts;
90+
// Preserve the exact pinned per-model ladder. In particular, GPT-5.6 Sol and Terra
91+
// include ultra while Luna intentionally ends at max.
92+
return levels.flatMap(l => typeof l.effort === "string" ? [l.effort] : []);
10193
}
10294
// gpt-5.3-codex-spark is not in upstream snapshot — use the standard old-ladder default.
10395
return ["low", "medium", "high", "xhigh"];
10496
}
10597

98+
/** Upstream-pinned default for a native slug, when present and non-empty. */
99+
export function nativeDefaultReasoningEffort(slug: string): string | undefined {
100+
const level = UPSTREAM_NATIVE_ENTRIES.get(slug)?.default_reasoning_level;
101+
return typeof level === "string" && level.length > 0 ? level : undefined;
102+
}
103+
106104
export function nativeParallelToolCalls(slug: string): boolean {
107105
return UPSTREAM_NATIVE_ENTRIES.get(slug)?.supports_parallel_tool_calls === true
108106
|| false;

src/server/index.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,7 @@ export function startServer(port?: number) {
474474
}
475475
throw error;
476476
}
477-
const { applyNativeVisibility, buildCatalogEntries, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, nativeOpenAiSlugs, orderForSubagents, filterCatalogVisibleModels, uniqueCatalogModelsForRawPublicList, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../codex/catalog");
477+
const { applyNativeVisibility, buildCatalogEntries, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, nativeOpenAiSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, orderForSubagents, filterCatalogVisibleModels, uniqueCatalogModelsForRawPublicList, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../codex/catalog");
478478
const nativeSlugs = nativeOpenAiSlugs();
479479
const goEnabled = filterCatalogVisibleModels(goModels, config);
480480
const goOrdered = orderForSubagents(goEnabled, config.subagentModels);
@@ -524,9 +524,45 @@ export function startServer(port?: number) {
524524
}
525525
// OpenAI list shape: native gpt bare + routed models namespaced "<provider>/<id>"
526526
// (pure availability list — disabled natives are omitted entirely).
527+
// Grok Build discovers models through this endpoint too, and its model picker only
528+
// enables /effort for entries that advertise the reasoning ladder in the Grok model
529+
// catalog shape (supports_reasoning_effort + reasoning_efforts[]). The Codex catalog
530+
// branch above already carries the same ladders, so mirror them here — native rows
531+
// from the upstream snapshot, routed rows from the configured provider tiers. The
532+
// default uses the same canonical fallback as the Codex catalog resolver
533+
// (configured default, then medium, then high, then the first tier). Extra fields
534+
// are ignored by plain OpenAI clients.
535+
const grokEffortOption = (value: string, isDefault: boolean) => ({
536+
value,
537+
label: `${value[0].toUpperCase()}${value.slice(1)} Effort`,
538+
...(isDefault ? { default: true } : {}),
539+
});
540+
const grokEffortFields = (efforts: string[], configuredDefault?: string) => {
541+
if (efforts.length === 0) return {};
542+
const defaultEffort = configuredDefault && efforts.includes(configuredDefault)
543+
? configuredDefault
544+
: efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high" : efforts[0];
545+
return {
546+
supports_reasoning_effort: true,
547+
reasoning_effort: defaultEffort,
548+
reasoning_efforts: efforts.map(effort => grokEffortOption(effort, effort === defaultEffort)),
549+
};
550+
};
527551
const data = [
528-
...visibleNativeSlugs(config).map(id => ({ id, object: "model", created: 0, owned_by: "openai" })),
529-
...uniqueCatalogModelsForRawPublicList(goOrdered).map(m => ({ id: m.alias ?? `${m.provider}/${m.id}`, object: "model", created: 0, owned_by: m.owned_by ?? m.provider })),
552+
...visibleNativeSlugs(config).map(id => ({
553+
id,
554+
object: "model",
555+
created: 0,
556+
owned_by: "openai",
557+
...grokEffortFields(nativeReasoningEfforts(id), nativeDefaultReasoningEffort(id)),
558+
})),
559+
...uniqueCatalogModelsForRawPublicList(goOrdered).map(m => ({
560+
id: m.alias ?? `${m.provider}/${m.id}`,
561+
object: "model",
562+
created: 0,
563+
owned_by: m.owned_by ?? m.provider,
564+
...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort),
565+
})),
530566
];
531567
return jsonResponse({ object: "list", data }, 200, req, config);
532568
}
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2+
import { mkdtempSync, rmSync } from "node:fs";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
import { saveConfig } from "../src/config";
6+
import { startServer } from "../src/server";
7+
import type { OcxConfig } from "../src/types";
8+
9+
const previousHome = process.env.OPENCODEX_HOME;
10+
let testHome = "";
11+
12+
function effortConfig(): OcxConfig {
13+
return {
14+
port: 0,
15+
hostname: "127.0.0.1",
16+
defaultProvider: "kimi",
17+
providers: {
18+
kimi: {
19+
adapter: "openai-chat",
20+
baseUrl: "https://kimi.test/v1",
21+
models: ["k3", "kimi-for-coding"],
22+
modelReasoningEfforts: {
23+
k3: ["low", "high", "max"],
24+
"kimi-for-coding": [],
25+
},
26+
modelDefaultReasoningEfforts: { k3: "high" },
27+
},
28+
},
29+
};
30+
}
31+
32+
beforeEach(() => {
33+
testHome = mkdtempSync(join(tmpdir(), "ocx-grok-effort-list-"));
34+
process.env.OPENCODEX_HOME = testHome;
35+
});
36+
37+
afterEach(() => {
38+
if (previousHome === undefined) delete process.env.OPENCODEX_HOME;
39+
else process.env.OPENCODEX_HOME = previousHome;
40+
if (testHome) rmSync(testHome, { recursive: true, force: true });
41+
testHome = "";
42+
});
43+
44+
describe("raw /v1/models list reasoning-effort advertisement (Grok Build discovery)", () => {
45+
test("routed models with configured tiers advertise the Grok reasoning catalog shape", async () => {
46+
saveConfig(effortConfig());
47+
const server = startServer(0);
48+
try {
49+
const res = await fetch(new URL("/v1/models", server.url));
50+
expect(res.status).toBe(200);
51+
const body = await res.json() as { data: Array<Record<string, unknown>> };
52+
const k3 = body.data.find(m => m.id === "kimi/k3");
53+
expect(k3).toBeDefined();
54+
expect(k3!.supports_reasoning_effort).toBe(true);
55+
expect(k3!.reasoning_effort).toBe("high");
56+
expect(k3!.reasoning_efforts).toEqual([
57+
{ value: "low", label: "Low Effort" },
58+
{ value: "high", label: "High Effort", default: true },
59+
{ value: "max", label: "Max Effort" },
60+
]);
61+
// Native rows preserve the pinned per-model ladder and default. Sol and Terra include
62+
// ultra, while Luna intentionally ends at max, matching the canonical Codex catalog.
63+
const nativeExpectations = [
64+
{
65+
id: "gpt-5.6-sol",
66+
defaultEffort: "low",
67+
efforts: ["low", "medium", "high", "xhigh", "max", "ultra"],
68+
},
69+
{
70+
id: "gpt-5.6-terra",
71+
defaultEffort: "medium",
72+
efforts: ["low", "medium", "high", "xhigh", "max", "ultra"],
73+
},
74+
{
75+
id: "gpt-5.6-luna",
76+
defaultEffort: "medium",
77+
efforts: ["low", "medium", "high", "xhigh", "max"],
78+
},
79+
];
80+
for (const expected of nativeExpectations) {
81+
const native = body.data.find(m => m.id === expected.id);
82+
expect(native).toBeDefined();
83+
expect(native!.supports_reasoning_effort).toBe(true);
84+
expect(native!.reasoning_effort).toBe(expected.defaultEffort);
85+
expect((native!.reasoning_efforts as Array<{ value: string }>).map(option => option.value))
86+
.toEqual(expected.efforts);
87+
}
88+
} finally {
89+
await server.stop(true);
90+
}
91+
});
92+
93+
test("models with an empty tier list advertise no effort fields", async () => {
94+
saveConfig(effortConfig());
95+
const server = startServer(0);
96+
try {
97+
const res = await fetch(new URL("/v1/models", server.url));
98+
const body = await res.json() as { data: Array<Record<string, unknown>> };
99+
const plain = body.data.find(m => m.id === "kimi/kimi-for-coding");
100+
expect(plain).toBeDefined();
101+
expect("supports_reasoning_effort" in plain!).toBe(false);
102+
expect("reasoning_effort" in plain!).toBe(false);
103+
expect("reasoning_efforts" in plain!).toBe(false);
104+
} finally {
105+
await server.stop(true);
106+
}
107+
});
108+
109+
test("a ladder without a configured default uses the canonical medium default", async () => {
110+
const config = effortConfig();
111+
config.providers.kimi!.modelDefaultReasoningEfforts = {};
112+
config.providers.kimi!.modelReasoningEfforts = { k3: ["low", "medium", "high"] };
113+
saveConfig(config);
114+
const server = startServer(0);
115+
try {
116+
const res = await fetch(new URL("/v1/models", server.url));
117+
const body = await res.json() as { data: Array<Record<string, unknown>> };
118+
const k3 = body.data.find(m => m.id === "kimi/k3");
119+
expect(k3!.reasoning_effort).toBe("medium");
120+
const options = k3!.reasoning_efforts as Array<Record<string, unknown>>;
121+
expect(options[1]).toEqual({ value: "medium", label: "Medium Effort", default: true });
122+
} finally {
123+
await server.stop(true);
124+
}
125+
});
126+
127+
test("an invalid configured default falls back with the canonical medium/high/first order", async () => {
128+
const config = effortConfig();
129+
config.providers.kimi!.modelDefaultReasoningEfforts = { k3: "medium" };
130+
saveConfig(config);
131+
const server = startServer(0);
132+
try {
133+
const res = await fetch(new URL("/v1/models", server.url));
134+
const body = await res.json() as { data: Array<Record<string, unknown>> };
135+
const k3 = body.data.find(m => m.id === "kimi/k3");
136+
// k3's ladder is low/high/max: no medium, so the canonical fallback picks high.
137+
expect(k3!.reasoning_effort).toBe("high");
138+
const options = k3!.reasoning_efforts as Array<Record<string, unknown>>;
139+
expect(options[1]).toEqual({ value: "high", label: "High Effort", default: true });
140+
} finally {
141+
await server.stop(true);
142+
}
143+
});
144+
145+
test("falls back to the first tier when neither medium nor high is available", async () => {
146+
const config = effortConfig();
147+
config.providers.kimi!.models = [...(config.providers.kimi!.models ?? []), "custom-test"];
148+
config.providers.kimi!.modelReasoningEfforts = { "custom-test": ["low", "max"] };
149+
config.providers.kimi!.modelDefaultReasoningEfforts = { "custom-test": "medium" };
150+
saveConfig(config);
151+
const server = startServer(0);
152+
try {
153+
const res = await fetch(new URL("/v1/models", server.url));
154+
const body = await res.json() as { data: Array<Record<string, unknown>> };
155+
const model = body.data.find(m => m.id === "kimi/custom-test");
156+
expect(model!.reasoning_effort).toBe("low");
157+
expect(model!.reasoning_efforts).toEqual([
158+
{ value: "low", label: "Low Effort", default: true },
159+
{ value: "max", label: "Max Effort" },
160+
]);
161+
} finally {
162+
await server.stop(true);
163+
}
164+
});
165+
});

0 commit comments

Comments
 (0)