Merge remote main

This commit is contained in:
diegosouzapw
2026-03-30 20:48:25 -03:00
25 changed files with 615 additions and 214 deletions

View File

@@ -1542,6 +1542,8 @@ Models:
**Models:** Access 100+ models from all major providers through a single API key.
**Dashboard behavior:** OpenRouter models are managed from **Available Models**. Manual add, import, and auto-sync all update the same list.
</details>
<details>

View File

@@ -686,25 +686,25 @@ Additional processing layers in the translation pipeline:
## Supported API Endpoints
| Endpoint | Format | Handler |
| -------------------------------------------------- | ------------------ | ---------------------------------------------------- |
| `POST /v1/chat/completions` | OpenAI Chat | `src/sse/handlers/chat.ts` |
| `POST /v1/messages` | Claude Messages | Same handler (auto-detected) |
| `POST /v1/responses` | OpenAI Responses | `open-sse/handlers/responsesHandler.ts` |
| `POST /v1/embeddings` | OpenAI Embeddings | `open-sse/handlers/embeddings.ts` |
| `GET /v1/embeddings` | Model listing | API route |
| `POST /v1/images/generations` | OpenAI Images | `open-sse/handlers/imageGeneration.ts` |
| `GET /v1/images/generations` | Model listing | API route |
| `POST /v1/providers/{provider}/chat/completions` | OpenAI Chat | Dedicated per-provider with model validation |
| `POST /v1/providers/{provider}/embeddings` | OpenAI Embeddings | Dedicated per-provider with model validation |
| `POST /v1/providers/{provider}/images/generations` | OpenAI Images | Dedicated per-provider with model validation |
| `POST /v1/messages/count_tokens` | Claude Token Count | API route |
| `GET /v1/models` | OpenAI Models list | API route (chat + embedding + image + custom models) |
| `GET /api/models/catalog` | Catalog | All models grouped by provider + type |
| `POST /v1beta/models/*:streamGenerateContent` | Gemini native | API route |
| `GET/PUT/DELETE /api/settings/proxy` | Proxy Config | Network proxy configuration |
| `POST /api/settings/proxy/test` | Proxy Connectivity | Proxy health/connectivity test endpoint |
| `GET/POST/DELETE /api/provider-models` | Custom Models | Custom model management per provider |
| Endpoint | Format | Handler |
| -------------------------------------------------- | ------------------ | ------------------------------------------------------------------- |
| `POST /v1/chat/completions` | OpenAI Chat | `src/sse/handlers/chat.ts` |
| `POST /v1/messages` | Claude Messages | Same handler (auto-detected) |
| `POST /v1/responses` | OpenAI Responses | `open-sse/handlers/responsesHandler.ts` |
| `POST /v1/embeddings` | OpenAI Embeddings | `open-sse/handlers/embeddings.ts` |
| `GET /v1/embeddings` | Model listing | API route |
| `POST /v1/images/generations` | OpenAI Images | `open-sse/handlers/imageGeneration.ts` |
| `GET /v1/images/generations` | Model listing | API route |
| `POST /v1/providers/{provider}/chat/completions` | OpenAI Chat | Dedicated per-provider with model validation |
| `POST /v1/providers/{provider}/embeddings` | OpenAI Embeddings | Dedicated per-provider with model validation |
| `POST /v1/providers/{provider}/images/generations` | OpenAI Images | Dedicated per-provider with model validation |
| `POST /v1/messages/count_tokens` | Claude Token Count | API route |
| `GET /v1/models` | OpenAI Models list | API route (chat + embedding + image + custom models) |
| `GET /api/models/catalog` | Catalog | All models grouped by provider + type |
| `POST /v1beta/models/*:streamGenerateContent` | Gemini native | API route |
| `GET/PUT/DELETE /api/settings/proxy` | Proxy Config | Network proxy configuration |
| `POST /api/settings/proxy/test` | Proxy Connectivity | Proxy health/connectivity test endpoint |
| `GET/POST/DELETE /api/provider-models` | Provider Models | Provider model metadata backing custom and managed available models |
## Bypass Handler

View File

@@ -596,6 +596,11 @@ curl -X POST http://localhost:20128/api/provider-models \
Or use Dashboard: **Providers → [Provider] → Custom Models**.
Notes:
- OpenRouter and OpenAI/Anthropic-compatible providers are managed from **Available Models** only. Manual add, import, and auto-sync all land in the same available-model list, so there is no separate Custom Models section for those providers.
- The **Custom Models** section is intended for providers that do not expose managed available-model imports.
### Dedicated Provider Routes
Route requests directly to a specific provider with model validation:

View File

