mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Merge pull request #1102 from diegosouzapw/release/v3.5.9
Release v3.5.9 merged to main
This commit is contained in:
17
CHANGELOG.md
17
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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute-desktop",
|
||||
"version": "3.5.8",
|
||||
"version": "3.5.9",
|
||||
"description": "OmniRoute Desktop Application",
|
||||
"main": "main.js",
|
||||
"author": {
|
||||
|
||||
4
llm.txt
4
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 || "";
|
||||
|
||||
@@ -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
6
package-lock.json
generated
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
51
src/app/api/combos/reorder/route.ts
Normal file
51
src/app/api/combos/reorder/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
16
src/lib/db/migrations/020_combo_sort_order.sql
Normal file
16
src/lib/db/migrations/020_combo_sort_order.sql
Normal 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
|
||||
);
|
||||
@@ -75,6 +75,7 @@ export {
|
||||
getComboByName,
|
||||
createCombo,
|
||||
updateCombo,
|
||||
reorderCombos,
|
||||
deleteCombo,
|
||||
} from "./db/combos";
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -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" };
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
90
tests/unit/build-next-isolated.test.mjs
Normal file
90
tests/unit/build-next-isolated.test.mjs
Normal 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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
75
tests/unit/request-log-detail-layout.test.mjs
Normal file
75
tests/unit/request-log-detail-layout.test.mjs
Normal 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/);
|
||||
});
|
||||
40
tests/unit/sidebar-visibility.test.mjs
Normal file
40
tests/unit/sidebar-visibility.test.mjs
Normal 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",
|
||||
]
|
||||
);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user