Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<call_id>|<item_id>`, 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

Expand Down
88 changes: 84 additions & 4 deletions src/converters.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -219,6 +220,82 @@ interface ToolCallState {
resultIds: ReadonlySet<string>
}

/**
* Bound for tool-call ids on the `/alpha/generate` wire (#141).
*
* Hosts store Responses tool-call ids compounded as `<call_id>|<item_id>`
* (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 `_<hash>` suffix so every distinct id still maps to a distinct value.
*/
function generateToolCallIdMap(messages?: readonly MessageLike[]): Map<string, string> {
const referenced = new Set<string>()
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<string>()
const mapped = new Map<string, string>()
// 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<string>()
const resultIds = new Set<string>()
Expand Down Expand Up @@ -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++) {
Expand All @@ -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,
Expand Down Expand Up @@ -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 }
Expand Down
145 changes: 145 additions & 0 deletions tests/test-pure-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]), [])
})
Expand Down
Loading