diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index b85252b142..0385033679 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1561,7 +1561,10 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): outBody = promoteClientLoadedTools(outBody); } if (provider.authMode !== "forward") { - const rewritten = rewriteRoutedCustomToolsForUpstream(outBody); + const rewritten = rewriteRoutedCustomToolsForUpstream( + outBody, + provider.customToolTransport === "function-json" ? "direct-first" : "legacy", + ); outBody = rewritten.body; convertedRoutedCustomToolNames = rewritten.names; } diff --git a/src/adapters/tool-catalog-nudge.ts b/src/adapters/tool-catalog-nudge.ts index 626bd93d8d..d81a55fbe7 100644 --- a/src/adapters/tool-catalog-nudge.ts +++ b/src/adapters/tool-catalog-nudge.ts @@ -113,6 +113,27 @@ export function buildNonOpenAIToolCatalogNudgeFromNames( ); const verifiedCodeModeExecName = codeModeExecWireName(advertised, codeModeExecName); + // Function-only providers such as Grok can use the direct Codex helpers without composing + // JSON -> JavaScript -> nested helper calls. Keep the old nested-helper guidance for the + // legacy one-tool catalog, but make a projected direct surface explicitly direct-first. + const directEditName = uniqueNames([ + "apply_patch", "functions__apply_patch", toWireName("apply_patch"), + ]).find(name => advertised.has(name)); + const directShellName = uniqueNames([ + "exec_command", "shell_command", "functions__exec_command", + toWireName("exec_command"), toWireName("shell_command"), + ]).find(name => advertised.has(name)); + const directFirst = Boolean(verifiedCodeModeExecName && (directEditName || directShellName)); + const directGuidance = directFirst && verifiedCodeModeExecName + ? [ + "Use a direct listed tool whenever one call completes the operation.", + directEditName ? "Use `" + directEditName + "` directly for targeted edits." : undefined, + directShellName ? "Use `" + directShellName + "` directly for reads, searches, tests, builds, formatters, and genuinely mechanical transformations." : undefined, + "Use `" + verifiedCodeModeExecName + "` only for JavaScript control flow, dependent calls, aggregation, error handling, internal parallelism, or a helper available only inside Code Mode.", + "Emit a real tool call; never print JavaScript or JSON as ordinary text.", + ].filter((line): line is string => typeof line === "string").join(" ") + : undefined; + return [ "Tool contract: use the current tool catalog as ground truth.", "Valid tool names for this turn are exactly " + quoteNames(names) + ".", @@ -120,11 +141,16 @@ export function buildNonOpenAIToolCatalogNudgeFromNames( "Call only listed names with their listed argument keys; do not invent, translate, or rename tools.", "Names mentioned only in instructions, tool descriptions, argument descriptions, or nested helper APIs are not additional top-level tools.", verifiedCodeModeExecName - ? "`" + verifiedCodeModeExecName + "` is Codex code mode: its body is JavaScript evaluated in a V8 isolate. Nested helpers are called INSIDE that body as `await tools.(...)`, for example `await tools.exec_command({cmd: \"ls\"})` or `await tools.codex_app__list_threads({})`. Absence from the top-level catalog or from `" + verifiedCodeModeExecName + "`'s description is not absence: deferred helpers stay callable on `tools.`. Discover them from the isolate global `ALL_TOOLS`, not `tools.ALL_TOOLS`. Do not skip an available nested helper because it is omitted from the listed top-level names." + ? directFirst + ? directGuidance + : "`" + verifiedCodeModeExecName + "` is Codex code mode: its body is JavaScript evaluated in a V8 isolate. Nested helpers are called INSIDE that body as `await tools.(...)`, for example `await tools.exec_command({cmd: \"ls\"})` or `await tools.codex_app__list_threads({})`. Absence from the top-level catalog or from `" + verifiedCodeModeExecName + "`'s description is not absence: deferred helpers stay callable on `tools.`. Discover them from the isolate global `ALL_TOOLS`, not `tools.ALL_TOOLS`. Do not skip an available nested helper because it is omitted from the listed top-level names." : "If a listed tool exposes nested helpers such as a tools.* API, call the listed parent tool and use those helpers only inside that tool's input.", unavailableNeighborNames.length > 0 ? "Do not use neighboring-agent tool names " + quoteNames(unavailableNeighborNames) + " unless this turn's catalog lists those exact names." : undefined, + directEditName + ? "Do not use shell redirection, Node, Python, sed, or heredocs for a targeted workspace edit when the direct edit tool is listed; wait for its result before considering any fallback." + : undefined, "If you need shell, file search, file read, edit, or discovery behavior, choose the listed tool that provides that capability.", "Count a tool call only after its tool result returns; batch independent read-only calls when the runtime supports it.", ].filter((line): line is string => typeof line === "string").join(" "); @@ -141,8 +167,13 @@ export function buildNonOpenAIToolCatalogNudgeForTools( // to wire names first throws away the only thing that distinguishes Codex's JavaScript // `exec` from an ordinary structured tool that happens to share the name. const codeModeExecTool = visible?.find(isCodexCodeModeExecTool); + const hasDirectEditTool = visible?.some(tool => !tool.namespace && tool.name === "apply_patch"); const codeModeExecName = codeModeExecTool - && !visible?.some(isBareShellBridgeTool) + // A bare shell bridge normally identifies the legacy flat-tool shape rather than Code + // Mode. The hybrid direct-first surface is the intentional exception: its first-class + // apply_patch tool proves that exec and exec_command are being advertised together rather + // than that an ordinary structured shell tool merely happens to be named exec. + && (!visible?.some(isBareShellBridgeTool) || hasDirectEditTool) ? toWireName(codeModeExecTool) : undefined; // Neighbor names are bare and un-namespaced, so probe the same transform with a bare tool. diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index 0d1b2c2aaa..d63441545c 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -128,10 +128,11 @@ export interface CatalogModel { supportsReasoningSummaries?: boolean; /** * Codex tool calling mode for this routed model. + * "code_mode" selects a direct-first routed surface and serializes entry.tool_mode = "code_mode". * "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"; + codexToolMode?: "code_mode" | "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. */ @@ -431,12 +432,16 @@ export const ROUTED_CODEX_TOOL_MODE = "code_mode_only"; export function applyRoutedCodexToolMode( entry: RawEntry, - toolMode?: "code_mode_only" | "shell" | string, + toolMode?: "code_mode" | "code_mode_only" | "shell" | string, ): RawEntry { if (toolMode === "shell") { delete entry.tool_mode; return entry; } + if (toolMode === "code_mode") { + entry.tool_mode = "code_mode"; + return entry; + } entry.tool_mode = ROUTED_CODEX_TOOL_MODE; return entry; } @@ -506,7 +511,7 @@ export function applyMultiAgentMode( export function normalizeRoutedCatalogEntry( entry: RawEntry, parallelToolCalls = false, - toolMode?: "code_mode_only" | "shell" | string, + toolMode?: "code_mode" | "code_mode_only" | "shell" | string, ): RawEntry { delete entry.model_messages; delete entry.tool_mode; diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 9c3384b4e3..3655b34b06 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -28,7 +28,7 @@ import { type OAuthActiveTokenObservation, } from "../../oauth"; import type { OcxConfig, OcxProviderConfig } from "../../types"; -import { modelInList } from "../../types"; +import { MODEL_ADAPTER_OVERRIDE_ALLOWED, modelInList } from "../../types"; import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata"; import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; @@ -37,7 +37,7 @@ import { serviceTierSupportForModel, } from "../../providers/service-tier"; import type { FastPolicyAuthority } from "../../providers/fastwire"; -import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry"; +import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport, providerModelCustomToolTransport, providerModelWireDefault } from "../../providers/registry"; import { parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels } from "../../providers/antigravity-models"; import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap"; import { routedSlug, slugEquals, slugsEquivalent } from "../../providers/slug-codec"; @@ -632,7 +632,15 @@ function configuredReasoningSummarySupport(prov: OcxProviderConfig | undefined, } export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, model: CatalogModel, providerCap?: number): CatalogModel { - void name; + const configuredWire = prov.modelAdapters?.[model.id]; + const defaultWire = providerModelWireDefault(name, prov, model.id, MODEL_ADAPTER_OVERRIDE_ALLOWED, "responses"); + const effectiveWire = configuredWire && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(configuredWire) + ? configuredWire + : (defaultWire ?? prov.adapter); + const registryMode = effectiveWire === "openai-responses" + && providerModelCustomToolTransport(name, prov, model.id, "responses") === "function-json" + ? "code_mode" as const + : undefined; const configuredCap = configuredContextWindow(prov, model.id); const configuredMaxInput = configuredMaxInputTokens(prov, model.id); let inputModalities = configuredInputModalities(prov, model.id); @@ -677,6 +685,9 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, ...(prov.parallelToolCalls === true || (prov.adapter === "openai-chat" && prov.parallelToolCalls !== false) ? { parallelToolCalls: true } : {}), + ...(registryMode !== undefined && model.codexToolMode === undefined + ? { codexToolMode: registryMode } + : {}), ...(prov.codexToolMode !== undefined ? { codexToolMode: prov.codexToolMode } : {}), }; const capped = applyProviderContextCap(hinted.contextWindow, providerCap); diff --git a/src/config.ts b/src/config.ts index 60178f3d4f..a7f9cf75a2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -732,6 +732,7 @@ const providerConfigSchema = z.object({ // undeclared key survives verbatim. A misspelled `codexToolMode` therefore used to be // accepted, persisted, and then silently resolved to the `code_mode_only` default — the // operator asked for shell mode, got code mode, and was told nothing (#2106). + // `code_mode` is registry-derived. Persisted config cannot claim that provider capability. codexToolMode: z.enum(["code_mode_only", "shell"]).optional(), responsesItemIdRepair: z.object({ message: z.array(z.string().min(1)).optional(), diff --git a/src/providers/registry.ts b/src/providers/registry.ts index bd25a5ea3f..93d6cf38eb 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -39,6 +39,7 @@ export type ModelWireDefault = string | { authModes?: readonly ProviderAuthKind[]; /** Whether this registry-selected route may relay a caller-owned service_tier. */ forwardCallerServiceTier?: boolean; + customToolTransport?: "freeform" | "function-json"; }; export interface ResponsesTerminalRepairPolicy { @@ -1032,12 +1033,14 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ wire: "openai-responses", inbound: ["responses"], authModes: ["oauth"], + customToolTransport: "function-json", forwardCallerServiceTier: false, }, "grok-4.5": { wire: "openai-responses", inbound: ["responses"], authModes: ["oauth"], + customToolTransport: "function-json", forwardCallerServiceTier: false, }, }, @@ -2754,6 +2757,26 @@ export function providerModelWireDefault( return wire !== undefined && allowedWires.has(wire) ? wire : undefined; } +export function providerModelCustomToolTransport( + id: string, + provider: Pick & Partial>, + modelId: string, + inbound: InboundWire = "responses", +): "freeform" | "function-json" | undefined { + const entry = getProviderRegistryEntry(id); + if (!entry?.modelWireDefaults) return undefined; + const declared = entry.modelWireDefaults[modelId.trim().toLowerCase()]; + if (!declared || typeof declared === "string") return undefined; + if (declared.wire !== "openai-responses" || !declared.inbound.includes(inbound)) return undefined; + const matchesConfiguredTransport = providerMatchesRegistryTransport(id, provider); + const matchesResolvedModelWire = provider.adapter === declared.wire + && normalizedProviderEndpoint(provider.baseUrl) === normalizedProviderEndpoint(entry.baseUrl); + if (!matchesConfiguredTransport && !matchesResolvedModelWire) return undefined; + const authMode = provider.authMode ?? entry.authKind; + if (declared.authModes && !declared.authModes.includes(authMode)) return undefined; + return declared.customToolTransport; +} + /** Resolve a registry-only upstream-streaming compatibility hint for Responses turns. */ export function providerModelResponsesUpstreamStreaming( id: string, diff --git a/src/responses/custom-tool-compat.ts b/src/responses/custom-tool-compat.ts index 83ddc1be19..82ebf2991e 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -1,5 +1,14 @@ +type ProjectedField = "code" | "patch" | "input"; +export type RoutedCustomToolProjection = "legacy" | "direct-first"; + const ROUTED_CUSTOM_TOOL_PASSTHROUGH = new Set(["apply_patch"]); +export function projectedCustomToolField(name: string): ProjectedField { + if (name === "exec") return "code"; + if (name === "apply_patch") return "patch"; + return "input"; +} + function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } @@ -18,7 +27,10 @@ export function customToolItemId(id: unknown): unknown { return id.startsWith("fc_") ? `ctc_${id.slice(3)}` : id; } -export function collectRoutedCustomToolNames(body: unknown): Set { +export function collectRoutedCustomToolNames( + body: unknown, + projection: RoutedCustomToolProjection = "legacy", +): Set { const names = new Set(); const visit = (value: unknown): void => { if (Array.isArray(value)) { @@ -29,7 +41,7 @@ export function collectRoutedCustomToolNames(body: unknown): Set { if ( value.type === "custom" && typeof value.name === "string" - && !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(value.name) + && (projection === "direct-first" || !ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(value.name)) ) { names.add(value.name); } @@ -60,8 +72,9 @@ function rewriteForUpstream( value: unknown, names: ReadonlySet, callIds: ReadonlySet, + projection: RoutedCustomToolProjection, ): unknown { - if (Array.isArray(value)) return value.map(entry => rewriteForUpstream(entry, names, callIds)); + if (Array.isArray(value)) return value.map(entry => rewriteForUpstream(entry, names, callIds, projection)); if (!isPlainObject(value)) return value; if (value.type === "custom" && typeof value.name === "string" && names.has(value.name)) { @@ -70,21 +83,24 @@ function rewriteForUpstream( || isPlainObject(value.format) || isPlainObject(value.parameters); if (!isDefinition) return { ...rest, type: "function" }; - const inputDescription = value.name === "exec" + const field = projection === "direct-first" ? projectedCustomToolField(value.name) : "input"; + const inputDescription = field === "code" ? "JavaScript source for unified exec. Use await tools.exec_command(...) for shell commands and text(...) to return textual output; do not provide a bare shell command." - : "Raw input for this client-executed custom tool."; + : field === "patch" + ? "Patch text for apply_patch, beginning exactly with `*** Begin Patch`." + : "Raw input for this client-executed custom tool."; return { ...rest, type: "function", parameters: { type: "object", properties: { - input: { + [field]: { type: "string", description: inputDescription, }, }, - required: ["input"], + required: [field], additionalProperties: false, }, }; @@ -96,10 +112,11 @@ function rewriteForUpstream( && names.has(value.name) ) { const { input, id: _id, ...rest } = value; + const field = projection === "direct-first" ? projectedCustomToolField(value.name) : "input"; return { ...rest, type: "function_call", - arguments: JSON.stringify({ input: typeof input === "string" ? input : "" }), + arguments: JSON.stringify({ [field]: typeof input === "string" ? input : "" }), }; } @@ -114,22 +131,61 @@ function rewriteForUpstream( let changed = false; const next: Record = {}; for (const [key, entry] of Object.entries(value)) { - const rewritten = rewriteForUpstream(entry, names, callIds); + const rewritten = rewriteForUpstream(entry, names, callIds, projection); next[key] = rewritten; changed ||= rewritten !== entry; } return changed ? next : value; } -export function rewriteRoutedCustomToolsForUpstream(body: unknown): { +function directFirstToolOrder(body: unknown): unknown { + if (!isPlainObject(body)) return body; + let changed = false; + const next = { ...body }; + const moveExecLast = (value: unknown): unknown => { + if (!Array.isArray(value)) return value; + const direct = value.filter(entry => !(isPlainObject(entry) && entry.name === "exec")); + const exec = value.filter(entry => isPlainObject(entry) && entry.name === "exec"); + if (exec.length === 0) return value; + const ordered = [...direct, ...exec]; + return ordered.every((entry, index) => entry === value[index]) ? value : ordered; + }; + const tools = moveExecLast(body.tools); + if (tools !== body.tools) { + next.tools = tools; + changed = true; + } + if (Array.isArray(body.input)) { + const originalInput = body.input; + const input = originalInput.map(item => { + if (!isPlainObject(item) || item.type !== "additional_tools") return item; + const additional = moveExecLast(item.tools); + return additional === item.tools ? item : { ...item, tools: additional }; + }); + if (input.some((item, index) => item !== originalInput[index])) { + next.input = input; + changed = true; + } + } + return changed ? next : body; +} + +export function rewriteRoutedCustomToolsForUpstream( + body: unknown, + projection: RoutedCustomToolProjection = "legacy", +): { body: unknown; names: Set; } { - const names = collectRoutedCustomToolNames(body); + const names = collectRoutedCustomToolNames(body, projection); if (names.size === 0) return { body, names }; const callIds = new Set(); collectConvertedCallIds(body, names, callIds); - return { body: rewriteForUpstream(body, names, callIds), names }; + const rewritten = rewriteForUpstream(body, names, callIds, projection); + return { + body: projection === "direct-first" ? directFirstToolOrder(rewritten) : rewritten, + names, + }; } export function restoreRoutedCustomCalls( @@ -158,7 +214,16 @@ export function restoreRoutedCustomCalls( if (value.type === "function_call" && typeof value.name === "string" && names.has(value.name)) { restored.type = "custom_tool_call"; restored.id = customToolItemId(value.id); - restored.input = customToolInput(value.arguments); + const field = projectedCustomToolField(value.name); + let input = customToolInput(value.arguments); + // Accept projected Responses arguments and legacy {input} history during replay. + if (typeof value.arguments === "string") { + try { + const parsed = JSON.parse(value.arguments) as unknown; + if (isPlainObject(parsed) && typeof parsed[field] === "string") input = parsed[field] as string; + } catch { /* malformed arguments remain visible */ } + } + restored.input = input; delete restored.arguments; changed = true; } @@ -180,6 +245,14 @@ export function restoreRoutedCustomCallsInJson( return restored.changed ? JSON.stringify(restored.value) : text; } -export function unwrapRoutedCustomToolArguments(argumentsText: unknown): string { +export function unwrapRoutedCustomToolArguments(argumentsText: unknown, name?: string): string { + if (typeof argumentsText !== "string" || !name) return customToolInput(argumentsText); + try { + const parsed = JSON.parse(argumentsText) as unknown; + if (isPlainObject(parsed)) { + const field = projectedCustomToolField(name); + if (typeof parsed[field] === "string") return parsed[field] as string; + } + } catch { /* malformed arguments stay visible */ } return customToolInput(argumentsText); } diff --git a/src/server/adapter-resolve.ts b/src/server/adapter-resolve.ts index 8587b3191d..f4777799bb 100644 --- a/src/server/adapter-resolve.ts +++ b/src/server/adapter-resolve.ts @@ -2,7 +2,7 @@ import { createRegisteredAdapter } from "../adapters/registry"; import type { OcxProviderConfig } from "../types"; import { isWirePinnedModel, MODEL_ADAPTER_OVERRIDE_ALLOWED, pinnedWireAdapter } from "../types"; import { isCanonicalOpenAiForwardProvider } from "../providers/openai-tiers"; -import { type InboundWire, providerModelWireDefault } from "../providers/registry"; +import { type InboundWire, providerModelCustomToolTransport, providerModelWireDefault } from "../providers/registry"; /** * Resolve the wire a single model should use: a hard pin first, then a configured @@ -23,28 +23,40 @@ export function resolveWireProtocolOverride( providerConfig: OcxProviderConfig, inbound: InboundWire = "responses", ): OcxProviderConfig { + const { customToolTransport: _staleCustomToolTransport, ...providerWithoutTransient } = providerConfig; + const baseProvider = providerWithoutTransient as OcxProviderConfig; const pinned = pinnedWireAdapter(providerName, modelId); - if (pinned && providerConfig.adapter !== pinned) { - return { ...providerConfig, adapter: pinned }; + if (pinned && baseProvider.adapter !== pinned) { + return { ...baseProvider, adapter: pinned }; } // Re-check the allow-list here, not just in the config validator: the file may have // been hand-edited, or written by a build that allowed more values. - const configured = providerConfig.modelAdapters?.[modelId]; + const configured = baseProvider.modelAdapters?.[modelId]; // An explicit allowed override wins, including one naming the provider-wide adapter (the // opt-out from a registry default). Invalid hand-edited values fall through to the default. const requested = configured && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(configured) ? configured - : providerModelWireDefault(providerName, providerConfig, modelId, MODEL_ADAPTER_OVERRIDE_ALLOWED, inbound); + : providerModelWireDefault(providerName, baseProvider, modelId, MODEL_ADAPTER_OVERRIDE_ALLOWED, inbound); + const registryCustomToolTransport = providerModelCustomToolTransport(providerName, baseProvider, modelId, inbound); if (requested && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(requested) - && requested !== providerConfig.adapter + && requested !== baseProvider.adapter && !isWirePinnedModel(providerName, modelId) // A forward provider hands the caller's own credential upstream; the chat adapter // only ever sends provider.apiKey, so switching wires here would drop the auth. - && !isCanonicalOpenAiForwardProvider(providerConfig)) { - return { ...providerConfig, adapter: requested }; + && !isCanonicalOpenAiForwardProvider(baseProvider)) { + return { + ...baseProvider, + adapter: requested, + ...(requested === "openai-responses" && registryCustomToolTransport + ? { customToolTransport: registryCustomToolTransport } + : {}), + }; } - return providerConfig; + const customToolTransport = baseProvider.adapter === "openai-responses" + ? registryCustomToolTransport + : undefined; + return customToolTransport ? { ...baseProvider, customToolTransport } : baseProvider; } /** Build the provider adapter for a resolved provider config. */ diff --git a/src/server/responses-custom-tool-repair.ts b/src/server/responses-custom-tool-repair.ts index c3c40b134d..f7081e9e6d 100644 --- a/src/server/responses-custom-tool-repair.ts +++ b/src/server/responses-custom-tool-repair.ts @@ -12,8 +12,7 @@ import { /** Exact compact prefix used by our upstream rewriter; progressive matching also * tolerates insignificant JSON whitespace via FREEFORM_WRAP_PREFIX_RE. */ -const FREEFORM_WRAP_PREFIX = '{"input":"'; -const FREEFORM_WRAP_PREFIX_RE = /^\s*\{\s*"input"\s*:\s*"/; +const FREEFORM_WRAP_PREFIX_RE = /^\s*\{\s*"(?:input|code|patch)"\s*:\s*"/; function isPlainObject(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); @@ -233,7 +232,7 @@ export function createRoutedCustomToolRestoreBlockRewrite( openCalls.set(upstreamItemId, open); // Still accumulating toward the compact wrapper, or an unrecognized shape: // suppress progressive emission and let the done event carry input. - if (FREEFORM_WRAP_PREFIX.startsWith(open.argumentsText)) return []; + if (open.argumentsText.length < 12 && !FREEFORM_WRAP_PREFIX_RE.test(open.argumentsText)) return []; const fullInput = partialCustomToolInput(open.argumentsText); if (fullInput === null) return []; if (!fullInput.startsWith(open.emittedInput) || fullInput.length === open.emittedInput.length) return []; @@ -263,7 +262,7 @@ export function createRoutedCustomToolRestoreBlockRewrite( ...rest, type: nextType, item_id: customToolItemId(upstreamItemId), - input: unwrapRoutedCustomToolArguments(source), + input: unwrapRoutedCustomToolArguments(source, itemNames.get(upstreamItemId)), }; return [replaceSseDataPayload(replaceSseEventName(block, nextType), JSON.stringify(next))]; } diff --git a/src/types/config.ts b/src/types/config.ts index f00bfbcdd4..d8a8848557 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -201,10 +201,11 @@ export interface OcxCustomModel { defaultReasoningEffort?: string; /** * Codex tool calling mode override for this custom model. + * "code_mode" selects a direct-first routed surface (direct tools by default, exec for complex orchestration). * "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"; + codexToolMode?: "code_mode" | "code_mode_only" | "shell"; /** 추가 시각 (ISO 8601) */ addedAt?: string; } diff --git a/src/types/provider.ts b/src/types/provider.ts index 72fbc10033..f78ff426f6 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -128,12 +128,15 @@ export type TierDecision = */ export interface OcxProviderConfig { adapter: string; + /** Internal per-model Responses custom-tool projection capability. */ + customToolTransport?: "freeform" | "function-json"; /** * Codex tool calling mode for routed models. + * "code_mode" selects a direct-first routed surface (direct tools by default, exec for complex orchestration). * "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"; + codexToolMode?: "code_mode" | "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/adapter-resolve.test.ts b/tests/adapter-resolve.test.ts index ceffd92149..9a50709723 100644 --- a/tests/adapter-resolve.test.ts +++ b/tests/adapter-resolve.test.ts @@ -102,26 +102,41 @@ describe("registry per-model wire defaults", () => { test("routes current xAI subscription models through Responses for native Codex traffic", () => { for (const model of ["grok-4.6", "grok-4.5"]) { - expect(resolveWireProtocolOverride("xai", model, xai("oauth"), "responses").adapter) - .toBe("openai-responses"); + const resolved = resolveWireProtocolOverride("xai", model, xai("oauth"), "responses"); + expect(resolved.adapter).toBe("openai-responses"); + expect(resolved.customToolTransport).toBe("function-json"); } }); test("keeps xAI key auth and translated callers on their existing Chat wire", () => { - expect(resolveWireProtocolOverride("xai", "grok-4.6", xai("key"), "responses").adapter) - .toBe("openai-chat"); - expect(resolveWireProtocolOverride("xai", "grok-4.6", xai("oauth"), "chat").adapter) - .toBe("openai-chat"); - expect(resolveWireProtocolOverride("xai", "grok-4.6", xai("oauth"), "anthropic").adapter) - .toBe("openai-chat"); - expect(resolveWireProtocolOverride("xai", "grok-4.3", xai("oauth"), "responses").adapter) - .toBe("openai-chat"); + const cases = [ + [xai("key"), "responses"], [xai("oauth"), "chat"], + [xai("oauth"), "anthropic"], [xai("oauth"), "responses"], + ] as const; + for (const [provider, inbound] of cases) { + const model = inbound === "responses" && provider.authMode === "oauth" ? "grok-4.3" : "grok-4.6"; + const resolved = resolveWireProtocolOverride("xai", model, provider, inbound); + expect(resolved.adapter).toBe("openai-chat"); + expect(resolved.customToolTransport).toBeUndefined(); + } }); test("an explicit xAI Chat override opts out of the subscription Responses default", () => { const provider = xai("oauth", { modelAdapters: { "grok-4.6": "openai-chat" } }); - expect(resolveWireProtocolOverride("xai", "grok-4.6", provider, "responses").adapter) - .toBe("openai-chat"); + const resolved = resolveWireProtocolOverride("xai", "grok-4.6", provider, "responses"); + expect(resolved.adapter).toBe("openai-chat"); + expect(resolved.customToolTransport).toBeUndefined(); + }); + + test("clears a stale function-json capability when a second resolve no longer qualifies", () => { + const resolved = resolveWireProtocolOverride("xai", "grok-4.6", xai("oauth"), "responses"); + expect(resolved.customToolTransport).toBe("function-json"); + const optedOut = resolveWireProtocolOverride("xai", "grok-4.6", { + ...resolved, + modelAdapters: { "grok-4.6": "openai-chat" }, + }, "responses"); + expect(optedOut.adapter).toBe("openai-chat"); + expect(optedOut.customToolTransport).toBeUndefined(); }); function deepseek(overrides: Partial = {}): OcxProviderConfig { diff --git a/tests/codex-tool-mode.test.ts b/tests/codex-tool-mode.test.ts index 9fa11092f4..f39d9dda2c 100644 --- a/tests/codex-tool-mode.test.ts +++ b/tests/codex-tool-mode.test.ts @@ -22,6 +22,12 @@ describe("Codex tool mode configuration (#2106)", () => { expect(explicitCodeMode.tool_mode).toBe(ROUTED_CODEX_TOOL_MODE); }); + test("accepts direct-first code_mode as a routed capability", () => { + const entry: Record = {}; + applyRoutedCodexToolMode(entry, "code_mode"); + expect(entry.tool_mode).toBe("code_mode"); + }); + test("applyRoutedCodexToolMode deletes tool_mode when toolMode is shell", () => { const entry: RawEntry = { slug: "deepseek/deepseek-v4-flash", @@ -206,5 +212,3 @@ describe("Codex tool mode configuration (#2106)", () => { expect(daybreak?.use_responses_lite).toBe(true); }); }); - - diff --git a/tests/config.test.ts b/tests/config.test.ts index 4f86db8743..b6307b1bec 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -801,7 +801,7 @@ describe("opencodex config defaults", () => { } }); - test("accepts both codexToolMode values and rejects a misspelled one (#2106)", () => { + test("accepts public codexToolMode values and rejects internal or misspelled ones (#2106)", () => { for (const codexToolMode of ["code_mode_only", "shell"] as const) { writeConfig({ port: 12345, @@ -818,15 +818,17 @@ describe("opencodex config defaults", () => { // undeclared key survives verbatim. Before the enum was declared, "shel" was accepted, // persisted, and then silently resolved to the `code_mode_only` default — the operator // asked for shell mode, got code mode, and was told nothing. - writeConfig({ - port: 12345, - providers: { - custom: { adapter: "openai-chat", baseUrl: "https://example.test/v1", codexToolMode: "shel" }, - }, - defaultProvider: "custom", - }); - expect(readConfigDiagnostics().source).toBe("fallback"); - expect(readConfigDiagnostics().error).toContain("codexToolMode"); + for (const codexToolMode of ["code_mode", "shel"]) { + writeConfig({ + port: 12345, + providers: { + custom: { adapter: "openai-chat", baseUrl: "https://example.test/v1", codexToolMode }, + }, + defaultProvider: "custom", + }); + expect(readConfigDiagnostics().source).toBe("fallback"); + expect(readConfigDiagnostics().error).toContain("codexToolMode"); + } }); test("accepts the exact responsesItemIdRepair shape and rejects the old nested placeholderIds proposal", () => { diff --git a/tests/custom-tool-compat.test.ts b/tests/custom-tool-compat.test.ts index 4d2a500857..0a2998a6dc 100644 --- a/tests/custom-tool-compat.test.ts +++ b/tests/custom-tool-compat.test.ts @@ -1,16 +1,17 @@ import { describe, expect, test } from "bun:test"; -import { rewriteRoutedCustomToolsForUpstream } from "../src/responses/custom-tool-compat"; +import { restoreRoutedCustomCalls, rewriteRoutedCustomToolsForUpstream } from "../src/responses/custom-tool-compat"; function convertedInputDescription(name: string): string | undefined { const result = rewriteRoutedCustomToolsForUpstream({ tools: [{ type: "custom", name, description: "client tool", format: { type: "text" } }], - }); + }, "direct-first"); const body = result.body as { tools?: Array<{ parameters?: { properties?: { input?: { description?: string } } }; }>; }; - return body.tools?.[0]?.parameters?.properties?.input?.description; + const properties = body.tools?.[0]?.parameters?.properties; + return properties?.[name === "exec" ? "code" : "input"]?.description; } describe("routed custom-tool compatibility", () => { @@ -23,7 +24,91 @@ describe("routed custom-tool compatibility", () => { }); test("other converted custom tools keep the generic raw-input contract", () => { - expect(convertedInputDescription("review_patch")) - .toBe("Raw input for this client-executed custom tool."); + expect(convertedInputDescription("review_patch")).toContain("Raw input"); + const body = { tools: [{ type: "custom", name: "review_patch", description: "client tool" }] }; + const rewritten = rewriteRoutedCustomToolsForUpstream(body, "direct-first"); + expect(rewritten.names).toEqual(new Set(["review_patch"])); + expect((rewritten.body as { tools: Array> }).tools[0]).toMatchObject({ + type: "function", + name: "review_patch", + parameters: { + properties: { input: { type: "string" } }, + required: ["input"], + }, + }); + }); + + test("projects exec and apply_patch onto distinct Responses function fields", () => { + const result = rewriteRoutedCustomToolsForUpstream({ + tools: [ + { type: "custom", name: "exec", description: "exec", format: { type: "text" } }, + { type: "custom", name: "apply_patch", description: "patch", format: { type: "text" } }, + ], + }, "direct-first"); + const tools = (result.body as { tools: Array<{ parameters: { properties: Record; required: string[] } }> }).tools; + expect(Object.keys(tools[0].parameters.properties)).toEqual(["patch"]); + expect(tools[0].parameters.required).toEqual(["patch"]); + expect(Object.keys(tools[1].parameters.properties)).toEqual(["code"]); + expect(tools[1].parameters.required).toEqual(["code"]); + }); + + test("keeps every direct tool ahead of exec without reordering tool choices", () => { + const result = rewriteRoutedCustomToolsForUpstream({ + tools: [ + { type: "custom", name: "exec", description: "exec", format: { type: "text" } }, + { type: "function", name: "update_goal", parameters: { type: "object" } }, + { type: "custom", name: "apply_patch", description: "patch", format: { type: "text" } }, + { type: "custom", name: "exec", description: "second exec", format: { type: "text" } }, + ], + tool_choice: { + type: "allowed_tools", + mode: "auto", + tools: [{ type: "custom", name: "exec" }, { type: "custom", name: "apply_patch" }], + }, + }, "direct-first").body as { + tools: Array<{ name: string }>; + tool_choice: { tools: Array<{ name: string }> }; + }; + expect(result.tools.map(tool => tool.name)).toEqual(["update_goal", "apply_patch", "exec", "exec"]); + expect(result.tool_choice.tools.map(tool => tool.name)).toEqual(["exec", "apply_patch"]); + }); + + test("restores projected calls and accepts legacy input replay", () => { + const projected = restoreRoutedCustomCalls({ type: "function_call", name: "exec", id: "fc_1", arguments: '{"code":"1+1"}' }, new Set(["exec"])); + expect(projected.value).toMatchObject({ type: "custom_tool_call", input: "1+1", id: "ctc_1" }); + const legacy = restoreRoutedCustomCalls({ type: "function_call", name: "apply_patch", id: "fc_2", arguments: '{"input":"*** Begin Patch"}' }, new Set(["apply_patch"])); + expect(legacy.value).toMatchObject({ type: "custom_tool_call", input: "*** Begin Patch" }); + const generic = restoreRoutedCustomCalls({ type: "function_call", name: "review_patch", id: "fc_3", arguments: '{"input":"review this"}' }, new Set(["review_patch"])); + expect(generic.value).toMatchObject({ type: "custom_tool_call", input: "review this", id: "ctc_3" }); + }); + + test("projects named and allowed custom tool choices while preserving ordinary modes", () => { + const declaration = { type: "custom", name: "exec", description: "exec", format: { type: "text" } }; + for (const toolChoice of ["auto", "required", "none"] as const) { + const result = rewriteRoutedCustomToolsForUpstream({ tools: [declaration], tool_choice: toolChoice }, "direct-first"); + expect((result.body as { tool_choice: string }).tool_choice).toBe(toolChoice); + } + + const named = rewriteRoutedCustomToolsForUpstream({ + tools: [declaration], + tool_choice: { type: "custom", name: "exec" }, + }, "direct-first").body as { tool_choice: Record }; + expect(named.tool_choice).toEqual({ type: "function", name: "exec" }); + + const allowed = rewriteRoutedCustomToolsForUpstream({ + tools: [declaration], + tool_choice: { + type: "allowed_tools", + mode: "required", + tools: [ + { type: "custom", name: "exec" }, + { type: "custom", name: "unknown_custom" }, + ], + }, + }, "direct-first").body as { tool_choice: { tools: Array> } }; + expect(allowed.tool_choice.tools).toEqual([ + { type: "function", name: "exec" }, + { type: "function", name: "unknown_custom" }, + ]); }); }); diff --git a/tests/provider-registry-parity.test.ts b/tests/provider-registry-parity.test.ts index d2858f6f44..27f9aa7740 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -871,6 +871,16 @@ describe("provider registry parity", () => { const model = applyProviderConfigHints("xai", seed, { id: "grok-4.5", provider: "xai" }); expect(model.contextWindow).toBe(500_000); expect(model.reasoningEfforts).toEqual(["low", "medium", "high"]); + expect(model.codexToolMode).toBe("code_mode"); + + const apiKeyModel = applyProviderConfigHints("xai", { ...seed, authMode: "key" }, { id: "grok-4.5", provider: "xai" }); + expect(apiKeyModel.codexToolMode).toBeUndefined(); + + const optedOut = applyProviderConfigHints("xai", { + ...seed, + modelAdapters: { "grok-4.5": "openai-chat" }, + }, { id: "grok-4.5", provider: "xai" }); + expect(optedOut.codexToolMode).toBeUndefined(); const entries = buildCatalogEntries(nativeTemplate() as never, [], [model]); const entry = entries.find(e => e.slug === "xai/grok-4.5"); @@ -880,12 +890,20 @@ describe("provider registry parity", () => { .toEqual(["low", "medium", "high", "max", "ultra"]); }); + test("native OpenAI seed does not receive the external direct-first capability", () => { + const openai = PROVIDER_REGISTRY.find(entry => entry.id === "openai"); + const model = applyProviderConfigHints("openai", providerConfigSeed(openai!), { id: "gpt-5.5", provider: "openai" }); + expect(model.codexToolMode).toBeUndefined(); + }); + test("grok-4.6 advertises the documented xhigh rung from the xai registry seed", () => { const xai = PROVIDER_REGISTRY.find(entry => entry.id === "xai"); const seed = providerConfigSeed(xai!); + expect(seed.codexToolMode).toBeUndefined(); const model = applyProviderConfigHints("xai", seed, { id: "grok-4.6", provider: "xai" }); expect(model.contextWindow).toBe(500_000); expect(model.reasoningEfforts).toEqual(["low", "medium", "high", "xhigh"]); + expect(model.codexToolMode).toBe("code_mode"); const entries = buildCatalogEntries(nativeTemplate() as never, [], [model]); const entry = entries.find(e => e.slug === "xai/grok-4.6"); diff --git a/tests/responses-custom-tool-repair.test.ts b/tests/responses-custom-tool-repair.test.ts index a5fdafabee..a4e08ae2ab 100644 --- a/tests/responses-custom-tool-repair.test.ts +++ b/tests/responses-custom-tool-repair.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { collectRoutedCustomToolNames, restoreRoutedCustomCallsInJson, @@ -6,9 +6,24 @@ import { } from "../src/responses/custom-tool-compat"; import { createRoutedCustomToolRestoreBlockRewrite } from "../src/server/responses-custom-tool-repair"; import { handleResponses } from "../src/server/responses"; +import { removeCredential, saveCredential } from "../src/oauth/store"; import type { OcxConfig } from "../src/types"; import { createTestTranslatorBudget } from "./helpers/translator-budget"; +beforeAll(async () => { + await saveCredential("xai", { + access: "fixture-xai-access", + refresh: "fixture-xai-refresh", + expires: Date.now() + 3_600_000, + accountId: "fixture-xai-account", + source: "oauth", + }); +}); + +afterAll(async () => { + await removeCredential("xai"); +}); + function dataPayload(block: string): Record { const line = block.split(/\r?\n/).find(entry => entry.startsWith("data:")); if (!line) throw new Error("missing SSE data line"); @@ -20,7 +35,7 @@ function frame(event: string, payload: Record): string { } describe("routed Responses custom-tool compatibility", () => { - test("rewrites exec definitions and paired history without touching apply_patch", () => { + test("projects exec and apply_patch definitions and paired history onto native function fields", () => { const raw = { model: "deepseek-v4-flash", tools: [ @@ -36,35 +51,51 @@ describe("routed Responses custom-tool compatibility", () => { ], }; - expect(collectRoutedCustomToolNames(raw)).toEqual(new Set(["exec"])); - const rewritten = rewriteRoutedCustomToolsForUpstream(raw); - expect(rewritten.names).toEqual(new Set(["exec"])); + expect(collectRoutedCustomToolNames(raw, "direct-first")).toEqual(new Set(["exec", "apply_patch"])); + const rewritten = rewriteRoutedCustomToolsForUpstream(raw, "direct-first"); + expect(rewritten.names).toEqual(new Set(["exec", "apply_patch"])); expect(rewritten.body).not.toBe(raw); expect(raw.tools[0]?.type).toBe("custom"); const body = rewritten.body as typeof raw; - expect(body.tools[0]).toMatchObject({ + const execTool = body.tools.find(tool => tool.name === "exec"); + const patchTool = body.tools.find(tool => tool.name === "apply_patch"); + expect(body.tools.map(tool => tool.name)).toEqual(["apply_patch", "ordinary", "exec"]); + expect(execTool).toMatchObject({ type: "function", name: "exec", parameters: { type: "object", - properties: { input: { type: "string" } }, - required: ["input"], + properties: { code: { type: "string" } }, + required: ["code"], + }, + }); + expect(execTool).not.toHaveProperty("format"); + expect(patchTool).toMatchObject({ + type: "function", + name: "apply_patch", + parameters: { + type: "object", + properties: { patch: { type: "string" } }, + required: ["patch"], }, }); - expect(body.tools[0]).not.toHaveProperty("format"); - expect(body.tools[1]).toEqual(raw.tools[1]); - expect(body.tools[2]).toEqual(raw.tools[2]); + expect(body.tools[1]).toEqual(raw.tools[2]); expect(body.input[0]).toMatchObject({ type: "function_call", call_id: "call_exec", name: "exec", - arguments: JSON.stringify({ input: "await sky.list_apps()" }), + arguments: JSON.stringify({ code: "await sky.list_apps()" }), }); expect(body.input[0]).not.toHaveProperty("input"); expect(body.input[1]).toMatchObject({ type: "function_call_output", call_id: "call_exec" }); - expect(body.input[2]).toEqual(raw.input[2]); - expect(body.input[3]).toEqual(raw.input[3]); + expect(body.input[2]).toMatchObject({ + type: "function_call", + call_id: "call_patch", + name: "apply_patch", + arguments: JSON.stringify({ patch: "*** Begin Patch" }), + }); + expect(body.input[3]).toMatchObject({ type: "function_call_output", call_id: "call_patch" }); }); test("restores non-streaming exec calls while leaving ordinary functions alone", () => { @@ -168,6 +199,39 @@ describe("routed Responses custom-tool compatibility", () => { rewrite.dispose?.(); }); + test("streams projected code and patch fields without losing partial input", () => { + for (const [name, field, input] of [ + ["exec", "code", "text(\"café\\n\")"], + ["apply_patch", "patch", "*** Begin Patch\n*** End Patch\n"], + ] as const) { + const rewrite = createRoutedCustomToolRestoreBlockRewrite(new Set([name])); + const itemId = `fc_${name}`; + rewrite(frame("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: itemId, call_id: `call_${name}`, name, arguments: "", status: "in_progress" }, + })); + const encoded = JSON.stringify({ [field]: input }); + let streamed = ""; + for (const fragment of [encoded.slice(0, 5), encoded.slice(5, 11), encoded.slice(11)]) { + for (const block of rewrite(frame("response.function_call_arguments.delta", { + output_index: 0, + item_id: itemId, + delta: fragment, + }))) { + streamed += String(dataPayload(block).delta ?? ""); + } + } + const done = rewrite(frame("response.function_call_arguments.done", { + output_index: 0, + item_id: itemId, + arguments: encoded, + })); + expect(streamed).toBe(input); + expect(dataPayload(done[0]!).input).toBe(input); + rewrite.dispose?.(); + } + }); + test("buffers argument events until a missing added event is identified by item done", () => { const budget = createTestTranslatorBudget(); const rewrite = createRoutedCustomToolRestoreBlockRewrite(new Set(["exec"]), budget); @@ -491,12 +555,12 @@ describe("routed Responses custom-tool compatibility", () => { }) as typeof fetch; const config = { port: 0, - defaultProvider: "fixture", + defaultProvider: "xai", providers: { - fixture: { - adapter: "openai-responses", - baseUrl: "https://fixture.test/v1", - authMode: "key", + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "oauth", apiKey: "fixture-key", }, }, @@ -507,7 +571,7 @@ describe("routed Responses custom-tool compatibility", () => { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - model: "fixture/deepseek-v4-flash", + model: "xai/grok-4.6", stream: true, input: [{ role: "user", content: [{ type: "input_text", text: "list apps" }] }], tools: [{ type: "custom", name: "exec", description: "Run JavaScript", format: { type: "grammar", syntax: "lark" } }], @@ -570,12 +634,12 @@ describe("routed Responses custom-tool compatibility", () => { }) as typeof fetch; const config = { port: 0, - defaultProvider: "fixture", + defaultProvider: "xai", providers: { - fixture: { - adapter: "openai-responses", - baseUrl: "https://fixture.test/v1", - authMode: "key", + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "oauth", apiKey: "fixture-key", }, }, @@ -587,7 +651,7 @@ describe("routed Responses custom-tool compatibility", () => { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - model: "fixture/deepseek-v4-flash", + model: "xai/grok-4.6", stream: true, input: [{ role: "user", content: [{ type: "input_text", text: "list apps" }] }], tools, @@ -602,7 +666,7 @@ describe("routed Responses custom-tool compatibility", () => { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - model: "fixture/deepseek-v4-flash", + model: "xai/grok-4.6", stream: true, input: [ { role: "user", content: [{ type: "input_text", text: "list apps" }] }, @@ -627,7 +691,7 @@ describe("routed Responses custom-tool compatibility", () => { type: "function_call", call_id: "call_exec", name: "exec", - arguments: JSON.stringify({ input: "const apps = await sky.list_apps();" }), + arguments: JSON.stringify({ code: "const apps = await sky.list_apps();" }), }), expect.objectContaining({ type: "function_call_output", @@ -666,12 +730,12 @@ describe("routed Responses custom-tool compatibility", () => { }), { headers: { "content-type": "application/json" } })) as typeof fetch; const config = { port: 0, - defaultProvider: "fixture", + defaultProvider: "xai", providers: { - fixture: { - adapter: "openai-responses", - baseUrl: "https://fixture.test/v1", - authMode: "key", + xai: { + adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", + authMode: "oauth", apiKey: "fixture-key", }, }, @@ -682,7 +746,7 @@ describe("routed Responses custom-tool compatibility", () => { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - model: "fixture/deepseek-v4-flash", + model: "xai/grok-4.6", stream: false, input: [{ role: "user", content: [{ type: "input_text", text: "list apps" }] }], tools: [{ type: "custom", name: "exec", description: "Run JavaScript", format: { type: "grammar", syntax: "lark" } }], diff --git a/tests/tool-catalog-nudge.test.ts b/tests/tool-catalog-nudge.test.ts index 6316892034..9cff35a5f6 100644 --- a/tests/tool-catalog-nudge.test.ts +++ b/tests/tool-catalog-nudge.test.ts @@ -133,6 +133,49 @@ describe("non-OpenAI tool catalog nudge", () => { expect(note).toContain("`custom_exec` is Codex code mode"); }); + test("uses direct-first guidance when projected edit and shell tools are listed", () => { + const note = buildNonOpenAIToolCatalogNudgeForTools([ + codeModeExec(), + { name: "apply_patch", parameters: {} } as OcxTool, + { name: "exec_command", parameters: {} } as OcxTool, + ]); + + expect(note).toContain("Use a direct listed tool whenever one call completes the operation"); + expect(note).toContain("`apply_patch` directly for targeted edits"); + expect(note).toContain("`exec_command` directly for reads"); + expect(note).toContain("Use `exec` only for JavaScript control flow"); + expect(note).toContain("Do not use shell redirection, Node, Python, sed, or heredocs"); + expect(note).not.toContain("for example `await tools.exec_command"); + expect(note).not.toContain("await tools.apply_patch"); + }); + + test("does not invent an edit tool when only direct shell is listed", () => { + const note = buildNonOpenAIToolCatalogNudgeFromNames( + ["exec", "exec_command"], + name => name, + "exec", + ); + expect(note).toContain("`exec_command` directly for reads"); + expect(note).not.toContain("directly for targeted edits"); + expect(note).not.toContain("apply_patch"); + expect(note).not.toContain("targeted workspace edit"); + }); + + test("recognizes transformed direct tool names without naming their bare aliases", () => { + const note = buildNonOpenAIToolCatalogNudgeForTools( + [ + codeModeExec(), + { name: "apply_patch", parameters: {} } as OcxTool, + { name: "exec_command", parameters: {} } as OcxTool, + ], + undefined, + tool => `custom_${tool.name}`, + ); + expect(note).toContain("`custom_apply_patch` directly for targeted edits"); + expect(note).toContain("`custom_exec_command` directly for reads"); + expect(note).toContain("Use `custom_exec` only for JavaScript control flow"); + }); + // "Bare" means un-namespaced. An MCP server can advertise its own `exec_command` — docker, // k8s and ssh servers plausibly do — and that is not Codex's shell bridge. Letting it cancel // code mode silently strips the guidance from a genuine code-mode turn, which is how the