Merge pull request #1102 from diegosouzapw/release/v3.5.9

Release v3.5.9 merged to main
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-04-09 22:22:28 -03:00
committed by GitHub
32 changed files with 1077 additions and 109 deletions

View File

@@ -4,6 +4,23 @@
---
## [3.5.9] — 2026-04-09
### ✨ New Features
- **Persistent Combo Ordering:** Drag combo cards by handle to reorder them in the dashboard; order is persisted to SQLite via a new `sort_order` column and `POST /api/combos/reorder` endpoint. Includes DB migration `020_combo_sort_order.sql` and JSON import preservation (#1095)
- **Sidebar Group Reorder:** Moved "Logs" before "Health" in the System section and "Limits & Quotas" after "Cache" in the Primary section for a more logical navigation flow (#1095)
### 🐛 Bug Fixes
- **Stream Failure Surfacing:** Upstream `response.failed` events (e.g. Codex rate-limit errors) are now properly surfaced as non-200 errors instead of being silently swallowed as empty 200 OK streams. Rate-limit failures return HTTP 429 (#1098, closes #1093)
- **Upstream Model Preservation:** The Responses-to-OpenAI stream translator now preserves the actual upstream model (e.g. `gpt-5.4`) instead of hardcoding a `gpt-4` fallback (#1098, closes #1094)
- **Docker EXDEV Fix:** `build-next-isolated.mjs` now falls back from `fs.rename()` to `cp/rm` when Docker buildx raises `EXDEV` (cross-device link), unblocking the Docker image publish workflow (#1097)
- **macOS CLI Path Resolution:** `cliRuntime.ts` resolves symlink parents with `fs.realpath()` to handle macOS `/var``/private/var` chains, preventing false `symlink_escape` rejections (#1097)
- **Request Log Token Layout:** Split token badges into separate Input (Total In, Cache Read, Cache Write) and Output (Total Out, Reasoning) groups for clearer readability; renamed "Time" label to "Completed Time" (#1096)
---
## [3.5.8] — 2026-04-09
### ✨ New Features & Analytics

View File

@@ -486,6 +486,7 @@ Developers who want all responses in a specific language, with a specific tone,
- **9 Routing Strategies** — Global strategies that determine how requests are distributed
- **Wildcard Router** — `provider/*` patterns route dynamically to any provider
- **Combo Enable/Disable Toggle** — Toggle combos directly from the dashboard
- **Manual Combo Ordering** — Drag combo cards by handle and persist the order in SQLite
- **Provider Toggle** — Enable/disable all connections for a provider with one click
- **Blocked Providers** — Exclude specific providers from `/v1/models` listing

View File

@@ -73,7 +73,7 @@ Main pages under `src/app/(dashboard)/dashboard/`:
- `/dashboard` — quick start + provider overview
- `/dashboard/endpoint` — endpoint proxy + MCP + A2A + API endpoint tabs
- `/dashboard/providers` — provider connections and credentials
- `/dashboard/combos` — combo strategies, templates, model routing rules
- `/dashboard/combos` — combo strategies, templates, model routing rules, manual persisted ordering
- `/dashboard/costs` — cost aggregation and pricing visibility
- `/dashboard/analytics` — usage analytics and evaluations
- `/dashboard/limits` — quota/rate controls

View File

@@ -221,6 +221,8 @@ Models: kr/claude-sonnet-4.5, kr/claude-haiku-4.5
## 🎨 Combos
You can reorder combo cards directly in **Dashboard → Combos** by dragging the handle on each card. The order is stored in SQLite and restored on reload.
### Example 1: Maximize Subscription → Cheap Backup
```

View File

@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 3.5.8
version: 3.5.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,

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute-desktop",
"version": "3.5.8",
"version": "3.5.9",
"description": "OmniRoute Desktop Application",
"main": "main.js",
"author": {

View File

@@ -8,7 +8,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
**Key value:** One endpoint (`http://localhost:20128/v1`), unlimited models, zero downtime, minimal cost.
**Current version:** 3.5.8
**Current version:** 3.5.9
## Tech Stack
@@ -279,7 +279,7 @@ OmniRoute solves the problem of managing multiple AI provider subscriptions, quo
└── .env.example # Environment variable template
```
## Key Features (v3.5.8)
## Key Features (v3.5.9)
### Core Proxy
- **60+ AI providers** with automatic format translation

View File

@@ -1,6 +1,6 @@
{
"name": "@omniroute/open-sse",
"version": "3.5.8",
"version": "3.5.9",
"description": "Express SSE sidecar for OmniRoute — handles streaming, protocol translation, and provider orchestration",
"type": "module",
"main": "index.js",

View File

@@ -425,6 +425,31 @@ function flushEvents(state) {
return events;
}
function normalizeUpstreamFailure(data, fallbackType = "server_error") {
const response = data?.response && typeof data.response === "object" ? data.response : null;
const error =
response?.error && typeof response.error === "object"
? response.error
: data?.error && typeof data.error === "object"
? data.error
: null;
const code = typeof error?.code === "string" ? error.code : "";
const message =
typeof error?.message === "string"
? error.message
: typeof data?.message === "string"
? data.message
: "Upstream failure";
return {
status: code === "rate_limit_exceeded" ? 429 : 502,
type: code === "rate_limit_exceeded" ? "rate_limit_error" : fallbackType,
code: code || (fallbackType === "rate_limit_error" ? "rate_limit_exceeded" : "bad_gateway"),
message,
};
}
/**
* Translate OpenAI Responses API chunk to OpenAI Chat Completions format
* This is for when Codex returns data and we need to send it to an OpenAI-compatible client
@@ -456,6 +481,19 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
const eventType = chunk.type || chunk.event;
const data = chunk.data || chunk;
if (!state.model) {
const upstreamModel =
(data?.response && typeof data.response === "object" && data.response.model) ||
data?.model ||
data?.modelVersion ||
data?.model_version ||
null;
if (typeof upstreamModel === "string" && upstreamModel.trim().length > 0) {
state.model = upstreamModel.trim();
}
}
// Initialize state
if (!state.started) {
state.started = true;
@@ -716,6 +754,12 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
return null;
}
if (eventType === "response.failed" || eventType === "error") {
state.upstreamError = normalizeUpstreamFailure(data);
state.finishReasonSent = true;
return null;
}
// Reasoning events — emit as reasoning_content in Chat format
if (eventType === "response.reasoning_summary_text.delta") {
const reasoningDelta = data.delta || "";

View File

@@ -26,6 +26,7 @@ import {
sanitizeStreamingChunk,
extractThinkingFromContent,
} from "../handlers/responseSanitizer.ts";
import { buildErrorBody } from "./error.ts";
export { COLORS, formatSSE };
@@ -67,6 +68,12 @@ type TranslateState = ReturnType<typeof initState> & {
finishReason?: unknown;
/** Accumulated message content for call log response body */
accumulatedContent?: string;
upstreamError?: {
status: number;
type: string;
code: string;
message: string;
} | null;
};
type ToolCall = {
@@ -468,6 +475,10 @@ export function createSSEStream(options: StreamOptions = {}) {
// Translate mode
if (!trimmed) continue;
if (state?.upstreamError) {
continue;
}
const parsed = parseSSELine(trimmed);
if (!parsed) continue;
providerPayloadCollector.push(parsed);
@@ -795,6 +806,36 @@ export function createSSEStream(options: StreamOptions = {}) {
}
}
if (state?.upstreamError) {
const err = state.upstreamError;
trackPendingRequest(model, provider, connectionId, false);
const errorBody = buildErrorBody(err.status, err.message);
if (onComplete) {
try {
onComplete({
status: err.status,
usage: state?.usage,
responseBody: errorBody,
providerPayload: providerPayloadCollector.build(
buildStreamSummaryFromEvents(
providerPayloadCollector.getEvents(),
targetFormat,
model
),
{ includeEvents: false }
),
clientPayload: clientPayloadCollector.build(errorBody, {
includeEvents: false,
}),
});
} catch {}
}
controller.error(new Error(err.message || "Upstream failure"));
return;
}
// Flush remaining events (only once at stream end)
const flushed = translateResponse(targetFormat, sourceFormat, null, state);

6
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.5.8",
"version": "3.5.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.5.8",
"version": "3.5.9",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -20980,7 +20980,7 @@
},
"open-sse": {
"name": "@omniroute/open-sse",
"version": "3.5.8"
"version": "3.5.9"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "3.5.8",
"version": "3.5.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": {

View File

@@ -3,6 +3,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { spawn } from "node:child_process";
import { pathToFileURL } from "node:url";
/**
* This repository contains a legacy `app/` snapshot (packaging/runtime artifacts)
@@ -24,6 +25,27 @@ async function exists(targetPath) {
}
}
export async function movePath(sourcePath, destinationPath, fsImpl = fs) {
try {
await fsImpl.rename(sourcePath, destinationPath);
} catch (error) {
if (error?.code !== "EXDEV") {
throw error;
}
console.warn(
`[build-next-isolated] EXDEV while moving ${sourcePath} -> ${destinationPath}; falling back to copy/remove`
);
await fsImpl.cp(sourcePath, destinationPath, {
recursive: true,
preserveTimestamps: true,
force: false,
errorOnExist: true,
});
await fsImpl.rm(sourcePath, { recursive: true, force: true });
}
}
function runNextBuild() {
return new Promise((resolve) => {
const nextBin = path.join(projectRoot, "node_modules", "next", "dist", "bin", "next");
@@ -52,12 +74,12 @@ function runNextBuild() {
});
}
async function main() {
export async function main() {
let moved = false;
try {
if (await exists(legacyAppDir)) {
await fs.rename(legacyAppDir, backupDir);
await movePath(legacyAppDir, backupDir);
moved = true;
}
@@ -86,7 +108,7 @@ async function main() {
} finally {
if (moved) {
try {
await fs.rename(backupDir, legacyAppDir);
await movePath(backupDir, legacyAppDir);
} catch (restoreError) {
console.error(
`[build-next-isolated] Failed to restore legacy app dir from ${backupDir}:`,
@@ -98,4 +120,8 @@ async function main() {
}
}
await main();
const entryScript = process.argv[1] ? pathToFileURL(process.argv[1]).href : null;
if (entryScript === import.meta.url) {
await main();
}

View File

@@ -331,6 +331,13 @@ function getI18nOrFallback(t, key, fallback) {
return fallback;
}
function moveArrayItem(items, fromIndex, toIndex) {
const nextItems = [...items];
const [movedItem] = nextItems.splice(fromIndex, 1);
nextItems.splice(toIndex, 0, movedItem);
return nextItems;
}
function getStrategyGuideText(t, strategy, field) {
const strategyFallback =
STRATEGY_GUIDANCE_FALLBACK[strategy] || STRATEGY_GUIDANCE_FALLBACK.priority;
@@ -388,6 +395,9 @@ export default function CombosPage() {
const [providerNodes, setProviderNodes] = useState([]);
const [showUsageGuide, setShowUsageGuide] = useState(true);
const [recentlyCreatedCombo, setRecentlyCreatedCombo] = useState("");
const [comboDragIndex, setComboDragIndex] = useState(null);
const [comboDragOverIndex, setComboDragOverIndex] = useState(null);
const [savingComboOrder, setSavingComboOrder] = useState(false);
useEffect(() => {
fetchData();
@@ -560,6 +570,75 @@ export default function CombosPage() {
} catch {}
};
const resetComboDragState = () => {
setComboDragIndex(null);
setComboDragOverIndex(null);
};
const handleComboDragStart = (e, index) => {
if (savingComboOrder || combos.length < 2) {
e.preventDefault();
return;
}
setComboDragIndex(index);
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", combos[index]?.id || `${index}`);
if (e.currentTarget instanceof HTMLElement) {
setTimeout(() => {
e.currentTarget.style.opacity = "0.5";
}, 0);
}
};
const handleComboDragEnd = (e) => {
if (e.currentTarget instanceof HTMLElement) {
e.currentTarget.style.opacity = "1";
}
resetComboDragState();
};
const handleComboDragOver = (e, index) => {
e.preventDefault();
if (comboDragIndex === null || comboDragIndex === index) return;
e.dataTransfer.dropEffect = "move";
setComboDragOverIndex(index);
};
const handleComboDrop = async (e, dropIndex) => {
e.preventDefault();
const fromIndex = comboDragIndex;
resetComboDragState();
if (fromIndex === null || fromIndex === dropIndex) return;
const previousCombos = combos;
const nextCombos = moveArrayItem(combos, fromIndex, dropIndex);
setCombos(nextCombos);
setSavingComboOrder(true);
try {
const res = await fetch("/api/combos/reorder", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ comboIds: nextCombos.map((combo) => combo.id) }),
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error?.message || data.error || "Failed to reorder combos");
}
if (Array.isArray(data.combos)) {
setCombos(data.combos);
}
} catch {
setCombos(previousCombos);
notify.error(getI18nOrFallback(t, "failedReorder", "Failed to save combo order"));
} finally {
setSavingComboOrder(false);
}
};
if (loading) {
return (
<div className="flex flex-col gap-6">
@@ -655,23 +734,34 @@ export default function CombosPage() {
/>
) : (
<div className="flex flex-col gap-4">
{combos.map((combo) => (
<ComboCard
{combos.map((combo, index) => (
<div
key={combo.id}
combo={combo}
metrics={metrics[combo.name]}
providerNodes={providerNodes}
copied={copied}
onCopy={copy}
onEdit={() => setEditingCombo(combo)}
onDelete={() => handleDelete(combo.id)}
onDuplicate={() => handleDuplicate(combo)}
onTest={() => handleTestCombo(combo)}
testing={testingCombo === combo.name}
onProxy={() => setProxyTargetCombo(combo)}
hasProxy={!!proxyConfig?.combos?.[combo.id]}
onToggle={() => handleToggleCombo(combo)}
/>
data-testid={`combo-card-${combo.id}`}
onDragOver={(e) => handleComboDragOver(e, index)}
onDrop={(e) => handleComboDrop(e, index)}
>
<ComboCard
combo={combo}
metrics={metrics[combo.name]}
providerNodes={providerNodes}
copied={copied}
onCopy={copy}
onEdit={() => setEditingCombo(combo)}
onDelete={() => handleDelete(combo.id)}
onDuplicate={() => handleDuplicate(combo)}
onTest={() => handleTestCombo(combo)}
testing={testingCombo === combo.name}
onProxy={() => setProxyTargetCombo(combo)}
hasProxy={!!proxyConfig?.combos?.[combo.id]}
onToggle={() => handleToggleCombo(combo)}
dragDisabled={savingComboOrder || combos.length < 2}
isDragged={comboDragIndex === index}
isDropTarget={comboDragOverIndex === index && comboDragIndex !== index}
onDragStart={(e) => handleComboDragStart(e, index)}
onDragEnd={handleComboDragEnd}
/>
</div>
))}
</div>
)}
@@ -977,6 +1067,11 @@ function ComboCard({
hasProxy,
onToggle,
providerNodes,
dragDisabled,
isDragged,
isDropTarget,
onDragStart,
onDragEnd,
}) {
const strategy = combo.strategy || "priority";
const models = combo.models || [];
@@ -997,9 +1092,33 @@ function ComboCard({
};
return (
<Card padding="sm" className={`group ${isDisabled ? "opacity-50" : ""}`}>
<Card
padding="sm"
className={`group transition-all ${
isDisabled ? "opacity-50" : ""
} ${isDropTarget ? "border border-primary/30 bg-primary/5" : ""} ${
isDragged ? "opacity-60" : ""
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 flex-1 min-w-0">
<button
type="button"
draggable={!dragDisabled}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
data-testid={`combo-drag-handle-${combo.id}`}
className={`p-1 rounded-md transition-colors shrink-0 ${
dragDisabled
? "cursor-not-allowed text-text-muted/40"
: "cursor-grab active:cursor-grabbing text-text-muted hover:text-primary hover:bg-black/5 dark:hover:bg-white/5"
}`}
title={getI18nOrFallback(t, "reorderHandle", "Drag to reorder combo")}
aria-label={getI18nOrFallback(t, "reorderHandle", "Drag to reorder combo")}
>
<span className="material-symbols-outlined text-[18px]">drag_indicator</span>
</button>
{/* Icon */}
<div className="size-8 rounded-lg bg-primary/10 flex items-center justify-center shrink-0">
<span className="material-symbols-outlined text-primary text-[18px]">layers</span>
@@ -1222,17 +1341,20 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
agentContextCache: boolean;
};
const getEmptyCreateDraftSnapshot = (): CreateDraftSnapshot => ({
name: "",
models: [],
strategy: "priority",
config: {},
showAdvanced: false,
nameError: "",
agentSystemMessage: "",
agentToolFilter: "",
agentContextCache: false,
});
const getEmptyCreateDraftSnapshot = useCallback(
(): CreateDraftSnapshot => ({
name: "",
models: [],
strategy: "priority",
config: {},
showAdvanced: false,
nameError: "",
agentSystemMessage: "",
agentToolFilter: "",
agentContextCache: false,
}),
[]
);
const t = useTranslations("combos");
const tc = useTranslations("common");
@@ -1486,7 +1608,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
return () => {
cancelled = true;
};
}, [combo, isOpen, resetFormForCombo]);
}, [combo, getEmptyCreateDraftSnapshot, isOpen, resetFormForCombo]);
useEffect(() => {
if (!strategyChangeMountedRef.current) {

View File

@@ -0,0 +1,51 @@
import { NextResponse } from "next/server";
import { reorderCombos, isCloudEnabled } from "@/lib/localDb";
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { syncToCloud } from "@/lib/cloudSync";
import { reorderCombosSchema } from "@/shared/validation/schemas";
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
// POST /api/combos/reorder - Persist combo ordering
export async function POST(request) {
let rawBody;
try {
rawBody = await request.json();
} catch {
return NextResponse.json(
{
error: {
message: "Invalid request",
details: [{ field: "body", message: "Invalid JSON body" }],
},
},
{ status: 400 }
);
}
try {
const validation = validateBody(reorderCombosSchema, rawBody);
if (isValidationFailure(validation)) {
return NextResponse.json({ error: validation.error }, { status: 400 });
}
const combos = await reorderCombos(validation.data.comboIds);
await syncToCloudIfEnabled();
return NextResponse.json({ combos });
} catch (error) {
console.log("Error reordering combos:", error);
return NextResponse.json({ error: "Failed to reorder combos" }, { status: 500 });
}
}
async function syncToCloudIfEnabled() {
try {
const cloudEnabled = await isCloudEnabled();
if (!cloudEnabled) return;
const machineId = await getConsistentMachineId();
await syncToCloud(machineId);
} catch (error) {
console.log("Error syncing to cloud:", error);
}
}

View File

@@ -17,33 +17,57 @@ function getSerializedData(value: unknown): string | null {
return typeof row.data === "string" ? row.data : null;
}
function getSortOrder(value: unknown): number | null {
const row = asRecord(value);
return typeof row.sort_order === "number" ? row.sort_order : null;
}
function withSortOrder(payload: string, sortOrder: number | null): JsonRecord {
const parsed = JSON.parse(payload) as JsonRecord;
if (typeof sortOrder === "number") {
parsed.sortOrder = sortOrder;
}
return parsed;
}
function getNextSortOrder() {
const db = getDbInstance();
const row = db.prepare("SELECT COALESCE(MAX(sort_order), 0) AS sort_order FROM combos").get();
const sortOrder = getSortOrder(row);
return (sortOrder ?? 0) + 1;
}
export async function getCombos() {
const db = getDbInstance();
return db
.prepare("SELECT data FROM combos ORDER BY name")
.prepare("SELECT data, sort_order FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC")
.all()
.map((row) => getSerializedData(row))
.filter((row): row is string => row !== null)
.map((row) => JSON.parse(row));
.map((row) => {
const payload = getSerializedData(row);
if (!payload) return null;
return withSortOrder(payload, getSortOrder(row));
})
.filter((row): row is JsonRecord => row !== null);
}
export async function getComboById(id: string) {
const db = getDbInstance();
const row = db.prepare("SELECT data FROM combos WHERE id = ?").get(id);
const row = db.prepare("SELECT data, sort_order FROM combos WHERE id = ?").get(id);
const payload = getSerializedData(row);
return payload ? JSON.parse(payload) : null;
return payload ? withSortOrder(payload, getSortOrder(row)) : null;
}
export async function getComboByName(name: string) {
const db = getDbInstance();
const row = db.prepare("SELECT data FROM combos WHERE name = ?").get(name);
const row = db.prepare("SELECT data, sort_order FROM combos WHERE name = ?").get(name);
const payload = getSerializedData(row);
return payload ? JSON.parse(payload) : null;
return payload ? withSortOrder(payload, getSortOrder(row)) : null;
}
export async function createCombo(data: JsonRecord) {
const db = getDbInstance();
const now = new Date().toISOString();
const sortOrder = typeof data.sortOrder === "number" ? data.sortOrder : getNextSortOrder();
const combo = {
id: uuidv4(),
@@ -52,13 +76,14 @@ export async function createCombo(data: JsonRecord) {
strategy: data.strategy || "priority",
config: data.config || {},
isHidden: Boolean(data.isHidden),
sortOrder,
createdAt: now,
updatedAt: now,
};
db.prepare(
"INSERT INTO combos (id, name, data, created_at, updated_at) VALUES (?, ?, ?, ?, ?)"
).run(combo.id, combo.name, JSON.stringify(combo), now, now);
"INSERT INTO combos (id, name, data, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)"
).run(combo.id, combo.name, JSON.stringify(combo), sortOrder, now, now);
backupDbFile("pre-write");
return combo;
@@ -66,23 +91,99 @@ export async function createCombo(data: JsonRecord) {
export async function updateCombo(id: string, data: JsonRecord) {
const db = getDbInstance();
const existing = db.prepare("SELECT data FROM combos WHERE id = ?").get(id);
const existing = db.prepare("SELECT data, sort_order FROM combos WHERE id = ?").get(id);
if (!existing) return null;
const serializedCurrent = getSerializedData(existing);
if (!serializedCurrent) return null;
const current = JSON.parse(serializedCurrent);
const merged = { ...current, ...data, updatedAt: new Date().toISOString() };
const current = withSortOrder(serializedCurrent, getSortOrder(existing));
const sortOrder =
typeof data.sortOrder === "number"
? data.sortOrder
: typeof current.sortOrder === "number"
? current.sortOrder
: getNextSortOrder();
const merged: JsonRecord = {
...current,
...data,
sortOrder,
updatedAt: new Date().toISOString(),
};
const currentName = typeof current.name === "string" ? current.name : "";
const nextName =
typeof merged["name"] === "string" && merged["name"].trim().length > 0
? merged["name"]
: currentName;
db.prepare("UPDATE combos SET name = ?, data = ?, updated_at = ? WHERE id = ?").run(
merged.name,
JSON.stringify(merged),
merged.updatedAt,
id
);
db.prepare(
"UPDATE combos SET name = ?, data = ?, sort_order = ?, updated_at = ? WHERE id = ?"
).run(nextName, JSON.stringify({ ...merged, name: nextName }), sortOrder, merged.updatedAt, id);
backupDbFile("pre-write");
return merged;
return { ...merged, name: nextName };
}
export async function reorderCombos(comboIds: string[]) {
const db = getDbInstance();
const rows = db
.prepare(
"SELECT id, name, data, sort_order FROM combos ORDER BY sort_order ASC, name COLLATE NOCASE ASC"
)
.all();
if (rows.length === 0) return [];
const existingIds = new Set(
rows
.map((row) => {
const record = asRecord(row);
return typeof record.id === "string" ? record.id : null;
})
.filter((id): id is string => id !== null)
);
const seen = new Set<string>();
const requestedIds = comboIds.filter((id) => {
if (!existingIds.has(id) || seen.has(id)) return false;
seen.add(id);
return true;
});
const orderedIds = [
...requestedIds,
...rows
.map((row) => {
const record = asRecord(row);
return typeof record.id === "string" ? record.id : null;
})
.filter((id): id is string => id !== null && !seen.has(id)),
];
const update = db.prepare(
"UPDATE combos SET data = ?, sort_order = ?, updated_at = ? WHERE id = ?"
);
const now = new Date().toISOString();
const rowById = new Map(
rows.map((row) => {
const record = asRecord(row);
return [String(record.id), row];
})
);
const reorderTransaction = db.transaction(() => {
orderedIds.forEach((id, index) => {
const row = rowById.get(id);
const payload = row ? getSerializedData(row) : null;
if (!payload) return;
const combo = withSortOrder(payload, getSortOrder(row));
const sortOrder = index + 1;
const updatedCombo = { ...combo, sortOrder, updatedAt: now };
update.run(JSON.stringify(updatedCombo), sortOrder, now, id);
});
});
reorderTransaction();
backupDbFile("pre-write");
return getCombos();
}
export async function deleteCombo(id: string) {

View File

@@ -111,6 +111,7 @@ const SCHEMA_SQL = `
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
data TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
@@ -406,6 +407,11 @@ function ensureCallLogsColumns(db: SqliteDatabase) {
}
}
function hasColumn(db: SqliteDatabase, tableName: string, columnName: string): boolean {
const rows = db.prepare(`PRAGMA table_info(${tableName})`).all() as Array<{ name?: string }>;
return rows.some((row) => row.name === columnName);
}
export function getDbInstance(): SqliteDatabase {
const existing = getDb();
if (existing) return existing;
@@ -512,6 +518,12 @@ export function getDbInstance(): SqliteDatabase {
INSERT OR IGNORE INTO _omniroute_migrations (version, name)
VALUES ('001', 'initial_schema');
`);
if (hasColumn(db, "combos", "sort_order")) {
db.prepare("INSERT OR IGNORE INTO _omniroute_migrations (version, name) VALUES (?, ?)").run(
"020",
"combo_sort_order"
);
}
runMigrations(db);
// Auto-migrate from db.json if exists
@@ -702,16 +714,21 @@ function migrateFromJson(db: SqliteDatabase, jsonPath: string) {
// 4. Combos
const insertCombo = db.prepare(`
INSERT OR REPLACE INTO combos (id, name, data, created_at, updated_at)
VALUES (@id, @name, @data, @createdAt, @updatedAt)
INSERT OR REPLACE INTO combos (id, name, data, sort_order, created_at, updated_at)
VALUES (@id, @name, @data, @sortOrder, @createdAt, @updatedAt)
`);
for (const combo of data.combos || []) {
for (const [index, combo] of (data.combos || []).entries()) {
const normalizedCombo = {
...combo,
sortOrder: typeof combo.sortOrder === "number" ? combo.sortOrder : index + 1,
};
insertCombo.run({
id: combo.id,
name: combo.name,
data: JSON.stringify(combo),
createdAt: combo.createdAt || new Date().toISOString(),
updatedAt: combo.updatedAt || new Date().toISOString(),
id: normalizedCombo.id,
name: normalizedCombo.name,
data: JSON.stringify(normalizedCombo),
sortOrder: normalizedCombo.sortOrder,
createdAt: normalizedCombo.createdAt || new Date().toISOString(),
updatedAt: normalizedCombo.updatedAt || new Date().toISOString(),
});
}

View File

@@ -75,8 +75,8 @@ export function runJsonMigration(
);
const insertCombo = db.prepare(`
INSERT OR REPLACE INTO combos (id, name, data, created_at, updated_at)
VALUES (@id, @name, @data, @createdAt, @updatedAt)
INSERT OR REPLACE INTO combos (id, name, data, sort_order, created_at, updated_at)
VALUES (@id, @name, @data, @sortOrder, @createdAt, @updatedAt)
`);
const insertKey = db.prepare(`
@@ -171,13 +171,18 @@ export function runJsonMigration(
}
// 5. Combos
for (const combo of data.combos ?? []) {
for (const [index, combo] of (data.combos ?? []).entries()) {
const normalizedCombo = {
...combo,
sortOrder: typeof combo.sortOrder === "number" ? combo.sortOrder : index + 1,
};
insertCombo.run({
id: combo.id,
name: combo.name,
data: JSON.stringify(combo),
createdAt: combo.createdAt ?? new Date().toISOString(),
updatedAt: combo.updatedAt ?? new Date().toISOString(),
id: normalizedCombo.id,
name: normalizedCombo.name,
data: JSON.stringify(normalizedCombo),
sortOrder: normalizedCombo.sortOrder,
createdAt: normalizedCombo.createdAt ?? new Date().toISOString(),
updatedAt: normalizedCombo.updatedAt ?? new Date().toISOString(),
});
}

View File

@@ -0,0 +1,16 @@
ALTER TABLE combos ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0;
WITH ordered_combos AS (
SELECT
id,
ROW_NUMBER() OVER (
ORDER BY created_at ASC, updated_at ASC, name COLLATE NOCASE ASC
) AS next_sort_order
FROM combos
)
UPDATE combos
SET sort_order = (
SELECT next_sort_order
FROM ordered_combos
WHERE ordered_combos.id = combos.id
);

View File

@@ -75,6 +75,7 @@ export {
getComboByName,
createCombo,
updateCombo,
reorderCombos,
deleteCombo,
} from "./db/combos";

View File

@@ -109,6 +109,15 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
: [];
const requestJson = detail?.requestBody ? toPrettyJson(detail.requestBody) : null;
const responseJson = detail?.responseBody ? toPrettyJson(detail.responseBody) : null;
const tokenStats = {
totalIn: detail?.tokens?.in ?? log.tokens?.in ?? 0,
totalOut: detail?.tokens?.out ?? log.tokens?.out ?? 0,
cacheRead: detail?.tokens?.cacheRead ?? log.tokens?.cacheRead,
cacheWrite: detail?.tokens?.cacheWrite ?? log.tokens?.cacheWrite,
reasoning: detail?.tokens?.reasoning ?? log.tokens?.reasoning,
};
const formatTokenValue = (value) => (value != null ? value.toLocaleString() : "N/A");
return (
<div
@@ -146,9 +155,14 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
<div className="p-6 flex flex-col gap-6">
{/* Metadata Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-bg-subtle rounded-xl border border-border">
<div
className="grid grid-cols-2 md:grid-cols-4 gap-4 p-4 bg-bg-subtle rounded-xl border border-border"
data-testid="request-log-metadata-grid"
>
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Time</div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
Completed Time
</div>
<div className="text-sm font-medium">{formatDate(log.timestamp)}</div>
</div>
<div>
@@ -158,33 +172,29 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
<div className="text-sm font-medium">{formatDuration(log.duration)}</div>
</div>
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
Tokens
</div>
<div className="flex flex-wrap items-center gap-1.5">
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">Input</div>
<div className="flex flex-wrap items-center gap-1.5" data-testid="token-group-input">
<span className="px-2 py-0.5 rounded bg-primary/20 text-primary text-xs font-bold">
Total In: {(detail?.tokens?.in ?? log.tokens?.in ?? 0).toLocaleString()}
</span>
<span className="px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 text-xs font-bold">
Total Out: {(detail?.tokens?.out ?? log.tokens?.out ?? 0).toLocaleString()}
Total In: {tokenStats.totalIn.toLocaleString()}
</span>
<span className="px-2 py-0.5 rounded bg-sky-500/20 text-sky-700 dark:text-sky-400 text-xs font-bold">
Cache Read:{" "}
{(detail?.tokens?.cacheRead ?? log.tokens?.cacheRead) != null
? (detail?.tokens?.cacheRead ?? log.tokens?.cacheRead).toLocaleString()
: "N/A"}
Cache Read: {formatTokenValue(tokenStats.cacheRead)}
</span>
<span className="px-2 py-0.5 rounded bg-amber-500/20 text-amber-700 dark:text-amber-400 text-xs font-bold">
Cache Write:{" "}
{(detail?.tokens?.cacheWrite ?? log.tokens?.cacheWrite) != null
? (detail?.tokens?.cacheWrite ?? log.tokens?.cacheWrite).toLocaleString()
: "N/A"}
Cache Write: {formatTokenValue(tokenStats.cacheWrite)}
</span>
</div>
</div>
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
Output
</div>
<div className="flex flex-wrap items-center gap-1.5" data-testid="token-group-output">
<span className="px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-700 dark:text-emerald-400 text-xs font-bold">
Total Out: {tokenStats.totalOut.toLocaleString()}
</span>
<span className="px-2 py-0.5 rounded bg-violet-500/20 text-violet-700 dark:text-violet-400 text-xs font-bold">
Reasoning:{" "}
{(detail?.tokens?.reasoning ?? log.tokens?.reasoning) != null
? (detail?.tokens?.reasoning ?? log.tokens?.reasoning).toLocaleString()
: "N/A"}
Reasoning: {formatTokenValue(tokenStats.reasoning)}
</span>
</div>
</div>

View File

@@ -7,8 +7,8 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
"auto-combo",
"costs",
"analytics",
"limits",
"cache",
"limits",
"cli-tools",
"agents",
"memory",
@@ -17,8 +17,8 @@ export const HIDEABLE_SIDEBAR_ITEM_IDS = [
"playground",
"media",
"search-tools",
"health",
"logs",
"health",
"audit",
"settings",
"docs",
@@ -55,8 +55,8 @@ const PRIMARY_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
{ id: "auto-combo", href: "/dashboard/auto-combo", i18nKey: "autoCombo", icon: "auto_awesome" },
{ id: "costs", href: "/dashboard/costs", i18nKey: "costs", icon: "account_balance_wallet" },
{ id: "analytics", href: "/dashboard/analytics", i18nKey: "analytics", icon: "analytics" },
{ id: "limits", href: "/dashboard/limits", i18nKey: "limits", icon: "tune" },
{ id: "cache", href: "/dashboard/cache", i18nKey: "cache", icon: "cached" },
{ id: "limits", href: "/dashboard/limits", i18nKey: "limits", icon: "tune" },
{ id: "media", href: "/dashboard/cache/media", i18nKey: "media", icon: "perm_media" },
];
@@ -79,8 +79,8 @@ const DEBUG_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
];
const SYSTEM_SIDEBAR_ITEMS: readonly SidebarItemDefinition[] = [
{ id: "health", href: "/dashboard/health", i18nKey: "health", icon: "health_and_safety" },
{ id: "logs", href: "/dashboard/logs", i18nKey: "logs", icon: "description" },
{ id: "health", href: "/dashboard/health", i18nKey: "health", icon: "health_and_safety" },
{ id: "audit", href: "/dashboard/audit", i18nKey: "auditLog", icon: "history" },
{ id: "settings", href: "/dashboard/settings", i18nKey: "settings", icon: "settings" },
];

View File

@@ -587,8 +587,26 @@ const checkKnownPath = async (commandPath: string) => {
const realPath = await fs.realpath(commandPath);
// Verify the resolved path is still within expected directories
// Use pre-computed expected parent paths (cached at module startup for performance)
const isWithinExpected = EXPECTED_PARENT_PATHS.some((parent) => isPathWithin(realPath, parent));
// Use pre-computed expected parent paths (cached at module startup for performance).
// On macOS temp directories often resolve from /var -> /private/var, so compare both
// the configured parent and its canonical realpath when available.
let isWithinExpected = false;
for (const parent of EXPECTED_PARENT_PATHS) {
if (isPathWithin(realPath, parent)) {
isWithinExpected = true;
break;
}
try {
const resolvedParent = await fs.realpath(parent);
if (isPathWithin(realPath, resolvedParent)) {
isWithinExpected = true;
break;
}
} catch {
// Ignore missing/unresolvable parents and continue checking the remaining ones.
}
}
if (!isWithinExpected) {
return { installed: false, commandPath: null, reason: "symlink_escape" };

View File

@@ -925,6 +925,20 @@ export const updateComboSchema = z
}
});
export const reorderCombosSchema = z
.object({
comboIds: z.array(z.string().trim().min(1).max(200)).min(1).max(1000),
})
.superRefine((value, ctx) => {
if (new Set(value.comboIds).size !== value.comboIds.length) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "comboIds must be unique",
path: ["comboIds"],
});
}
});
export const testComboSchema = z.object({
comboName: z.string().trim().min(1, "comboName is required"),
});

View File

@@ -7,6 +7,7 @@ type ComboStub = {
models: unknown[];
config: Record<string, unknown>;
isActive: boolean;
sortOrder?: number;
};
type ComboCreatePayload = {
@@ -210,4 +211,134 @@ test.describe("Combos flow", () => {
const testResultsModal = page.getByRole("dialog").last();
await expect(testResultsModal).toContainText(/qa-test-model/i);
});
test("allows dragging combo cards to persist manual order", async ({ page }) => {
const state: {
combos: ComboStub[];
reorderRequests: number;
} = {
combos: [
{
id: "combo-1",
name: "alpha-combo",
strategy: "priority",
models: ["openai/alpha"],
config: {},
isActive: true,
sortOrder: 1,
},
{
id: "combo-2",
name: "bravo-combo",
strategy: "priority",
models: ["openai/bravo"],
config: {},
isActive: true,
sortOrder: 2,
},
{
id: "combo-3",
name: "charlie-combo",
strategy: "priority",
models: ["openai/charlie"],
config: {},
isActive: true,
sortOrder: 3,
},
],
reorderRequests: 0,
};
await page.route("**/api/combos/metrics", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ metrics: {} }),
});
});
await page.route("**/api/settings/proxy", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ combos: {} }),
});
});
await page.route("**/api/providers", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ connections: [] }),
});
});
await page.route("**/api/provider-nodes", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ nodes: [] }),
});
});
await page.route("**/api/combos/reorder", async (route) => {
state.reorderRequests += 1;
const payload = route.request().postDataJSON() as { comboIds?: string[] };
const nextIds = Array.isArray(payload?.comboIds) ? payload.comboIds : [];
const comboById = new Map(state.combos.map((combo) => [combo.id, combo]));
state.combos = nextIds
.map((id, index) => {
const combo = comboById.get(id);
return combo ? { ...combo, sortOrder: index + 1 } : null;
})
.filter((combo): combo is ComboStub => combo !== null);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ combos: state.combos }),
});
});
await page.route("**/api/combos", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ combos: state.combos }),
});
});
await page.goto("/dashboard/combos");
await page.waitForLoadState("networkidle");
const redirectedToLogin = page.url().includes("/login");
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
const comboCards = page.locator('[data-testid^="combo-card-"]');
await expect
.poll(async () =>
comboCards.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")))
)
.toEqual(["combo-card-combo-1", "combo-card-combo-2", "combo-card-combo-3"]);
await page
.getByTestId("combo-drag-handle-combo-3")
.dragTo(page.getByTestId("combo-card-combo-1"));
await expect.poll(() => state.reorderRequests).toBe(1);
await expect
.poll(async () =>
comboCards.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")))
)
.toEqual(["combo-card-combo-3", "combo-card-combo-1", "combo-card-combo-2"]);
await page.reload();
await page.waitForLoadState("networkidle");
await expect
.poll(async () =>
comboCards.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-testid")))
)
.toEqual(["combo-card-combo-3", "combo-card-combo-1", "combo-card-combo-2"]);
});
});

View File

@@ -364,6 +364,8 @@ test("launchAutoUpdate returns validation failures and starts detached update sc
},
};
},
existsImpl: async (targetPath) =>
targetPath === repoDir || targetPath === composeFile || targetPath === "/var/run/docker.sock",
});
try {

View File

@@ -0,0 +1,90 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
const { movePath } = await import("../../scripts/build-next-isolated.mjs");
async function withTempDir(fn) {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omniroute-build-next-isolated-"));
try {
await fn(tempDir);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
}
test("movePath falls back to copy/remove when rename raises EXDEV", async () => {
await withTempDir(async (tempDir) => {
const sourceDir = path.join(tempDir, "app");
const destinationDir = path.join(tempDir, ".app-build-backup");
const nestedFile = path.join(sourceDir, "nested", "file.txt");
await fs.mkdir(path.dirname(nestedFile), { recursive: true });
await fs.writeFile(nestedFile, "legacy payload");
let copyCalled = false;
let removeCalled = false;
const warnings = [];
const originalWarn = console.warn;
console.warn = (message) => warnings.push(String(message));
try {
await movePath(sourceDir, destinationDir, {
rename: async () => {
const error = new Error("cross-device link not permitted");
error.code = "EXDEV";
throw error;
},
cp: async (...args) => {
copyCalled = true;
return fs.cp(...args);
},
rm: async (...args) => {
removeCalled = true;
return fs.rm(...args);
},
});
} finally {
console.warn = originalWarn;
}
assert.equal(copyCalled, true);
assert.equal(removeCalled, true);
assert.equal(fsSync.existsSync(sourceDir), false);
assert.equal(
await fs.readFile(path.join(destinationDir, "nested", "file.txt"), "utf8"),
"legacy payload"
);
assert.match(warnings[0] ?? "", /EXDEV while moving/);
});
});
test("movePath rethrows non-EXDEV rename failures", async () => {
await withTempDir(async (tempDir) => {
const sourceDir = path.join(tempDir, "app");
const destinationDir = path.join(tempDir, ".app-build-backup");
await fs.mkdir(sourceDir, { recursive: true });
await assert.rejects(
movePath(sourceDir, destinationDir, {
rename: async () => {
const error = new Error("permission denied");
error.code = "EACCES";
throw error;
},
cp: async () => {
throw new Error("copy fallback should not run");
},
rm: async () => {
throw new Error("remove fallback should not run");
},
}),
(error) => error?.code === "EACCES"
);
});
});

View File

@@ -47,11 +47,12 @@ test("createCombo stores default strategy and supports lookup by id and name", a
});
assert.equal(combo.strategy, "priority");
assert.equal(combo.sortOrder, 1);
assert.deepEqual(await combosDb.getComboById(combo.id), combo);
assert.deepEqual(await combosDb.getComboByName("Priority Combo"), combo);
});
test("getCombos returns parsed combos sorted by name", async () => {
test("getCombos returns parsed combos in persisted sort order", async () => {
await combosDb.createCombo({
name: "Zulu",
models: [{ provider: "openai", model: "gpt-4.1" }],
@@ -65,7 +66,11 @@ test("getCombos returns parsed combos sorted by name", async () => {
assert.deepEqual(
combos.map((combo) => combo.name),
["Alpha", "Zulu"]
["Zulu", "Alpha"]
);
assert.deepEqual(
combos.map((combo) => combo.sortOrder),
[1, 2]
);
});
@@ -91,6 +96,35 @@ test("updateCombo merges fields while preserving immutable data", async () => {
assert.deepEqual(await combosDb.getComboById(combo.id), updated);
});
test("reorderCombos persists manual combo ordering in sqlite", async () => {
const alpha = await combosDb.createCombo({
name: "Alpha",
models: [{ provider: "openai", model: "gpt-4.1" }],
});
const bravo = await combosDb.createCombo({
name: "Bravo",
models: [{ provider: "anthropic", model: "claude-3-7-sonnet" }],
});
const charlie = await combosDb.createCombo({
name: "Charlie",
models: [{ provider: "google", model: "gemini-2.5-pro" }],
});
const reordered = await combosDb.reorderCombos([charlie.id, alpha.id, bravo.id]);
assert.deepEqual(
reordered.map((combo) => combo.name),
["Charlie", "Alpha", "Bravo"]
);
assert.deepEqual(
reordered.map((combo) => combo.sortOrder),
[1, 2, 3]
);
assert.equal((await combosDb.getComboById(charlie.id))?.sortOrder, 1);
assert.equal((await combosDb.getComboById(alpha.id))?.sortOrder, 2);
assert.equal((await combosDb.getComboById(bravo.id))?.sortOrder, 3);
});
test("deleteCombo reports missing ids and removes existing rows", async () => {
const combo = await combosDb.createCombo({
name: "Delete Me",

View File

@@ -0,0 +1,75 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
const { default: RequestLoggerDetail } =
await import("../../src/shared/components/RequestLoggerDetail.tsx");
test("request log detail splits token badges into input and output groups", () => {
const html = renderToStaticMarkup(
React.createElement(RequestLoggerDetail, {
log: {
status: 200,
method: "POST",
path: "/v1/chat/completions",
timestamp: "2026-04-09T21:27:08.000Z",
duration: 2500,
provider: "openai-compatible-sp-openai",
sourceFormat: "openai-chat",
model: "gpt-5.4",
requestedModel: "openai-compatible-sp-openai/gpt-5.4",
account: "main",
apiKeyName: "tools",
apiKeyId: "29d9***7e37",
comboName: "_Latest-Discounted",
tokens: {
in: 21818,
out: 42,
cacheRead: 21632,
cacheWrite: null,
reasoning: null,
},
},
detail: {
account: "main",
apiKeyName: "tools",
apiKeyId: "29d9***7e37",
comboName: "_Latest-Discounted",
requestedModel: "openai-compatible-sp-openai/gpt-5.4",
tokens: {
in: 21818,
out: 42,
cacheRead: 21632,
cacheWrite: null,
reasoning: null,
},
},
loading: false,
onClose: () => {},
onCopy: async () => true,
})
);
const inputLabelIndex = html.indexOf(">Input<");
const outputLabelIndex = html.indexOf(">Output<");
const modelLabelIndex = html.indexOf(">Model<");
const requestedModelLabelIndex = html.indexOf(">Requested Model<");
assert.notEqual(html.indexOf(">Completed Time<"), -1);
assert.equal(html.includes(">Time<"), false);
assert.notEqual(inputLabelIndex, -1);
assert.notEqual(outputLabelIndex, -1);
assert.notEqual(modelLabelIndex, -1);
assert.notEqual(requestedModelLabelIndex, -1);
assert.equal(html.includes(">Tokens<"), false);
assert.equal(inputLabelIndex < outputLabelIndex, true);
assert.equal(outputLabelIndex < modelLabelIndex, true);
assert.equal(modelLabelIndex < requestedModelLabelIndex, true);
assert.match(
html,
/data-testid="token-group-input"[\s\S]*Total In: 21,818[\s\S]*Cache Read: 21,632[\s\S]*Cache Write: N\/A/
);
assert.match(html, /data-testid="token-group-output"[\s\S]*Total Out: 42[\s\S]*Reasoning: N\/A/);
});

View File

@@ -0,0 +1,40 @@
import test from "node:test";
import assert from "node:assert/strict";
const sidebarVisibility = await import("../../src/shared/constants/sidebarVisibility.ts");
test("system sidebar items place logs before health", () => {
const systemSection = sidebarVisibility.SIDEBAR_SECTIONS.find(
(section) => section.id === "system"
);
assert.ok(systemSection, "expected system sidebar section to exist");
assert.deepEqual(
systemSection.items.map((item) => item.id),
["logs", "health", "audit", "settings"]
);
});
test("primary sidebar items place limits after cache", () => {
const primarySection = sidebarVisibility.SIDEBAR_SECTIONS.find(
(section) => section.id === "primary"
);
assert.ok(primarySection, "expected primary sidebar section to exist");
assert.deepEqual(
primarySection.items.map((item) => item.id),
[
"home",
"endpoints",
"api-manager",
"providers",
"combos",
"auto-combo",
"costs",
"analytics",
"cache",
"limits",
"media",
]
);
});

View File

@@ -237,6 +237,59 @@ test("createSSEStream passthrough preserves Responses API events and completion
assert.equal(onCompletePayload.providerPayload.summary.object, "response");
});
test("createSSEStream translate mode aborts on Responses failure with rate limit error", async () => {
let onCompletePayload = null;
await assert.rejects(
readTransformed(
[
`data: ${JSON.stringify({
type: "response.created",
response: {
id: "resp_fail",
object: "response",
model: "gpt-5.4",
status: "in_progress",
output: [],
},
})}\n\n`,
`data: ${JSON.stringify({
type: "response.failed",
response: {
id: "resp_fail",
object: "response",
model: "gpt-5.4",
status: "failed",
error: {
message: "Rate limit reached for gpt-5.4",
code: "rate_limit_exceeded",
},
},
})}\n\n`,
`data: [DONE]\n\n`,
],
{
mode: "translate",
targetFormat: FORMATS.OPENAI_RESPONSES,
sourceFormat: FORMATS.OPENAI,
provider: "codex",
model: "gpt-5.4",
body: { messages: [{ role: "user", content: "hello" }] },
onComplete(payload) {
onCompletePayload = payload;
},
}
),
/Rate limit reached for gpt-5\.4|Upstream failure/
);
assert.ok(onCompletePayload, "should capture completion payload before aborting");
assert.equal(onCompletePayload.status, 429);
assert.equal(onCompletePayload.responseBody.error.type, "rate_limit_error");
assert.equal(onCompletePayload.responseBody.error.code, "rate_limit_exceeded");
assert.match(onCompletePayload.responseBody.error.message, /Rate limit reached/);
});
test("createSSEStream passthrough restores Claude tool names from the mapping table", async () => {
const toolNameMap = new Map([["tool_alias", "read_file"]]);
const text = await readTransformed(

View File

@@ -296,3 +296,60 @@ test("Responses -> OpenAI: tool-call delta, reasoning delta and completed usage
assert.equal(completed.usage.prompt_tokens_details.cached_tokens, 1);
assert.equal(completed.usage.prompt_tokens_details.cache_creation_tokens, 2);
});
test("Responses -> OpenAI: preserves upstream model instead of defaulting to gpt-4", () => {
const state = {};
const created = openaiResponsesToOpenAIResponse(
{
type: "response.created",
response: {
id: "resp_1",
object: "response",
model: "gpt-5.4",
status: "in_progress",
output: [],
},
},
state
);
const text = openaiResponsesToOpenAIResponse(
{ type: "response.output_text.delta", delta: "hello" },
state
);
const final = openaiResponsesToOpenAIResponse(
{
type: "response.completed",
response: {
model: "gpt-5.4",
},
},
state
);
assert.equal(text.model, "gpt-5.4");
assert.equal(final.model, "gpt-5.4");
assert.equal(created, null);
});
test("Responses -> OpenAI: response.failed records upstream error", () => {
const state = {};
const result = openaiResponsesToOpenAIResponse(
{
type: "response.failed",
response: {
error: {
message: "Rate limit reached for gpt-5.4",
code: "rate_limit_exceeded",
},
},
},
state
);
assert.equal(result, null);
assert.ok(state.upstreamError);
assert.equal(state.upstreamError.status, 429);
assert.equal(state.upstreamError.type, "rate_limit_error");
assert.equal(state.upstreamError.code, "rate_limit_exceeded");
assert.match(state.upstreamError.message, /Rate limit reached/);
});