@@ -212,7 +212,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
"gemini-cli": {
id: "gemini-cli",
alias: "gc",
alias: "gemini-cli",
format: "gemini-cli",
executor: "gemini-cli",
baseUrl: "https://cloudcode-pa.googleapis.com/v1internal",

View File

@@ -950,11 +950,24 @@ export async function handleChatCore({
const executeProviderRequest = async (modelToCall = effectiveModel, allowDedup = false) => {
const execute = async () => {
const bodyToSend =
let bodyToSend =
translatedBody.model === modelToCall
? translatedBody
: { ...translatedBody, model: modelToCall };
// Inject prompt_cache_key for OpenAI providers if not already set
if (
targetFormat === FORMATS.OPENAI &&
!bodyToSend.prompt_cache_key &&
Array.isArray(bodyToSend.messages)
) {
const { generatePromptCacheKey } = await import("@/lib/promptCache");
const cacheKey = generatePromptCacheKey(bodyToSend.messages);
if (cacheKey) {
bodyToSend = { ...bodyToSend, prompt_cache_key: cacheKey };
}
}
const rawResult = await withRateLimit(provider, connectionId, modelToCall, () =>
executor.execute({
model: modelToCall,
@@ -1444,11 +1457,19 @@ export async function handleChatCore({
const cachedTokens = toPositiveNumber(
usage.cache_read_input_tokens ??
usage.cached_tokens ??
((usage as Record<string, unknown>).prompt_tokens_details as Record<string, unknown> | undefined)?.cached_tokens
(
(usage as Record<string, unknown>).prompt_tokens_details as
| Record<string, unknown>
| undefined
)?.cached_tokens
);
const cacheCreationTokens = toPositiveNumber(
usage.cache_creation_input_tokens ??
((usage as Record<string, unknown>).prompt_tokens_details as Record<string, unknown> | undefined)?.cache_creation_tokens
(
(usage as Record<string, unknown>).prompt_tokens_details as
| Record<string, unknown>
| undefined
)?.cache_creation_tokens
);
saveRequestUsage({
@@ -1604,11 +1625,19 @@ export async function handleChatCore({
const cachedTokens = toPositiveNumber(
streamUsage.cache_read_input_tokens ??
streamUsage.cached_tokens ??
((streamUsage as Record<string, unknown>).prompt_tokens_details as Record<string, unknown> | undefined)?.cached_tokens
(
(streamUsage as Record<string, unknown>).prompt_tokens_details as
| Record<string, unknown>
| undefined
)?.cached_tokens
);
const cacheCreationTokens = toPositiveNumber(
streamUsage.cache_creation_input_tokens ??
((streamUsage as Record<string, unknown>).prompt_tokens_details as Record<string, unknown> | undefined)?.cache_creation_tokens
(
(streamUsage as Record<string, unknown>).prompt_tokens_details as
| Record<string, unknown>
| undefined
)?.cache_creation_tokens
);
saveRequestUsage({

View File

@@ -52,6 +52,7 @@ type GeminiRequest = {
safetySettings: unknown;
systemInstruction?: GeminiContent;
tools?: Array<{ functionDeclarations: GeminiFunctionDeclaration[] }>;
cachedContent?: string;
};
type CloudCodeEnvelope = {
@@ -82,6 +83,11 @@ function openaiToGeminiBase(model, body, stream) {
safetySettings: DEFAULT_SAFETY_SETTINGS,
};
// Preserve cachedContent if provided by client (for explicit Gemini caching)
if (body.cachedContent) {
result.cachedContent = body.cachedContent;
}
// Generation config
if (body.temperature !== undefined) {
result.generationConfig.temperature = body.temperature;

View File

@@ -5,6 +5,7 @@ import { Card, Button, EmptyState } from "@/shared/components";
import { useNotificationStore } from "@/store/notificationStore";
import { useTranslations } from "next-intl";
import CacheEntriesTab from "./components/CacheEntriesTab";
import CacheStatsCard from "../settings/components/CacheStatsCard";
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -371,7 +372,9 @@ export default function CachePage() {
<div className="text-lg font-semibold tabular-nums text-green-500">
{promptCacheHitRate.toFixed(1)}%
</div>
<div className="text-xs text-text-muted mt-0.5">{t("cacheHitRate")}</div>
<div className="text-xs text-text-muted mt-0.5">
{t("cacheHitRate")} ({pc.requestsWithCacheControl}/{pc.totalRequests})
</div>
</div>
<div className="p-3 rounded-lg bg-surface/50">
<div className="text-lg font-semibold tabular-nums text-blue-400">
@@ -432,6 +435,9 @@ export default function CachePage() {
</Card>
)}
{/* Prompt Cache Metrics (cumulative with reset) */}
<CacheStatsCard />
{/* Cache Trend (24h) */}
{trend.length > 0 && (
<Card>

View File

@@ -1439,7 +1439,7 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders }) {
};
const FREE_STACK_PRESET_MODELS = [
{ model: "gc/gemini-3-flash-preview", weight: 0 },
{ model: "gemini-cli/gemini-3-flash-preview", weight: 0 },
{ model: "kr/claude-sonnet-4.5", weight: 0 },
{ model: "if/kimi-k2-thinking", weight: 0 },
{ model: "if/qwen3-coder-plus", weight: 0 },

View File

@@ -36,6 +36,7 @@ import {
MODEL_COMPAT_PROTOCOL_KEYS,
type ModelCompatProtocolKey,
} from "@/shared/constants/modelCompat";
import { resolveManagedModelAlias } from "@/shared/utils/providerModelAliases";
type CompatByProtocolMap = Partial<
Record<
@@ -331,6 +332,10 @@ interface CompatibleModelsSectionProps {
providerStorageAlias: string;
providerDisplayAlias: string;
modelAliases: Record<string, string>;
fallbackModels?: CompatModelRow[];
description: string;
inputLabel: string;
inputPlaceholder: string;
copied?: string;
onCopy: (text: string, key: string) => void;
onSetAlias: (modelId: string, alias: string, providerStorageAlias?: string) => Promise<void>;
@@ -850,6 +855,7 @@ export default function ProviderDetailPage() {
const isOpenAICompatible = isOpenAICompatibleProvider(providerId);
const isAnthropicCompatible = isAnthropicCompatibleProvider(providerId);
const isCompatible = isOpenAICompatible || isAnthropicCompatible;
const isManagedAvailableModelsProvider = isCompatible || providerId === "openrouter";
const isSearchProvider = providerId.endsWith("-search");
const providerStorageAlias = isCompatible ? providerId : providerAlias;
@@ -1666,6 +1672,14 @@ export default function ProviderDetailPage() {
};
const [clearingModels, setClearingModels] = useState(false);
const providerAliasEntries = useMemo(
() =>
Object.entries(modelAliases).filter(([, model]) =>
(model as string).startsWith(`${providerStorageAlias}/`)
),
[modelAliases, providerStorageAlias]
);
const handleClearAllModels = async () => {
if (clearingModels) return;
if (!confirm(t("clearAllModelsConfirm"))) return;
@@ -1677,11 +1691,8 @@ export default function ProviderDetailPage() {
);
if (res.ok) {
// Also delete all aliases that belong to this provider
const aliasEntries = Object.entries(modelAliases).filter(([, model]) =>
(model as string).startsWith(`${providerStorageAlias}/`)
);
await Promise.all(
aliasEntries.map(([alias]) =>
providerAliasEntries.map(([alias]) =>
fetch(`/api/models/alias?alias=${encodeURIComponent(alias)}`, {
method: "DELETE",
}).catch(() => {})
@@ -1808,7 +1819,8 @@ export default function ProviderDetailPage() {
</button>
);
const clearAllButton = modelMeta.customModels.length > 0 && (
const clearAllButton = (modelMeta.customModels.length > 0 ||
providerAliasEntries.length > 0) && (
<button
onClick={handleClearAllModels}
disabled={clearingModels}
@@ -1820,7 +1832,21 @@ export default function ProviderDetailPage() {
</button>
);
if (isCompatible) {
if (isManagedAvailableModelsProvider) {
const description =
providerId === "openrouter"
? t("openRouterAnyModelHint")
: t("compatibleModelsDescription", {
type: isAnthropicCompatible ? t("anthropic") : t("openai"),
});
const inputLabel = providerId === "openrouter" ? t("modelIdFromOpenRouter") : t("modelId");
const inputPlaceholder =
providerId === "openrouter"
? t("openRouterModelPlaceholder")
: isAnthropicCompatible
? t("anthropicCompatibleModelPlaceholder")
: t("openaiCompatibleModelPlaceholder");
return (
<div>
<div className="flex items-center gap-2 mb-4">
@@ -1831,6 +1857,10 @@ export default function ProviderDetailPage() {
providerStorageAlias={providerStorageAlias}
providerDisplayAlias={providerDisplayAlias}
modelAliases={modelAliases}
fallbackModels={providerId === "openrouter" ? modelMeta.customModels : undefined}
description={description}
inputLabel={inputLabel}
inputPlaceholder={inputPlaceholder}
copied={copied}
onCopy={copy}
onSetAlias={handleSetAlias}
@@ -2369,8 +2399,8 @@ export default function ProviderDetailPage() {
<h2 className="text-lg font-semibold mb-4">{t("availableModels")}</h2>
{renderModelsSection()}
{/* Custom Models — available for non-compatible, non-search providers */}
{!isCompatible && (
{/* Custom Models — available for providers without managed available-model metadata */}
{!isManagedAvailableModelsProvider && (
<CustomModelsSection
providerId={providerId}
providerAlias={providerDisplayAlias}
@@ -3412,6 +3442,10 @@ function CompatibleModelsSection({
providerStorageAlias,
providerDisplayAlias,
modelAliases,
fallbackModels = [],
description,
inputLabel,
inputPlaceholder,
copied,
onCopy,
onSetAlias,
@@ -3432,33 +3466,45 @@ function CompatibleModelsSection({
const [importing, setImporting] = useState(false);
const notify = useNotificationStore();
const providerAliases = Object.entries(modelAliases).filter(([, model]: [string, any]) =>
(model as string).startsWith(`${providerStorageAlias}/`)
const providerAliases = useMemo(
() =>
Object.entries(modelAliases).filter(([, model]: [string, any]) =>
(model as string).startsWith(`${providerStorageAlias}/`)
),
[modelAliases, providerStorageAlias]
);
const allModels = providerAliases.map(([alias, fullModel]: [string, any]) => ({
modelId: (fullModel as string).replace(`${providerStorageAlias}/`, ""),
fullModel,
alias,
}));
const allModels = useMemo(() => {
const rows = providerAliases.map(([alias, fullModel]: [string, any]) => ({
modelId: (fullModel as string).replace(`${providerStorageAlias}/`, ""),
alias,
}));
const generateDefaultAlias = (modelId) => {
const parts = modelId.split("/");
return parts[parts.length - 1];
};
const seenModelIds = new Set(rows.map((row) => row.modelId));
for (const model of fallbackModels) {
if (!model?.id || seenModelIds.has(model.id)) continue;
rows.push({ modelId: model.id, alias: null });
seenModelIds.add(model.id);
}
const resolveAlias = (modelId) => {
const baseAlias = generateDefaultAlias(modelId);
if (!modelAliases[baseAlias]) return baseAlias;
const prefixedAlias = `${providerDisplayAlias}-${baseAlias}`;
if (!modelAliases[prefixedAlias]) return prefixedAlias;
return null;
};
return rows;
}, [fallbackModels, providerAliases, providerStorageAlias]);
const resolveAlias = useCallback(
(modelId: string, workingAliases: Record<string, string>) =>
resolveManagedModelAlias({
modelId,
fullModel: `${providerStorageAlias}/${modelId}`,
providerDisplayAlias,
existingAliases: workingAliases,
}),
[providerDisplayAlias, providerStorageAlias]
);
const handleAdd = async () => {
if (!newModel.trim() || adding) return;
const modelId = newModel.trim();
const resolvedAlias = resolveAlias(modelId);
const resolvedAlias = resolveAlias(modelId, modelAliases);
if (!resolvedAlias) {
notify.error(t("allSuggestedAliasesExist"));
return;
@@ -3508,6 +3554,7 @@ function CompatibleModelsSection({
setImporting(true);
try {
const workingAliases = { ...modelAliases };
await onImportWithProgress(
// fetchModels callback
async () => {
@@ -3520,7 +3567,7 @@ function CompatibleModelsSection({
async (model: any) => {
const modelId = model.id || model.name || model.model;
if (!modelId) return false;
const resolvedAlias = resolveAlias(modelId);
const resolvedAlias = resolveAlias(modelId, workingAliases);
if (!resolvedAlias) return false;
// Save to customModels DB FIRST - only create alias if this succeeds
@@ -3542,6 +3589,7 @@ function CompatibleModelsSection({
// Only create alias after customModel is saved successfully
await onSetAlias(modelId, resolvedAlias, providerStorageAlias);
workingAliases[resolvedAlias] = `${providerStorageAlias}/${modelId}`;
return true;
}
);
@@ -3556,7 +3604,7 @@ function CompatibleModelsSection({
const canImport = connections.some((conn) => conn.isActive !== false);
// Handle delete: remove from both alias and customModels DB
const handleDeleteModel = async (modelId: string, alias: string) => {
const handleDeleteModel = async (modelId: string, alias?: string | null) => {
try {
// Remove from customModels DB
const res = await fetch(
@@ -3567,7 +3615,9 @@ function CompatibleModelsSection({
throw new Error(t("failedRemoveModelFromDatabase"));
}
// Also delete the alias
await onDeleteAlias(alias);
if (alias) {
await onDeleteAlias(alias);
}
notify.success(t("modelRemovedSuccess"));
onModelsChanged?.();
} catch (error) {
@@ -3578,11 +3628,7 @@ function CompatibleModelsSection({
return (
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">
{t("compatibleModelsDescription", {
type: isAnthropic ? t("anthropic") : t("openai"),
})}
</p>
<p className="text-sm text-text-muted">{description}</p>
<div className="flex items-end gap-2 flex-wrap">
<div className="flex-1 min-w-[240px]">
@@ -3590,7 +3636,7 @@ function CompatibleModelsSection({
htmlFor="new-compatible-model-input"
className="text-xs text-text-muted mb-1 block"
>
{t("modelId")}
{inputLabel}
</label>
<input
id="new-compatible-model-input"
@@ -3598,11 +3644,7 @@ function CompatibleModelsSection({
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
placeholder={
isAnthropic
? t("anthropicCompatibleModelPlaceholder")
: t("openaiCompatibleModelPlaceholder")
}
placeholder={inputPlaceholder}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/>
</div>
@@ -3624,9 +3666,9 @@ function CompatibleModelsSection({
{allModels.length > 0 && (
<div className="flex flex-col gap-3">
{allModels.map(({ modelId, fullModel, alias }) => (
{allModels.map(({ modelId, alias }) => (
<PassthroughModelRow
key={fullModel as string}
key={`${providerStorageAlias}:${modelId}`}
modelId={modelId}
fullModel={`${providerDisplayAlias}/${modelId}`}
copied={copied}
@@ -3651,6 +3693,10 @@ CompatibleModelsSection.propTypes = {
providerStorageAlias: PropTypes.string.isRequired,
providerDisplayAlias: PropTypes.string.isRequired,
modelAliases: PropTypes.object.isRequired,
fallbackModels: PropTypes.array,
description: PropTypes.string.isRequired,
inputLabel: PropTypes.string.isRequired,
inputPlaceholder: PropTypes.string.isRequired,
copied: PropTypes.string,
onCopy: PropTypes.func.isRequired,
onSetAlias: PropTypes.func.isRequired,

View File

@@ -106,7 +106,10 @@ export default function AppearanceTab() {
{ id: "cyan", color: COLOR_THEMES.cyan, label: t("themeCyan") },
];
const sidebarSections = SIDEBAR_SECTIONS.map((section) => ({
const showDebug = settings.debugMode === true;
const sidebarSections = SIDEBAR_SECTIONS.filter(
(section) => section.visibility !== "debug" || showDebug
).map((section) => ({
...section,
title: getSidebarLabel(section.titleKey, section.titleFallback),
items: section.items.map((item) => ({ ...item, label: tSidebar(item.i18nKey) })),

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { Card } from "@/shared/components";
import { useTranslations } from "next-intl";
@@ -33,19 +33,26 @@ interface CacheMetrics {
lastUpdated: string;
}
const REFRESH_INTERVAL_MS = 10_000;
const REFRESH_INTERVAL_SECONDS = REFRESH_INTERVAL_MS / 1000;
export default function CacheStatsCard() {
const [metrics, setMetrics] = useState<CacheMetrics | null>(null);
const [resetting, setResetting] = useState(false);
const t = useTranslations("settings");
const t = useTranslations("cache");
const fetchMetrics = () => {
const fetchMetrics = useCallback(() => {
fetch("/api/settings/cache-metrics")
.then((r) => r.json())
.then(setMetrics)
.catch(() => {});
};
}, []);
useEffect(fetchMetrics, []);
useEffect(() => {
void fetchMetrics();
const id = setInterval(() => void fetchMetrics(), REFRESH_INTERVAL_MS);
return () => clearInterval(id);
}, [fetchMetrics]);
const handleReset = async () => {
setResetting(true);
@@ -63,132 +70,148 @@ export default function CacheStatsCard() {
: 0;
return (
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-text-main flex items-center gap-2">
<span className="material-symbols-outlined text-[20px]">insights</span>
Prompt Cache Metrics
</h3>
<button
onClick={handleReset}
disabled={resetting}
className="px-3 py-1.5 text-xs rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors disabled:opacity-50"
>
{resetting ? "Resetting..." : "Reset Metrics"}
</button>
</div>
{metrics ? (
<div className="space-y-4">
{/* Overview Stats */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-text-muted">Total Requests</p>
<p className="font-mono text-lg text-text-main">{metrics.totalRequests}</p>
</div>
<div>
<p className="text-text-muted">With Cache Control</p>
<p className="font-mono text-lg text-text-main">{metrics.requestsWithCacheControl}</p>
</div>
<Card>
<div className="p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span
className="material-symbols-outlined text-base text-text-muted"
aria-hidden="true"
>
insights
</span>
<h2 className="font-medium text-sm">{t("cacheMetrics")}</h2>
</div>
{/* Token Stats */}
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<p className="text-text-muted">Input Tokens</p>
<p className="font-mono text-lg text-text-main">
{metrics.totalInputTokens.toLocaleString()}
</p>
</div>
<div>
<p className="text-text-muted">Cached Tokens (Read)</p>
<p className="font-mono text-lg text-green-400">
{metrics.totalCachedTokens.toLocaleString()}
</p>
</div>
<div>
<p className="text-text-muted">Cache Creation (Write)</p>
<p className="font-mono text-lg text-blue-400">
{metrics.totalCacheCreationTokens.toLocaleString()}
</p>
</div>
<div className="flex items-center gap-3">
<span className="text-xs text-text-muted">
{t("autoRefresh", { seconds: REFRESH_INTERVAL_SECONDS })}
</span>
<button
onClick={handleReset}
disabled={resetting}
className="px-3 py-1.5 text-xs rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors disabled:opacity-50"
>
{resetting ? t("resetting") : t("resetMetrics")}
</button>
</div>
{/* Cache Ratio */}
<div className="rounded-lg bg-surface/50 border border-border/30 p-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-text-main">Cache Reuse Ratio</p>
<p className="text-xs text-text-muted">Cached tokens / Total input tokens</p>
</div>
<p className="font-mono text-xl text-green-400">{cacheHitRate.toFixed(1)}%</p>
</div>
{/* Progress bar */}
<div className="mt-2 h-2 rounded-full bg-border/30 overflow-hidden">
<div
className="h-full bg-green-500 transition-all duration-300"
style={{ width: `${Math.min(cacheHitRate, 100)}%` }}
/>
</div>
</div>
{/* Savings */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-text-muted">Tokens Saved</p>
<p className="font-mono text-lg text-green-400">
{metrics.tokensSaved.toLocaleString()}
</p>
</div>
<div>
<p className="text-text-muted">Est. Cost Saved</p>
<p className="font-mono text-lg text-green-400">
${metrics.estimatedCostSaved.toFixed(4)}
</p>
</div>
</div>
{/* By Provider */}
{Object.keys(metrics.byProvider).length > 0 && (
<div className="pt-3 border-t border-border/30">
<p className="text-xs font-medium text-text-muted mb-2">By Provider</p>
<div className="space-y-2">
{Object.entries(metrics.byProvider).map(([provider, stats]) => {
const providerCacheRate =
stats.inputTokens > 0 ? (stats.cachedTokens / stats.inputTokens) * 100 : 0;
return (
<div
key={provider}
className="flex items-center justify-between px-3 py-2 rounded bg-surface/30 text-xs"
>
<div className="flex items-center gap-3">
<span className="text-text-main capitalize w-24">{provider}</span>
<span className="text-text-muted">{stats.requests} reqs</span>
</div>
<div className="flex items-center gap-4 font-mono">
<span className="text-text-muted" title="Input tokens">
In: {stats.inputTokens.toLocaleString()}
</span>
<span className="text-green-400" title="Cached tokens (reads)">
Cached: {stats.cachedTokens.toLocaleString()}
</span>
<span className="text-blue-400" title="Cache creation tokens (writes)">
Write: {stats.cacheCreationTokens.toLocaleString()}
</span>
<span className="text-green-400 w-12 text-right">
{providerCacheRate.toFixed(0)}%
</span>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
) : (
<p className="text-sm text-text-muted">Loading cache metrics...</p>
)}
{metrics ? (
<div className="flex flex-col gap-4">
{/* Overview Stats */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-text-muted">{t("totalRequests")}</p>
<p className="font-mono text-lg text-text-main">{metrics.totalRequests}</p>
</div>
<div>
<p className="text-text-muted">{t("withCacheControl")}</p>
<p className="font-mono text-lg text-text-main">
{metrics.requestsWithCacheControl}
</p>
</div>
</div>
{/* Token Stats */}
<div className="grid grid-cols-3 gap-4 text-sm">
<div>
<p className="text-text-muted">{t("inputTokens")}</p>
<p className="font-mono text-lg text-text-main">
{metrics.totalInputTokens.toLocaleString()}
</p>
</div>
<div>
<p className="text-text-muted">{t("cachedTokensRead")}</p>
<p className="font-mono text-lg text-green-400">
{metrics.totalCachedTokens.toLocaleString()}
</p>
</div>
<div>
<p className="text-text-muted">{t("cacheCreationWrite")}</p>
<p className="font-mono text-lg text-blue-400">
{metrics.totalCacheCreationTokens.toLocaleString()}
</p>
</div>
</div>
{/* Cache Ratio */}
<div className="rounded-lg bg-surface/50 border border-border/30 p-3">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-text-main">{t("cacheReuseRatio")}</p>
<p className="text-xs text-text-muted">{t("cacheReuseRatioDesc")}</p>
</div>
<p className="font-mono text-xl text-green-400">{cacheHitRate.toFixed(1)}%</p>
</div>
{/* Progress bar */}
<div className="mt-2 h-2 rounded-full bg-border/30 overflow-hidden">
<div
className="h-full bg-green-500 transition-all duration-300"
style={{ width: `${Math.min(cacheHitRate, 100)}%` }}
/>
</div>
</div>
{/* Savings */}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-text-muted">{t("tokensSaved")}</p>
<p className="font-mono text-lg text-green-400">
{metrics.tokensSaved.toLocaleString()}
</p>
</div>
<div>
<p className="text-text-muted">{t("estCostSaved")}</p>
<p className="font-mono text-lg text-green-400">
${metrics.estimatedCostSaved.toFixed(4)}
</p>
</div>
</div>
{/* By Provider */}
{Object.keys(metrics.byProvider).length > 0 && (
<div className="pt-3 border-t border-border/30">
<p className="text-xs font-medium text-text-muted mb-2">{t("byProvider")}</p>
<div className="space-y-2">
{Object.entries(metrics.byProvider).map(([provider, stats]) => {
const providerCacheRate =
stats.inputTokens > 0 ? (stats.cachedTokens / stats.inputTokens) * 100 : 0;
return (
<div
key={provider}
className="flex items-center justify-between px-3 py-2 rounded bg-surface/30 text-xs"
>
<div className="flex items-center gap-3">
<span className="text-text-main capitalize w-24">{provider}</span>
<span className="text-text-muted">
{stats.requests} {t("requestsShort")}
</span>
</div>
<div className="flex items-center gap-4 font-mono">
<span className="text-text-muted" title={t("inputTokens")}>
{t("inputShort")}: {stats.inputTokens.toLocaleString()}
</span>
<span className="text-green-400" title={t("cachedTokensRead")}>
{t("cachedShort")}: {stats.cachedTokens.toLocaleString()}
</span>
<span className="text-blue-400" title={t("cacheCreationWrite")}>
{t("writeShort")}: {stats.cacheCreationTokens.toLocaleString()}
</span>
<span className="text-green-400 w-12 text-right">
{providerCacheRate.toFixed(0)}%
</span>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
) : (
<p className="text-sm text-text-muted">{t("loading")}</p>
)}
</div>
</Card>
);
}

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { getDbInstance } from "@/lib/db/core";
import { isAuthenticated } from "@/shared/utils/apiAuth";
interface CacheEntry {
id: string;
@@ -12,6 +13,10 @@ interface CacheEntry {
}
export async function GET(req: NextRequest) {
if (!(await isAuthenticated(req))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { searchParams } = new URL(req.url);
const page = Math.max(1, parseInt(searchParams.get("page") || "1", 10));
@@ -71,6 +76,10 @@ export async function GET(req: NextRequest) {
}
export async function DELETE(req: NextRequest) {
if (!(await isAuthenticated(req))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { searchParams } = new URL(req.url);
const signature = searchParams.get("signature");

View File

@@ -9,15 +9,21 @@ import {
} from "@/lib/semanticCache";
import { getIdempotencyStats } from "@/lib/idempotencyLayer";
import { getCacheMetrics, getCacheTrend } from "@/lib/db/settings";
import { isAuthenticated } from "@/shared/utils/apiAuth";
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
export async function GET(req: NextRequest) {
if (!(await isAuthenticated(req))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { searchParams } = new URL(req.url);
const trendHours = parseInt(searchParams.get("trendHours") || "24", 10);
const rawHours = parseInt(searchParams.get("trendHours") || "24", 10);
const trendHours = Math.min(720, Math.max(1, Number.isNaN(rawHours) ? 24 : rawHours));
const cacheStats = getCacheStats();
const idempotencyStats = getIdempotencyStats();
@@ -36,6 +42,10 @@ export async function GET(req: NextRequest) {
}
export async function DELETE(req: NextRequest) {
if (!(await isAuthenticated(req))) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
try {
const { searchParams } = new URL(req.url);
const model = searchParams.get("model");

View File

@@ -1,6 +1,10 @@
import { NextResponse } from "next/server";
import { getProviderConnectionById } from "@/models";
import { replaceCustomModels } from "@/lib/db/models";
import {
syncManagedAvailableModelAliases,
usesManagedAvailableModels,
} from "@/lib/providerModels/managedAvailableModels";
import { saveCallLog } from "@/lib/usage/callLogs";
import { isAuthenticated } from "@/shared/utils/apiAuth";
import {
@@ -77,9 +81,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
const fetchedModels = modelsData.models || [];
// Filter out models already in the built-in registry
const registryIds = new Set(
getModelsByProviderId(connection.provider).map((m: any) => m.id)
);
const registryIds = new Set(getModelsByProviderId(connection.provider).map((m: any) => m.id));
// Replace the full model list
const models = fetchedModels
@@ -92,6 +94,15 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
const replaced = await replaceCustomModels(connection.provider, models);
let syncedAliases = 0;
if (usesManagedAvailableModels(connection.provider)) {
const aliasSync = await syncManagedAvailableModelAliases(
connection.provider,
models.map((model: any) => model.id)
);
syncedAliases = aliasSync.assignedAliases.length;
}
// Log the successful sync
await saveCallLog({
method: "GET",
@@ -105,6 +116,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
requestType: "model-sync",
responseBody: {
syncedModels: models.length,
syncedAliases,
provider: connection.provider,
},
});
@@ -113,6 +125,7 @@ export async function POST(request: Request, { params }: { params: Promise<{ id:
ok: true,
provider: connection.provider,
syncedModels: replaced.length,
syncedAliases,
models: replaced,
});
} catch (error: any) {

View File

@@ -2937,6 +2937,19 @@
"cacheHitRate": "Cache Hit Rate",
"cachedTokens": "Cached Tokens",
"cacheCreationTokens": "Cache Creation Tokens",
"cacheMetrics": "Prompt Cache Metrics",
"withCacheControl": "With Cache Control",
"cachedTokensRead": "Cached Tokens (Read)",
"cacheCreationWrite": "Cache Creation (Write)",
"cacheReuseRatio": "Cache Reuse Ratio",
"cacheReuseRatioDesc": "Cached tokens / Total input tokens",
"estCostSaved": "Est. Cost Saved",
"requestsShort": "reqs",
"inputShort": "In",
"cachedShort": "Cached",
"writeShort": "Write",
"resetting": "Resetting...",
"resetMetrics": "Reset Metrics",
"byProvider": "Breakdown by Provider",
"provider": "Provider",
"requests": "Requests",

View File

@@ -630,7 +630,7 @@ export async function getCacheMetrics() {
totalCachedTokens: totalsRow?.totalCachedTokens || 0,
totalCacheCreationTokens: totalsRow?.totalCacheCreationTokens || 0,
tokensSaved,
estimatedCostSaved: 0, // Would need pricing data to calculate
estimatedCostSaved,
byProvider,
byStrategy,
lastUpdated: new Date().toISOString(),

View File

@@ -75,9 +75,9 @@ const LITELLM_PRICING_URL =
const LITELLM_PROVIDER_MAP: Record<string, string[]> = {
openai: ["openai", "cx"],
anthropic: ["anthropic", "cc"],
vertex_ai: ["gemini", "gc"],
vertex_ai: ["gemini", "gemini-cli"],
"vertex_ai-anthropic_models": ["anthropic"],
google: ["gemini", "gc"],
google: ["gemini", "gemini-cli"],
deepseek: ["if"],
groq: ["groq"],
together_ai: ["openrouter"],

View File

@@ -1 +1 @@
export { analyzePrefix, shouldInjectCacheControl } from "./prefixAnalyzer";
export { analyzePrefix, shouldInjectCacheControl, generatePromptCacheKey } from "./prefixAnalyzer";

View File

@@ -75,3 +75,11 @@ export function analyzePrefix(messages: Message[]): PrefixAnalysis {
export function shouldInjectCacheControl(analysis: PrefixAnalysis, minTokens = 1024): boolean {
return analysis.prefixTokens >= minTokens && analysis.confidence >= 0.7;
}
export function generatePromptCacheKey(messages: Message[]): string {
const analysis = analyzePrefix(messages);
if (analysis.prefixHash) {
return `omni-${analysis.prefixHash.slice(0, 32)}`;
}
return "";
}

View File

@@ -0,0 +1,91 @@
import {
deleteModelAlias,
getModelAliases,
getProviderNodeById,
setModelAlias,
} from "@/lib/localDb";
import {
getProviderAlias,
isAnthropicCompatibleProvider,
isOpenAICompatibleProvider,
} from "@/shared/constants/providers";
import { resolveManagedModelAlias } from "@/shared/utils/providerModelAliases";
function isCompatibleProvider(providerId: string): boolean {
return isOpenAICompatibleProvider(providerId) || isAnthropicCompatibleProvider(providerId);
}
export function usesManagedAvailableModels(providerId: string): boolean {
return providerId === "openrouter" || isCompatibleProvider(providerId);
}
function getProviderStoragePrefix(providerId: string): string {
if (isCompatibleProvider(providerId)) return providerId;
return getProviderAlias(providerId) || providerId;
}
async function getProviderDisplayPrefix(providerId: string): Promise<string> {
if (!isCompatibleProvider(providerId)) {
return getProviderAlias(providerId) || providerId;
}
const providerNode = await getProviderNodeById(providerId);
const prefix = providerNode?.prefix;
return typeof prefix === "string" && prefix.trim().length > 0 ? prefix.trim() : providerId;
}
export async function syncManagedAvailableModelAliases(providerId: string, modelIds: string[]) {
const storagePrefix = getProviderStoragePrefix(providerId);
const displayPrefix = await getProviderDisplayPrefix(providerId);
const existingAliasesRaw = await getModelAliases();
const workingAliases = Object.fromEntries(
Object.entries(existingAliasesRaw).filter((entry): entry is [string, string] => {
const [, value] = entry;
return typeof value === "string";
})
);
const targetModelIds = Array.from(
new Set(
modelIds.map((modelId) => (typeof modelId === "string" ? modelId.trim() : "")).filter(Boolean)
)
);
const targetFullModels = new Set(targetModelIds.map((modelId) => `${storagePrefix}/${modelId}`));
const removedAliases: string[] = [];
for (const [alias, value] of Object.entries(workingAliases)) {
if (!value.startsWith(`${storagePrefix}/`)) continue;
if (targetFullModels.has(value)) continue;
await deleteModelAlias(alias);
delete workingAliases[alias];
removedAliases.push(alias);
}
const assignedAliases: string[] = [];
for (const modelId of targetModelIds) {
const fullModel = `${storagePrefix}/${modelId}`;
const alias = resolveManagedModelAlias({
modelId,
fullModel,
providerDisplayAlias: displayPrefix,
existingAliases: workingAliases,
});
if (!alias) continue;
if (workingAliases[alias] !== fullModel) {
await setModelAlias(alias, fullModel);
workingAliases[alias] = fullModel;
}
assignedAliases.push(alias);
}
return {
assignedAliases,
removedAliases,
storagePrefix,
};
}

View File

@@ -40,7 +40,7 @@ export default function Sidebar({
useEffect(() => {
const applySettings = (data) => {
setShowDebug(data?.enableRequestLogs === true);
setShowDebug(data?.debugMode === true);
setHiddenSidebarItems(normalizeHiddenSidebarItems(data?.[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]));
};
@@ -52,8 +52,8 @@ export default function Sidebar({
const handleSettingsUpdated = (event: Event) => {
const detail = (event as CustomEvent<Record<string, unknown>>).detail || {};
if ("enableRequestLogs" in detail) {
setShowDebug(detail.enableRequestLogs === true);
if ("debugMode" in detail) {
setShowDebug(detail.debugMode === true);
}
if (HIDDEN_SIDEBAR_ITEMS_SETTING_KEY in detail) {

View File

@@ -189,8 +189,8 @@ export const DEFAULT_PRICING = {
},
},
// Gemini CLI (gc)
gc: {
// Gemini CLI
"gemini-cli": {
"gemini-3-flash-preview": {
input: 0.5,
output: 3.0,
@@ -1299,7 +1299,7 @@ type TokenUsage = Record<string, number | undefined>;
/**
* Get pricing for a specific provider and model
* @param {string} provider - Provider ID (e.g., "openai", "cc", "gc")
* @param {string} provider - Provider ID (e.g., "openai", "cc", "gemini-cli")
* @param {string} model - Model ID
* @returns {object|null} Pricing object or null if not found
*/

View File

@@ -6,7 +6,7 @@ export const FREE_PROVIDERS = {
qwen: { id: "qwen", alias: "qw", name: "Qwen Code", icon: "psychology", color: "#10B981" },
"gemini-cli": {
id: "gemini-cli",
alias: "gc",
alias: "gemini-cli",
name: "Gemini CLI",
icon: "terminal",
color: "#4285F4",

View File

@@ -0,0 +1,68 @@
type AliasMap = Record<string, string>;
export function getDefaultModelAliasBase(modelId: string): string {
const trimmed = modelId.trim();
if (!trimmed) return "";
const segments = trimmed
.split("/")
.map((segment) => segment.trim())
.filter(Boolean);
return segments[segments.length - 1] || trimmed;
}
export function resolveManagedModelAlias({
modelId,
fullModel,
providerDisplayAlias,
existingAliases,
}: {
modelId: string;
fullModel: string;
providerDisplayAlias: string;
existingAliases: AliasMap;
}): string | null {
const baseAlias = getDefaultModelAliasBase(modelId);
if (!baseAlias) return null;
for (const [alias, value] of Object.entries(existingAliases)) {
if (value === fullModel) return alias;
}
const displayAlias = providerDisplayAlias.trim();
const candidates: string[] = [];
const seen = new Set<string>();
const pushCandidate = (candidate: string) => {
const trimmed = candidate.trim();
if (!trimmed || seen.has(trimmed)) return;
seen.add(trimmed);
candidates.push(trimmed);
};
pushCandidate(baseAlias);
if (displayAlias) {
pushCandidate(`${displayAlias}-${baseAlias}`);
}
for (const candidate of candidates) {
if (!(candidate in existingAliases) || existingAliases[candidate] === fullModel) {
return candidate;
}
}
for (let suffix = 2; suffix <= 5000; suffix += 1) {
if (displayAlias) {
const prefixed = `${displayAlias}-${baseAlias}-${suffix}`;
if (!(prefixed in existingAliases) || existingAliases[prefixed] === fullModel) {
return prefixed;
}
}
const fallback = `${baseAlias}-${suffix}`;
if (!(fallback in existingAliases) || existingAliases[fallback] === fullModel) {
return fallback;
}
}
return null;
}

View File

@@ -0,0 +1,69 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-managed-models-"));
process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const modelsDb = await import("../../src/lib/db/models.ts");
const managedModels = await import("../../src/lib/providerModels/managedAvailableModels.ts");
const aliasUtils = await import("../../src/shared/utils/providerModelAliases.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(async () => {
await resetStorage();
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("resolveManagedModelAlias preserves existing aliases and falls back to provider-prefixed suffixes", () => {
const first = aliasUtils.resolveManagedModelAlias({
modelId: "anthropic/claude-3.7-sonnet",
fullModel: "openrouter/anthropic/claude-3.7-sonnet",
providerDisplayAlias: "openrouter",
existingAliases: {
"claude-3.7-sonnet": "other-provider/claude-3.7-sonnet",
"openrouter-claude-3.7-sonnet": "other-provider/claude-3.7-sonnet",
},
});
assert.equal(first, "openrouter-claude-3.7-sonnet-2");
const preserved = aliasUtils.resolveManagedModelAlias({
modelId: "openai/gpt-4.1",
fullModel: "openrouter/openai/gpt-4.1",
providerDisplayAlias: "openrouter",
existingAliases: {
kept: "openrouter/openai/gpt-4.1",
"gpt-4.1": "other-provider/gpt-4.1",
},
});
assert.equal(preserved, "kept");
});
test("syncManagedAvailableModelAliases backfills openrouter aliases and removes stale entries", async () => {
await modelsDb.setModelAlias("kept", "openrouter/openai/gpt-4.1");
await modelsDb.setModelAlias("claude-3.7-sonnet", "other-provider/claude-3.7-sonnet");
await modelsDb.setModelAlias("stale-model", "openrouter/legacy/stale-model");
const result = await managedModels.syncManagedAvailableModelAliases("openrouter", [
"openai/gpt-4.1",
"anthropic/claude-3.7-sonnet",
]);
const aliases = await modelsDb.getModelAliases();
assert.deepEqual(result.removedAliases, ["stale-model"]);
assert.equal(aliases.kept, "openrouter/openai/gpt-4.1");
assert.equal(aliases["openrouter-claude-3.7-sonnet"], "openrouter/anthropic/claude-3.7-sonnet");
assert.equal(aliases["stale-model"], undefined);
});