From a77d5f972e77a6d07c4c925daf2402dcd21d8e92 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:31:56 +0800 Subject: [PATCH 1/4] fix(catalog): allow per-provider/model opt-out of code_mode_only tool mode (#2106) --- src/codex/catalog/aggregation.ts | 3 + src/codex/catalog/parsing.ts | 23 ++++- src/codex/catalog/provider-fetch.ts | 3 + src/codex/catalog/sync.ts | 4 +- src/types.ts | 12 +++ tests/codex-tool-mode.test.ts | 140 ++++++++++++++++++++++++++++ 6 files changed, 180 insertions(+), 5 deletions(-) create mode 100644 tests/codex-tool-mode.test.ts diff --git a/src/codex/catalog/aggregation.ts b/src/codex/catalog/aggregation.ts index a4736ab4b0..a605534227 100644 --- a/src/codex/catalog/aggregation.ts +++ b/src/codex/catalog/aggregation.ts @@ -183,6 +183,9 @@ export function deriveComboCatalogModel( ? { supportsServiceTier: false } : {}), ...(members.some(member => member.supportsReasoningSummaries === false) ? { supportsReasoningSummaries: false } : {}), + ...(members.every(member => member.codexToolMode === "shell") + ? { codexToolMode: "shell" as const } + : {}), }; } diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index f42049150e..0d1b2c2aaa 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -126,6 +126,12 @@ export interface CatalogModel { /** Whether this exact routed model has a verified OpenAI-compatible service tier. */ supportsServiceTier?: boolean; supportsReasoningSummaries?: boolean; + /** + * Codex tool calling mode for this routed model. + * "code_mode_only" (default) sets entry.tool_mode = "code_mode_only". + * "shell" leaves tool_mode unset so Codex declares top-level shell tools (exec_command). + */ + codexToolMode?: "code_mode_only" | "shell"; /** Normalized upstream capability names retained for management/API consumers (#485 follow-up). */ capabilities?: string[]; /** OpenCodex-only catalog ownership marker; Codex ignores the serialized extension field. */ @@ -423,7 +429,14 @@ export function catalogEntryIsNativeChatGpt(entry: RawEntry): boolean { export const ROUTED_CODEX_TOOL_MODE = "code_mode_only"; -export function applyRoutedCodexToolMode(entry: RawEntry): RawEntry { +export function applyRoutedCodexToolMode( + entry: RawEntry, + toolMode?: "code_mode_only" | "shell" | string, +): RawEntry { + if (toolMode === "shell") { + delete entry.tool_mode; + return entry; + } entry.tool_mode = ROUTED_CODEX_TOOL_MODE; return entry; } @@ -490,10 +503,14 @@ export function applyMultiAgentMode( return entries; } -export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls = false): RawEntry { +export function normalizeRoutedCatalogEntry( + entry: RawEntry, + parallelToolCalls = false, + toolMode?: "code_mode_only" | "shell" | string, +): RawEntry { delete entry.model_messages; delete entry.tool_mode; - applyRoutedCodexToolMode(entry); + applyRoutedCodexToolMode(entry, toolMode); delete entry.multi_agent_version; delete entry.use_responses_lite; delete entry.supports_websockets; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 93d36ac8ae..a578524bda 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -676,6 +676,7 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, ...(prov.parallelToolCalls === true || (prov.adapter === "openai-chat" && prov.parallelToolCalls !== false) ? { parallelToolCalls: true } : {}), + ...(prov.codexToolMode !== undefined ? { codexToolMode: prov.codexToolMode } : {}), }; const capped = applyProviderContextCap(hinted.contextWindow, providerCap); if (providerCap !== undefined && capped !== hinted.contextWindow) { @@ -1881,6 +1882,7 @@ async function gatherRoutedModelsUncached( ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}), ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}), ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}), + ...(cm.codexToolMode !== undefined ? { codexToolMode: cm.codexToolMode } : {}), }; // #962: the dedupe below drops the provider-derived row this custom row replaces. Inherit that // row's provider capability metadata (reasoning ladder, default effort, parallel tool calls, @@ -1905,6 +1907,7 @@ async function gatherRoutedModelsUncached( ...(base.parallelToolCalls === undefined && replaced.parallelToolCalls !== undefined ? { parallelToolCalls: replaced.parallelToolCalls } : {}), ...(base.supportsVerbosity === undefined && replaced.supportsVerbosity !== undefined ? { supportsVerbosity: replaced.supportsVerbosity } : {}), ...(base.supportsReasoningSummaries === undefined && replaced.supportsReasoningSummaries !== undefined ? { supportsReasoningSummaries: replaced.supportsReasoningSummaries } : {}), + ...(base.codexToolMode === undefined && replaced.codexToolMode !== undefined ? { codexToolMode: replaced.codexToolMode } : {}), ...(base.capabilities === undefined && replaced.capabilities !== undefined ? { capabilities: replaced.capabilities } : {}), } : base; // Vision-sidecar coverage ONLY: if the custom model is in the enriched provider's diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 5d833e2c17..16309f0d9f 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -331,7 +331,7 @@ export function deriveEntry( // This exact provider/model pair is the ChatGPT/Codex forward surface. Keep the pinned // native tool/search/responses-lite contract while preserving the routed slug and wire id. if (!codexForwardNativeCapabilityAlias) { - normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true); + normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true, model?.codexToolMode); } if (model) applyCatalogMetadata(e, model.provider, model.id, model.contextCap); applyCatalogModelMetadata(e, model); @@ -373,7 +373,7 @@ export function deriveEntry( : {}), }; if (isRouted) { - applyRoutedCodexToolMode(entry); + applyRoutedCodexToolMode(entry, model?.codexToolMode); applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact); } else { diff --git a/src/types.ts b/src/types.ts index 77c88be200..beec8c051c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -637,6 +637,12 @@ export interface OcxCustomModel { reasoningEfforts?: string[]; /** Default effort label when `reasoningEfforts` is non-empty. */ defaultReasoningEffort?: string; + /** + * Codex tool calling mode override for this custom model. + * "code_mode_only" (default) sets entry.tool_mode = "code_mode_only". + * "shell" leaves tool_mode unset so Codex declares top-level shell tools (exec_command). + */ + codexToolMode?: "code_mode_only" | "shell"; /** 추가 시각 (ISO 8601) */ addedAt?: string; } @@ -1376,6 +1382,12 @@ export type TierDecision = */ export interface OcxProviderConfig { adapter: string; + /** + * Codex tool calling mode for routed models. + * "code_mode_only" (default) sets entry.tool_mode = "code_mode_only" (unified exec helper tool). + * "shell" leaves tool_mode unset so Codex declares top-level shell tools (exec_command). + */ + codexToolMode?: "code_mode_only" | "shell"; /** Optional outbound request-start pacing shared by this provider and its model overrides. */ requestPacing?: ProviderRequestPacingConfig; /** Cursor MCP compatibility bounds; positive integers when configured. */ diff --git a/tests/codex-tool-mode.test.ts b/tests/codex-tool-mode.test.ts new file mode 100644 index 0000000000..cd8b3352d5 --- /dev/null +++ b/tests/codex-tool-mode.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, test } from "bun:test"; +import { + applyRoutedCodexToolMode, + normalizeRoutedCatalogEntry, + ROUTED_CODEX_TOOL_MODE, + type RawEntry, +} from "../src/codex/catalog/parsing"; +import { buildCatalogEntries } from "../src/codex/catalog"; +import { + collectDeclaredWireToolNames, + undeclaredToolCallName, +} from "../src/server/responses-undeclared-tool-guard"; + +describe("Codex tool mode configuration (#2106)", () => { + test("applyRoutedCodexToolMode defaults to code_mode_only", () => { + const entry: RawEntry = { slug: "deepseek/deepseek-v4-flash" }; + applyRoutedCodexToolMode(entry); + expect(entry.tool_mode).toBe(ROUTED_CODEX_TOOL_MODE); + + const explicitCodeMode: RawEntry = { slug: "deepseek/deepseek-v4-flash" }; + applyRoutedCodexToolMode(explicitCodeMode, "code_mode_only"); + expect(explicitCodeMode.tool_mode).toBe(ROUTED_CODEX_TOOL_MODE); + }); + + test("applyRoutedCodexToolMode deletes tool_mode when toolMode is shell", () => { + const entry: RawEntry = { + slug: "deepseek/deepseek-v4-flash", + tool_mode: "code_mode_only", + }; + applyRoutedCodexToolMode(entry, "shell"); + expect(entry.tool_mode).toBeUndefined(); + expect(Object.hasOwn(entry, "tool_mode")).toBe(false); + }); + + test("normalizeRoutedCatalogEntry respects toolMode", () => { + const defaultEntry: RawEntry = { + slug: "deepseek/deepseek-v4-flash", + model_messages: { input: [] }, + }; + normalizeRoutedCatalogEntry(defaultEntry); + expect(defaultEntry.tool_mode).toBe("code_mode_only"); + + const shellEntry: RawEntry = { + slug: "deepseek/deepseek-v4-flash", + model_messages: { input: [] }, + }; + normalizeRoutedCatalogEntry(shellEntry, false, "shell"); + expect(shellEntry.tool_mode).toBeUndefined(); + expect(Object.hasOwn(shellEntry, "tool_mode")).toBe(false); + }); + + test("buildCatalogEntries preserves tool_mode = code_mode_only by default", () => { + const entries = buildCatalogEntries(null, [], [ + { id: "deepseek-v4-flash", provider: "deepseek" }, + ]); + const deepseekEntry = entries.find(e => e.slug === "deepseek/deepseek-v4-flash"); + expect(deepseekEntry).toBeDefined(); + expect(deepseekEntry?.tool_mode).toBe("code_mode_only"); + expect(deepseekEntry?.shell_type).toBe("shell_command"); + }); + + test("buildCatalogEntries leaves tool_mode unset when codexToolMode is shell", () => { + const entries = buildCatalogEntries(null, [], [ + { + id: "deepseek-v4-flash", + provider: "deepseek", + codexToolMode: "shell", + }, + ]); + const deepseekEntry = entries.find(e => e.slug === "deepseek/deepseek-v4-flash"); + expect(deepseekEntry).toBeDefined(); + expect(deepseekEntry?.tool_mode).toBeUndefined(); + expect(deepseekEntry?.shell_type).toBe("shell_command"); + }); + + test("under shell mode with declared exec_command, undeclared-tool-guard allows exec_command", () => { + // When Codex operates under flat shell mode (tool_mode omitted), it declares exec_command on the wire: + const wireBody = { + tools: [ + { + type: "function", + name: "exec_command", + description: "Execute a shell command", + }, + ], + }; + const declaredTools = collectDeclaredWireToolNames(wireBody); + expect(declaredTools.has("exec_command")).toBe(true); + + const sseEvent = { + type: "response.output_item.added", + item: { + type: "function_call", + name: "exec_command", + call_id: "call_abc", + }, + }; + const undeclared = undeclaredToolCallName(sseEvent, declaredTools); + expect(undeclared).toBeUndefined(); + }); + + test("catalogHintsFromProviderConfig propagates codexToolMode", () => { + const { catalogHintsFromProviderConfig } = require("../src/codex/catalog/provider-fetch"); + const hints = catalogHintsFromProviderConfig( + "deepseek", + { + adapter: "openai-responses", + baseUrl: "https://api.deepseek.com", + codexToolMode: "shell", + }, + "deepseek-v4-flash", + ); + expect(hints.codexToolMode).toBe("shell"); + }); + + test("deriveComboCatalogModel sets codexToolMode = shell when all members specify shell", () => { + const { deriveComboCatalogModel } = require("../src/codex/catalog/aggregation"); + const combo = { + name: "all-shell-combo", + targets: [ + { provider: "deepseek", model: "v4" }, + { provider: "qwen", model: "max" }, + ], + }; + const members = [ + { id: "v4", provider: "deepseek", contextWindow: 128000, codexToolMode: "shell" as const }, + { id: "max", provider: "qwen", contextWindow: 128000, codexToolMode: "shell" as const }, + ]; + const derived = deriveComboCatalogModel("all-shell-combo", combo, members); + expect(derived?.codexToolMode).toBe("shell"); + + const mixedMembers = [ + { id: "v4", provider: "deepseek", contextWindow: 128000, codexToolMode: "shell" as const }, + { id: "max", provider: "qwen", contextWindow: 128000 }, + ]; + const mixedDerived = deriveComboCatalogModel("all-shell-combo", combo, mixedMembers); + expect(mixedDerived?.codexToolMode).toBeUndefined(); + }); +}); + From 02e4011dbdbadc7aa3081088533d54acc9c78131 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:36:45 +0800 Subject: [PATCH 2/4] fix(catalog): inherit provider codexToolMode for custom models --- src/codex/catalog/provider-fetch.ts | 6 +++- tests/codex-tool-mode.test.ts | 52 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index a578524bda..9857f3870e 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -1882,7 +1882,11 @@ async function gatherRoutedModelsUncached( ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}), ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}), ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}), - ...(cm.codexToolMode !== undefined ? { codexToolMode: cm.codexToolMode } : {}), + ...(cm.codexToolMode !== undefined + ? { codexToolMode: cm.codexToolMode } + : effectiveProvider?.codexToolMode !== undefined + ? { codexToolMode: effectiveProvider.codexToolMode } + : {}), }; // #962: the dedupe below drops the provider-derived row this custom row replaces. Inherit that // row's provider capability metadata (reasoning ladder, default effort, parallel tool calls, diff --git a/tests/codex-tool-mode.test.ts b/tests/codex-tool-mode.test.ts index cd8b3352d5..5cdd80a6b8 100644 --- a/tests/codex-tool-mode.test.ts +++ b/tests/codex-tool-mode.test.ts @@ -136,5 +136,57 @@ describe("Codex tool mode configuration (#2106)", () => { const mixedDerived = deriveComboCatalogModel("all-shell-combo", combo, mixedMembers); expect(mixedDerived?.codexToolMode).toBeUndefined(); }); + + test("gatherRoutedModels custom model inherits provider codexToolMode when undiscovered", async () => { + const { gatherRoutedModels } = require("../src/codex/catalog"); + const { withStubbedProviderFetch } = require("./helpers/catalog-provider-fetch"); + const config = { + providers: { + customprov: { + adapter: "openai-responses", + baseUrl: "https://api.custom.com", + codexToolMode: "shell", + liveModels: false, + }, + }, + customModels: [ + { + provider: "customprov", + modelId: "undiscovered-model", + }, + ], + }; + const models = await gatherRoutedModels(withStubbedProviderFetch(config as any)); + const model = models.find((m: any) => m.id === "undiscovered-model"); + expect(model).toBeDefined(); + expect(model.codexToolMode).toBe("shell"); + }); + + test("gatherRoutedModels custom model explicit codexToolMode overrides provider setting", async () => { + const { gatherRoutedModels } = require("../src/codex/catalog"); + const { withStubbedProviderFetch } = require("./helpers/catalog-provider-fetch"); + const config = { + providers: { + customprov: { + adapter: "openai-responses", + baseUrl: "https://api.custom.com", + codexToolMode: "shell", + liveModels: false, + }, + }, + customModels: [ + { + provider: "customprov", + modelId: "override-model", + codexToolMode: "code_mode_only", + }, + ], + }; + const models = await gatherRoutedModels(withStubbedProviderFetch(config as any)); + const model = models.find((m: any) => m.id === "override-model"); + expect(model).toBeDefined(); + expect(model.codexToolMode).toBe("code_mode_only"); + }); }); + From 73dfa7cdcfa55f9b02441103db88e8f1a613ed35 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:41:25 +0800 Subject: [PATCH 3/4] fix(sync): apply explicit codexToolMode for native capability alias --- src/codex/catalog/sync.ts | 2 ++ tests/codex-tool-mode.test.ts | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index 16309f0d9f..e83056afc3 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -332,6 +332,8 @@ export function deriveEntry( // native tool/search/responses-lite contract while preserving the routed slug and wire id. if (!codexForwardNativeCapabilityAlias) { normalizeRoutedCatalogEntry(e, model?.parallelToolCalls === true, model?.codexToolMode); + } else if (model?.codexToolMode !== undefined) { + applyRoutedCodexToolMode(e, model.codexToolMode); } if (model) applyCatalogMetadata(e, model.provider, model.id, model.contextCap); applyCatalogModelMetadata(e, model); diff --git a/tests/codex-tool-mode.test.ts b/tests/codex-tool-mode.test.ts index 5cdd80a6b8..e9efcdf3db 100644 --- a/tests/codex-tool-mode.test.ts +++ b/tests/codex-tool-mode.test.ts @@ -187,6 +187,24 @@ describe("Codex tool mode configuration (#2106)", () => { expect(model).toBeDefined(); expect(model.codexToolMode).toBe("code_mode_only"); }); + + test("buildCatalogEntries with codexForwardNativeCapabilityAlias applies codexToolMode = shell", () => { + const { buildCatalogEntries, NATIVE_DAYBREAK_BLUE_MODEL, upstreamNativeEntry } = require("../src/codex/catalog"); + const { CODEX_CUSTOM_MODEL_CATALOG_KIND, findNativeTemplate } = require("../src/codex/catalog/parsing"); + const nativeTemplate = () => findNativeTemplate(upstreamNativeEntry("gpt-5.6-sol")!); + const models = [{ + id: NATIVE_DAYBREAK_BLUE_MODEL, + provider: "openai", + catalogKind: CODEX_CUSTOM_MODEL_CATALOG_KIND, + codexForwardNativeCapabilityAlias: true, + codexToolMode: "shell" as const, + }]; + const entries = buildCatalogEntries(nativeTemplate(), [], models); + const daybreak = entries.find((entry: any) => entry.slug === `openai/${NATIVE_DAYBREAK_BLUE_MODEL}`); + expect(daybreak).toBeDefined(); + expect(daybreak?.tool_mode).toBeUndefined(); + expect(daybreak?.use_responses_lite).toBe(true); + }); }); From 457a3b175ed5d7be168ca89b5953b16bf7a92d65 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:46:26 +0800 Subject: [PATCH 4/4] chore(sync): refine fallback comment and template fixture in test --- src/codex/catalog/sync.ts | 4 ++-- tests/codex-tool-mode.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/codex/catalog/sync.ts b/src/codex/catalog/sync.ts index e83056afc3..1167bacc27 100644 --- a/src/codex/catalog/sync.ts +++ b/src/codex/catalog/sync.ts @@ -360,8 +360,8 @@ export function deriveEntry( }); } // Fallback when no template is available (best-effort; strict parser may need more). - // All routed fallbacks enable deferred code-mode tool exposure; otherwise the nested catalog - // expands into `exec.description` and can exceed Cursor's 120 KB serialized tool limit (#1830). + // Routed fallbacks default to code-mode tool exposure (or shell mode when codexToolMode === "shell"); + // otherwise the nested catalog expands into `exec.description` and can exceed Cursor's 120 KB serialized tool limit (#1830). // Cursor still omits hosted web-search metadata because runTurn bypasses that separate sidecar. const isCursorFallback = isRouted && model?.provider === "cursor"; const entry: RawEntry = { diff --git a/tests/codex-tool-mode.test.ts b/tests/codex-tool-mode.test.ts index e9efcdf3db..9fa11092f4 100644 --- a/tests/codex-tool-mode.test.ts +++ b/tests/codex-tool-mode.test.ts @@ -191,7 +191,7 @@ describe("Codex tool mode configuration (#2106)", () => { test("buildCatalogEntries with codexForwardNativeCapabilityAlias applies codexToolMode = shell", () => { const { buildCatalogEntries, NATIVE_DAYBREAK_BLUE_MODEL, upstreamNativeEntry } = require("../src/codex/catalog"); const { CODEX_CUSTOM_MODEL_CATALOG_KIND, findNativeTemplate } = require("../src/codex/catalog/parsing"); - const nativeTemplate = () => findNativeTemplate(upstreamNativeEntry("gpt-5.6-sol")!); + const nativeTemplate = () => findNativeTemplate({ models: [upstreamNativeEntry("gpt-5.6-sol")!] }); const models = [{ id: NATIVE_DAYBREAK_BLUE_MODEL, provider: "openai",