From 5323b3477cb2bcd2ef8e88fd92b74821055b76f0 Mon Sep 17 00:00:00 2001 From: Farhan Tawfeeq Date: Tue, 20 Oct 2026 13:04:47 +0530 Subject: [PATCH] fix(stream): alias tool_search on the generate wire Command Code's /alpha/generate backend rebuilds OpenAI Responses items for Responses-backed models. It special-cases a tool literally named `tool_search` and drops the required `arguments` from that item, so every follow-up turn fails with `400 Missing required parameter: 'input[N].arguments'` (reported on gpt-6-luna). Send the tool as `search_tools` in the generate payload (tool list, tool calls, and tool results) and map the model's `search_tools` call back to `tool_search` before handing it to the host. The alias is enabled only for models served by the Responses wire, so Chat Completions (DeepSeek) and Anthropic models keep their existing names and behavior. --- CHANGELOG.md | 2 + index.ts | 3 +- src/converters.ts | 17 +++++-- src/core.ts | 35 +++++++++++++- src/types.ts | 2 + tests/test-pure-functions.ts | 42 ++++++++++++++++ tests/test-stream.ts | 94 ++++++++++++++++++++++++++++++++++++ 7 files changed, 187 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ffbf98..4a89de8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Fix recurring `400 Missing required parameter: 'input[N].arguments'` failures on the `/alpha/generate` fallback for Responses-backed models such as `gpt-6-luna`. The backend emits the replayed `tool_search` call without its required `arguments`, so the generate wire now sends that tool as `search_tools` and maps the model's call back to `tool_search`; other models, including Chat Completions and Anthropic, are unchanged. + - Refresh the static capability snapshot to `command-code@1.83.0`: add image input, reasoning, and selectable `low`/`medium`/`high` efforts for `meta/muse-spark-open` and `nvidia/nemotron-3.5-super-vl`; publish reasoning for the free `stealth/cat-jam:free` preview with `low`/`medium`/`high`/`xhigh`/`max` efforts and a 131,072-token output limit. Retire `inclusionai/ling-3.0-flash-sante:free` and drop its reasoning flag, output limit, and display-price entry now that the API no longer advertises it. No prices changed; the reviewed snapshot now covers all 92 advertised models. ## 0.7.8 - 2026-10-15 diff --git a/index.ts b/index.ts index da485c6..4a74469 100644 --- a/index.ts +++ b/index.ts @@ -204,10 +204,12 @@ export default async function (pi: ExtensionAPI) { const modelsTimeoutMs = getModelsTimeoutMs() const modelsCachePath = process.env.COMMANDCODE_MODELS_CACHE ?? join(getAgentDir(), "commandcode-models.json") + const modelApis = new Map() const streamGenerate = createStreamCommandCode({ createStream: () => new AssistantMessageEventStream(), calculateCost: calculateCommandCodeCost, apiBase: legacyApiBase(apiBase), + isResponsesModel: (modelId) => modelApis.get(modelId) === "openai-responses", }) const resolveStreamOptions = (options?: Parameters[2]) => withResolvedCommandCodeApiKey(options, getConfiguredApiKey()) @@ -218,7 +220,6 @@ export default async function (pi: ExtensionAPI) { // The host dispatches every Command Code model through one custom api id, so // the wire each model answers on is resolved here from the catalog that // `createProviderConfig` registered. Unknown ids fall back to the id rule. - const modelApis = new Map() const resolveModelApi = (modelId: string): CommandCodeApi => modelApis.get(modelId) ?? apiForModelId(modelId) let streamWarning: string | undefined diff --git a/src/converters.ts b/src/converters.ts index f4d33d5..922f4c1 100644 --- a/src/converters.ts +++ b/src/converters.ts @@ -201,14 +201,18 @@ export function getEnvironmentInfo(): string { return `${process.platform}-${process.arch}, Node.js ${process.version}` } -export function toolsToJson(tools?: readonly ToolLike[], modelId?: string): unknown[] { +export function toolsToJson( + tools?: readonly ToolLike[], + modelId?: string, + toolNameAlias?: (name: string) => string, +): unknown[] { if (!tools) return [] const geminiSafe = modelId !== undefined && modelId.startsWith("google/gemini-") return tools.map((tool) => { const schema = tool.parameters ? toJsonSchema(tool.parameters) : {} return { type: "function", - name: tool.name, + name: toolNameAlias ? toolNameAlias(tool.name) : tool.name, description: tool.description, input_schema: geminiSafe ? geminiSafeJsonSchema(schema) : schema, } @@ -318,9 +322,12 @@ function toolCallState(messages?: readonly MessageLike[]): ToolCallState { export function messagesToCC( messages?: readonly MessageLike[], - options: { allowImages?: boolean } = {}, + options: { allowImages?: boolean; toolNameAlias?: (name: string) => string } = {}, ): unknown[] { const allowImages = options.allowImages ?? false + // Some backend wires special-case certain tool names and reject the replay + // (see TOOL_SEARCH_ALIAS in core.ts), so calls/results can be renamed. + const aliasToolName = options.toolNameAlias ?? ((name: string) => name) if (!allowImages) assertTextOnlyMessages(messages) const out: unknown[] = [] @@ -354,7 +361,7 @@ export function messagesToCC( parts.push({ type: "reasoning", text: stringValue(content.thinking) ?? "" }) } else if (content.type === "toolCall") { const rawToolCallId = stringValue(content.id) ?? "" - const toolName = stringValue(content.name) ?? "" + const toolName = aliasToolName(stringValue(content.name) ?? "") if (!rawToolCallId) continue const toolCallId = wireId(rawToolCallId) parts.push({ @@ -397,7 +404,7 @@ export function messagesToCC( { type: "tool-result", toolCallId: wireId(toolMsg.toolCallId), - toolName: toolMsg.toolName, + toolName: aliasToolName(toolMsg.toolName ?? ""), output: toolMsg.isError ? { type: "error-text", value: outputText } : { type: "text", value: outputText }, diff --git a/src/core.ts b/src/core.ts index f58f856..30becd6 100644 --- a/src/core.ts +++ b/src/core.ts @@ -52,6 +52,27 @@ const DEFAULT_MAX_RETRIES = 0 const DEFAULT_MAX_RETRY_DELAY_MS = 60_000 const BASE_RETRY_DELAY_MS = 500 +/** + * Command Code's `/alpha/generate` backend rebuilds OpenAI Responses items for + * Responses-backed models. It special-cases a tool literally named + * `tool_search` and emits that item without its required `arguments`, so every + * follow-up turn fails with + * `400 Missing required parameter: 'input[N].arguments'`. + * + * `shortcut:` send the tool under an alias on that wire and map the model's + * call back to the host name. Remove once the backend keeps the arguments. + */ +const TOOL_SEARCH_NAME = "tool_search" +const TOOL_SEARCH_WIRE_ALIAS = "search_tools" + +function wireToolName(name: string, alias: boolean): string { + return alias && name === TOOL_SEARCH_NAME ? TOOL_SEARCH_WIRE_ALIAS : name +} + +function hostToolName(name: string, alias: boolean): string { + return alias && name === TOOL_SEARCH_WIRE_ALIAS ? TOOL_SEARCH_NAME : name +} + function isRetryableStatus(status: number): boolean { return status === 429 || (status >= 500 && status < 600) } @@ -235,6 +256,9 @@ export function createStreamCommandCode(deps: CoreDependencies) { options?: StreamOptions, ): AssistantMessageEventStreamLike { const stream = deps.createStream() + // Only Responses-backed models hit the generate backend's tool_search bug. + const aliasToolSearch = deps.isResponsesModel?.(model.id) === true + const toWireToolName = (name: string) => wireToolName(name, aliasToolSearch) async function run() { // Some hosts pass a literal env-var reference instead of resolving it. @@ -332,6 +356,10 @@ export function createStreamCommandCode(deps: CoreDependencies) { const handleEvent = (event: unknown) => { if (!isRecord(event)) return + // Translate the aliased wire tool name back before the host sees it. + if (aliasToolSearch && event.toolName === TOOL_SEARCH_WIRE_ALIAS) { + event.toolName = hostToolName(TOOL_SEARCH_WIRE_ALIAS, aliasToolSearch) + } switch (event.type) { case "text-delta": { @@ -560,8 +588,11 @@ export function createStreamCommandCode(deps: CoreDependencies) { skills: null, params: { model: model.id, - messages: messagesToCC(context.messages, { allowImages }), - tools: toolsToJson(context.tools, model.id), + messages: messagesToCC(context.messages, { + allowImages, + toolNameAlias: toWireToolName, + }), + tools: toolsToJson(context.tools, model.id, toWireToolName), system: systemPromptToText(context.systemPrompt), max_tokens: generateMaxTokens(model, options), stream: true, diff --git a/src/types.ts b/src/types.ts index d73911a..8faf5cd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -209,4 +209,6 @@ export interface CoreDependencies { homeDir?: () => string /** Injectable delay for retry backoff. Defaults to setTimeout. */ delay?: (ms: number, signal: AbortSignal) => Promise + /** Whether the model is served by the OpenAI Responses wire (see TOOL_SEARCH_ALIAS). */ + isResponsesModel?: (modelId: string) => boolean } diff --git a/tests/test-pure-functions.ts b/tests/test-pure-functions.ts index 5971dd8..fc36b61 100644 --- a/tests/test-pure-functions.ts +++ b/tests/test-pure-functions.ts @@ -550,6 +550,18 @@ describe("toolsToJson()", () => { it("returns an empty array for missing tools", () => { assert.deepEqual(toolsToJson(), []) }) + + it("renames tool names when an alias is supplied", () => { + const alias = (name: string) => (name === "tool_search" ? "search_tools" : name) + const [tool] = toolsToJson( + [{ name: "tool_search", description: "Search tools" }], + "gpt-6-luna", + alias, + ) as [{ name: string }] + assert.equal(tool.name, "search_tools") + const [plain] = toolsToJson([{ name: "tool_search" }]) as [{ name: string }] + assert.equal(plain.name, "tool_search") + }) }) describe("toolsToJson() gemini schema dialect", () => { @@ -722,6 +734,36 @@ describe("messagesToCC()", () => { assert.equal(objectAt(result, ["2", "content", "0", "output", "value"]), "hello\nworld") }) + it("renames tool calls and results when an alias is supplied", () => { + const alias = (name: string) => (name === "tool_search" ? "search_tools" : name) + const result = messagesToCC( + [ + { + role: "assistant", + content: [{ type: "toolCall", id: "c1", name: "tool_search", arguments: { query: "x" } }], + }, + { + role: "toolResult", + toolCallId: "c1", + toolName: "tool_search", + content: [{ type: "text", text: "Loaded 1 tool." }], + }, + ], + { toolNameAlias: alias }, + ) + + assert.equal(objectAt(result, ["0", "content", "0", "toolName"]), "search_tools") + assert.deepEqual(objectAt(result, ["0", "content", "0", "input"]), { query: "x" }) + assert.equal(objectAt(result, ["1", "content", "0", "toolName"]), "search_tools") + const plain = messagesToCC([ + { + role: "assistant", + content: [{ type: "toolCall", id: "c1", name: "tool_search", arguments: {} }], + }, + ]) + assert.equal(objectAt(plain, ["0", "content", "0", "toolName"]), "tool_search") + }) + it("preserves malformed string tool results instead of sending empty output", () => { const result = messagesToCC([ { diff --git a/tests/test-stream.ts b/tests/test-stream.ts index 402e384..b00bbaa 100644 --- a/tests/test-stream.ts +++ b/tests/test-stream.ts @@ -1290,3 +1290,97 @@ describe("streamCommandCode — upstream errors and malformed streams", () => { ) }) }) + +describe("streamCommandCode — tool_search wire alias", () => { + const messages = [ + { role: "user", content: "find a tool" }, + { + role: "assistant", + content: [{ type: "toolCall", id: "c1", name: "tool_search", arguments: { query: "x" } }], + }, + { + role: "toolResult", + toolCallId: "c1", + toolName: "tool_search", + content: [{ type: "text", text: "Loaded 1 tool." }], + }, + { role: "user", content: "continue" }, + ] + const tools = [ + { name: "tool_search", description: "Search tools", parameters: { type: "object" } }, + ] + + it("sends the alias and maps the model's call back on the Responses wire", async () => { + server.mockResponse({ + type: "success", + events: [ + JSON.stringify({ + type: "tool-call", + toolCallId: "c2", + toolName: "search_tools", + input: { query: "y" }, + }), + JSON.stringify({ type: "finish", finishReason: "tool-calls" }), + ], + }) + const { streamCommandCode } = createTestDeps({ + apiBase: server.baseUrl(), + isResponsesModel: (modelId) => modelId === "gpt-6-luna", + }) + + const events = await collectEvents( + streamCommandCode(makeModel({ id: "gpt-6-luna" }), makeContext({ messages, tools }), { + apiKey: "mock-key", + }), + ) + + const body = server.lastRequestBody() + assert.equal(objectAt(body, ["params", "tools", "0", "name"]), "search_tools") + assert.equal( + objectAt(body, ["params", "messages", "1", "content", "0", "toolName"]), + "search_tools", + ) + assert.equal( + objectAt(body, ["params", "messages", "2", "content", "0", "toolName"]), + "search_tools", + ) + + const done = events.at(-1) + if (done?.type !== "done") throw new Error("expected done") + const toolCall = done.message.content[0] + assert.equal(toolCall?.type, "toolCall") + if (toolCall?.type !== "toolCall") throw new Error("expected tool call") + assert.equal(toolCall.name, "tool_search") + assert.deepEqual(toolCall.arguments, { query: "y" }) + }) + + it("leaves non-Responses models unchanged", async () => { + server.mockResponse({ + type: "success", + events: [JSON.stringify({ type: "finish", finishReason: "stop" })], + }) + const { streamCommandCode } = createTestDeps({ + apiBase: server.baseUrl(), + isResponsesModel: () => false, + }) + + await collectEvents( + streamCommandCode( + makeModel({ id: "deepseek/deepseek-v4.1-flash" }), + makeContext({ messages, tools }), + { apiKey: "mock-key" }, + ), + ) + + const body = server.lastRequestBody() + assert.equal(objectAt(body, ["params", "tools", "0", "name"]), "tool_search") + assert.equal( + objectAt(body, ["params", "messages", "1", "content", "0", "toolName"]), + "tool_search", + ) + assert.equal( + objectAt(body, ["params", "messages", "2", "content", "0", "toolName"]), + "tool_search", + ) + }) +})