Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 508a6d99f5 | |||
| 63e42047e3 | |||
| 13829de0d9 | |||
| ad7f570be5 | |||
| 9ba4f966db | |||
| e88d260acd | |||
| 8121238872 | |||
| 161e377ec1 |
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -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
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user