diff --git a/CHANGELOG.md b/CHANGELOG.md index b9c06d23df..2758efdae8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 7bf3527b06..7e6c6b806c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c633c9d498..05dbce91d9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index e526d7c554..a42d7c29ed 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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 ``` diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 636c8967c2..a193412ad6 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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, diff --git a/electron/package.json b/electron/package.json index cac1e7841d..f456ce06a3 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,6 +1,6 @@ { "name": "omniroute-desktop", - "version": "3.5.8", + "version": "3.5.9", "description": "OmniRoute Desktop Application", "main": "main.js", "author": { diff --git a/llm.txt b/llm.txt index 032cc53203..d866b81c8d 100644 --- a/llm.txt +++ b/llm.txt @@ -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 diff --git a/open-sse/package.json b/open-sse/package.json index 72f2367ff2..9d86567a66 100644 --- a/open-sse/package.json +++ b/open-sse/package.json @@ -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", diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index ccfee194bf..bdddc2dd87 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -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 || ""; diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 502e256549..83eef18e71 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -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 & { 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); diff --git a/package-lock.json b/package-lock.json index 420bbf5dd0..c5cf7990dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" } } } diff --git a/package.json b/package.json index abf1113300..7be6aae2b2 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/scripts/build-next-isolated.mjs b/scripts/build-next-isolated.mjs index 7d0a116aaa..f062085ad6 100644 --- a/scripts/build-next-isolated.mjs +++ b/scripts/build-next-isolated.mjs @@ -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(); +} diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 8270d64ca3..fbc7513896 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -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 (
@@ -655,23 +734,34 @@ export default function CombosPage() { /> ) : (
- {combos.map((combo) => ( - ( +
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)} + > + 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} + /> +
))}
)} @@ -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 ( - +
+ + {/* Icon */}
layers @@ -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) { diff --git a/src/app/api/combos/reorder/route.ts b/src/app/api/combos/reorder/route.ts new file mode 100644 index 0000000000..174c87e5cd --- /dev/null +++ b/src/app/api/combos/reorder/route.ts @@ -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); + } +} diff --git a/src/lib/db/combos.ts b/src/lib/db/combos.ts index d52832e284..05494bfc8b 100644 --- a/src/lib/db/combos.ts +++ b/src/lib/db/combos.ts @@ -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(); + 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) { diff --git a/src/lib/db/core.ts b/src/lib/db/core.ts index 90e66d4420..6512505037 100644 --- a/src/lib/db/core.ts +++ b/src/lib/db/core.ts @@ -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(), }); } diff --git a/src/lib/db/jsonMigration.ts b/src/lib/db/jsonMigration.ts index 0ab294a561..b3128cdd9c 100644 --- a/src/lib/db/jsonMigration.ts +++ b/src/lib/db/jsonMigration.ts @@ -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(), }); } diff --git a/src/lib/db/migrations/020_combo_sort_order.sql b/src/lib/db/migrations/020_combo_sort_order.sql new file mode 100644 index 0000000000..4f07e4781d --- /dev/null +++ b/src/lib/db/migrations/020_combo_sort_order.sql @@ -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 +); diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 3f10285bed..d85ac54ae7 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -75,6 +75,7 @@ export { getComboByName, createCombo, updateCombo, + reorderCombos, deleteCombo, } from "./db/combos"; diff --git a/src/shared/components/RequestLoggerDetail.tsx b/src/shared/components/RequestLoggerDetail.tsx index c1b2b0aa84..2cd4171c29 100644 --- a/src/shared/components/RequestLoggerDetail.tsx +++ b/src/shared/components/RequestLoggerDetail.tsx @@ -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 (
{/* Metadata Grid */} -
+
-
Time
+
+ Completed Time +
{formatDate(log.timestamp)}
@@ -158,33 +172,29 @@ export default function RequestLoggerDetail({ log, detail, loading, onClose, onC
{formatDuration(log.duration)}
-
- Tokens -
-
+
Input
+
- Total In: {(detail?.tokens?.in ?? log.tokens?.in ?? 0).toLocaleString()} - - - Total Out: {(detail?.tokens?.out ?? log.tokens?.out ?? 0).toLocaleString()} + Total In: {tokenStats.totalIn.toLocaleString()} - Cache Read:{" "} - {(detail?.tokens?.cacheRead ?? log.tokens?.cacheRead) != null - ? (detail?.tokens?.cacheRead ?? log.tokens?.cacheRead).toLocaleString() - : "N/A"} + Cache Read: {formatTokenValue(tokenStats.cacheRead)} - Cache Write:{" "} - {(detail?.tokens?.cacheWrite ?? log.tokens?.cacheWrite) != null - ? (detail?.tokens?.cacheWrite ?? log.tokens?.cacheWrite).toLocaleString() - : "N/A"} + Cache Write: {formatTokenValue(tokenStats.cacheWrite)} + +
+
+
+
+ Output +
+
+ + Total Out: {tokenStats.totalOut.toLocaleString()} - Reasoning:{" "} - {(detail?.tokens?.reasoning ?? log.tokens?.reasoning) != null - ? (detail?.tokens?.reasoning ?? log.tokens?.reasoning).toLocaleString() - : "N/A"} + Reasoning: {formatTokenValue(tokenStats.reasoning)}
diff --git a/src/shared/constants/sidebarVisibility.ts b/src/shared/constants/sidebarVisibility.ts index b6e68bef2d..ebf27f05ec 100644 --- a/src/shared/constants/sidebarVisibility.ts +++ b/src/shared/constants/sidebarVisibility.ts @@ -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" }, ]; diff --git a/src/shared/services/cliRuntime.ts b/src/shared/services/cliRuntime.ts index ac8b390bfa..8b7a7cdbc3 100644 --- a/src/shared/services/cliRuntime.ts +++ b/src/shared/services/cliRuntime.ts @@ -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" }; diff --git a/src/shared/validation/schemas.ts b/src/shared/validation/schemas.ts index 4b9b129d67..89391e293c 100644 --- a/src/shared/validation/schemas.ts +++ b/src/shared/validation/schemas.ts @@ -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"), }); diff --git a/tests/e2e/combos-flow.spec.ts b/tests/e2e/combos-flow.spec.ts index dd8382c178..92a8c26c04 100644 --- a/tests/e2e/combos-flow.spec.ts +++ b/tests/e2e/combos-flow.spec.ts @@ -7,6 +7,7 @@ type ComboStub = { models: unknown[]; config: Record; 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"]); + }); }); diff --git a/tests/unit/auto-update.test.mjs b/tests/unit/auto-update.test.mjs index 67cc2d79b0..2f4e2b5e57 100644 --- a/tests/unit/auto-update.test.mjs +++ b/tests/unit/auto-update.test.mjs @@ -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 { diff --git a/tests/unit/build-next-isolated.test.mjs b/tests/unit/build-next-isolated.test.mjs new file mode 100644 index 0000000000..cac7f91272 --- /dev/null +++ b/tests/unit/build-next-isolated.test.mjs @@ -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" + ); + }); +}); diff --git a/tests/unit/db-combos-crud.test.mjs b/tests/unit/db-combos-crud.test.mjs index 8989210374..c97f9eb714 100644 --- a/tests/unit/db-combos-crud.test.mjs +++ b/tests/unit/db-combos-crud.test.mjs @@ -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", diff --git a/tests/unit/request-log-detail-layout.test.mjs b/tests/unit/request-log-detail-layout.test.mjs new file mode 100644 index 0000000000..d6cb98a37a --- /dev/null +++ b/tests/unit/request-log-detail-layout.test.mjs @@ -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/); +}); diff --git a/tests/unit/sidebar-visibility.test.mjs b/tests/unit/sidebar-visibility.test.mjs new file mode 100644 index 0000000000..78b52d9de8 --- /dev/null +++ b/tests/unit/sidebar-visibility.test.mjs @@ -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", + ] + ); +}); diff --git a/tests/unit/stream-utils.test.mjs b/tests/unit/stream-utils.test.mjs index e9c5351fba..6c0ac87482 100644 --- a/tests/unit/stream-utils.test.mjs +++ b/tests/unit/stream-utils.test.mjs @@ -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( diff --git a/tests/unit/translator-resp-openai-responses.test.mjs b/tests/unit/translator-resp-openai-responses.test.mjs index 0662c42586..1eda0ad847 100644 --- a/tests/unit/translator-resp-openai-responses.test.mjs +++ b/tests/unit/translator-resp-openai-responses.test.mjs @@ -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/); +});