From dff02960e616802bab0d884932e06a25805da103 Mon Sep 17 00:00:00 2001 From: vishal veerareddy Date: Sun, 14 Jun 2026 21:35:26 -0700 Subject: [PATCH 1/3] fix(responses): repair blank/orphan tool_call_id before provider dispatch Codex Desktop's bundled-plugin (Browser/Computer-use) function_call_output items arrive without a usable call_id, which flattened to an empty tool_call_id and 400'd at Moonshot ("Invalid request: tool_call_id is not found"). The old repair loop in openrouter-utils couldn't catch it because "" === "" read as a match. Add a shared repairToolCallIds() helper that backfills synthetic ids on assistant tool_calls, re-links blank/drifted tool_call_ids to the nearest preceding assistant tool_call, and drops true orphans. Wire it into both convertResponsesToChat() and convertAnthropicMessagesToOpenRouter(). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/clients/openrouter-utils.js | 35 ++------- src/clients/responses-format.js | 7 ++ src/clients/tool-call-repair.js | 130 ++++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 29 deletions(-) create mode 100644 src/clients/tool-call-repair.js diff --git a/src/clients/openrouter-utils.js b/src/clients/openrouter-utils.js index 7978f8c..100e941 100644 --- a/src/clients/openrouter-utils.js +++ b/src/clients/openrouter-utils.js @@ -1,4 +1,5 @@ const logger = require("../logger"); +const { repairToolCallIds } = require("./tool-call-repair"); /** * Convert Anthropic tool format to OpenAI/OpenRouter format @@ -146,35 +147,11 @@ function convertAnthropicMessagesToOpenRouter(anthropicMessages) { } } - // Fix tool_call_id mismatches: ensure every tool message's tool_call_id - // matches the id in the preceding assistant's tool_calls array. - // IDs can drift when multiple conversion layers (Anthropic↔OpenAI) each - // generate their own IDs. - for (let i = 0; i < converted.length; i++) { - const msg = converted[i]; - if (msg.role !== 'tool') continue; - - // Find the nearest preceding assistant with tool_calls - for (let j = i - 1; j >= 0; j--) { - const prev = converted[j]; - if (prev.role === 'user') break; - if (prev.role === 'assistant' && Array.isArray(prev.tool_calls) && prev.tool_calls.length > 0) { - if (!prev.tool_calls.some(tc => tc.id === msg.tool_call_id)) { - // Mismatch — pick the first unmatched tool_call id - const usedIds = new Set(); - for (let k = j + 1; k < converted.length; k++) { - if (converted[k].role === 'tool' && k !== i) usedIds.add(converted[k].tool_call_id); - } - const available = prev.tool_calls.find(tc => !usedIds.has(tc.id)); - if (available) { - logger.info({ from: msg.tool_call_id, to: available.id }, "Fixed tool_call_id mismatch"); - msg.tool_call_id = available.id; - } - } - break; - } - } - } + // Repair tool_call_id linkage before handing to OpenAI-compatible providers: + // backfill blank assistant tool_call ids, re-link drifted/blank tool_call_ids + // to the nearest preceding assistant tool_call, and drop orphan tool results. + // Moonshot/Kimi (and others) hard-400 on any empty or unmatched tool_call_id. + repairToolCallIds(converted); // Kimi/Moonshot (and some OpenAI-compatible APIs) reject a message whose // content is an empty string with "Invalid request: tokenization failed". diff --git a/src/clients/responses-format.js b/src/clients/responses-format.js index 33f2a23..897431e 100644 --- a/src/clients/responses-format.js +++ b/src/clients/responses-format.js @@ -8,6 +8,7 @@ */ const logger = require("../logger"); +const { repairToolCallIds } = require("./tool-call-repair"); /** * Map client tool names back to Lynkr tool names @@ -203,6 +204,12 @@ function convertResponsesToChat(responsesRequest) { return cleaned; }); + // Repair tool_call_id linkage now, before anything downstream consumes the + // converted array. Codex bundled-plugin (Browser/Computer-use) calls and + // synthetic tool outputs can arrive without a usable call_id, which + // otherwise flattens to a blank tool_call_id and 400s at the provider. + repairToolCallIds(messages); + logger.info({ originalCount: input.length, filteredCount: messages.length, diff --git a/src/clients/tool-call-repair.js b/src/clients/tool-call-repair.js new file mode 100644 index 0000000..b19f379 --- /dev/null +++ b/src/clients/tool-call-repair.js @@ -0,0 +1,130 @@ +/** + * Tool-call id repair for OpenAI-format message arrays. + * + * OpenAI-compatible providers (Moonshot/Kimi, OpenAI, OpenRouter, …) reject a + * request with `Invalid request: tool_call_id is not found` whenever a + * `tool` message references an id that has no matching entry in a preceding + * assistant `tool_calls` array. This happens in practice when: + * + * 1. A `tool` message's tool_call_id is empty/missing — e.g. Codex Desktop's + * bundled-plugin (Browser/Computer-use) function_call_output items, and + * synthetic "unsupported call: shell" outputs, arrive without a usable + * `call_id`, so both the assistant tool_call id and the tool_call_id + * flatten to "". (The error shows a blank id: "tool_call_id is not found".) + * 2. An assistant tool_calls entry has an empty/missing id. + * 3. Ids drift across multiple conversion layers (Responses↔Chat↔Anthropic), + * leaving a `tool` message pointing at an id no assistant ever issued. + * + * This helper repairs all three in place: it backfills synthetic ids onto + * assistant tool_calls that lack one, re-links each `tool` message to an unused + * tool_call id on the nearest preceding assistant, and drops any `tool` message + * that has no assistant tool_call to attach to (a dangling result is a hard + * 400 at the provider, so dropping it is strictly safer than forwarding it). + * + * @module clients/tool-call-repair + */ + +const logger = require("../logger"); + +function isBlankId(id) { + return !id || String(id).trim() === ""; +} + +/** + * Repair tool_call_id linkage in an OpenAI chat-format message array, in place. + * + * @param {Array} messages - OpenAI chat-format messages (role/content, with + * assistant `tool_calls` and `tool` `tool_call_id`). Mutated in place. + * @returns {Array} the same array reference, with orphan tool messages removed. + */ +function repairToolCallIds(messages) { + if (!Array.isArray(messages) || messages.length === 0) return messages; + + let synthCounter = 0; + const nextSyntheticId = () => `call_auto_${synthCounter++}`; + + // Pass 1 — guarantee every assistant tool_call has a non-empty id. + for (const msg of messages) { + if (msg && msg.role === "assistant" && Array.isArray(msg.tool_calls)) { + for (const tc of msg.tool_calls) { + if (tc && isBlankId(tc.id)) { + tc.id = nextSyntheticId(); + logger.info({ assignedId: tc.id }, "Backfilled missing assistant tool_call id"); + } + } + } + } + + // Pass 2 — relink (or drop) every tool message. + const repaired = []; + let dropped = 0; + for (let i = 0; i < messages.length; i++) { + const msg = messages[i]; + if (!msg || msg.role !== "tool") { + repaired.push(msg); + continue; + } + + // Nearest preceding assistant that carries tool_calls (stop at a user turn). + let assistant = null; + for (let j = i - 1; j >= 0; j--) { + const prev = messages[j]; + if (!prev) continue; + if (prev.role === "user") break; + if (prev.role === "assistant" && Array.isArray(prev.tool_calls) && prev.tool_calls.length > 0) { + assistant = prev; + break; + } + } + + const matches = + assistant && + !isBlankId(msg.tool_call_id) && + assistant.tool_calls.some((tc) => tc.id === msg.tool_call_id); + + if (matches) { + repaired.push(msg); + continue; + } + + if (assistant) { + // Pick the first tool_call id not already consumed by an earlier result. + const usedIds = new Set( + repaired.filter((r) => r && r.role === "tool" && r.tool_call_id).map((r) => r.tool_call_id) + ); + const available = assistant.tool_calls.find((tc) => !usedIds.has(tc.id)); + if (available) { + logger.info( + { from: isBlankId(msg.tool_call_id) ? "(blank)" : msg.tool_call_id, to: available.id }, + "Repaired tool_call_id linkage" + ); + msg.tool_call_id = available.id; + repaired.push(msg); + continue; + } + } + + // No assistant tool_call to attach to — drop the orphan rather than let it + // 400 the whole request at the provider. + dropped++; + logger.warn( + { + tool_call_id: isBlankId(msg.tool_call_id) ? "(blank)" : msg.tool_call_id, + contentPreview: typeof msg.content === "string" ? msg.content.slice(0, 80) : "", + }, + "Dropped orphan tool message with no matching tool_call" + ); + } + + if (dropped > 0) { + logger.info({ dropped, before: messages.length, after: repaired.length }, "Removed orphan tool messages"); + } + + // Rewrite the array contents in place so callers holding this reference see + // the repaired result. + messages.length = 0; + for (const m of repaired) messages.push(m); + return messages; +} + +module.exports = { repairToolCallIds, isBlankId }; From d88a91eba7676338bbef56374f753427338f6d20 Mon Sep 17 00:00:00 2001 From: vishal veerareddy Date: Sun, 14 Jun 2026 21:35:29 -0700 Subject: [PATCH 2/3] docs(readme): update Homebrew section (verify + upgrade, auto-tracked tap) Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 765b430..8ba26b3 100644 --- a/README.md +++ b/README.md @@ -631,11 +631,13 @@ npm install -g lynkr curl -fsSL https://raw.githubusercontent.com/Fast-Editor/Lynkr/main/install.sh | bash ``` -**Homebrew** +**Homebrew** (macOS / Linux) ```bash brew tap fast-editor/lynkr brew install lynkr +lynkr --version ``` +Upgrade later with `brew update && brew upgrade lynkr`. The formula tracks the latest [`lynkr` npm release](https://www.npmjs.com/package/lynkr) automatically. **Docker** ```bash From 36cd3d42af4fc7921355706dc8b86cecaa08438f Mon Sep 17 00:00:00 2001 From: vishal veerareddy Date: Sun, 14 Jun 2026 21:39:19 -0700 Subject: [PATCH 3/3] docs: align INSTALL.md and installation.md Homebrew sections Match the README: verify step + auto-tracked tap upgrade note. Co-Authored-By: Claude Opus 4.8 (1M context) --- INSTALL.md | 3 +++ documentation/installation.md | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/INSTALL.md b/INSTALL.md index a50e4a0..29ddea4 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -163,9 +163,12 @@ wire_api = "responses" ```bash brew tap fast-editor/lynkr brew install lynkr +lynkr --version lynkr start ``` +Upgrade later with `brew update && brew upgrade lynkr` — the formula tracks the latest [`lynkr` npm release](https://www.npmjs.com/package/lynkr) automatically. + ### Docker ```bash diff --git a/documentation/installation.md b/documentation/installation.md index bef768a..fae13b7 100644 --- a/documentation/installation.md +++ b/documentation/installation.md @@ -128,8 +128,9 @@ lynkr start **Update Lynkr:** ```bash -brew upgrade lynkr +brew update && brew upgrade lynkr ``` +The formula tracks the latest [`lynkr` npm release](https://www.npmjs.com/package/lynkr) automatically. **Benefits:** - ✅ Native macOS/Linux package management