Compare commits

...

8 Commits

Author SHA1 Message Date
diegosouzapw 508a6d99f5 chore(release): bump version to v3.1.7 and fix SSE parsing bug
Build Electron Desktop App / Validate version (push) Failing after 34s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
Build Electron Desktop App / Publish to npm (push) Has been skipped
2026-03-27 15:17:13 -03:00
Paijo 63e42047e3 fix: hasValuableContent explicit boolean returns for SSE streaming (#676)
The hasValuableContent() function in streamHelpers.ts returned undefined
instead of explicit false when checking empty delta chunks. This caused
JavaScript type coercion issues where undefined !== '' evaluated to true,
passing empty chunks through to clients.

Fix: Replace implicit returns with explicit boolean returns using
typeof checks and length comparisons for all content fields (content,
reasoning_content, tool_calls, text, thinking, partial_json).

Test: Added unit tests covering OpenAI, Claude, and Gemini format edge cases.

Co-authored-by: oyi77 <oyi77@users.noreply.github.com>
2026-03-27 15:12:51 -03:00
diegosouzapw 13829de0d9 release: v3.1.6 — Claude tool name fix + Clear All Models alias cleanup
Build Electron Desktop App / Validate version (push) Failing after 35s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
Build Electron Desktop App / Publish to npm (push) Has been skipped
Changes:
- fix: restore native Claude tool names in passthrough responses (PR #663 by @coobabm)
- fix: Clear All Models button now also removes aliases (PR #664 by @rdself)
- fix: completed truncated test from PR #663, added Claude-to-Claude passthrough test
- docs: update CHANGELOG and OpenAPI spec
2026-03-27 06:23:52 -03:00
Diego Rodrigues de Sa e Souza ad7f570be5 Merge pull request #664 from rdself/fix/clear-all-models-button
fix: Clear All Models button now also removes aliases
2026-03-27 06:14:16 -03:00
Diego Rodrigues de Sa e Souza 9ba4f966db Merge pull request #663 from coobabm/codex/claude-native-tool-fix-push
Fix Claude native tool names for Claude Code
2026-03-27 06:14:12 -03:00
cai kerui e88d260acd Merge branch 'main' into codex/claude-native-tool-fix-push 2026-03-27 14:37:02 +09:00
R.D. 8121238872 fix: Clear All Models button now also removes associated aliases
The Clear All Models button was only deleting custom models from the
database but leaving their aliases intact, so the UI didn't reflect
the deletion. Now it also deletes all aliases belonging to the provider
and refreshes the alias state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 01:07:03 -04:00
cai kerui 161e377ec1 Fix Claude native tool names for Claude Code 2026-03-27 14:00:05 +09:00
11 changed files with 304 additions and 21 deletions
+16
View File
@@ -2,6 +2,22 @@
## [Unreleased]
## [3.1.7] - 2026-03-27
### 🐛 Bug Fixes
- **Streaming Stability:** Fixed `hasValuableContent` returning `undefined` for empty chunks in SSE streams (#676).
- **Tool Calling:** Fixed an issue in `sseParser.ts` where non-streaming Claude responses with multiple tool calls dropped the `id` of subsequent tool calls due to incorrect index-based deduplication (#671).
---
## [3.1.6] — 2026-03-27
### 🐛 Bug Fixes
- **Claude Native Tool Name Restoration** — Tool names like `TodoWrite` are no longer prefixed with `proxy_` in Claude passthrough responses (both streaming and non-streaming). Includes unit test coverage (PR #663 by @coobabm)
- **Clear All Models Alias Cleanup** — "Clear All Models" button now also removes associated model aliases, preventing ghost models in the UI (PR #664 by @rdself)
---
## [3.1.5] — 2026-03-27
+1 -1
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 3.1.5
version: 3.1.7
description: |
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
endpoint that routes requests to multiple AI providers with load balancing,
+60 -1
View File
@@ -32,6 +32,7 @@ import {
getModelUpstreamExtraHeaders,
} from "@/lib/localDb";
import { getExecutor } from "../executors/index.ts";
import { CLAUDE_OAUTH_TOOL_PREFIX } from "../translator/request/openai-to-claude.ts";
import {
parseCodexQuotaHeaders,
getCodexResetTime,
@@ -84,6 +85,51 @@ export function shouldUseNativeCodexPassthrough({
return segments.includes("responses");
}
function buildClaudePassthroughToolNameMap(body: Record<string, unknown> | null | undefined) {
if (!body || !Array.isArray(body.tools)) return null;
const toolNameMap = new Map<string, string>();
for (const tool of body.tools) {
const toolRecord = tool as Record<string, unknown>;
const toolData =
toolRecord?.type === "function" &&
toolRecord.function &&
typeof toolRecord.function === "object"
? (toolRecord.function as Record<string, unknown>)
: toolRecord;
const originalName = typeof toolData?.name === "string" ? toolData.name.trim() : "";
if (!originalName) continue;
toolNameMap.set(`${CLAUDE_OAUTH_TOOL_PREFIX}${originalName}`, originalName);
}
return toolNameMap.size > 0 ? toolNameMap : null;
}
function restoreClaudePassthroughToolNames(
responseBody: Record<string, unknown>,
toolNameMap: Map<string, string> | null
) {
if (!toolNameMap || !Array.isArray(responseBody?.content)) return responseBody;
let changed = false;
const content = responseBody.content.map((block: Record<string, unknown>) => {
if (block?.type !== "tool_use" || typeof block?.name !== "string") return block;
const restoredName = toolNameMap.get(block.name) ?? block.name;
if (restoredName === block.name) return block;
changed = true;
return {
...block,
name: restoredName,
};
});
if (!changed) return responseBody;
return {
...responseBody,
content,
};
}
/**
* Core chat handler - shared between SSE and Worker
* Returns { success, response, status, error } for caller to handle fallback
@@ -415,6 +461,7 @@ export async function handleChatCore({
FORMATS.OPENAI,
FORMATS.CLAUDE,
model,
{ ...translatedBody, _disableToolPrefix: true },
translatedBody,
stream,
credentials,
@@ -566,7 +613,14 @@ export async function handleChatCore({
}
// Extract toolNameMap for response translation (Claude OAuth)
const toolNameMap = translatedBody._toolNameMap;
const translatedToolNameMap = translatedBody._toolNameMap;
const nativeClaudeToolNameMap = isClaudePassthrough
? buildClaudePassthroughToolNameMap(body)
: null;
const toolNameMap =
translatedToolNameMap instanceof Map && translatedToolNameMap.size > 0
? translatedToolNameMap
: nativeClaudeToolNameMap;
delete translatedBody._toolNameMap;
delete translatedBody._disableToolPrefix;
@@ -1014,6 +1068,10 @@ export async function handleChatCore({
}
}
if (sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE) {
responseBody = restoreClaudePassthroughToolNames(responseBody, toolNameMap);
}
// Notify success - caller can clear error status if needed
if (onRequestSuccess) {
await onRequestSuccess();
@@ -1235,6 +1293,7 @@ export async function handleChatCore({
transformStream = createPassthroughStreamWithLogger(
provider,
reqLogger,
toolNameMap,
model,
connectionId,
body,
+1 -1
View File
@@ -36,8 +36,8 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
let usage = null;
const getToolCallKey = (toolCall: Record<string, unknown>) => {
if (toolCall?.id) return `id:${toolCall.id}`;
if (Number.isInteger(toolCall?.index)) return `idx:${toolCall.index}`;
if (toolCall?.id) return `id:${toolCall.id}`;
unknownToolCallSeq += 1;
return `seq:${unknownToolCallSeq}`;
};
+24
View File
@@ -72,6 +72,22 @@ function getOpenAIIntermediateChunks(value: unknown): unknown[] {
return Array.isArray(candidate) ? candidate : [];
}
function restoreClaudePassthroughToolUseName(parsed: JsonRecord, toolNameMap: unknown): boolean {
if (!(toolNameMap instanceof Map)) return false;
if (!parsed || typeof parsed !== "object") return false;
const block =
parsed.content_block && typeof parsed.content_block === "object"
? (parsed.content_block as JsonRecord)
: null;
if (!block || block.type !== "tool_use" || typeof block.name !== "string") return false;
const restoredName = toolNameMap.get(block.name) ?? block.name;
if (restoredName === block.name) return false;
block.name = restoredName;
return true;
}
// Note: TextDecoder/TextEncoder are created per-stream inside createSSEStream()
// to avoid shared state issues with concurrent streams (TextDecoder with {stream:true}
// maintains internal buffering state between decode() calls).
@@ -248,6 +264,7 @@ export function createSSEStream(options: StreamOptions = {}) {
if (eu.cache_creation_input_tokens)
u.cache_creation_input_tokens = eu.cache_creation_input_tokens;
}
const restoredToolName = restoreClaudePassthroughToolUseName(parsed, toolNameMap);
// Track content length and accumulate from Claude format
if (parsed.delta?.text) {
totalContentLength += parsed.delta.text.length;
@@ -257,6 +274,11 @@ export function createSSEStream(options: StreamOptions = {}) {
totalContentLength += parsed.delta.thinking.length;
passthroughAccumulatedContent += parsed.delta.thinking;
}
if (restoredToolName) {
output = `data: ${JSON.stringify(parsed)}
`;
injectedUsage = true;
}
} else {
// Chat Completions: full sanitization pipeline
parsed = sanitizeStreamingChunk(parsed);
@@ -771,6 +793,7 @@ export function createSSETransformStreamWithLogger(
export function createPassthroughStreamWithLogger(
provider: string | null = null,
reqLogger: StreamLogger | null = null,
toolNameMap: unknown = null,
model: string | null = null,
connectionId: string | null = null,
body: unknown = null,
@@ -781,6 +804,7 @@ export function createPassthroughStreamWithLogger(
mode: STREAM_MODE.PASSTHROUGH,
provider,
reqLogger,
toolNameMap,
model,
connectionId,
apiKeyInfo,
+17 -15
View File
@@ -39,26 +39,28 @@ export function parseSSELine(line) {
// Check if chunk has valuable content (not empty)
export function hasValuableContent(chunk, format) {
// OpenAI format
if (format === FORMATS.OPENAI && chunk.choices?.[0]?.delta) {
if (format === FORMATS.OPENAI) {
if (!chunk.choices?.[0]?.delta) return false;
const delta = chunk.choices[0].delta;
return (
(delta.content && delta.content !== "") ||
(delta.reasoning_content && delta.reasoning_content !== "") ||
(delta.tool_calls && delta.tool_calls.length > 0) ||
chunk.choices[0].finish_reason ||
delta.role
);
if (typeof delta.content === "string" && delta.content.length > 0) return true;
if (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0)
return true;
if (Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0) return true;
if (chunk.choices[0].finish_reason) return true;
if (typeof delta.role === "string" && delta.role.length > 0) return true;
return false;
}
// Claude format
if (format === FORMATS.CLAUDE) {
const isContentBlockDelta = chunk.type === "content_block_delta";
const hasText = chunk.delta?.text && chunk.delta.text !== "";
const hasThinking = chunk.delta?.thinking && chunk.delta.thinking !== "";
const hasInputJson = chunk.delta?.partial_json && chunk.delta.partial_json !== "";
if (isContentBlockDelta && !hasText && !hasThinking && !hasInputJson) {
return false;
if (isContentBlockDelta) {
const hasText = typeof chunk.delta?.text === "string" && chunk.delta.text.length > 0;
const hasThinking =
typeof chunk.delta?.thinking === "string" && chunk.delta.thinking.length > 0;
const hasInputJson =
typeof chunk.delta?.partial_json === "string" && chunk.delta.partial_json.length > 0;
if (!hasText && !hasThinking && !hasInputJson) return false;
}
return true;
}
@@ -73,7 +75,7 @@ export function hasValuableContent(chunk, format) {
if (!parts || parts.length === 0) return false;
// Filter out chunks where all parts have empty text
const hasContent = parts.some(
(p) => (p.text && p.text !== "") || p.functionCall || p.executableCode
(p) => (typeof p.text === "string" && p.text.length > 0) || p.functionCall || p.executableCode
);
return hasContent;
}
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.1.5",
"version": "3.1.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.1.5",
"version": "3.1.6",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "3.1.5",
"version": "3.1.7",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {
@@ -1553,7 +1553,19 @@ export default function ProviderDetailPage() {
{ method: "DELETE" }
);
if (res.ok) {
// Also delete all aliases that belong to this provider
const aliasEntries = Object.entries(modelAliases).filter(([, model]) =>
(model as string).startsWith(`${providerStorageAlias}/`)
);
await Promise.all(
aliasEntries.map(([alias]) =>
fetch(`/api/models/alias?alias=${encodeURIComponent(alias)}`, {
method: "DELETE",
}).catch(() => {})
)
);
await fetchProviderModelMeta();
await fetchAliases();
notify.success(t("clearAllModelsSuccess"));
} else {
notify.error(t("clearAllModelsFailed"));
@@ -0,0 +1,98 @@
import test from "node:test";
import assert from "node:assert/strict";
const { translateRequest } = await import("../../open-sse/translator/index.ts");
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
test("Claude native passthrough normalization keeps original tool names", () => {
const body = {
model: "claude-sonnet-4-6",
max_tokens: 64,
messages: [
{
role: "user",
content: [{ type: "text", text: "Write a todo item" }],
},
],
tools: [
{
name: "TodoWrite",
description: "Create or update a todo list",
input_schema: {
type: "object",
properties: {
todos: {
type: "array",
items: { type: "string" },
},
},
required: ["todos"],
},
},
],
};
const openaiBody = translateRequest(
FORMATS.CLAUDE,
FORMATS.OPENAI,
body.model,
structuredClone(body),
false,
null,
null,
null
);
// The translated body should have function wrappers with the original name
assert.ok(Array.isArray(openaiBody.tools), "tools should be an array");
const tool = openaiBody.tools[0];
assert.ok(tool.function, "tool should have a function wrapper");
assert.ok(
tool.function.name === "TodoWrite" || tool.function.name === "proxy_TodoWrite",
`tool name should be preserved or prefixed, got: ${tool.function.name}`
);
});
test("Claude-to-Claude passthrough should not alter tool names", () => {
const body = {
model: "claude-sonnet-4-6",
max_tokens: 64,
messages: [
{
role: "user",
content: [{ type: "text", text: "Write a todo item" }],
},
],
tools: [
{
name: "TodoWrite",
description: "Create or update a todo list",
input_schema: {
type: "object",
properties: {
todos: {
type: "array",
items: { type: "string" },
},
},
required: ["todos"],
},
},
],
};
const result = translateRequest(
FORMATS.CLAUDE,
FORMATS.CLAUDE,
body.model,
structuredClone(body),
false,
null,
null,
null
);
// Claude-to-Claude should preserve tool names
assert.ok(Array.isArray(result.tools), "tools should be an array");
assert.equal(result.tools[0].name, "TodoWrite", "tool name should stay unchanged for Claude-to-Claude");
});
+72
View File
@@ -0,0 +1,72 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { hasValuableContent } from "../../open-sse/utils/streamHelpers.ts";
import { FORMATS } from "../../open-sse/translator/formats.ts";
describe("hasValuableContent", () => {
describe("OpenAI format", () => {
it("returns true for content with text", () => {
const chunk = { choices: [{ delta: { content: "Hello" } }] };
assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), true);
});
it("returns false for empty delta", () => {
const chunk = { choices: [{ delta: {} }] };
assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), false);
});
it("returns false for delta with empty string content", () => {
const chunk = { choices: [{ delta: { content: "" } }] };
assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), false);
});
it("returns true for reasoning_content", () => {
const chunk = { choices: [{ delta: { reasoning_content: "thinking" } }] };
assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), true);
});
it("returns true for finish_reason", () => {
const chunk = { choices: [{ delta: {}, finish_reason: "stop" }] };
assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), true);
});
it("returns true for role delta", () => {
const chunk = { choices: [{ delta: { role: "assistant" } }] };
assert.strictEqual(hasValuableContent(chunk, FORMATS.OPENAI), true);
});
});
describe("Claude format", () => {
it("returns true for content_block_delta with text", () => {
const chunk = { type: "content_block_delta", delta: { text: "Hello" } };
assert.strictEqual(hasValuableContent(chunk, FORMATS.CLAUDE), true);
});
it("returns false for empty content_block_delta", () => {
const chunk = { type: "content_block_delta", delta: {} };
assert.strictEqual(hasValuableContent(chunk, FORMATS.CLAUDE), false);
});
it("returns true for thinking blocks", () => {
const chunk = { type: "content_block_delta", delta: { thinking: "reasoning" } };
assert.strictEqual(hasValuableContent(chunk, FORMATS.CLAUDE), true);
});
});
describe("Gemini format", () => {
it("returns true for content with text", () => {
const chunk = { candidates: [{ content: { parts: [{ text: "Hello" }] } }] };
assert.strictEqual(hasValuableContent(chunk, FORMATS.GEMINI), true);
});
it("returns false for empty parts", () => {
const chunk = { candidates: [{ content: { parts: [] } }] };
assert.strictEqual(hasValuableContent(chunk, FORMATS.GEMINI), false);
});
it("returns true for finishReason", () => {
const chunk = { candidates: [{ finishReason: "STOP" }] };
assert.strictEqual(hasValuableContent(chunk, FORMATS.GEMINI), true);
});
});
});