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 @@ -15,6 +15,7 @@ _In development — bullets added per PR; finalized at release._
### 🐛 Fixed

- **fix(embeddings):** forward output dimensions to Gemini for consistent embedding dims. (thanks @nguyenha935)
- **fix(translator):** sanitize Read tool args from non-Anthropic models to prevent retry loops. (thanks @GodrezJr2)
- **fix(combo): round-robin members fail over faster under concurrency saturation via a configurable queue depth** — when a round-robin combo member was saturated, requests sat in the per-model semaphore's **unbounded** queue and only failed over to the next member after the full `queueTimeoutMs` (default 30s) elapsed — so a burst of agentic requests deep-queued one hot member instead of spilling to healthy ones. The per-model semaphore now accepts a bounded queue depth and emits `SEMAPHORE_QUEUE_FULL` once it is full (the round-robin loop already cascades on that code), so a configured low depth fails over immediately. A new `queueDepth` combo-config knob (global default / provider override / per-combo, default **20** for backward compatibility; **0** = never queue → fail over now) is exposed in Settings → Combo Defaults. ([#3872](https://github.com/diegosouzapw/OmniRoute/issues/3872) — thanks @KooshaPari)

---
Expand Down
47 changes: 41 additions & 6 deletions open-sse/translator/helpers/toolCallShim.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,51 @@ function coerceToArray(v: unknown): unknown[] {
return [];
}

// Claude Code's Read tool caps `limit` at 2000 lines per call. Non-Anthropic models
// (GPT-5.5, DeepSeek …) occasionally emit absurd values (e.g. `limit: 25999999999999999`)
// that Claude Code rejects, causing a retry loop that wastes tokens. Clamp here.
const READ_MAX_LIMIT = 2000;

// `pages` is only meaningful for PDFs and only as `"N"` or `"N-M"` (1-based).
// Reference: claude-code-tools docs + upstream decolua/9router#1144.
function isValidPdfPagesArg(filePath: unknown, pages: unknown): boolean {
return (
typeof filePath === "string" &&
filePath.toLowerCase().endsWith(".pdf") &&
typeof pages === "string" &&
/^\d+(?:-\d+)?$/.test(pages)
);
}

function sanitizeReadArgs(args: Record<string, unknown>): void {
// Coerce numeric-string limit/offset (some non-Anthropic models stringify everything).
if (typeof args.limit === "string" && /^\d+$/.test(args.limit)) {
args.limit = Number(args.limit);
}
if (typeof args.offset === "string" && /^-?\d+$/.test(args.offset)) {
args.offset = Number(args.offset);
}

if (typeof args.limit === "number") {
if (args.limit > READ_MAX_LIMIT) args.limit = READ_MAX_LIMIT;
if (args.limit < 1) delete args.limit;
}
if (typeof args.offset === "number" && args.offset < 0) args.offset = 0;

if ("pages" in args && !isValidPdfPagesArg(args.file_path, args.pages)) {
delete args.pages;
}
}

const TOOL_SHIMS: Record<string, ShimFn> = {
// Claude Code Read accepts `pages` only for PDFs and rejects an empty string.
// Some non-Anthropic models emit optional `pages: ""` for ordinary files.
// Buffer and emit one cleaned JSON delta so the client never sees the bad field.
// Claude Code Read rejects bad params and retries — wasting tokens with non-Anthropic
// models that emit oversized limits, negative offsets, stringified numbers, or stray
// `pages` on non-PDF files. Buffer and emit one cleaned JSON delta so the client never
// sees the bad fields. See `sanitizeReadArgs` for the per-field rules.
Read: (input) => {
if (typeof input !== "object" || input === null || Array.isArray(input)) return input;
const patched = { ...(input as Record<string, unknown>) };
if (patched.pages === "" || (Array.isArray(patched.pages) && patched.pages.length === 0)) {
delete patched.pages;
}
sanitizeReadArgs(patched);
return patched;
},
submit_pr_review: (input) => {
Expand Down
90 changes: 90 additions & 0 deletions tests/unit/translator-tool-call-shim.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,96 @@ test("applyToolCallShimToBuffer: Read removes empty pages but preserves valid ra
assert.deepEqual(withValidPages, { file_path: "/tmp/a.pdf", pages: "1-5" });
});

// Port of decolua/9router#1144: non-Anthropic models (GPT-5.5, DeepSeek …) sometimes
// emit absurd Read-tool args (e.g. limit: 99999999999) that Claude Code rejects and
// retries, wasting tokens. The shim clamps/normalizes those args before re-emitting.
test("applyToolCallShimToBuffer: Read clamps limit to 2000 (non-Anthropic models)", () => {
const out = JSON.parse(
applyToolCallShimToBuffer(
"Read",
JSON.stringify({ file_path: "/etc/hosts", limit: 99999999999 })
)
);
assert.equal(out.limit, 2000);
});

test("applyToolCallShimToBuffer: Read drops non-positive limit", () => {
const zero = JSON.parse(
applyToolCallShimToBuffer("Read", JSON.stringify({ file_path: "/etc/hosts", limit: 0 }))
);
assert.equal("limit" in zero, false);

const negative = JSON.parse(
applyToolCallShimToBuffer("Read", JSON.stringify({ file_path: "/etc/hosts", limit: -50 }))
);
assert.equal("limit" in negative, false);
});

test("applyToolCallShimToBuffer: Read clamps negative offset to 0", () => {
const out = JSON.parse(
applyToolCallShimToBuffer("Read", JSON.stringify({ file_path: "/etc/hosts", offset: -5 }))
);
assert.equal(out.offset, 0);
});

test("applyToolCallShimToBuffer: Read coerces numeric-string limit/offset", () => {
const out = JSON.parse(
applyToolCallShimToBuffer(
"Read",
JSON.stringify({ file_path: "/etc/hosts", limit: "100", offset: "5" })
)
);
assert.equal(out.limit, 100);
assert.equal(out.offset, 5);
});

test("applyToolCallShimToBuffer: Read strips pages for non-PDF files", () => {
const out = JSON.parse(
applyToolCallShimToBuffer(
"Read",
JSON.stringify({ file_path: "/etc/hosts", pages: "1-3" })
)
);
assert.equal("pages" in out, false);
});

test("applyToolCallShimToBuffer: Read strips malformed pages even on PDFs", () => {
const out = JSON.parse(
applyToolCallShimToBuffer(
"Read",
JSON.stringify({ file_path: "/tmp/doc.pdf", pages: "abc" })
)
);
assert.equal("pages" in out, false);
});

test("applyToolCallShimToBuffer: Read accepts a single page on PDFs", () => {
const out = JSON.parse(
applyToolCallShimToBuffer(
"Read",
JSON.stringify({ file_path: "/tmp/doc.PDF", pages: "7" })
)
);
assert.equal(out.pages, "7");
});

test("applyToolCallShimToBuffer: Read combined absurd args from non-Anthropic model", () => {
// Simulates the upstream issue exactly: GPT-5.5-style giant limit, negative offset,
// and a stray empty-string pages on a non-PDF.
const out = JSON.parse(
applyToolCallShimToBuffer(
"Read",
JSON.stringify({
file_path: "F:/repo/file.js",
offset: -5,
limit: 25999999999999999,
pages: "",
})
)
);
assert.deepEqual(out, { file_path: "F:/repo/file.js", offset: 0, limit: 2000 });
});

test("applyToolCallShimToBuffer: submit_pr_review with valid arrays preserved", () => {
const raw = JSON.stringify({
summary: "ok",
Expand Down
Loading