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
3 changes: 3 additions & 0 deletions src/codex/catalog/aggregation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
: {}),
};
}

Expand Down
23 changes: 20 additions & 3 deletions src/codex/catalog/parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions src/codex/catalog/provider-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -1881,6 +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 }
: 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,
Expand All @@ -1905,6 +1911,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
Expand Down
10 changes: 6 additions & 4 deletions src/codex/catalog/sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,9 @@ 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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else if (model?.codexToolMode !== undefined) {
applyRoutedCodexToolMode(e, model.codexToolMode);
}
if (model) applyCatalogMetadata(e, model.provider, model.id, model.contextCap);
applyCatalogModelMetadata(e, model);
Expand All @@ -358,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 = {
Expand All @@ -373,7 +375,7 @@ export function deriveEntry(
: {}),
};
if (isRouted) {
applyRoutedCodexToolMode(entry);
applyRoutedCodexToolMode(entry, model?.codexToolMode);
applyReasoningLevels(entry, model?.reasoningEfforts, model?.defaultReasoningEffort, preserveExact);
}
else {
Expand Down
12 changes: 12 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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. */
Expand Down
210 changes: 210 additions & 0 deletions tests/codex-tool-mode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
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();
});

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");
});

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({ models: [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);
});
});


Loading