diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index d01a023bdb..e8d9b70f80 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -2048,6 +2048,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): const rewritten = rewriteRoutedCustomToolsForUpstream( outBody, provider.supportsResponsesCustomTools, + 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 8653fde1d7..eef4b17ba6 100644 --- a/src/adapters/tool-catalog-nudge.ts +++ b/src/adapters/tool-catalog-nudge.ts @@ -114,6 +114,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) + ".", @@ -121,11 +142,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. " + CODE_MODE_RESULT_ECHO_SENTENCE + " Nested `tools.apply_patch(input)` is host-executed: the string must begin exactly with `*** Begin Patch` and end with `*** End Patch` (no trailing `***` on those lines). OpenCodex does not rewrite JavaScript inside exec, so a decorated `*** Begin Patch ***` envelope is rejected by Codex before the file is touched." + ? 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. " + CODE_MODE_RESULT_ECHO_SENTENCE + " Nested `tools.apply_patch(input)` is host-executed: the string must begin exactly with `*** Begin Patch` and end with `*** End Patch` (no trailing `***` on those lines). OpenCodex does not rewrite JavaScript inside exec, so a decorated `*** Begin Patch ***` envelope is rejected by Codex before the file is touched." : "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(" "); @@ -142,8 +168,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 94241e8a75..4f4be7d18d 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -133,10 +133,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. */ @@ -483,12 +484,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; } @@ -558,7 +563,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 5ef9dffa2b..34a00d3558 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"; @@ -38,7 +38,7 @@ import { serviceTierSupportFromPolicy, } from "../../providers/service-tier"; import type { FastPolicyAuthority } from "../../providers/fastwire"; -import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../../providers/registry"; +import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport, providerModelCustomToolTransport, providerModelWireDefault, registryEntryForProviderDestination } from "../../providers/registry"; import { parseAntigravityAvailableModels, registerAntigravityDiscoveredWireModels } from "../../providers/antigravity-models"; import { applyProviderContextCap, providerContextCap, resolveUnknownRoutedContextWindow } from "../../providers/context-cap"; import { clampAutoCompactTokenLimit } from "../../providers/auto-compact-budget"; @@ -664,6 +664,15 @@ function configuredVerbositySupport(name: string, prov: OcxProviderConfig | unde } export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, model: CatalogModel, providerCap?: number): CatalogModel { + 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); const configuredAutoCompact = configuredAutoCompactTokenLimit(prov, model.id); @@ -718,6 +727,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 1308b3a64b..5cc4f85897 100644 --- a/src/config.ts +++ b/src/config.ts @@ -522,6 +522,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 a8cca87828..c9a35655c1 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -40,6 +40,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 { @@ -1173,12 +1174,14 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ wire: "openai-chat", inbound: ["responses"], authModes: ["oauth"], + customToolTransport: "function-json", forwardCallerServiceTier: false, }, "grok-4.5": { wire: "openai-chat", inbound: ["responses"], authModes: ["oauth"], + customToolTransport: "function-json", forwardCallerServiceTier: false, }, }, @@ -2962,6 +2965,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 ea4c36a77f..d1be12ef54 100644 --- a/src/responses/custom-tool-compat.ts +++ b/src/responses/custom-tool-compat.ts @@ -7,14 +7,26 @@ import { import { compileCodeModeHelperInput } from "./code-mode-helper-compat"; import { collectResponsesToolGroups } from "./tool-groups"; +type ProjectedField = "code" | "patch" | "input"; +export type RoutedCustomToolProjection = "legacy" | "direct-first"; + const ROUTED_CUSTOM_TOOL_PASSTHROUGH = new Set(["apply_patch"]); const BUILTIN_FUNCTIONS_NAMESPACE = "functions"; function routedCustomToolPassesThrough( name: string, supportsResponsesCustomTools: boolean | undefined, + projection: RoutedCustomToolProjection = "legacy", ): boolean { - return supportsResponsesCustomTools !== false && ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(name); + return projection !== "direct-first" + && supportsResponsesCustomTools !== false + && ROUTED_CUSTOM_TOOL_PASSTHROUGH.has(name); +} + +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 { @@ -88,6 +100,7 @@ function collectRoutedCustomToolWireNames( body: unknown, supportsResponsesCustomTools?: boolean, passthrough = false, + projection: RoutedCustomToolProjection = "legacy", ): Set { const names = new Set(); const groups = collectResponsesToolGroups(body); @@ -108,7 +121,7 @@ function collectRoutedCustomToolWireNames( if ( tool.type === "custom" && typeof tool.name === "string" - && routedCustomToolPassesThrough(tool.name, supportsResponsesCustomTools) === passthrough + && routedCustomToolPassesThrough(tool.name, supportsResponsesCustomTools, projection) === passthrough ) { names.add(tool.name); continue; @@ -121,7 +134,7 @@ function collectRoutedCustomToolWireNames( isPlainObject(child) && child.type === "custom" && typeof child.name === "string" - && routedCustomToolPassesThrough(child.name, supportsResponsesCustomTools) === passthrough + && routedCustomToolPassesThrough(child.name, supportsResponsesCustomTools, projection) === passthrough && (!passthrough || tool.name === BUILTIN_FUNCTIONS_NAMESPACE) && !(tool.name === BUILTIN_FUNCTIONS_NAMESPACE && bareWireNames.has(child.name)) ) names.add(customToolWireName(tool.name, child.name)); @@ -137,8 +150,15 @@ export function customToolItemId(id: unknown): unknown { export function collectRoutedCustomToolNames( body: unknown, - supportsResponsesCustomTools?: boolean, + supportsOrProjection?: boolean | RoutedCustomToolProjection, + explicitProjection: RoutedCustomToolProjection = "legacy", ): Set { + const supportsResponsesCustomTools = typeof supportsOrProjection === "boolean" + ? supportsOrProjection + : undefined; + const projection = typeof supportsOrProjection === "string" + ? supportsOrProjection + : explicitProjection; const names = new Set(); const visit = (value: unknown): void => { if (Array.isArray(value)) { @@ -149,7 +169,7 @@ export function collectRoutedCustomToolNames( if ( value.type === "custom" && typeof value.name === "string" - && !routedCustomToolPassesThrough(value.name, supportsResponsesCustomTools) + && !routedCustomToolPassesThrough(value.name, supportsResponsesCustomTools, projection) ) { names.add(value.name); } @@ -180,8 +200,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)) { @@ -190,21 +211,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, }, }; @@ -216,10 +240,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 : "" }), }; } @@ -234,31 +259,89 @@ 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; } +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, - supportsResponsesCustomTools?: boolean, + supportsOrProjection?: boolean | RoutedCustomToolProjection, + explicitProjection: RoutedCustomToolProjection = "legacy", ): { body: unknown; names: Set; repairNames: Set; } { - const conversionNames = collectRoutedCustomToolNames(body, supportsResponsesCustomTools); - const names = collectRoutedCustomToolWireNames(body, supportsResponsesCustomTools); - const repairNames = collectRoutedCustomToolWireNames(body, supportsResponsesCustomTools, true); + const supportsResponsesCustomTools = typeof supportsOrProjection === "boolean" + ? supportsOrProjection + : undefined; + const projection = typeof supportsOrProjection === "string" + ? supportsOrProjection + : explicitProjection; + const conversionNames = collectRoutedCustomToolNames( + body, + supportsResponsesCustomTools, + projection, + ); + const names = collectRoutedCustomToolWireNames( + body, + supportsResponsesCustomTools, + false, + projection, + ); + const repairNames = collectRoutedCustomToolWireNames( + body, + supportsResponsesCustomTools, + true, + projection, + ); for (const name of repairNames) { if (!toolChoiceAllowsRoutedCustomTool(body, name, repairNames)) repairNames.delete(name); } if (conversionNames.size === 0) return { body, names, repairNames }; const callIds = new Set(); collectConvertedCallIds(body, conversionNames, callIds); - return { body: rewriteForUpstream(body, conversionNames, callIds), names, repairNames }; + const rewritten = rewriteForUpstream(body, conversionNames, callIds, projection); + return { + body: projection === "direct-first" ? directFirstToolOrder(rewritten) : rewritten, + names, + repairNames, + }; } export function restoreRoutedCustomCalls( @@ -288,7 +371,7 @@ export function restoreRoutedCustomCalls( name: aliased ? targetName : item.name, input: aliased && sourceInput !== "" ? compileCodeModeHelperInput(sourceInput, item.name) - : repairFreeformToolInput( + : unwrapRoutedCustomToolArguments( sourceInput, targetName, typeof item.namespace === "string" ? item.namespace : undefined, @@ -378,7 +461,16 @@ export function unwrapRoutedCustomToolArguments( toolName = "", namespace?: string, ): string { - return toolName - ? repairFreeformToolInput(argumentsText, toolName, namespace) - : unwrapFreeformToolInput(argumentsText); + if (!toolName) return unwrapFreeformToolInput(argumentsText); + let projected = argumentsText; + if (typeof argumentsText === "string") { + try { + const parsed = JSON.parse(argumentsText) as unknown; + const field = projectedCustomToolField(toolName); + if (isPlainObject(parsed) && typeof parsed[field] === "string") { + projected = JSON.stringify({ input: parsed[field] }); + } + } catch { /* malformed arguments stay visible */ } + } + return repairFreeformToolInput(projected, toolName, namespace); } 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 4463c8481c..9fe17a7152 100644 --- a/src/server/responses-custom-tool-repair.ts +++ b/src/server/responses-custom-tool-repair.ts @@ -16,8 +16,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); @@ -313,7 +312,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 []; diff --git a/src/types/config.ts b/src/types/config.ts index 10a87c9859..14051112cd 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 b7ba042506..de2f927008 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -143,12 +143,15 @@ export interface OcxProviderConfig { /** Override the global built-in model-alias switch for this provider. */ defaultAliases?: boolean; 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 927864eb9f..82b325cf5c 100644 --- a/tests/adapter-resolve.test.ts +++ b/tests/adapter-resolve.test.ts @@ -108,24 +108,39 @@ describe("registry per-model wire defaults", () => { }); 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 Responses override opts into the native wire", () => { for (const model of ["grok-4.6", "grok-4.5"]) { const provider = xai("oauth", { modelAdapters: { [model]: "openai-responses" } }); - expect(resolveWireProtocolOverride("xai", model, provider, "responses").adapter) - .toBe("openai-responses"); + const resolved = resolveWireProtocolOverride("xai", model, provider, "responses"); + expect(resolved.adapter).toBe("openai-responses"); + expect(resolved.customToolTransport).toBe("function-json"); } }); + test("clears a stale function-json capability when a second resolve no longer qualifies", () => { + const provider = xai("oauth", { modelAdapters: { "grok-4.6": "openai-responses" } }); + const resolved = resolveWireProtocolOverride("xai", "grok-4.6", provider, "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 { return gateway({ baseUrl: "https://api.deepseek.com", diff --git a/tests/codex-tool-mode.test.ts b/tests/codex-tool-mode.test.ts index eb87143707..bd714b7dcd 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", @@ -238,4 +244,3 @@ describe("#2503 combo derivation preserves a member's verbosity opt-out", () => expect(derived?.supportsVerbosity).toBeUndefined(); }); }); - diff --git a/tests/config.test.ts b/tests/config.test.ts index e3c55a0680..ff9710b763 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -803,7 +803,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, @@ -820,15 +820,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 3d581f6e7d..cb2d62dd72 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", () => { @@ -148,7 +149,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 53c7fc2076..08650217a7 100644 --- a/tests/provider-registry-parity.test.ts +++ b/tests/provider-registry-parity.test.ts @@ -919,6 +919,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"); @@ -928,12 +938,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 ba00963d3f..72f70abfe5 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, @@ -7,9 +7,24 @@ import { import { compileCodeModeHelperInput } from "../src/responses/code-mode-helper-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"); @@ -174,28 +189,44 @@ describe("routed Responses custom-tool compatibility", () => { 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", () => { @@ -545,6 +576,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); @@ -868,12 +932,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", }, }, @@ -884,7 +948,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" } }], @@ -1151,12 +1215,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", }, }, @@ -1168,7 +1232,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, @@ -1183,7 +1247,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" }] }, @@ -1208,7 +1272,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", @@ -1247,12 +1311,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", }, }, @@ -1263,7 +1327,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 5360982160..82e4af5192 100644 --- a/tests/tool-catalog-nudge.test.ts +++ b/tests/tool-catalog-nudge.test.ts @@ -138,6 +138,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