diff --git a/docs-site/src/content/docs/guides/grok-build.md b/docs-site/src/content/docs/guides/grok-build.md index 08ad9f9f0e..9b1fc1bf64 100644 --- a/docs-site/src/content/docs/guides/grok-build.md +++ b/docs-site/src/content/docs/guides/grok-build.md @@ -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 diff --git a/src/codex/catalog.ts b/src/codex/catalog.ts index c6f21c750c..a410b59b05 100644 --- a/src/codex/catalog.ts +++ b/src/codex/catalog.ts @@ -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"; diff --git a/src/codex/catalog/metadata.ts b/src/codex/catalog/metadata.ts index 4f4786e171..8f3bad4942 100644 --- a/src/codex/catalog/metadata.ts +++ b/src/codex/catalog/metadata.ts @@ -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", @@ -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; diff --git a/src/server/index.ts b/src/server/index.ts index 5757d28cd1..c8d7968c1d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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); @@ -524,9 +524,45 @@ export function startServer(port?: number) { } // OpenAI list shape: native gpt bare + routed models namespaced "/" // (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); } diff --git a/tests/grok-models-effort-list.test.ts b/tests/grok-models-effort-list.test.ts new file mode 100644 index 0000000000..5c24e93e53 --- /dev/null +++ b/tests/grok-models-effort-list.test.ts @@ -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> }; + 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> }; + 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> }; + const k3 = body.data.find(m => m.id === "kimi/k3"); + expect(k3!.reasoning_effort).toBe("medium"); + const options = k3!.reasoning_efforts as Array>; + 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> }; + 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>; + expect(options[1]).toEqual({ value: "high", label: "High Effort", default: true }); + } 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> }; + 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); + } + }); +});