mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Merge pull request #947 from diegosouzapw/release/v3.4.9
chore(release): v3.4.9
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -20,6 +20,7 @@ node_modules/
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
.data/
|
||||
|
||||
# testing
|
||||
coverage/
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
npx lint-staged
|
||||
node scripts/check-docs-sync.mjs
|
||||
npm run check:any-budget:t11
|
||||
npm run test:unit
|
||||
|
||||
23
CHANGELOG.md
23
CHANGELOG.md
@@ -4,6 +4,29 @@
|
||||
|
||||
---
|
||||
|
||||
## [3.4.9] — 2026-04-03
|
||||
|
||||
### Features & Refactoring
|
||||
|
||||
- **Dashboard Auto-Combo Panel:** Completely refactored the `/dashboard/auto-combo` UI to seamlessly integrate with native Dashboard Cards and standardized visual padding/headers. Added dynamic visual progress bars mapping model selection weight mechanisms.
|
||||
- **Settings Routing Sync:** Fully exposed advanced routing `priority` and `weighted` schema targets internally inside global settings fallback lists.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Memory & Skills Locale Nodes:** Resolved empty rendering tags for Memory and Skills options directly inside global settings views by wiring all `settings.*` mapping values internally into `en.json` (also mapped implicitly for cross-translation tools).
|
||||
|
||||
### Internal Integrations
|
||||
|
||||
- Integrated PR #946 — fix: preserve Claude Code compatibility in responses conversion
|
||||
- Integrated PR #944 — fix(gemini): preserve thought signatures across antigravity tool calls
|
||||
- Integrated PR #943 — fix: restore GitHub Copilot body
|
||||
- Integrated PR #942 — Fix cc-compatible cache markers
|
||||
- Integrated PR #941 — refactor(auth): improve NVIDIA alias lookup + add LKGP error logging
|
||||
- Integrated PR #939 — Restore Claude OAuth localhost callback handling
|
||||
- _(Note: PR #934 was omitted from 3.4.9 cycle to prevent core conflict regressions)_
|
||||
|
||||
---
|
||||
|
||||
## [3.4.8] — 2026-04-03
|
||||
|
||||
### Security
|
||||
|
||||
@@ -34,9 +34,9 @@ function ask(question) {
|
||||
return new Promise((resolve) => rl.question(question, resolve));
|
||||
}
|
||||
|
||||
function hashPassword(password) {
|
||||
function generateSecretDigest(input) {
|
||||
return createHash("sha256")
|
||||
.update(password) /* lgtm[js/insufficient-password-hash] */
|
||||
.update(input) /* lgtm[js/insufficient-password-hash] */
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ async function main() {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const hashed = hashPassword(password);
|
||||
const hashed = generateSecretDigest(password);
|
||||
|
||||
// Upsert the password
|
||||
const stmt = db.prepare(`
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 3.4.8
|
||||
version: 3.4.9
|
||||
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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute-desktop",
|
||||
"version": "3.4.8",
|
||||
"version": "3.4.9",
|
||||
"description": "OmniRoute Desktop Application",
|
||||
"main": "main.js",
|
||||
"author": {
|
||||
|
||||
@@ -232,7 +232,6 @@ export class AntigravityExecutor extends BaseExecutor {
|
||||
let timedOut = false;
|
||||
const timeout = AbortSignal.timeout(SSE_COLLECT_TIMEOUT_MS);
|
||||
try {
|
||||
|
||||
while (true) {
|
||||
if (signal?.aborted) throw new Error("Request aborted during SSE collection");
|
||||
const { done, value } = await Promise.race([
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@omniroute/open-sse",
|
||||
"version": "3.4.8",
|
||||
"version": "3.4.9",
|
||||
"description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration",
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { PROVIDERS, OAUTH_ENDPOINTS } from "../config/constants.ts";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
// Token expiry buffer (refresh if expires within 5 minutes)
|
||||
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
||||
|
||||
const CACHE_SECRET = "omniroute-token-cache";
|
||||
|
||||
// In-flight refresh promise cache to prevent race conditions
|
||||
// Key: "provider:sha256(refreshToken)" → Value: Promise<result>
|
||||
const refreshPromiseCache = new Map();
|
||||
|
||||
function getRefreshCacheKey(provider, refreshToken) {
|
||||
const tokenHash = createHash("sha256")
|
||||
.update(refreshToken) /* lgtm[js/insufficient-password-hash] */
|
||||
.digest("hex");
|
||||
const tokenHash = createHmac("sha256", CACHE_SECRET).update(refreshToken).digest("hex");
|
||||
return `${provider}:${tokenHash}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
import { register } from "../registry.ts";
|
||||
import { FORMATS } from "../formats.ts";
|
||||
|
||||
function normalizeToolName(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate OpenAI chunk to Responses API events
|
||||
* @returns {Array} Array of events with { event, data } structure
|
||||
@@ -490,6 +494,16 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||
if (eventType === "response.output_item.added" && data.item?.type === "function_call") {
|
||||
const item = data.item;
|
||||
state.currentToolCallId = item.call_id || `call_${Date.now()}`;
|
||||
state.currentToolCallArgsBuffer = ""; // reset per-call arg buffer
|
||||
state.currentToolCallDeferred = false;
|
||||
|
||||
const toolName = normalizeToolName(item.name);
|
||||
if (!toolName) {
|
||||
// Some Responses providers briefly emit placeholder/empty tool names.
|
||||
// Defer emission until output_item.done in case the final name is populated there.
|
||||
state.currentToolCallDeferred = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: state.chatId,
|
||||
@@ -506,7 +520,7 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||
id: state.currentToolCallId,
|
||||
type: "function",
|
||||
function: {
|
||||
name: item.name || "",
|
||||
name: toolName,
|
||||
arguments: "",
|
||||
},
|
||||
},
|
||||
@@ -526,6 +540,9 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||
const argsDelta = data.delta || "";
|
||||
if (!argsDelta) return null;
|
||||
|
||||
state.currentToolCallArgsBuffer = (state.currentToolCallArgsBuffer || "") + argsDelta;
|
||||
if (state.currentToolCallDeferred) return null;
|
||||
|
||||
return {
|
||||
id: state.chatId,
|
||||
object: "chat.completion.chunk",
|
||||
@@ -548,9 +565,93 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
|
||||
};
|
||||
}
|
||||
|
||||
// Function call done
|
||||
// Function call done — emit args chunk from item.arguments when no deltas were received,
|
||||
// then advance the tool-call index. This handles Codex Responses API payloads that
|
||||
// carry the complete arguments only in output_item.done (no preceding delta events).
|
||||
if (eventType === "response.output_item.done" && data.item?.type === "function_call") {
|
||||
const item = data.item;
|
||||
const buffered = state.currentToolCallArgsBuffer || "";
|
||||
const currentIndex = state.toolCallIndex; // capture before increment
|
||||
const callId = item.call_id || state.currentToolCallId || `call_${Date.now()}`;
|
||||
const toolName = normalizeToolName(item.name);
|
||||
|
||||
if (state.currentToolCallDeferred) {
|
||||
state.currentToolCallDeferred = false;
|
||||
state.currentToolCallArgsBuffer = "";
|
||||
state.currentToolCallId = null;
|
||||
|
||||
if (!toolName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
state.toolCallIndex++;
|
||||
|
||||
const argsStr =
|
||||
item.arguments != null
|
||||
? typeof item.arguments === "string"
|
||||
? item.arguments
|
||||
: JSON.stringify(item.arguments)
|
||||
: buffered;
|
||||
|
||||
return {
|
||||
id: state.chatId,
|
||||
object: "chat.completion.chunk",
|
||||
created: state.created,
|
||||
model: state.model || "gpt-4",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: currentIndex,
|
||||
id: callId,
|
||||
type: "function",
|
||||
function: {
|
||||
name: toolName,
|
||||
arguments: argsStr || "",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
state.toolCallIndex++;
|
||||
state.currentToolCallArgsBuffer = ""; // reset for next tool call
|
||||
state.currentToolCallId = null;
|
||||
|
||||
// Only emit if arguments exist in the done event AND they weren't already streamed via deltas
|
||||
if (item.arguments != null && !buffered) {
|
||||
const argsStr =
|
||||
typeof item.arguments === "string" ? item.arguments : JSON.stringify(item.arguments);
|
||||
if (argsStr) {
|
||||
return {
|
||||
id: state.chatId,
|
||||
object: "chat.completion.chunk",
|
||||
created: state.created,
|
||||
model: state.model || "gpt-4",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
tool_calls: [
|
||||
{
|
||||
index: currentIndex,
|
||||
function: { arguments: argsStr },
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -591,7 +591,10 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
// Content for call log is accumulated only from parsed (above) to avoid double-counting;
|
||||
// do not add again from item here.
|
||||
|
||||
// #723, #727: Sanitize intermediate stream chunks if source (client) is OpenAI format
|
||||
// #723, #727: Sanitize only when the client-facing stream is OpenAI Chat format.
|
||||
// When translating Responses -> Claude, `item` is already a Claude SSE event;
|
||||
// sanitizing it as an OpenAI chunk strips message_start/content_block_delta/message_stop
|
||||
// and causes Claude Code to drop the assistant message.
|
||||
let itemSanitized: Record<string, unknown> = item;
|
||||
if (sourceFormat === FORMATS.OPENAI || sourceFormat === FORMATS.OPENAI_RESPONSES) {
|
||||
itemSanitized = sanitizeStreamingChunk(itemSanitized) as Record<string, unknown>;
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.4.8",
|
||||
"version": "3.4.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "3.4.8",
|
||||
"version": "3.4.9",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -21047,7 +21047,7 @@
|
||||
},
|
||||
"open-sse": {
|
||||
"name": "@omniroute/open-sse",
|
||||
"version": "3.4.8"
|
||||
"version": "3.4.9"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.4.8",
|
||||
"version": "3.4.9",
|
||||
"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": {
|
||||
|
||||
@@ -110,7 +110,10 @@ for (const item of budget) {
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(absolutePath, "utf8");
|
||||
const matches = content.match(anyRegex);
|
||||
// Remove block and line comments to avoid false positives with the word "any" in comments
|
||||
let cleanContent = content.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
cleanContent = cleanContent.replace(/\/\/.*$/gm, "");
|
||||
const matches = cleanContent.match(anyRegex);
|
||||
const count = matches ? matches.length : 0;
|
||||
const status = count <= item.maxAny ? "OK" : "FAIL";
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { Card } from "@/shared/components";
|
||||
|
||||
interface ProviderScore {
|
||||
provider: string;
|
||||
@@ -152,102 +153,183 @@ export default function AutoComboDashboard() {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-7xl mx-auto">
|
||||
<h1 className="text-2xl font-bold mb-6">⚡ Auto-Combo Engine</h1>
|
||||
|
||||
{/* Status Bar */}
|
||||
<div className="flex gap-4 mb-6">
|
||||
<div
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium ${incidentMode ? "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300" : "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300"}`}
|
||||
>
|
||||
{incidentMode ? "🚨 INCIDENT MODE" : "✅ Normal"}
|
||||
</div>
|
||||
<div className="px-3 py-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg text-sm">
|
||||
Mode: <strong>{MODE_PACKS.find((m) => m.id === modePack)?.label || modePack}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode Pack Selector */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold mb-3">🎛️ Mode Pack</h2>
|
||||
<div className="flex gap-2">
|
||||
{MODE_PACKS.map((mp) => (
|
||||
<button
|
||||
key={mp.id}
|
||||
onClick={() => setModePack(mp.id)}
|
||||
className={`px-4 py-2 rounded-lg text-sm transition-colors ${
|
||||
modePack === mp.id
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-100 dark:bg-gray-800 hover:bg-gray-200 dark:hover:bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
{mp.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider Scores */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-lg font-semibold mb-3">📊 Provider Scores</h2>
|
||||
{scores.length === 0 ? (
|
||||
<p className="text-gray-500">
|
||||
No auto-combo configured. Create one via <code>POST /api/combos/auto</code>.
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">⚡ Auto-Combo Engine</h1>
|
||||
<p className="text-sm text-text-muted mt-1">
|
||||
Smart routing automatically adapting to latency, health, and throughput
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{scores.map((s) => (
|
||||
<div key={s.provider} className="p-3 bg-white dark:bg-gray-800 rounded-lg border">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-medium">
|
||||
{s.provider} / {s.model}
|
||||
</span>
|
||||
<span className="font-bold text-lg">{(s.score * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
{/* Score Bar */}
|
||||
<div className="h-2 bg-gray-200 dark:bg-gray-700 rounded overflow-hidden mb-2">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded"
|
||||
style={{ width: `${s.score * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
{/* Factor Breakdown */}
|
||||
<div className="grid grid-cols-3 gap-1 text-xs text-gray-500">
|
||||
{Object.entries(s.factors || {}).map(([key, val]) => (
|
||||
<span key={key}>
|
||||
{FACTOR_LABELS[key] || key}: {((val as number) * 100).toFixed(0)}%
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exclusions */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-3">🚫 Excluded Providers</h2>
|
||||
{exclusions.length === 0 ? (
|
||||
<p className="text-gray-500">No providers currently excluded.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{exclusions.map((e) => (
|
||||
<Card>
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold mb-4">Status Overview</h2>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div
|
||||
key={e.provider}
|
||||
className="p-3 bg-red-50 dark:bg-red-900/10 rounded border border-red-200 dark:border-red-800"
|
||||
className={`p-4 rounded-lg border flex items-center justify-between ${
|
||||
incidentMode
|
||||
? "bg-red-500/10 border-red-500/30 text-red-700 dark:text-red-300"
|
||||
: "bg-green-500/10 border-green-500/30 text-green-700 dark:text-green-300"
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<span className="font-medium text-red-700 dark:text-red-400">{e.provider}</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
Cooldown: {Math.round(e.cooldownMs / 60000)}min
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-[24px]">
|
||||
{incidentMode ? "warning" : "check_circle"}
|
||||
</span>
|
||||
<div>
|
||||
<h3 className="font-semibold">
|
||||
{incidentMode ? "Incident Mode" : "Normal Operation"}
|
||||
</h3>
|
||||
<p className="text-sm opacity-80">
|
||||
{incidentMode
|
||||
? "High circuit breaker trip rate detected"
|
||||
: "All providers reporting healthy metrics"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-1">{e.reason}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="p-4 rounded-lg border border-border/50 bg-surface/30 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-[24px] text-blue-500">tune</span>
|
||||
<div>
|
||||
<h3 className="font-semibold">Active Mode Pack</h3>
|
||||
<p className="text-sm text-text-muted">
|
||||
{MODE_PACKS.find((m) => m.id === modePack)?.label || modePack}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
<h2 className="text-lg font-semibold mb-4">Mode Pack</h2>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{MODE_PACKS.map((mp) => (
|
||||
<button
|
||||
key={mp.id}
|
||||
onClick={() => setModePack(mp.id)}
|
||||
className={`flex flex-col items-start p-3 rounded-lg border transition-all ${
|
||||
modePack === mp.id
|
||||
? "border-blue-500/50 bg-blue-500/5 ring-1 ring-blue-500/20"
|
||||
: "border-border/50 hover:border-border hover:bg-surface/30"
|
||||
}`}
|
||||
>
|
||||
<span className={`font-medium ${modePack === mp.id ? "text-blue-500" : ""}`}>
|
||||
{mp.label}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
leaderboard
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">Provider Scores</h3>
|
||||
</div>
|
||||
|
||||
{scores.length === 0 ? (
|
||||
<p className="text-sm text-text-muted py-4">
|
||||
No auto-combo configured or data loading... Create one via{" "}
|
||||
<code>POST /api/combos/auto</code>.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{scores.map((s) => (
|
||||
<div
|
||||
key={s.provider}
|
||||
className="p-3 bg-surface/30 rounded-lg border border-border/50"
|
||||
>
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="font-medium text-sm">
|
||||
{s.provider} / {s.model}
|
||||
</span>
|
||||
<span className="font-bold text-lg text-blue-500">
|
||||
{(s.score * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
{/* Score Bar */}
|
||||
<div className="h-1.5 bg-border/50 rounded-full overflow-hidden mb-3">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all duration-1000"
|
||||
style={{ width: `${s.score * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
{/* Factor Breakdown */}
|
||||
<div className="flex flex-wrap gap-2 text-[11px] text-text-muted">
|
||||
{Object.entries(s.factors || {}).map(([key, val]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="px-2 py-0.5 rounded-full bg-black/5 dark:bg-white/5 border border-border/30"
|
||||
>
|
||||
{FACTOR_LABELS[key] || key}:{" "}
|
||||
<span className="font-medium text-text-main">
|
||||
{((val as number) * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-red-500/10 text-red-500">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
block
|
||||
</span>
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">Excluded Providers</h3>
|
||||
</div>
|
||||
|
||||
{exclusions.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-text-muted">
|
||||
<span className="material-symbols-outlined text-[32px] mb-2 text-border">
|
||||
verified
|
||||
</span>
|
||||
<p className="text-sm">No providers currently excluded.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{exclusions.map((e) => (
|
||||
<div
|
||||
key={e.provider}
|
||||
className="p-3 bg-red-500/5 rounded-lg border border-red-500/20"
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-red-600 dark:text-red-400">
|
||||
{e.provider}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs px-2 py-0.5 rounded bg-red-500/10 text-red-600 dark:text-red-400 font-medium">
|
||||
Cooldown: {Math.round(e.cooldownMs / 60000)}m
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-1.5 flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[12px]">info</span>
|
||||
{e.reason}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4270,9 +4270,9 @@ function ConnectionRow({
|
||||
{connection.lastError && connection.isActive !== false && (
|
||||
<span
|
||||
className={`text-xs truncate max-w-[300px] ${statusPresentation.errorTextClass}`}
|
||||
title={connection.lastError.replace(/<[^>]*>?/gm, "")}
|
||||
title={connection.lastError.replace(/<[^>]+>/gm, "")}
|
||||
>
|
||||
{connection.lastError.replace(/<[^>]*>?/gm, "")}
|
||||
{connection.lastError.replace(/<[^>]+>/gm, "")}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-text-muted">#{connection.priority}</span>
|
||||
|
||||
@@ -1800,6 +1800,25 @@
|
||||
"enablePassword": "Enable Password",
|
||||
"darkMode": "Dark Mode",
|
||||
"lightMode": "Light Mode",
|
||||
"memoryTitle": "Memory",
|
||||
"memoryDesc": "Persistent cross-session conversational memory",
|
||||
"memoryEnabled": "Enable Memory",
|
||||
"memoryEnabledDesc": "When enabled, OmniRoute will inject relevant past context.",
|
||||
"maxTokens": "Max Tokens",
|
||||
"retentionDays": "Retention",
|
||||
"recent": "Recent",
|
||||
"recentDesc": "Chronological window",
|
||||
"semantic": "Semantic",
|
||||
"semanticDesc": "Vector search",
|
||||
"hybrid": "Hybrid",
|
||||
"hybridDesc": "Recent + Semantic",
|
||||
"skillsTitle": "Skills",
|
||||
"skillsDesc": "Tools for models",
|
||||
"skillsEnabled": "Enable Skills",
|
||||
"skillsEnabledDesc": "Allows agents to trigger functions.",
|
||||
"skillsComingSoon": "Skills marketplace coming soon.",
|
||||
"memorySkillsTitle": "Memory & Skills",
|
||||
"memorySkillsDesc": "Persistent context & capabilities",
|
||||
"systemTheme": "System Theme",
|
||||
"debugToggle": "Enable Debug Mode",
|
||||
"sidebarVisibilityToggle": "Show Sidebar Items",
|
||||
|
||||
@@ -84,6 +84,8 @@ export const ROUTING_STRATEGIES: RoutingStrategyOption[] = [
|
||||
];
|
||||
|
||||
export const SETTINGS_FALLBACK_STRATEGY_VALUES: RoutingStrategyValue[] = [
|
||||
"priority",
|
||||
"weighted",
|
||||
"fill-first",
|
||||
"round-robin",
|
||||
"p2c",
|
||||
|
||||
195
tests/unit/claude-code-rendering-fixes.test.mjs
Normal file
195
tests/unit/claude-code-rendering-fixes.test.mjs
Normal file
@@ -0,0 +1,195 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { openaiResponsesToOpenAIResponse } = await import(
|
||||
"../../open-sse/translator/response/openai-responses.ts"
|
||||
);
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
const { createSSETransformStreamWithLogger } = await import("../../open-sse/utils/stream.ts");
|
||||
|
||||
test("Responses->Chat: output_item.done emits arguments when no delta chunks were sent", () => {
|
||||
const state = {
|
||||
started: true,
|
||||
chatId: "chatcmpl-test",
|
||||
created: 1234567890,
|
||||
toolCallIndex: 0,
|
||||
finishReasonSent: false,
|
||||
currentToolCallId: "call_abc",
|
||||
currentToolCallArgsBuffer: "",
|
||||
};
|
||||
|
||||
const chunk = {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
call_id: "call_abc",
|
||||
name: "search_tasks",
|
||||
status: "completed",
|
||||
arguments: '{"query":"select:TaskCreate,TaskUpdate","max_results":10}',
|
||||
},
|
||||
};
|
||||
|
||||
const result = openaiResponsesToOpenAIResponse(chunk, state);
|
||||
|
||||
assert.ok(result);
|
||||
assert.equal(
|
||||
result.choices[0].delta.tool_calls[0].function.arguments,
|
||||
'{"query":"select:TaskCreate,TaskUpdate","max_results":10}'
|
||||
);
|
||||
assert.equal(state.toolCallIndex, 1);
|
||||
});
|
||||
|
||||
test("Responses->Chat: output_item.done does not re-emit arguments already streamed via deltas", () => {
|
||||
const state = {
|
||||
started: true,
|
||||
chatId: "chatcmpl-test",
|
||||
created: 1234567890,
|
||||
toolCallIndex: 0,
|
||||
finishReasonSent: false,
|
||||
currentToolCallId: "call_abc",
|
||||
currentToolCallArgsBuffer: '{"query":"search"}',
|
||||
};
|
||||
|
||||
const chunk = {
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
call_id: "call_abc",
|
||||
name: "search",
|
||||
status: "completed",
|
||||
arguments: '{"query":"search"}',
|
||||
},
|
||||
};
|
||||
|
||||
const result = openaiResponsesToOpenAIResponse(chunk, state);
|
||||
|
||||
assert.equal(result, null);
|
||||
assert.equal(state.toolCallIndex, 1);
|
||||
});
|
||||
|
||||
test("Responses->Chat: empty-name tool call is deferred until done provides a valid name", () => {
|
||||
const state = {
|
||||
started: true,
|
||||
chatId: "chatcmpl-test",
|
||||
created: 1234567890,
|
||||
toolCallIndex: 0,
|
||||
finishReasonSent: false,
|
||||
currentToolCallArgsBuffer: "",
|
||||
currentToolCallDeferred: false,
|
||||
};
|
||||
|
||||
const added = openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", call_id: "call_deferred", name: " " },
|
||||
},
|
||||
state
|
||||
);
|
||||
assert.equal(added, null);
|
||||
|
||||
const delta = openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.function_call_arguments.delta",
|
||||
delta: '{"query":"deferred"}',
|
||||
},
|
||||
state
|
||||
);
|
||||
assert.equal(delta, null);
|
||||
|
||||
const done = openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
call_id: "call_deferred",
|
||||
name: "search_tasks",
|
||||
arguments: '{"query":"deferred"}',
|
||||
},
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
assert.ok(done);
|
||||
assert.equal(done.choices[0].delta.tool_calls[0].function.name, "search_tasks");
|
||||
assert.equal(done.choices[0].delta.tool_calls[0].function.arguments, '{"query":"deferred"}');
|
||||
});
|
||||
|
||||
test("Responses->Chat: empty-name tool call is dropped when done still has no valid name", () => {
|
||||
const state = {
|
||||
started: true,
|
||||
chatId: "chatcmpl-test",
|
||||
created: 1234567890,
|
||||
toolCallIndex: 0,
|
||||
finishReasonSent: false,
|
||||
currentToolCallArgsBuffer: "",
|
||||
currentToolCallDeferred: false,
|
||||
};
|
||||
|
||||
openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "function_call", call_id: "call_empty", name: "" },
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
const done = openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
call_id: "call_empty",
|
||||
name: " ",
|
||||
arguments: '{"ignored":true}',
|
||||
},
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
assert.equal(done, null);
|
||||
assert.equal(state.toolCallIndex, 0);
|
||||
});
|
||||
|
||||
test("Responses->Claude: translated Claude SSE is not sanitized into empty OpenAI chunks", async () => {
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
const stream = createSSETransformStreamWithLogger(
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
FORMATS.CLAUDE,
|
||||
"codex",
|
||||
null,
|
||||
null,
|
||||
"gpt-5.4",
|
||||
"conn-test",
|
||||
{ messages: [{ role: "user", content: "hi" }] },
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
const writer = stream.writable.getWriter();
|
||||
await writer.write(
|
||||
encoder.encode('data: {"type":"response.output_text.delta","delta":"hello"}\n\n')
|
||||
);
|
||||
await writer.write(
|
||||
encoder.encode(
|
||||
'data: {"type":"response.completed","response":{"usage":{"input_tokens":12,"output_tokens":3}}}\n\n'
|
||||
)
|
||||
);
|
||||
await writer.close();
|
||||
|
||||
const reader = stream.readable.getReader();
|
||||
let output = "";
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) break;
|
||||
output += decoder.decode(value, { stream: true });
|
||||
}
|
||||
output += decoder.decode();
|
||||
|
||||
assert.match(output, /event: message_start/);
|
||||
assert.match(output, /event: content_block_start/);
|
||||
assert.match(output, /event: content_block_delta/);
|
||||
assert.match(output, /event: message_delta/);
|
||||
assert.match(output, /event: message_stop/);
|
||||
assert.doesNotMatch(output, /data: \{"object":"chat\.completion\.chunk"\}\n\n/);
|
||||
});
|
||||
Reference in New Issue
Block a user