Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 659e2b414d | |||
| 7bcb58e3db | |||
| 2d7d7776a6 | |||
| c5f429521c | |||
| 426d8636bc |
@@ -4,6 +4,26 @@
|
||||
|
||||
---
|
||||
|
||||
## [2.8.2] — 2026-03-19
|
||||
|
||||
> Sprint: 2 merged PRs, model aliases routing fix, log export, and issue triage.
|
||||
|
||||
### Features
|
||||
|
||||
- **Log Export**: New Export button on `/dashboard/logs` with time range dropdown (1h, 6h, 12h, 24h). Downloads JSON of request/proxy/call logs via `/api/logs/export` API (#user-request)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **Model Aliases Routing** (#472): Settings → Model Aliases now correctly affect provider routing, not just format detection. Previously `resolveModelAlias()` output was only used for `getModelTargetFormat()` but the original model ID was sent to the provider
|
||||
- **Stream Flush Usage** (#480): Usage data from the last SSE event in the buffer is now correctly extracted during stream flush (merged from @prakersh)
|
||||
|
||||
### Merged PRs
|
||||
|
||||
- #480 — Extract usage from remaining buffer in flush handler (@prakersh)
|
||||
- #479 — Add missing Codex 5.3/5.4 and Anthropic model ID pricing entries (@prakersh)
|
||||
|
||||
---
|
||||
|
||||
## [2.8.1] — 2026-03-19
|
||||
|
||||
> Sprint: Five community PRs — streaming call log fixes, Kiro compatibility, cache token analytics, Chinese translation, and configurable tool call IDs.
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: OmniRoute API
|
||||
version: 2.8.1
|
||||
version: 2.8.2
|
||||
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,
|
||||
|
||||
@@ -157,10 +157,16 @@ export async function handleChatCore({
|
||||
// Detect source format and get target format
|
||||
// Model-specific targetFormat takes priority over provider default
|
||||
|
||||
// Apply custom model aliases (Settings → Model Aliases → Pattern→Target) before routing (#315)
|
||||
// Apply custom model aliases (Settings → Model Aliases → Pattern→Target) before routing (#315, #472)
|
||||
// Custom aliases take priority over built-in and must be resolved here so the
|
||||
// downstream getModelTargetFormat() lookup uses the correct, aliased model ID.
|
||||
// downstream getModelTargetFormat() lookup AND the actual provider request use
|
||||
// the correct, aliased model ID. Without this, aliases only affect format detection.
|
||||
const resolvedModel = resolveModelAlias(model);
|
||||
// Use resolvedModel for all downstream operations (routing, provider requests, logging)
|
||||
const effectiveModel = resolvedModel !== model ? resolvedModel : model;
|
||||
if (resolvedModel !== model) {
|
||||
log?.info?.("ALIAS", `Model alias applied: ${model} → ${resolvedModel}`);
|
||||
}
|
||||
|
||||
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
|
||||
const modelTargetFormat = getModelTargetFormat(alias, resolvedModel);
|
||||
@@ -367,8 +373,8 @@ export async function handleChatCore({
|
||||
delete translatedBody._toolNameMap;
|
||||
delete translatedBody._disableToolPrefix;
|
||||
|
||||
// Update model in body
|
||||
translatedBody.model = model;
|
||||
// Update model in body — use resolved alias so the provider gets the correct model ID (#472)
|
||||
translatedBody.model = effectiveModel;
|
||||
|
||||
// Strip unsupported parameters for reasoning models (o1, o3, etc.)
|
||||
const unsupported = getUnsupportedParams(provider, model);
|
||||
@@ -397,7 +403,7 @@ export async function handleChatCore({
|
||||
const dedupEnabled = shouldDeduplicate(dedupRequestBody);
|
||||
const dedupHash = dedupEnabled ? computeRequestHash(dedupRequestBody) : null;
|
||||
|
||||
const executeProviderRequest = async (modelToCall = model, allowDedup = false) => {
|
||||
const executeProviderRequest = async (modelToCall = effectiveModel, allowDedup = false) => {
|
||||
const execute = async () => {
|
||||
const bodyToSend =
|
||||
translatedBody.model === modelToCall
|
||||
@@ -445,8 +451,8 @@ export async function handleChatCore({
|
||||
trackPendingRequest(model, provider, connectionId, true);
|
||||
|
||||
// T5: track which models we've tried for intra-family fallback
|
||||
const triedModels = new Set<string>([model]);
|
||||
let currentModel = model;
|
||||
const triedModels = new Set<string>([effectiveModel]);
|
||||
let currentModel = effectiveModel;
|
||||
|
||||
// Log start
|
||||
appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => {});
|
||||
@@ -465,7 +471,7 @@ export async function handleChatCore({
|
||||
let finalBody;
|
||||
|
||||
try {
|
||||
const result = await executeProviderRequest(model, true);
|
||||
const result = await executeProviderRequest(effectiveModel, true);
|
||||
|
||||
providerResponse = result.response;
|
||||
providerUrl = result.url;
|
||||
|
||||
@@ -518,6 +518,33 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
if (buffer.trim()) {
|
||||
const parsed = parseSSELine(buffer.trim());
|
||||
if (parsed && !parsed.done) {
|
||||
// Extract usage from remaining buffer — if the usage-bearing event
|
||||
// (e.g. response.completed) is the last SSE line, it ends up here
|
||||
// in the flush handler where extractUsage was not called.
|
||||
// Non-destructive merge: some providers send usage across multiple
|
||||
// events (e.g. prompt_tokens in message_start, completion_tokens
|
||||
// in message_delta). Direct assignment would lose earlier data.
|
||||
const extracted = extractUsage(parsed);
|
||||
if (extracted) {
|
||||
if (!state.usage) {
|
||||
state.usage = extracted;
|
||||
} else {
|
||||
if (extracted.prompt_tokens > 0)
|
||||
state.usage.prompt_tokens = extracted.prompt_tokens;
|
||||
if (extracted.completion_tokens > 0)
|
||||
state.usage.completion_tokens = extracted.completion_tokens;
|
||||
if (extracted.total_tokens > 0) state.usage.total_tokens = extracted.total_tokens;
|
||||
if (extracted.cache_read_input_tokens > 0)
|
||||
state.usage.cache_read_input_tokens = extracted.cache_read_input_tokens;
|
||||
if (extracted.cache_creation_input_tokens > 0)
|
||||
state.usage.cache_creation_input_tokens = extracted.cache_creation_input_tokens;
|
||||
if (extracted.cached_tokens > 0)
|
||||
state.usage.cached_tokens = extracted.cached_tokens;
|
||||
if (extracted.reasoning_tokens > 0)
|
||||
state.usage.reasoning_tokens = extracted.reasoning_tokens;
|
||||
}
|
||||
}
|
||||
|
||||
const translated = translateResponse(targetFormat, sourceFormat, parsed, state);
|
||||
|
||||
// Log OpenAI intermediate chunks
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "2.8.1",
|
||||
"version": "2.8.2",
|
||||
"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": {
|
||||
|
||||
@@ -1,27 +1,135 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { RequestLoggerV2, ProxyLogger, SegmentedControl } from "@/shared/components";
|
||||
import ConsoleLogViewer from "@/shared/components/ConsoleLogViewer";
|
||||
import AuditLogTab from "./AuditLogTab";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
const TIME_RANGES = [
|
||||
{ label: "1h", hours: 1 },
|
||||
{ label: "6h", hours: 6 },
|
||||
{ label: "12h", hours: 12 },
|
||||
{ label: "24h", hours: 24 },
|
||||
];
|
||||
|
||||
const TAB_TO_LOG_TYPE: Record<string, string> = {
|
||||
"request-logs": "request-logs",
|
||||
"proxy-logs": "proxy-logs",
|
||||
"audit-logs": "call-logs",
|
||||
console: "call-logs",
|
||||
};
|
||||
|
||||
export default function LogsPage() {
|
||||
const [activeTab, setActiveTab] = useState("request-logs");
|
||||
const [showExport, setShowExport] = useState(false);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const t = useTranslations("logs");
|
||||
|
||||
useEffect(() => {
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
|
||||
setShowExport(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => document.removeEventListener("mousedown", handleClickOutside);
|
||||
}, []);
|
||||
|
||||
async function handleExport(hours: number) {
|
||||
setExporting(true);
|
||||
setShowExport(false);
|
||||
try {
|
||||
const logType = TAB_TO_LOG_TYPE[activeTab] || "call-logs";
|
||||
const res = await fetch(`/api/logs/export?hours=${hours}&type=${logType}`);
|
||||
if (!res.ok) throw new Error("Export failed");
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `omniroute-${logType}-${hours}h-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) {
|
||||
console.error("Export failed:", err);
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: "request-logs", label: t("requestLogs") },
|
||||
{ value: "proxy-logs", label: t("proxyLogs") },
|
||||
{ value: "audit-logs", label: t("auditLog") },
|
||||
{ value: "console", label: t("console") },
|
||||
]}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<SegmentedControl
|
||||
options={[
|
||||
{ value: "request-logs", label: t("requestLogs") },
|
||||
{ value: "proxy-logs", label: t("proxyLogs") },
|
||||
{ value: "audit-logs", label: t("auditLog") },
|
||||
{ value: "console", label: t("console") },
|
||||
]}
|
||||
value={activeTab}
|
||||
onChange={setActiveTab}
|
||||
/>
|
||||
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
<button
|
||||
id="export-logs-btn"
|
||||
onClick={() => setShowExport(!showExport)}
|
||||
disabled={exporting}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium rounded-lg
|
||||
bg-[var(--card-bg,#1e1e2e)] border border-[var(--border,#333)]
|
||||
text-[var(--text-secondary,#aaa)] hover:text-[var(--text-primary,#fff)]
|
||||
hover:border-[var(--accent,#7c3aed)] transition-all duration-200
|
||||
disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
>
|
||||
<path
|
||||
d="M8 2v8m0 0l-3-3m3 3l3-3M3 12h10"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
{exporting ? "Exporting..." : "Export"}
|
||||
</button>
|
||||
|
||||
{showExport && (
|
||||
<div
|
||||
className="absolute right-0 top-full mt-1 z-50 min-w-[140px] rounded-lg
|
||||
bg-[var(--card-bg,#1e1e2e)] border border-[var(--border,#333)]
|
||||
shadow-xl overflow-hidden animate-in fade-in"
|
||||
>
|
||||
<div className="px-3 py-2 text-xs text-[var(--text-muted,#666)] border-b border-[var(--border,#333)] font-medium">
|
||||
Time Range
|
||||
</div>
|
||||
{TIME_RANGES.map((range) => (
|
||||
<button
|
||||
key={range.hours}
|
||||
id={`export-${range.hours}h-btn`}
|
||||
onClick={() => handleExport(range.hours)}
|
||||
className="w-full px-3 py-2 text-sm text-left hover:bg-[var(--hover-bg,#2a2a3e)]
|
||||
text-[var(--text-secondary,#aaa)] hover:text-[var(--text-primary,#fff)]
|
||||
transition-colors flex items-center justify-between"
|
||||
>
|
||||
<span>Last {range.label}</span>
|
||||
<span className="text-xs text-[var(--text-muted,#666)]">
|
||||
{range.hours === 24 ? "default" : ""}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{activeTab === "request-logs" && <RequestLoggerV2 />}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { getDbInstance } from "@/lib/db/core";
|
||||
|
||||
/**
|
||||
* GET /api/logs/export — export logs as JSON
|
||||
* Query params: ?hours=24 (1, 6, 12, 24; default 24)
|
||||
* &type=call-logs|request-logs|proxy-logs (default call-logs)
|
||||
*/
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const hours = Math.min(Math.max(parseInt(searchParams.get("hours") || "24") || 24, 1), 168);
|
||||
const logType = searchParams.get("type") || "call-logs";
|
||||
|
||||
const since = new Date(Date.now() - hours * 3600 * 1000).toISOString();
|
||||
const db = getDbInstance();
|
||||
|
||||
let rows: unknown[] = [];
|
||||
let tableName = "";
|
||||
|
||||
if (logType === "call-logs") {
|
||||
tableName = "call_logs";
|
||||
const stmt = db.prepare(
|
||||
"SELECT * FROM call_logs WHERE timestamp >= @since ORDER BY timestamp DESC"
|
||||
);
|
||||
rows = stmt.all({ since });
|
||||
} else if (logType === "request-logs") {
|
||||
tableName = "request_logs";
|
||||
const stmt = db.prepare(
|
||||
"SELECT * FROM request_logs WHERE timestamp >= @since ORDER BY timestamp DESC"
|
||||
);
|
||||
rows = stmt.all({ since });
|
||||
} else if (logType === "proxy-logs") {
|
||||
tableName = "proxy_logs";
|
||||
const stmt = db.prepare(
|
||||
"SELECT * FROM proxy_logs WHERE timestamp >= @since ORDER BY timestamp DESC"
|
||||
);
|
||||
rows = stmt.all({ since });
|
||||
}
|
||||
|
||||
const filename = `omniroute-${tableName}-${hours}h-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ logs: rows, count: rows.length, hours, type: logType }, null, 2),
|
||||
{
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Disposition": `attachment; filename="${filename}"`,
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
return Response.json(
|
||||
{ error: { message: (error as Error).message, type: "server_error" } },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,47 @@
|
||||
// All rates are in dollars per million tokens ($/1M tokens)
|
||||
// Based on user-provided pricing for Antigravity models and industry standards for others
|
||||
|
||||
// Shared pricing constants to reduce duplication
|
||||
const GPT_5_3_CODEX_PRICING = {
|
||||
input: 5.0,
|
||||
output: 20.0,
|
||||
cached: 2.5,
|
||||
reasoning: 30.0,
|
||||
cache_creation: 5.0,
|
||||
};
|
||||
|
||||
const CLAUDE_OPUS_4_PRICING = {
|
||||
input: 15.0,
|
||||
output: 75.0,
|
||||
cached: 7.5,
|
||||
reasoning: 112.5,
|
||||
cache_creation: 15.0,
|
||||
};
|
||||
|
||||
const CLAUDE_SONNET_4_PRICING = {
|
||||
input: 3.0,
|
||||
output: 15.0,
|
||||
cached: 1.5,
|
||||
reasoning: 15.0,
|
||||
cache_creation: 3.0,
|
||||
};
|
||||
|
||||
const CLAUDE_OPUS_46_PRICING = {
|
||||
input: 5.0,
|
||||
output: 25.0,
|
||||
cached: 2.5,
|
||||
reasoning: 37.5,
|
||||
cache_creation: 5.0,
|
||||
};
|
||||
|
||||
const CLAUDE_SONNET_46_PRICING = {
|
||||
input: 3.0,
|
||||
output: 15.0,
|
||||
cached: 1.5,
|
||||
reasoning: 22.5,
|
||||
cache_creation: 3.0,
|
||||
};
|
||||
|
||||
export const DEFAULT_PRICING = {
|
||||
// OAuth Providers (using aliases)
|
||||
|
||||
@@ -46,7 +87,14 @@ export const DEFAULT_PRICING = {
|
||||
|
||||
// OpenAI Codex (cx)
|
||||
cx: {
|
||||
// Issue #334: add gpt5.4
|
||||
// GPT 5.4
|
||||
"gpt-5.4": {
|
||||
input: 5.0,
|
||||
output: 20.0,
|
||||
cached: 2.5,
|
||||
reasoning: 30.0,
|
||||
cache_creation: 5.0,
|
||||
},
|
||||
"gpt5.4": {
|
||||
input: 5.0,
|
||||
output: 20.0,
|
||||
@@ -54,6 +102,19 @@ export const DEFAULT_PRICING = {
|
||||
reasoning: 30.0,
|
||||
cache_creation: 5.0,
|
||||
},
|
||||
// GPT 5.3 Codex family (all same pricing tier)
|
||||
"gpt-5.3-codex": GPT_5_3_CODEX_PRICING,
|
||||
"gpt-5.3-codex-xhigh": GPT_5_3_CODEX_PRICING,
|
||||
"gpt-5.3-codex-high": GPT_5_3_CODEX_PRICING,
|
||||
"gpt-5.3-codex-low": GPT_5_3_CODEX_PRICING,
|
||||
"gpt-5.3-codex-none": GPT_5_3_CODEX_PRICING,
|
||||
"gpt-5.1-codex-mini-high": {
|
||||
input: 1.5,
|
||||
output: 6.0,
|
||||
cached: 0.75,
|
||||
reasoning: 9.0,
|
||||
cache_creation: 1.5,
|
||||
},
|
||||
"gpt-5.2-codex": {
|
||||
input: 5.0,
|
||||
output: 20.0,
|
||||
@@ -525,6 +586,15 @@ export const DEFAULT_PRICING = {
|
||||
reasoning: 37.5,
|
||||
cache_creation: 5.0,
|
||||
},
|
||||
// Common model IDs (without dates) used across providers
|
||||
// Intentional duplicates of dot-notation variants (e.g. claude-opus-4.6)
|
||||
// to cover hyphen-notation IDs (claude-opus-4-6) used by some clients
|
||||
"claude-opus-4-6": CLAUDE_OPUS_46_PRICING,
|
||||
"claude-sonnet-4-6": CLAUDE_SONNET_46_PRICING,
|
||||
"claude-opus-4-5-20251101": CLAUDE_OPUS_4_PRICING,
|
||||
"claude-sonnet-4-5-20250929": CLAUDE_SONNET_4_PRICING,
|
||||
"claude-sonnet-4": CLAUDE_SONNET_4_PRICING,
|
||||
"claude-opus-4": CLAUDE_OPUS_4_PRICING,
|
||||
},
|
||||
|
||||
// Gemini
|
||||
|
||||
Reference in New Issue
Block a user