diff --git a/CHANGELOG.md b/CHANGELOG.md index 17bb94e..87264cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Recognize the `off` reasoning effort now published for the DeepSeek V4 and V4.1 models, so the catalog parser no longer rejects it and the generate transport never forwards `off` as `reasoning_effort`. The Provider API and Oh My Pi adapters translate the level from the model's thinking metadata on their own wire. - Refresh the static capability snapshot to `command-code@1.79.2`: add `claude-haiku-5-5`, `mistral/mistral-large-4` (image input, 262K output limit), and the free `stealth/glyph-cluster:free`; retire `stealth/pixel-canary` and `stealth/space-bunny-alpha`. - Add reviewed display pricing for the refreshed catalog — `claude-haiku-5-5` with its 100K long-context tier, `mistral/mistral-large-4`, and the free `stealth/glyph-cluster:free` — drop the retired `stealth/space-bunny-alpha`, and correct the `claude-sonnet-5-5` cache-read rate to $0.10 per million tokens, verified against the official pricing page on 2026-10-09. The snapshot now covers all 87 advertised models. +- Fix `400 input[N].call_id` failures on the `/alpha/generate` fallback by shortening compound Responses tool-call ids (`|`, 73 characters in the reported session) to the 64-character wire limit. The mapping is deterministic per request and shared by tool calls and their results, so pairing is preserved and distinct ids never collide; short ids pass through unchanged and the Provider API path is untouched ([#141](https://github.com/patlux/pi-commandcode-provider/issues/141)). ## 0.7.6 - 2026-10-06 diff --git a/src/converters.ts b/src/converters.ts index 4d028a9..c6fa0ad 100644 --- a/src/converters.ts +++ b/src/converters.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto" import { existsSync, readFileSync } from "node:fs" import { homedir } from "node:os" import { join } from "node:path" @@ -219,6 +220,82 @@ interface ToolCallState { resultIds: ReadonlySet } +/** + * Bound for tool-call ids on the `/alpha/generate` wire (#141). + * + * Hosts store Responses tool-call ids compounded as `|` + * (73 characters for the observed `call_<32hex>|fc_<32hex>` shape). The + * generate backend rejects `call_id` values longer than 64 characters with + * `400 input[N].call_id`, so compound ids must be shortened before sending. + * The mapping is deterministic per request and shared by calls and results, + * so pairing is preserved and distinct ids never collide. + */ +const GENERATE_TOOL_CALL_ID_LIMIT = 64 + +function generateToolCallIdHash(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex").slice(0, 8) +} + +function shortenGenerateToolCallId(value: string): string { + if (value.length <= GENERATE_TOOL_CALL_ID_LIMIT) return value + const suffix = generateToolCallIdHash(value) + const head = value.replace(/[^a-zA-Z0-9_-]/g, "_").replace(/_+$/, "") + const keep = GENERATE_TOOL_CALL_ID_LIMIT - suffix.length - 1 + const truncated = head.slice(0, Math.max(0, keep)).replace(/_+$/, "") + // Fall back to the raw hash when sanitizing leaves nothing usable. + return truncated ? `${truncated}_${suffix}` : suffix +} + +/** + * Map every tool-call id referenced in this request to its generate-wire + * form. Short ids are reserved first and pass through unchanged; overlong + * ids are shortened deterministically, and on collision receive the next + * free `_` suffix so every distinct id still maps to a distinct value. + */ +function generateToolCallIdMap(messages?: readonly MessageLike[]): Map { + const referenced = new Set() + for (const message of messages ?? []) { + if (message.role === "assistant") { + for (const content of recordArray(message.content)) { + if (content.type === "toolCall") { + const id = stringValue(content.id) + if (id) referenced.add(id) + } + } + } else if (message.role === "toolResult" && message.toolCallId) { + referenced.add(message.toolCallId) + } + } + + const used = new Set() + const mapped = new Map() + // Reserve existing short ids before any long id can claim their wire value. + for (const id of referenced) { + if (id.length <= GENERATE_TOOL_CALL_ID_LIMIT) { + mapped.set(id, id) + used.add(id) + } + } + for (const id of referenced) { + if (mapped.has(id)) continue + let candidate = shortenGenerateToolCallId(id) + if (used.has(candidate)) { + const base = candidate.replace(/_+$/, "") + let attempt = 0 + do { + const suffix = generateToolCallIdHash(`${id}#${attempt}`) + const keep = GENERATE_TOOL_CALL_ID_LIMIT - suffix.length - 1 + const head = base.slice(0, Math.max(0, keep)).replace(/_+$/, "") + candidate = head ? `${head}_${suffix}` : suffix + attempt += 1 + } while (used.has(candidate)) + } + mapped.set(id, candidate) + used.add(candidate) + } + return mapped +} + function toolCallState(messages?: readonly MessageLike[]): ToolCallState { const callIds = new Set() const resultIds = new Set() @@ -248,6 +325,8 @@ export function messagesToCC( const out: unknown[] = [] const { callIds, resultIds } = toolCallState(messages) + const wireIds = generateToolCallIdMap(messages) + const wireId = (id: string): string => wireIds.get(id) ?? id const rawMessages = messages ?? [] for (let i = 0; i < rawMessages.length; i++) { @@ -269,16 +348,17 @@ export function messagesToCC( if (content.type === "text") { parts.push({ type: "text", text: stringValue(content.text) ?? "" }) } else if (content.type === "toolCall") { - const toolCallId = stringValue(content.id) ?? "" + const rawToolCallId = stringValue(content.id) ?? "" const toolName = stringValue(content.name) ?? "" - if (!toolCallId) continue + if (!rawToolCallId) continue + const toolCallId = wireId(rawToolCallId) parts.push({ type: "tool-call", toolCallId, toolName, input: recordOrEmpty(content.arguments), }) - if (!resultIds.has(toolCallId)) { + if (!resultIds.has(rawToolCallId)) { missingResults.push({ type: "tool-result", toolCallId, @@ -311,7 +391,7 @@ export function messagesToCC( content: [ { type: "tool-result", - toolCallId: toolMsg.toolCallId, + toolCallId: wireId(toolMsg.toolCallId), toolName: toolMsg.toolName, output: toolMsg.isError ? { type: "error-text", value: outputText } diff --git a/tests/test-pure-functions.ts b/tests/test-pure-functions.ts index 22e22a4..851dc59 100644 --- a/tests/test-pure-functions.ts +++ b/tests/test-pure-functions.ts @@ -1005,6 +1005,151 @@ describe("messagesToCC()", () => { ) }) + it("shortens compound tool-call ids to the generate wire limit without breaking pairs", () => { + const compound = `call_${"a".repeat(32)}|fc_${"b".repeat(32)}` + assert.equal(compound.length, 73) + + const result = messagesToCC([ + { + role: "assistant", + content: [{ type: "toolCall", id: compound, name: "bash", arguments: { command: "ls" } }], + }, + { + role: "toolResult", + toolCallId: compound, + toolName: "bash", + content: [{ type: "text", text: "ok" }], + }, + ]) + + const wireCall = objectAt(result, ["0", "content", "0", "toolCallId"]) + assert.equal(typeof wireCall, "string") + assert.ok((wireCall as string).length <= 64) + assert.notEqual(wireCall, compound) + assert.equal(objectAt(result, ["1", "content", "0", "toolCallId"]), wireCall) + }) + + it("leaves short tool-call ids unchanged", () => { + const result = messagesToCC([ + { + role: "assistant", + content: [{ type: "toolCall", id: "c1", name: "bash", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: "c1", + toolName: "bash", + content: [{ type: "text", text: "ok" }], + }, + ]) + + assert.equal(objectAt(result, ["0", "content", "0", "toolCallId"]), "c1") + assert.equal(objectAt(result, ["1", "content", "0", "toolCallId"]), "c1") + }) + + for (const longFirst of [true, false]) { + it(`preserves a colliding short id when the ${longFirst ? "long" : "short"} id comes first`, () => { + const longId = `call_${"a".repeat(32)}|fc_${"b".repeat(32)}` + const standalone = messagesToCC([ + { + role: "assistant", + content: [{ type: "toolCall", id: longId, name: "bash", arguments: {} }], + }, + ]) + const shortId = String(objectAt(standalone, ["0", "content", "0", "toolCallId"])) + assert.equal(shortId.length, 64) + const ids = longFirst ? [longId, shortId] : [shortId, longId] + const result = messagesToCC([ + { + role: "assistant", + content: ids.map((id) => ({ type: "toolCall", id, name: "bash", arguments: {} })), + }, + ...ids.map((id) => ({ + role: "toolResult", + toolCallId: id, + toolName: "bash", + content: [{ type: "text", text: "ok" }], + })), + ]) + + const wireIds = ids.map((_, index) => + String(objectAt(result, ["0", "content", String(index), "toolCallId"])), + ) + assert.equal(wireIds[ids.indexOf(shortId)], shortId) + assert.equal(new Set(wireIds).size, ids.length) + for (const [index, wireId] of wireIds.entries()) { + assert.ok(wireId.length <= 64) + assert.equal(objectAt(result, [String(index + 1), "content", "0", "toolCallId"]), wireId) + } + }) + } + + it("keeps distinct overlong tool-call ids distinct on the generate wire", () => { + const first = `call_${"a".repeat(32)}|fc_${"b".repeat(32)}` + const second = `call_${"a".repeat(31)}c|fc_${"b".repeat(32)}` + const result = messagesToCC([ + { + role: "assistant", + content: [ + { type: "toolCall", id: first, name: "bash", arguments: {} }, + { type: "toolCall", id: second, name: "bash", arguments: {} }, + ], + }, + { + role: "toolResult", + toolCallId: first, + toolName: "bash", + content: [{ type: "text", text: "one" }], + }, + { + role: "toolResult", + toolCallId: second, + toolName: "bash", + content: [{ type: "text", text: "two" }], + }, + ]) + + const wireFirst = objectAt(result, ["0", "content", "0", "toolCallId"]) + const wireSecond = objectAt(result, ["0", "content", "1", "toolCallId"]) + assert.ok((wireFirst as string).length <= 64) + assert.ok((wireSecond as string).length <= 64) + assert.notEqual(wireFirst, wireSecond) + assert.equal(objectAt(result, ["1", "content", "0", "toolCallId"]), wireFirst) + assert.equal(objectAt(result, ["2", "content", "0", "toolCallId"]), wireSecond) + }) + + it("maps synthesized missing results with the shortened tool-call id", () => { + const compound = `call_${"a".repeat(32)}|fc_${"b".repeat(32)}` + const result = messagesToCC([ + { + role: "assistant", + content: [{ type: "toolCall", id: compound, name: "edit", arguments: { path: "x" } }], + }, + ]) + + const wireCall = objectAt(result, ["0", "content", "0", "toolCallId"]) + assert.ok((wireCall as string).length <= 64) + assert.equal(objectAt(result, ["1", "content", "0", "toolCallId"]), wireCall) + }) + + it("shortens the same tool-call id deterministically", () => { + const compound = `call_${"a".repeat(32)}|fc_${"b".repeat(32)}` + const messages: MessageLike[] = [ + { + role: "assistant", + content: [{ type: "toolCall", id: compound, name: "bash", arguments: {} }], + }, + { + role: "toolResult", + toolCallId: compound, + toolName: "bash", + content: [{ type: "text", text: "ok" }], + }, + ] + + assert.deepEqual(messagesToCC(messages), messagesToCC(messages)) + }) + it("handles empty conversations", () => { assert.deepEqual(messagesToCC([]), []) })