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
15 changes: 15 additions & 0 deletions docs-site/src/content/docs/guides/grok-build.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,21 @@ grok -m ocx-anthropic-claude-opus-4-8 -p "hello"
# or in the TUI: /model ocx-anthropic-claude-opus-4-8
```

## Reasoning effort

Grok Build's `/effort` (and `--effort`) only works for models whose catalog entry
advertises the ladder: its model list fetch reads the raw `GET /v1/models` response, and
entries there must carry `supports_reasoning_effort` plus `reasoning_efforts` menu
options. For routed model entries, opencodex mirrors the configured provider tiers
(`reasoningEfforts` / `modelReasoningEfforts`, and the default from
`modelDefaultReasoningEfforts`) onto that response. This metadata describes the
proxy-configured routed ladder — it does not claim native upstream reasoning support,
and adapters may emulate reasoning or map levels onto provider-specific fields. Routed
models with a configured ladder show the effort control in Grok Build just like they do
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.

## Authentication note

Grok Build requires a non-empty API key for custom models even on loopback. The injected
Expand Down
2 changes: 1 addition & 1 deletion src/codex/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// Public surface preserved exactly; importers keep using "src/codex/catalog".
export { isMediaGenerationModelId, shouldExposeRoutedModel, readCodexCatalogPath, readCatalog, normalizeRoutedCatalogEntry, catalogModelSlug, filterSupportedNativeSlugs, catalogModelSupportsReasoningSummaries } from "./catalog/parsing";
export type { CatalogModel, MultiAgentMode } from "./catalog/parsing";
export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs } from "./catalog/metadata";
export { NATIVE_OPENAI_MODELS, nativeOpenAiContextWindow, disabledNativeSlugs, visibleNativeSlugs, desktopVisibleNativeSlugs, nativeModelRows, applyNativeVisibility, upstreamNativeEntry, nativeOpenAiSlugs, listCatalogNativeSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort } from "./catalog/metadata";
export { isSpawnableCodexCandidate, codexExecInvocation, loadBundledCodexCatalog, materializeBundledCodexCatalog, loadCatalogTemplate } from "./catalog/bundled";
export { nativeEffortClamp, shouldApplyNativeEffortClamp, catalogModelEfforts, codexSupportedReasoningEfforts, clampedDefaultEffort, clampEntryToCodexSupportedEfforts, clampCatalogModelsToCodexSupport } from "./catalog/effort";
export { applyProviderConfigHints, isDatedVariantId, filterCatalogVisibleModels, gatherRoutedModels, clearGatherRoutedModelsInflight, augmentRoutedModelsWithRegistryOpenAiApiRows, augmentRoutedModelsWithJawcodeMetadata } from "./catalog/provider-fetch";
Expand Down
20 changes: 9 additions & 11 deletions src/codex/catalog/metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ import upstreamModelsSnapshot from "../data/upstream-models.json";
import { filterSupportedNativeSlugs } from "./parsing";
import type { RawEntry } from "./parsing";
import { readCurrentCatalogOrCache, unique } from "./bundled";
import { ensureGpt56ReasoningLevels, isGpt56NativeSlug } from "./effort";

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

/** Upstream-pinned default for a native slug, when present and non-empty. */
export function nativeDefaultReasoningEffort(slug: string): string | undefined {
const level = UPSTREAM_NATIVE_ENTRIES.get(slug)?.default_reasoning_level;
return typeof level === "string" && level.length > 0 ? level : undefined;
}

export function nativeParallelToolCalls(slug: string): boolean {
return UPSTREAM_NATIVE_ENTRIES.get(slug)?.supports_parallel_tool_calls === true
|| false;
Expand Down
42 changes: 39 additions & 3 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -474,7 +474,7 @@ export function startServer(port?: number) {
}
throw error;
}
const { applyNativeVisibility, buildCatalogEntries, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, nativeOpenAiSlugs, orderForSubagents, filterCatalogVisibleModels, uniqueCatalogModelsForRawPublicList, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../codex/catalog");
const { applyNativeVisibility, buildCatalogEntries, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, nativeOpenAiSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, orderForSubagents, filterCatalogVisibleModels, uniqueCatalogModelsForRawPublicList, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../codex/catalog");
const nativeSlugs = nativeOpenAiSlugs();
const goEnabled = filterCatalogVisibleModels(goModels, config);
const goOrdered = orderForSubagents(goEnabled, config.subagentModels);
Expand Down Expand Up @@ -524,9 +524,45 @@ export function startServer(port?: number) {
}
// OpenAI list shape: native gpt bare + routed models namespaced "<provider>/<id>"
// (pure availability list — disabled natives are omitted entirely).
// Grok Build discovers models through this endpoint too, and its model picker only
// enables /effort for entries that advertise the reasoning ladder in the Grok model
// catalog shape (supports_reasoning_effort + reasoning_efforts[]). The Codex catalog
// branch above already carries the same ladders, so mirror them here — native rows
// from the upstream snapshot, routed rows from the configured provider tiers. The
// default uses the same canonical fallback as the Codex catalog resolver
// (configured default, then medium, then high, then the first tier). Extra fields
// are ignored by plain OpenAI clients.
const grokEffortOption = (value: string, isDefault: boolean) => ({
value,
label: `${value[0].toUpperCase()}${value.slice(1)} Effort`,
...(isDefault ? { default: true } : {}),
});
const grokEffortFields = (efforts: string[], configuredDefault?: string) => {
if (efforts.length === 0) return {};
const defaultEffort = configuredDefault && efforts.includes(configuredDefault)
? configuredDefault
: efforts.includes("medium") ? "medium" : efforts.includes("high") ? "high" : efforts[0];
return {
supports_reasoning_effort: true,
reasoning_effort: defaultEffort,
reasoning_efforts: efforts.map(effort => grokEffortOption(effort, effort === defaultEffort)),
};
};
const data = [
...visibleNativeSlugs(config).map(id => ({ id, object: "model", created: 0, owned_by: "openai" })),
...uniqueCatalogModelsForRawPublicList(goOrdered).map(m => ({ id: m.alias ?? `${m.provider}/${m.id}`, object: "model", created: 0, owned_by: m.owned_by ?? m.provider })),
...visibleNativeSlugs(config).map(id => ({
id,
object: "model",
created: 0,
owned_by: "openai",
...grokEffortFields(nativeReasoningEfforts(id), nativeDefaultReasoningEffort(id)),
})),
...uniqueCatalogModelsForRawPublicList(goOrdered).map(m => ({
id: m.alias ?? `${m.provider}/${m.id}`,
object: "model",
created: 0,
owned_by: m.owned_by ?? m.provider,
...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort),
})),
];
return jsonResponse({ object: "list", data }, 200, req, config);
}
Expand Down
165 changes: 165 additions & 0 deletions tests/grok-models-effort-list.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { saveConfig } from "../src/config";
import { startServer } from "../src/server";
import type { OcxConfig } from "../src/types";

const previousHome = process.env.OPENCODEX_HOME;
let testHome = "";

function effortConfig(): OcxConfig {
return {
port: 0,
hostname: "127.0.0.1",
defaultProvider: "kimi",
providers: {
kimi: {
adapter: "openai-chat",
baseUrl: "https://kimi.test/v1",
models: ["k3", "kimi-for-coding"],
modelReasoningEfforts: {
k3: ["low", "high", "max"],
"kimi-for-coding": [],
},
modelDefaultReasoningEfforts: { k3: "high" },
},
},
};
}

beforeEach(() => {
testHome = mkdtempSync(join(tmpdir(), "ocx-grok-effort-list-"));
process.env.OPENCODEX_HOME = testHome;
});

afterEach(() => {
if (previousHome === undefined) delete process.env.OPENCODEX_HOME;
else process.env.OPENCODEX_HOME = previousHome;
if (testHome) rmSync(testHome, { recursive: true, force: true });
testHome = "";
});

describe("raw /v1/models list reasoning-effort advertisement (Grok Build discovery)", () => {
test("routed models with configured tiers advertise the Grok reasoning catalog shape", async () => {
saveConfig(effortConfig());
const server = startServer(0);
try {
const res = await fetch(new URL("/v1/models", server.url));
expect(res.status).toBe(200);
const body = await res.json() as { data: Array<Record<string, unknown>> };
const k3 = body.data.find(m => m.id === "kimi/k3");
expect(k3).toBeDefined();
expect(k3!.supports_reasoning_effort).toBe(true);
expect(k3!.reasoning_effort).toBe("high");
expect(k3!.reasoning_efforts).toEqual([
{ value: "low", label: "Low Effort" },
{ value: "high", label: "High Effort", default: true },
{ value: "max", label: "Max Effort" },
]);
// Native rows preserve the pinned per-model ladder and default. Sol and Terra include
// ultra, while Luna intentionally ends at max, matching the canonical Codex catalog.
const nativeExpectations = [
{
id: "gpt-5.6-sol",
defaultEffort: "low",
efforts: ["low", "medium", "high", "xhigh", "max", "ultra"],
},
{
id: "gpt-5.6-terra",
defaultEffort: "medium",
efforts: ["low", "medium", "high", "xhigh", "max", "ultra"],
},
{
id: "gpt-5.6-luna",
defaultEffort: "medium",
efforts: ["low", "medium", "high", "xhigh", "max"],
},
];
for (const expected of nativeExpectations) {
const native = body.data.find(m => m.id === expected.id);
expect(native).toBeDefined();
expect(native!.supports_reasoning_effort).toBe(true);
expect(native!.reasoning_effort).toBe(expected.defaultEffort);
expect((native!.reasoning_efforts as Array<{ value: string }>).map(option => option.value))
.toEqual(expected.efforts);
}
} finally {
await server.stop(true);
}
});

test("models with an empty tier list advertise no effort fields", async () => {
saveConfig(effortConfig());
const server = startServer(0);
try {
const res = await fetch(new URL("/v1/models", server.url));
const body = await res.json() as { data: Array<Record<string, unknown>> };
const plain = body.data.find(m => m.id === "kimi/kimi-for-coding");
expect(plain).toBeDefined();
expect("supports_reasoning_effort" in plain!).toBe(false);
expect("reasoning_effort" in plain!).toBe(false);
expect("reasoning_efforts" in plain!).toBe(false);
} finally {
await server.stop(true);
}
});

test("a ladder without a configured default uses the canonical medium default", async () => {
const config = effortConfig();
config.providers.kimi!.modelDefaultReasoningEfforts = {};
config.providers.kimi!.modelReasoningEfforts = { k3: ["low", "medium", "high"] };
saveConfig(config);
const server = startServer(0);
try {
const res = await fetch(new URL("/v1/models", server.url));
const body = await res.json() as { data: Array<Record<string, unknown>> };
const k3 = body.data.find(m => m.id === "kimi/k3");
expect(k3!.reasoning_effort).toBe("medium");
const options = k3!.reasoning_efforts as Array<Record<string, unknown>>;
expect(options[1]).toEqual({ value: "medium", label: "Medium Effort", default: true });
} finally {
await server.stop(true);
}
});

test("an invalid configured default falls back with the canonical medium/high/first order", async () => {
const config = effortConfig();
config.providers.kimi!.modelDefaultReasoningEfforts = { k3: "medium" };
saveConfig(config);
const server = startServer(0);
try {
const res = await fetch(new URL("/v1/models", server.url));
const body = await res.json() as { data: Array<Record<string, unknown>> };
const k3 = body.data.find(m => m.id === "kimi/k3");
// k3's ladder is low/high/max: no medium, so the canonical fallback picks high.
expect(k3!.reasoning_effort).toBe("high");
const options = k3!.reasoning_efforts as Array<Record<string, unknown>>;
expect(options[1]).toEqual({ value: "high", label: "High Effort", default: true });
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} finally {
await server.stop(true);
}
});

test("falls back to the first tier when neither medium nor high is available", async () => {
const config = effortConfig();
config.providers.kimi!.models = [...(config.providers.kimi!.models ?? []), "custom-test"];
config.providers.kimi!.modelReasoningEfforts = { "custom-test": ["low", "max"] };
config.providers.kimi!.modelDefaultReasoningEfforts = { "custom-test": "medium" };
saveConfig(config);
const server = startServer(0);
try {
const res = await fetch(new URL("/v1/models", server.url));
const body = await res.json() as { data: Array<Record<string, unknown>> };
const model = body.data.find(m => m.id === "kimi/custom-test");
expect(model!.reasoning_effort).toBe("low");
expect(model!.reasoning_efforts).toEqual([
{ value: "low", label: "Low Effort", default: true },
{ value: "max", label: "Max Effort" },
]);
} finally {
await server.stop(true);
}
});
});
Loading