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", + ) + }) +})