Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, CommandCodeApi>()
const streamGenerate = createStreamCommandCode({
createStream: () => new AssistantMessageEventStream(),
calculateCost: calculateCommandCodeCost,
apiBase: legacyApiBase(apiBase),
isResponsesModel: (modelId) => modelApis.get(modelId) === "openai-responses",
})
const resolveStreamOptions = (options?: Parameters<typeof streamNativeProvider>[2]) =>
withResolvedCommandCodeApiKey(options, getConfiguredApiKey())
Expand All @@ -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<string, CommandCodeApi>()
const resolveModelApi = (modelId: string): CommandCodeApi =>
modelApis.get(modelId) ?? apiForModelId(modelId)
let streamWarning: string | undefined
Expand Down
17 changes: 12 additions & 5 deletions src/converters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down Expand Up @@ -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[] = []
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 },
Expand Down
35 changes: 33 additions & 2 deletions src/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,4 +209,6 @@ export interface CoreDependencies {
homeDir?: () => string
/** Injectable delay for retry backoff. Defaults to setTimeout. */
delay?: (ms: number, signal: AbortSignal) => Promise<void>
/** Whether the model is served by the OpenAI Responses wire (see TOOL_SEARCH_ALIAS). */
isResponsesModel?: (modelId: string) => boolean
}
42 changes: 42 additions & 0 deletions tests/test-pure-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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([
{
Expand Down
94 changes: 94 additions & 0 deletions tests/test-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
)
})
})