OmniRoute Version
3.8.52
Installation Method
npm (global)
Operating System
Windows
OS Version
No response
Node.js Version
No response
Provider(s) Involved
No response
Model(s) Involved
No response
Client Tool
No response
Description
Summary
On /v1/chat/completions with stream: true, OmniRoute can emit a tool call whose
function.arguments string is the partial buffer followed by a full restart of the same
arguments, concatenated instead of replaced. The client receives syntactically invalid JSON
and cannot execute the tool, so the turn is lost.
Example shape observed (abbreviated; the second fragment restarts from the opening {
with a different key ordering than the first):
{"content": "import json, os, glob\n\nbase = os.path.expanduser(...
{"content": "import json, os, glob\n\nbase = os.path.expanduser(...) <- full restart, appendedImpact
Any OpenAI-compatible agent client that parses tool arguments strictly fails the call. In the client used here (OpenCode 2.1.2, AI SDK stream parser) it surfaces as:
"The tool input could not be parsed as JSON, so the call was not executed."
The work item is abandoned mid-turn and must be re-issued.
Environment
- OmniRoute
3.8.52(global npm install) - Combo:
mb-deepseek-flash, strategyfill-first - Client: OpenCode 2.1.2 (
/v1/chat/completions, OpenAI format, streaming) - Compression: RTK only, auto-trigger
Evidence
1. The wire is not the logged body
For every sampled failed call, the stored responseBody in ~/.omniroute/call_logs
contains valid JSON for that tool call. Validating 1842 response-side argument blobs across
a day of logs produced 0 invalid values. So whatever the client received differs from what
OmniRoute believes it sent.
2. It is not specific to one provider, model, or combo target
Across one day, 278 requests had the client-side parse error in their history. Distribution by the combo target that served them:
| combo target served | requests |
|---|---|
| 1 (no fallback at all) | 91 |
| 2 | 2 |
| 3 | 266 |
| 4 | 9 |
Failures appear on deepseek-v4.1-flash, openrouter/auto, gemini-3.8-flash-low,
grok-4.7, claude-opus-4-6-thinking, claude-sonnet-4-6, and
deepseek-ai/deepseek-v4-flash-0731. 91 of them had zero fallbacks, so mid-request
failover is not a precondition.
3. It is not compression
Compression runs on the request (compressionStage: "pre-translation" in
handlers/chatCore.ts, operating on the request body). In the whole services/compression
subtree, .arguments appears exactly once — engines/rtk/index.ts:604, inside a read-only
JSON.parse(fn.arguments) for a command lookup. There is no assignment to
tool_calls[].function.arguments anywhere. RTK's shouldCompressMessage only ever rewrites
message.content for role: "tool" / "assistant". Confirmed not the cause.
Minimal reproduction (of the accumulator behaviour)
Self-contained; exercises the same accumulation contract as
open-sse/utils/toolCallArguments.ts:
const append = (current, incoming) => {
const existing = typeof current === "string" ? current : "";
const next = typeof incoming === "string" ? incoming : incoming == null ? "" : JSON.stringify(incoming);
if (!existing) return next;
if (!next) return existing;
if (next === existing) return existing;
if (next.startsWith(existing)) return next;
return existing + next;
};
const partial = '{"path":"a.ts","newString":" IF @areaId IS NOT NULL SELECT @hqId = HeadQuarterId';
const restart = '{"newString":" IF @areaId IS NOT NULL SELECT @hqId = HeadQuarterId FROM dbo.Areas WHERE Id = @areaId;"}';
const out = append(partial, restart);
try { JSON.parse(out); console.log("valid"); }
catch (e) { console.log("INVALID:", e.message); } // INVALIDrestart is a superset of the payload but not a string-prefix of partial, because the
key ordering differs. appendToolCallArgumentDelta therefore falls through to
existing + next and emits both copies.
Likely cause
open-sse/utils/toolCallArguments.ts already exists specifically to collapse snapshot-style
deltas, and its header comment cites the exact duplication hazard. It is wired into
translator/response/responsesTransformer.ts, translator/response/openai-to-claude.ts,
translator/response/openai-responses.ts, handlers/sseParser.ts, and executors/bedrock.ts
— i.e. the translator paths. It does not appear to be applied on the path that relays an
already-OpenAI-format upstream stream through to an OpenAI-format client, so a non-conformant
upstream that re-sends a full snapshot can reach the client as two concatenated fragments.
Separately, handlers/chatCore/toolCallSpecViolationAudit.ts already audits "duplicate
tool_calls entry" as a known upstream contract violation — this looks like the same class of
defect one layer earlier, on the arguments string rather than the whole call.
Two candidate contributing behaviours, both worth checking:
- A relay that forwards upstream
argumentsfragments verbatim without the collapse helper. - An abandoned attempt leaving its partial
argumentsbuffer in place, so the next attempt's fragments are appended to it. (Note:comboAttemptLoop.tsresetsstate.fallbackCountper set-try but the count is incremented elsewhere; the buffer-reset path is the thing to verify — resuming an attempt should discard, not extend, a partial argument buffer.)
Suggested fix
- Apply
appendToolCallArgumentDeltaon the client-facing relay path, not just the translator paths (or normalise upstream snapshot-style deltas into incremental deltas before relaying). - Reset the per-attempt
argumentsbuffer whenever an attempt is abandoned. - Consider a
JSON.parseassertion on the assembled arguments beforefinish_reason: "tool_calls"is emitted, as a guard against both cases.
Note on appendToolCallArgumentDelta itself
The existing next.startsWith(existing) rule is the right conservative choice — its comment
correctly warns that fuzzy overlap dedup silently truncates legitimate incremental deltas.
The gap here is coverage (where it is applied), not the rule. Key-order-changed snapshots are
inherently ambiguous from the fragment alone, so a per-attempt reset is likely the safer lever
than widening the string heuristic.
Steps to Reproduce
Summary
On /v1/chat/completions with stream: true, OmniRoute can emit a tool call whose
function.arguments string is the partial buffer followed by a full restart of the same
arguments, concatenated instead of replaced. The client receives syntactically invalid JSON
and cannot execute the tool, so the turn is lost.
Example shape observed (abbreviated; the second fragment restarts from the opening {
with a different key ordering than the first):
{"content": "import json, os, glob\n\nbase = os.path.expanduser(...
{"content": "import json, os, glob\n\nbase = os.path.expanduser(...) <- full restart, appendedImpact
Any OpenAI-compatible agent client that parses tool arguments strictly fails the call. In the client used here (OpenCode 2.1.2, AI SDK stream parser) it surfaces as:
"The tool input could not be parsed as JSON, so the call was not executed."
The work item is abandoned mid-turn and must be re-issued.
Environment
- OmniRoute
3.8.52(global npm install) - Combo:
mb-deepseek-flash, strategyfill-first - Client: OpenCode 2.1.2 (
/v1/chat/completions, OpenAI format, streaming) - Compression: RTK only, auto-trigger
Evidence
1. The wire is not the logged body
For every sampled failed call, the stored responseBody in ~/.omniroute/call_logs
contains valid JSON for that tool call. Validating 1842 response-side argument blobs across
a day of logs produced 0 invalid values. So whatever the client received differs from what
OmniRoute believes it sent.
2. It is not specific to one provider, model, or combo target
Across one day, 278 requests had the client-side parse error in their history. Distribution by the combo target that served them:
| combo target served | requests |
|---|---|
| 1 (no fallback at all) | 91 |
| 2 | 2 |
| 3 | 266 |
| 4 | 9 |
Failures appear on deepseek-v4.1-flash, openrouter/auto, gemini-3.8-flash-low,
grok-4.7, claude-opus-4-6-thinking, claude-sonnet-4-6, and
deepseek-ai/deepseek-v4-flash-0731. 91 of them had zero fallbacks, so mid-request
failover is not a precondition.
3. It is not compression
Compression runs on the request (compressionStage: "pre-translation" in
handlers/chatCore.ts, operating on the request body). In the whole services/compression
subtree, .arguments appears exactly once — engines/rtk/index.ts:604, inside a read-only
JSON.parse(fn.arguments) for a command lookup. There is no assignment to
tool_calls[].function.arguments anywhere. RTK's shouldCompressMessage only ever rewrites
message.content for role: "tool" / "assistant". Confirmed not the cause.
Minimal reproduction (of the accumulator behaviour)
Self-contained; exercises the same accumulation contract as
open-sse/utils/toolCallArguments.ts:
const append = (current, incoming) => {
const existing = typeof current === "string" ? current : "";
const next = typeof incoming === "string" ? incoming : incoming == null ? "" : JSON.stringify(incoming);
if (!existing) return next;
if (!next) return existing;
if (next === existing) return existing;
if (next.startsWith(existing)) return next;
return existing + next;
};
const partial = '{"path":"a.ts","newString":" IF @areaId IS NOT NULL SELECT @hqId = HeadQuarterId';
const restart = '{"newString":" IF @areaId IS NOT NULL SELECT @hqId = HeadQuarterId FROM dbo.Areas WHERE Id = @areaId;"}';
const out = append(partial, restart);
try { JSON.parse(out); console.log("valid"); }
catch (e) { console.log("INVALID:", e.message); } // INVALIDrestart is a superset of the payload but not a string-prefix of partial, because the
key ordering differs. appendToolCallArgumentDelta therefore falls through to
existing + next and emits both copies.
Likely cause
open-sse/utils/toolCallArguments.ts already exists specifically to collapse snapshot-style
deltas, and its header comment cites the exact duplication hazard. It is wired into
translator/response/responsesTransformer.ts, translator/response/openai-to-claude.ts,
translator/response/openai-responses.ts, handlers/sseParser.ts, and executors/bedrock.ts
— i.e. the translator paths. It does not appear to be applied on the path that relays an
already-OpenAI-format upstream stream through to an OpenAI-format client, so a non-conformant
upstream that re-sends a full snapshot can reach the client as two concatenated fragments.
Separately, handlers/chatCore/toolCallSpecViolationAudit.ts already audits "duplicate
tool_calls entry" as a known upstream contract violation — this looks like the same class of
defect one layer earlier, on the arguments string rather than the whole call.
Two candidate contributing behaviours, both worth checking:
- A relay that forwards upstream
argumentsfragments verbatim without the collapse helper. - An abandoned attempt leaving its partial
argumentsbuffer in place, so the next attempt's fragments are appended to it. (Note:comboAttemptLoop.tsresetsstate.fallbackCountper set-try but the count is incremented elsewhere; the buffer-reset path is the thing to verify — resuming an attempt should discard, not extend, a partial argument buffer.)
Suggested fix
- Apply
appendToolCallArgumentDeltaon the client-facing relay path, not just the translator paths (or normalise upstream snapshot-style deltas into incremental deltas before relaying). - Reset the per-attempt
argumentsbuffer whenever an attempt is abandoned. - Consider a
JSON.parseassertion on the assembled arguments beforefinish_reason: "tool_calls"is emitted, as a guard against both cases.
Note on appendToolCallArgumentDelta itself
The existing next.startsWith(existing) rule is the right conservative choice — its comment
correctly warns that fuzzy overlap dedup silently truncates legitimate incremental deltas.
The gap here is coverage (where it is applied), not the rule. Key-order-changed snapshots are
inherently ambiguous from the fragment alone, so a per-attempt reset is likely the safer lever
than widening the string heuristic.
Expected Behavior
Summary
On /v1/chat/completions with stream: true, OmniRoute can emit a tool call whose
function.arguments string is the partial buffer followed by a full restart of the same
arguments, concatenated instead of replaced. The client receives syntactically invalid JSON
and cannot execute the tool, so the turn is lost.
Example shape observed (abbreviated; the second fragment restarts from the opening {
with a different key ordering than the first):
{"content": "import json, os, glob\n\nbase = os.path.expanduser(...
{"content": "import json, os, glob\n\nbase = os.path.expanduser(...) <- full restart, appendedImpact
Any OpenAI-compatible agent client that parses tool arguments strictly fails the call. In the client used here (OpenCode 2.1.2, AI SDK stream parser) it surfaces as:
"The tool input could not be parsed as JSON, so the call was not executed."
The work item is abandoned mid-turn and must be re-issued.
Environment
- OmniRoute
3.8.52(global npm install) - Combo:
mb-deepseek-flash, strategyfill-first - Client: OpenCode 2.1.2 (
/v1/chat/completions, OpenAI format, streaming) - Compression: RTK only, auto-trigger
Evidence
1. The wire is not the logged body
For every sampled failed call, the stored responseBody in ~/.omniroute/call_logs
contains valid JSON for that tool call. Validating 1842 response-side argument blobs across
a day of logs produced 0 invalid values. So whatever the client received differs from what
OmniRoute believes it sent.
2. It is not specific to one provider, model, or combo target
Across one day, 278 requests had the client-side parse error in their history. Distribution by the combo target that served them:
| combo target served | requests |
|---|---|
| 1 (no fallback at all) | 91 |
| 2 | 2 |
| 3 | 266 |
| 4 | 9 |
Failures appear on deepseek-v4.1-flash, openrouter/auto, gemini-3.8-flash-low,
grok-4.7, claude-opus-4-6-thinking, claude-sonnet-4-6, and
deepseek-ai/deepseek-v4-flash-0731. 91 of them had zero fallbacks, so mid-request
failover is not a precondition.
3. It is not compression
Compression runs on the request (compressionStage: "pre-translation" in
handlers/chatCore.ts, operating on the request body). In the whole services/compression
subtree, .arguments appears exactly once — engines/rtk/index.ts:604, inside a read-only
JSON.parse(fn.arguments) for a command lookup. There is no assignment to
tool_calls[].function.arguments anywhere. RTK's shouldCompressMessage only ever rewrites
message.content for role: "tool" / "assistant". Confirmed not the cause.
Minimal reproduction (of the accumulator behaviour)
Self-contained; exercises the same accumulation contract as
open-sse/utils/toolCallArguments.ts:
const append = (current, incoming) => {
const existing = typeof current === "string" ? current : "";
const next = typeof incoming === "string" ? incoming : incoming == null ? "" : JSON.stringify(incoming);
if (!existing) return next;
if (!next) return existing;
if (next === existing) return existing;
if (next.startsWith(existing)) return next;
return existing + next;
};
const partial = '{"path":"a.ts","newString":" IF @areaId IS NOT NULL SELECT @hqId = HeadQuarterId';
const restart = '{"newString":" IF @areaId IS NOT NULL SELECT @hqId = HeadQuarterId FROM dbo.Areas WHERE Id = @areaId;"}';
const out = append(partial, restart);
try { JSON.parse(out); console.log("valid"); }
catch (e) { console.log("INVALID:", e.message); } // INVALIDrestart is a superset of the payload but not a string-prefix of partial, because the
key ordering differs. appendToolCallArgumentDelta therefore falls through to
existing + next and emits both copies.
Likely cause
open-sse/utils/toolCallArguments.ts already exists specifically to collapse snapshot-style
deltas, and its header comment cites the exact duplication hazard. It is wired into
translator/response/responsesTransformer.ts, translator/response/openai-to-claude.ts,
translator/response/openai-responses.ts, handlers/sseParser.ts, and executors/bedrock.ts
— i.e. the translator paths. It does not appear to be applied on the path that relays an
already-OpenAI-format upstream stream through to an OpenAI-format client, so a non-conformant
upstream that re-sends a full snapshot can reach the client as two concatenated fragments.
Separately, handlers/chatCore/toolCallSpecViolationAudit.ts already audits "duplicate
tool_calls entry" as a known upstream contract violation — this looks like the same class of
defect one layer earlier, on the arguments string rather than the whole call.
Two candidate contributing behaviours, both worth checking:
- A relay that forwards upstream
argumentsfragments verbatim without the collapse helper. - An abandoned attempt leaving its partial
argumentsbuffer in place, so the next attempt's fragments are appended to it. (Note:comboAttemptLoop.tsresetsstate.fallbackCountper set-try but the count is incremented elsewhere; the buffer-reset path is the thing to verify — resuming an attempt should discard, not extend, a partial argument buffer.)
Suggested fix
- Apply
appendToolCallArgumentDeltaon the client-facing relay path, not just the translator paths (or normalise upstream snapshot-style deltas into incremental deltas before relaying). - Reset the per-attempt
argumentsbuffer whenever an attempt is abandoned. - Consider a
JSON.parseassertion on the assembled arguments beforefinish_reason: "tool_calls"is emitted, as a guard against both cases.
Note on appendToolCallArgumentDelta itself
The existing next.startsWith(existing) rule is the right conservative choice — its comment
correctly warns that fuzzy overlap dedup silently truncates legitimate incremental deltas.
The gap here is coverage (where it is applied), not the rule. Key-order-changed snapshots are
inherently ambiguous from the fragment alone, so a per-attempt reset is likely the safer lever
than widening the string heuristic.
Actual Behavior
Summary
On /v1/chat/completions with stream: true, OmniRoute can emit a tool call whose
function.arguments string is the partial buffer followed by a full restart of the same
arguments, concatenated instead of replaced. The client receives syntactically invalid JSON
and cannot execute the tool, so the turn is lost.
Example shape observed (abbreviated; the second fragment restarts from the opening {
with a different key ordering than the first):
{"content": "import json, os, glob\n\nbase = os.path.expanduser(...
{"content": "import json, os, glob\n\nbase = os.path.expanduser(...) <- full restart, appendedImpact
Any OpenAI-compatible agent client that parses tool arguments strictly fails the call. In the client used here (OpenCode 2.1.2, AI SDK stream parser) it surfaces as:
"The tool input could not be parsed as JSON, so the call was not executed."
The work item is abandoned mid-turn and must be re-issued.
Environment
- OmniRoute
3.8.52(global npm install) - Combo:
mb-deepseek-flash, strategyfill-first - Client: OpenCode 2.1.2 (
/v1/chat/completions, OpenAI format, streaming) - Compression: RTK only, auto-trigger
Evidence
1. The wire is not the logged body
For every sampled failed call, the stored responseBody in ~/.omniroute/call_logs
contains valid JSON for that tool call. Validating 1842 response-side argument blobs across
a day of logs produced 0 invalid values. So whatever the client received differs from what
OmniRoute believes it sent.
2. It is not specific to one provider, model, or combo target
Across one day, 278 requests had the client-side parse error in their history. Distribution by the combo target that served them:
| combo target served | requests |
|---|---|
| 1 (no fallback at all) | 91 |
| 2 | 2 |
| 3 | 266 |
| 4 | 9 |
Failures appear on deepseek-v4.1-flash, openrouter/auto, gemini-3.8-flash-low,
grok-4.7, claude-opus-4-6-thinking, claude-sonnet-4-6, and
deepseek-ai/deepseek-v4-flash-0731. 91 of them had zero fallbacks, so mid-request
failover is not a precondition.
3. It is not compression
Compression runs on the request (compressionStage: "pre-translation" in
handlers/chatCore.ts, operating on the request body). In the whole services/compression
subtree, .arguments appears exactly once — engines/rtk/index.ts:604, inside a read-only
JSON.parse(fn.arguments) for a command lookup. There is no assignment to
tool_calls[].function.arguments anywhere. RTK's shouldCompressMessage only ever rewrites
message.content for role: "tool" / "assistant". Confirmed not the cause.
Minimal reproduction (of the accumulator behaviour)
Self-contained; exercises the same accumulation contract as
open-sse/utils/toolCallArguments.ts:
const append = (current, incoming) => {
const existing = typeof current === "string" ? current : "";
const next = typeof incoming === "string" ? incoming : incoming == null ? "" : JSON.stringify(incoming);
if (!existing) return next;
if (!next) return existing;
if (next === existing) return existing;
if (next.startsWith(existing)) return next;
return existing + next;
};
const partial = '{"path":"a.ts","newString":" IF @areaId IS NOT NULL SELECT @hqId = HeadQuarterId';
const restart = '{"newString":" IF @areaId IS NOT NULL SELECT @hqId = HeadQuarterId FROM dbo.Areas WHERE Id = @areaId;"}';
const out = append(partial, restart);
try { JSON.parse(out); console.log("valid"); }
catch (e) { console.log("INVALID:", e.message); } // INVALIDrestart is a superset of the payload but not a string-prefix of partial, because the
key ordering differs. appendToolCallArgumentDelta therefore falls through to
existing + next and emits both copies.
Likely cause
open-sse/utils/toolCallArguments.ts already exists specifically to collapse snapshot-style
deltas, and its header comment cites the exact duplication hazard. It is wired into
translator/response/responsesTransformer.ts, translator/response/openai-to-claude.ts,
translator/response/openai-responses.ts, handlers/sseParser.ts, and executors/bedrock.ts
— i.e. the translator paths. It does not appear to be applied on the path that relays an
already-OpenAI-format upstream stream through to an OpenAI-format client, so a non-conformant
upstream that re-sends a full snapshot can reach the client as two concatenated fragments.
Separately, handlers/chatCore/toolCallSpecViolationAudit.ts already audits "duplicate
tool_calls entry" as a known upstream contract violation — this looks like the same class of
defect one layer earlier, on the arguments string rather than the whole call.
Two candidate contributing behaviours, both worth checking:
- A relay that forwards upstream
argumentsfragments verbatim without the collapse helper. - An abandoned attempt leaving its partial
argumentsbuffer in place, so the next attempt's fragments are appended to it. (Note:comboAttemptLoop.tsresetsstate.fallbackCountper set-try but the count is incremented elsewhere; the buffer-reset path is the thing to verify — resuming an attempt should discard, not extend, a partial argument buffer.)
Suggested fix
- Apply
appendToolCallArgumentDeltaon the client-facing relay path, not just the translator paths (or normalise upstream snapshot-style deltas into incremental deltas before relaying). - Reset the per-attempt
argumentsbuffer whenever an attempt is abandoned. - Consider a
JSON.parseassertion on the assembled arguments beforefinish_reason: "tool_calls"is emitted, as a guard against both cases.
Note on appendToolCallArgumentDelta itself
The existing next.startsWith(existing) rule is the right conservative choice — its comment
correctly warns that fuzzy overlap dedup silently truncates legitimate incremental deltas.
The gap here is coverage (where it is applied), not the rule. Key-order-changed snapshots are
inherently ambiguous from the fragment alone, so a per-attempt reset is likely the safer lever
than widening the string heuristic.
Test Impact
Needs a new unit test
Error Logs / Output
undefinedScreenshots
No response
Additional Context
No response
Validation Plan
No response
Activity
0No activity yet.