diff --git a/README.md b/README.md index 2c61085ac6..09fb5285d2 100644 --- a/README.md +++ b/README.md @@ -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. +
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 17625dfc2a..5db3edc0cc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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 diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 04a6fb22fd..44d4855453 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -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: diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index ec430a3b55..4d69ded5fc 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -212,7 +212,7 @@ export const REGISTRY: Record = { "gemini-cli": { id: "gemini-cli", - alias: "gc", + alias: "gemini-cli", format: "gemini-cli", executor: "gemini-cli", baseUrl: "https://cloudcode-pa.googleapis.com/v1internal", diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index c60b379a0e..5c3312ab87 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -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).prompt_tokens_details as Record | undefined)?.cached_tokens + ( + (usage as Record).prompt_tokens_details as + | Record + | undefined + )?.cached_tokens ); const cacheCreationTokens = toPositiveNumber( usage.cache_creation_input_tokens ?? - ((usage as Record).prompt_tokens_details as Record | undefined)?.cache_creation_tokens + ( + (usage as Record).prompt_tokens_details as + | Record + | 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).prompt_tokens_details as Record | undefined)?.cached_tokens + ( + (streamUsage as Record).prompt_tokens_details as + | Record + | undefined + )?.cached_tokens ); const cacheCreationTokens = toPositiveNumber( streamUsage.cache_creation_input_tokens ?? - ((streamUsage as Record).prompt_tokens_details as Record | undefined)?.cache_creation_tokens + ( + (streamUsage as Record).prompt_tokens_details as + | Record + | undefined + )?.cache_creation_tokens ); saveRequestUsage({ diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index 4aa3bba95f..8d4d2b4047 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -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; diff --git a/src/app/(dashboard)/dashboard/cache/page.tsx b/src/app/(dashboard)/dashboard/cache/page.tsx index 24064e8403..0f514543cb 100644 --- a/src/app/(dashboard)/dashboard/cache/page.tsx +++ b/src/app/(dashboard)/dashboard/cache/page.tsx @@ -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() {
{promptCacheHitRate.toFixed(1)}%
-
{t("cacheHitRate")}
+
+ {t("cacheHitRate")} ({pc.requestsWithCacheControl}/{pc.totalRequests}) +
@@ -432,6 +435,9 @@ export default function CachePage() { )} + {/* Prompt Cache Metrics (cumulative with reset) */} + + {/* Cache Trend (24h) */} {trend.length > 0 && ( diff --git a/src/app/(dashboard)/dashboard/combos/page.tsx b/src/app/(dashboard)/dashboard/combos/page.tsx index 952b50276e..181cded1fb 100644 --- a/src/app/(dashboard)/dashboard/combos/page.tsx +++ b/src/app/(dashboard)/dashboard/combos/page.tsx @@ -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 }, diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx index c7fd8c35eb..2b8e405427 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.tsx @@ -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; + fallbackModels?: CompatModelRow[]; + description: string; + inputLabel: string; + inputPlaceholder: string; copied?: string; onCopy: (text: string, key: string) => void; onSetAlias: (modelId: string, alias: string, providerStorageAlias?: string) => Promise; @@ -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() { ); - const clearAllButton = modelMeta.customModels.length > 0 && ( + const clearAllButton = (modelMeta.customModels.length > 0 || + providerAliasEntries.length > 0) && ( -
- - {metrics ? ( -
- {/* Overview Stats */} -
-
-

Total Requests

-

{metrics.totalRequests}

-
-
-

With Cache Control

-

{metrics.requestsWithCacheControl}

-
+ +
+
+
+ +

{t("cacheMetrics")}

- - {/* Token Stats */} -
-
-

Input Tokens

-

- {metrics.totalInputTokens.toLocaleString()} -

-
-
-

Cached Tokens (Read)

-

- {metrics.totalCachedTokens.toLocaleString()} -

-
-
-

Cache Creation (Write)

-

- {metrics.totalCacheCreationTokens.toLocaleString()} -

-
+
+ + {t("autoRefresh", { seconds: REFRESH_INTERVAL_SECONDS })} + +
- - {/* Cache Ratio */} -
-
-
-

Cache Reuse Ratio

-

Cached tokens / Total input tokens

-
-

{cacheHitRate.toFixed(1)}%

-
- {/* Progress bar */} -
-
-
-
- - {/* Savings */} -
-
-

Tokens Saved

-

- {metrics.tokensSaved.toLocaleString()} -

-
-
-

Est. Cost Saved

-

- ${metrics.estimatedCostSaved.toFixed(4)} -

-
-
- - {/* By Provider */} - {Object.keys(metrics.byProvider).length > 0 && ( -
-

By Provider

-
- {Object.entries(metrics.byProvider).map(([provider, stats]) => { - const providerCacheRate = - stats.inputTokens > 0 ? (stats.cachedTokens / stats.inputTokens) * 100 : 0; - return ( -
-
- {provider} - {stats.requests} reqs -
-
- - In: {stats.inputTokens.toLocaleString()} - - - Cached: {stats.cachedTokens.toLocaleString()} - - - Write: {stats.cacheCreationTokens.toLocaleString()} - - - {providerCacheRate.toFixed(0)}% - -
-
- ); - })} -
-
- )}
- ) : ( -

Loading cache metrics...

- )} + + {metrics ? ( +
+ {/* Overview Stats */} +
+
+

{t("totalRequests")}

+

{metrics.totalRequests}

+
+
+

{t("withCacheControl")}

+

+ {metrics.requestsWithCacheControl} +

+
+
+ + {/* Token Stats */} +
+
+

{t("inputTokens")}

+

+ {metrics.totalInputTokens.toLocaleString()} +

+
+
+

{t("cachedTokensRead")}

+

+ {metrics.totalCachedTokens.toLocaleString()} +

+
+
+

{t("cacheCreationWrite")}

+

+ {metrics.totalCacheCreationTokens.toLocaleString()} +

+
+
+ + {/* Cache Ratio */} +
+
+
+

{t("cacheReuseRatio")}

+

{t("cacheReuseRatioDesc")}

+
+

{cacheHitRate.toFixed(1)}%

+
+ {/* Progress bar */} +
+
+
+
+ + {/* Savings */} +
+
+

{t("tokensSaved")}

+

+ {metrics.tokensSaved.toLocaleString()} +

+
+
+

{t("estCostSaved")}

+

+ ${metrics.estimatedCostSaved.toFixed(4)} +

+
+
+ + {/* By Provider */} + {Object.keys(metrics.byProvider).length > 0 && ( +
+

{t("byProvider")}

+
+ {Object.entries(metrics.byProvider).map(([provider, stats]) => { + const providerCacheRate = + stats.inputTokens > 0 ? (stats.cachedTokens / stats.inputTokens) * 100 : 0; + return ( +
+
+ {provider} + + {stats.requests} {t("requestsShort")} + +
+
+ + {t("inputShort")}: {stats.inputTokens.toLocaleString()} + + + {t("cachedShort")}: {stats.cachedTokens.toLocaleString()} + + + {t("writeShort")}: {stats.cacheCreationTokens.toLocaleString()} + + + {providerCacheRate.toFixed(0)}% + +
+
+ ); + })} +
+
+ )} +
+ ) : ( +

{t("loading")}

+ )} +
); } diff --git a/src/app/api/cache/entries/route.ts b/src/app/api/cache/entries/route.ts index 26f30b02a9..97ff51ca9a 100644 --- a/src/app/api/cache/entries/route.ts +++ b/src/app/api/cache/entries/route.ts @@ -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"); diff --git a/src/app/api/cache/route.ts b/src/app/api/cache/route.ts index d1bca53891..dd7364f929 100644 --- a/src/app/api/cache/route.ts +++ b/src/app/api/cache/route.ts @@ -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"); diff --git a/src/app/api/providers/[id]/sync-models/route.ts b/src/app/api/providers/[id]/sync-models/route.ts index b9a4e9d4f5..7b6d8b858e 100644 --- a/src/app/api/providers/[id]/sync-models/route.ts +++ b/src/app/api/providers/[id]/sync-models/route.ts @@ -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) { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 2e5fe96d74..4eac924275 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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", diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 333d71379f..5173e81e14 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -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(), diff --git a/src/lib/pricingSync.ts b/src/lib/pricingSync.ts index f3836eee36..b6f1fe65c6 100644 --- a/src/lib/pricingSync.ts +++ b/src/lib/pricingSync.ts @@ -75,9 +75,9 @@ const LITELLM_PRICING_URL = const LITELLM_PROVIDER_MAP: Record = { 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"], diff --git a/src/lib/promptCache/index.ts b/src/lib/promptCache/index.ts index f4ee1fd501..2d1446f864 100644 --- a/src/lib/promptCache/index.ts +++ b/src/lib/promptCache/index.ts @@ -1 +1 @@ -export { analyzePrefix, shouldInjectCacheControl } from "./prefixAnalyzer"; +export { analyzePrefix, shouldInjectCacheControl, generatePromptCacheKey } from "./prefixAnalyzer"; diff --git a/src/lib/promptCache/prefixAnalyzer.ts b/src/lib/promptCache/prefixAnalyzer.ts index 43f622c87f..6f8e86e12e 100644 --- a/src/lib/promptCache/prefixAnalyzer.ts +++ b/src/lib/promptCache/prefixAnalyzer.ts @@ -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 ""; +} diff --git a/src/lib/providerModels/managedAvailableModels.ts b/src/lib/providerModels/managedAvailableModels.ts new file mode 100644 index 0000000000..152f8b6127 --- /dev/null +++ b/src/lib/providerModels/managedAvailableModels.ts @@ -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 { + 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, + }; +} diff --git a/src/shared/components/Sidebar.tsx b/src/shared/components/Sidebar.tsx index fb77a3017a..2eeb5a3b65 100644 --- a/src/shared/components/Sidebar.tsx +++ b/src/shared/components/Sidebar.tsx @@ -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>).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) { diff --git a/src/shared/constants/pricing.ts b/src/shared/constants/pricing.ts index 827e467e35..a08fc3e693 100644 --- a/src/shared/constants/pricing.ts +++ b/src/shared/constants/pricing.ts @@ -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; /** * 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 */ diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 54f78c0247..6f81b77246 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -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", diff --git a/src/shared/utils/providerModelAliases.ts b/src/shared/utils/providerModelAliases.ts new file mode 100644 index 0000000000..ca3ae54a4b --- /dev/null +++ b/src/shared/utils/providerModelAliases.ts @@ -0,0 +1,68 @@ +type AliasMap = Record; + +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(); + 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; +} diff --git a/tests/unit/managed-available-models.test.mjs b/tests/unit/managed-available-models.test.mjs new file mode 100644 index 0000000000..eef8513c8c --- /dev/null +++ b/tests/unit/managed-available-models.test.mjs @@ -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); +});