Bug: tool-call id/name overwritten by empty strings from OpenAI-compatible providers (every tool call becomes unknown tool "")
#2540
Replies: 3 comments
|
Verified line-by-line against master (47f9438) — your analysis is exact on every point I checked. This report is the empty-string sibling of an earlier pair in the same family, and your fix direction is the one that closes the whole family. Adding the cross-thread layer: 1. Family convergence — the 2. Second independent repro already exists. lanjie8 reproduced the same wire-level cause on #2343 and posted a cherry-pick-ready commit ( 3. One residual site neither fix covers — the shared assembler. Your diff (and lanjie8's) fixes 4. Your follow-on is confirmed. lanjie8 noted the persisted Your regression test follows the existing |
|
I independently reproduced this on a DeepSeek-compatible OpenAI-style stream while integrating DSH with a gateway. The wire shape is:
With the current I prepared and validated a minimal adapter-level patch in my fork: Commit: The patch only updates the accumulated call id when the wire value is non-empty, and adds a regression fixture for the empty-id continuation shape. Validation completed with 30 focused DeepSeek translation tests, the package TypeScript build, and the repository pre-push build/typecheck hook. No internal endpoints, credentials, or customer data are included. This is an independent confirmation of the same empty-string variant. The upstream PR path is currently disabled, so I am sharing the ready-to-review branch here for maintainers to fold into the preferred family-wide fix. |
|
Cross-posting from #2855, TL;DRtranslate.ts guards alone are not sufficient. Even after Layer 1 is fixed, a provider or The two assembler defects (rc.7 source)Defect 1 — unconditional id capture ( partial.toolCallId = chunk.id; // later empty-id deltas clobber it
if (chunk.name) partial.toolCallName = chunk.name; // guarded — the asymmetry proves intentDefect 2 — case 'block-end':
partial.block = chunk.block; // raw block may carry id:"" / name:""
...
assemble(partial, index) {
if (partial.block)
return partial.block; // short-circuit: partial.* accumulators are droppedThe fix (two small patches, both applied)L2 — guard id exactly like name: if (chunk.id) partial.toolCallId = chunk.id;
if (chunk.name) partial.toolCallName = chunk.name;L3 — backfill in assemble(partial, index) {
if (partial.block) {
let block = partial.block;
if ((block.name === undefined || block.name === null || block.name === '') &&
partial.toolCallName) block = { ...block, name: partial.toolCallName };
if ((block.id === undefined || block.id === null || block.id === '') &&
partial.toolCallId) block = { ...block, id: partial.toolCallId };
if ((block.arguments === undefined || block.arguments === null || block.arguments === '') &&
partial.toolCallArguments) block = { ...block, arguments: partial.toolCallArguments };
return block;
}
...
}The invariant: a fully-populated block-end is returned unchanged; only missing fields get Regression test (exercises all layers)40 parallel tool calls × 25 SSE chunks each, arguments split across all chunks, id/name only for (let c = 0; c < CHUNKS; c++) {
for (let t = 0; t < N_TOOLS; t++) {
if (c === 0) asm.push({ type: "block-start", index: t, blockType: "tool-call" });
asm.push({
type: "tool-call-delta", index: t,
id: c === 0 ? `call_${t}` : undefined,
name: c === 0 ? `tool_${t}` : undefined,
argumentsDelta: segs[c] ?? "",
});
}
}
for (let t = 0; t < N_TOOLS; t++)
asm.push({ type: "block-end", index: t, block: { type: "tool-call", arguments: "" } });
// assert every assembled block has call_${t} / tool_${t} / full argumentsUnpatched: every block comes back with One operational noteThe runtime loads the bundled Happy to share my idempotent patch script + stress test verbatim if the maintainers want them |
Uh oh!
There was an error while loading. Please reload this page.
Summary
When
dshis pointed at an OpenAI-compatible gateway (reproduced with SiliconFlow,https://api.siliconflow.cn/v1) instead of the official DeepSeek API, every tool call fails withunknown tool "". The arguments JSON assembles correctly, but the call'sidandnameend up as empty strings, so the tool registry lookup rejects the call. The agent retries in a loop and can never execute any tool.Root cause
packages/llm/llm-deepseek/src/translate.tsaccumulates streamingtool_callsfragments with:Only the first fragment of a tool call carries
idandfunction.name. The official DeepSeek API omits both fields on continuation fragments (undefined), so the guards hold. SiliconFlow (and other OpenAI-compatible implementations) instead send them as empty strings on every continuation fragment:{"index": 0, "id": "", "function": {"name": "", "arguments": "{\"pattern\""}}"" !== undefined, so the second fragment overwrites the realid/namewith"". The closingblock-endthen carries the empty values, and (with the authoritative-block path inBlockAssembler) the finaltool-callblock hasname: "".Session-log evidence (SiliconFlow,
deepseek-ai/DeepSeek-V4-Flash):{"type":"tool-call-delta","index":2,"id":"01a00b657d8e5e2208f74d77907a55cc","name":"glob","argumentsDelta":""} {"type":"tool-call-delta","index":2,"id":"","name":"","argumentsDelta":"{"} ... {"type":"block-end","index":2,"block":{"type":"tool-call","id":"","name":"","arguments":"{\"pattern\": \"*\", ...}"}}Note
BlockAssembler.pushalready guards name accumulation with truthiness (if (chunk.name)), but it cannot recover because the adapter's authoritativeblock-endwins, and its unconditionalpartial.toolCallId = chunk.idhas the same empty-string overwrite for the id.Fix
Treat empty strings the same as absent fields — an empty
id/namenever replaces the opening fragment's value:Regression test (passes with the fix, fails without; full
translate.spec.tssuite still green, 31/31):Verified end-to-end: with the patch applied, the same Web UI session executes
bash/globtool calls through SiliconFlow normally.Environment
pnpm dsh web)deepseek-ai/DeepSeek-V4-FlashCONTRIBUTING.md says external PRs are not accepted at the moment, so reporting here with the patch inline — happy to provide anything else that helps.
All reactions