From dcb32a6ba06d6e9fde7fe1724827f676fb3ca565 Mon Sep 17 00:00:00 2001 From: Dohyun Jung Date: Tue, 12 May 2026 09:14:25 +0900 Subject: [PATCH] feat(api): aggregate combo model metadata in catalog (#2166) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.0 — adds target-based metadata aggregation for combo entries in /v1/models using least-common-denominator approach (context_length, max_output_tokens, capabilities, modalities). --- CHANGELOG.md | 81 +++-- open-sse/config/providerRegistry.ts | 186 ++++++++++-- scripts/scratch/check_usage.js | 39 ++- src/app/api/v1/models/catalog.ts | 282 +++++++++++++++-- src/i18n/messages/bg.json | 2 +- src/i18n/messages/bn.json | 2 +- src/i18n/messages/cs.json | 2 +- src/i18n/messages/da.json | 2 +- src/i18n/messages/de.json | 2 +- src/i18n/messages/es.json | 2 +- src/i18n/messages/fa.json | 2 +- src/i18n/messages/fi.json | 2 +- src/i18n/messages/fr.json | 2 +- src/i18n/messages/gu.json | 2 +- src/i18n/messages/he.json | 2 +- src/i18n/messages/hi.json | 2 +- src/i18n/messages/hu.json | 2 +- src/i18n/messages/id.json | 2 +- src/i18n/messages/in.json | 2 +- src/i18n/messages/it.json | 2 +- src/i18n/messages/ja.json | 2 +- src/i18n/messages/ko.json | 2 +- src/i18n/messages/mr.json | 2 +- src/i18n/messages/ms.json | 2 +- src/i18n/messages/nl.json | 2 +- src/i18n/messages/no.json | 2 +- src/i18n/messages/phi.json | 2 +- src/lib/db/providers.ts | 6 +- src/shared/components/AutoRoutingBanner.tsx | 2 +- src/shared/utils/apiKeyPolicy.ts | 27 +- src/shared/utils/rateLimiter.ts | 34 +-- tests/unit/api-key-regeneration.test.ts | 8 +- .../base-executor-sanitize-effort.test.ts | 11 +- tests/unit/models-catalog-route.test.ts | 286 +++++++++++++++++- tests/unit/proxy-connection-test.test.ts | 5 +- .../translator-claude-helper-thinking.test.ts | 29 +- 36 files changed, 839 insertions(+), 203 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17c9ec1261..0ce5714d4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -138,48 +138,47 @@ Thank you to all **40 community contributors** who made v3.8.0 possible! 🎉 -| Contributor | PRs | Contributions | -| :--------------------------------------------------------- | :-: | :------------------------------------------------------------ | +| Contributor | PRs | Contributions | +| :--------------------------------------------------------- | :-: | :------------------------------------------------------------------- | | [@oyi77](https://github.com/oyi77) | 8 | #2010, #2014, #2041, #2052, #2061, #2074, #2091, #2094, #2096, #2131 | -| [@backryun](https://github.com/backryun) | 6 | #1992, #2033, #2088, #2123, #2138, #2141 | -| [@dhaern](https://github.com/dhaern) | 4 | #2028, #2039, #2087, #2090 | -| [@Tentoxa](https://github.com/Tentoxa) | 2 | #2011, #2053 | -| [@wauputr4](https://github.com/wauputr4) | 2 | #2009, #2046 | -| [@payne0420](https://github.com/payne0420) | 1 | #2082 | -| [@Tr0sT](https://github.com/Tr0sT) | 1 | #2012 | -| [@AveryanAlex](https://github.com/AveryanAlex) | 1 | #2008 | -| [@nickwizard](https://github.com/nickwizard) | 1 | #1991 | -| [@rodrigogbbr-stack](https://github.com/rodrigogbbr-stack) | 1 | #1996 | -| [@NekoMonci12](https://github.com/NekoMonci12) | 1 | #1999 | -| [@congvc-dev](https://github.com/congvc-dev) | 1 | #2004 | -| [@tatsster](https://github.com/tatsster) | 1 | #2007 | -| [@xssdem](https://github.com/xssdem) | 1 | #2023 | -| [@bypanghu](https://github.com/bypanghu) | 1 | #2027 | -| [@herjarsa](https://github.com/herjarsa) | 2 | #2030, #2136 | -| [@wucm667](https://github.com/wucm667) | 1 | #2031 | -| [@hartmark](https://github.com/hartmark) | 2 | #2045, #2137 | -| [@ddarkr](https://github.com/ddarkr) | 1 | #2047 | -| [@tces1](https://github.com/tces1) | 1 | #2048 | -| [@guanbear](https://github.com/guanbear) | 1 | #2054 | -| [@Gi99lin](https://github.com/Gi99lin) | 1 | #2055 | -| [@ivan-mezentsev](https://github.com/ivan-mezentsev) | 1 | #2063 | -| [@JxnLexn](https://github.com/JxnLexn) | 1 | #2019 | -| [@yoviarpauzi](https://github.com/yoviarpauzi) | 1 | #2092 | -| [@rafacpti23](https://github.com/rafacpti23) | 1 | #2086 | -| [@gleber](https://github.com/gleber) | 1 | #2103 | -| [@rilham97](https://github.com/rilham97) | 1 | #2104 | -| [@Gioxaa](https://github.com/Gioxaa) | 1 | #2105 | -| [@boa-z](https://github.com/boa-z) | 1 | #2115 | -| [@eleata](https://github.com/eleata) | 2 | #2116, #2133 | -| [@rdself](https://github.com/rdself) | 1 | #2118 | -| [@clousky2020](https://github.com/clousky2020) | 1 | #2119 | -| [@abhinavjnu](https://github.com/abhinavjnu) | 1 | #2122 | -| [@HoaPham98](https://github.com/HoaPham98) | 1 | #2089 | -| [@christlau](https://github.com/christlau) | 1 | #2129 | -| [@flyingmongoose](https://github.com/flyingmongoose) | 1 | #2134 | -| [@05dunski](https://github.com/05dunski) | 1 | #1978 (cherry-picked) | -| [@DavyMassoneto](https://github.com/DavyMassoneto) | 1 | #2140 | - +| [@backryun](https://github.com/backryun) | 6 | #1992, #2033, #2088, #2123, #2138, #2141 | +| [@dhaern](https://github.com/dhaern) | 4 | #2028, #2039, #2087, #2090 | +| [@Tentoxa](https://github.com/Tentoxa) | 2 | #2011, #2053 | +| [@wauputr4](https://github.com/wauputr4) | 2 | #2009, #2046 | +| [@payne0420](https://github.com/payne0420) | 1 | #2082 | +| [@Tr0sT](https://github.com/Tr0sT) | 1 | #2012 | +| [@AveryanAlex](https://github.com/AveryanAlex) | 1 | #2008 | +| [@nickwizard](https://github.com/nickwizard) | 1 | #1991 | +| [@rodrigogbbr-stack](https://github.com/rodrigogbbr-stack) | 1 | #1996 | +| [@NekoMonci12](https://github.com/NekoMonci12) | 1 | #1999 | +| [@congvc-dev](https://github.com/congvc-dev) | 1 | #2004 | +| [@tatsster](https://github.com/tatsster) | 1 | #2007 | +| [@xssdem](https://github.com/xssdem) | 1 | #2023 | +| [@bypanghu](https://github.com/bypanghu) | 1 | #2027 | +| [@herjarsa](https://github.com/herjarsa) | 2 | #2030, #2136 | +| [@wucm667](https://github.com/wucm667) | 1 | #2031 | +| [@hartmark](https://github.com/hartmark) | 2 | #2045, #2137 | +| [@ddarkr](https://github.com/ddarkr) | 1 | #2047 | +| [@tces1](https://github.com/tces1) | 1 | #2048 | +| [@guanbear](https://github.com/guanbear) | 1 | #2054 | +| [@Gi99lin](https://github.com/Gi99lin) | 1 | #2055 | +| [@ivan-mezentsev](https://github.com/ivan-mezentsev) | 1 | #2063 | +| [@JxnLexn](https://github.com/JxnLexn) | 1 | #2019 | +| [@yoviarpauzi](https://github.com/yoviarpauzi) | 1 | #2092 | +| [@rafacpti23](https://github.com/rafacpti23) | 1 | #2086 | +| [@gleber](https://github.com/gleber) | 1 | #2103 | +| [@rilham97](https://github.com/rilham97) | 1 | #2104 | +| [@Gioxaa](https://github.com/Gioxaa) | 1 | #2105 | +| [@boa-z](https://github.com/boa-z) | 1 | #2115 | +| [@eleata](https://github.com/eleata) | 2 | #2116, #2133 | +| [@rdself](https://github.com/rdself) | 1 | #2118 | +| [@clousky2020](https://github.com/clousky2020) | 1 | #2119 | +| [@abhinavjnu](https://github.com/abhinavjnu) | 1 | #2122 | +| [@HoaPham98](https://github.com/HoaPham98) | 1 | #2089 | +| [@christlau](https://github.com/christlau) | 1 | #2129 | +| [@flyingmongoose](https://github.com/flyingmongoose) | 1 | #2134 | +| [@05dunski](https://github.com/05dunski) | 1 | #1978 (cherry-picked) | +| [@DavyMassoneto](https://github.com/DavyMassoneto) | 1 | #2140 | ## [3.7.9] — 2026-05-03 diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 8d55e95c57..7853ed71de 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -143,8 +143,13 @@ const KIMI_CODING_SHARED = { "Anthropic-Version": ANTHROPIC_VERSION_HEADER, }, models: [ - { id: "kimi-k2.6", name: "Kimi K2.6", contextLength: 262144, maxOutputTokens: 262144 }, - { id: "kimi-k2.6-thinking", name: "Kimi K2.6 Thinking", contextLength: 262144, maxOutputTokens: 262144 }, + { id: "kimi-k2.6", name: "Kimi K2.6", contextLength: 262144, maxOutputTokens: 262144 }, + { + id: "kimi-k2.6-thinking", + name: "Kimi K2.6 Thinking", + contextLength: 262144, + maxOutputTokens: 262144, + }, ] as RegistryModel[], } as const; @@ -283,9 +288,9 @@ const CHAT_OPENAI_COMPAT_MODELS: Record = { maritalk: buildModels(["sabia-4", "sabia-3.1", "sabiazinho-4", "sabiazinho-3"]), "xiaomi-mimo": [ { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", contextLength: 1048576, maxOutputTokens: 131072 }, - { id: "mimo-v2.5", name: "MiMo-V2.5", contextLength: 1048576, maxOutputTokens: 131072 }, - { id: "mimo-v2-omni", name: "MiMo-V2-Omni", contextLength: 262144, maxOutputTokens: 131072 }, - { id: "mimo-v2-flash", name: "MiMo-V2-Flash", contextLength: 262144, maxOutputTokens: 65536 }, + { id: "mimo-v2.5", name: "MiMo-V2.5", contextLength: 1048576, maxOutputTokens: 131072 }, + { id: "mimo-v2-omni", name: "MiMo-V2-Omni", contextLength: 262144, maxOutputTokens: 131072 }, + { id: "mimo-v2-flash", name: "MiMo-V2-Flash", contextLength: 262144, maxOutputTokens: 65536 }, ], "inference-net": buildModels([ "meta-llama/Llama-3.3-70B-Instruct", @@ -384,12 +389,48 @@ export const REGISTRY: Record = { tokenUrl: "https://console.anthropic.com/v1/oauth/token", }, models: [ - { id: "claude-opus-4-7", name: "Claude Opus 4.7", supportsXHighEffort: true, contextLength: 1000000, maxOutputTokens: 128000 }, - { id: "claude-opus-4-6", name: "Claude Opus 4.6", supportsXHighEffort: false, contextLength: 1000000, maxOutputTokens: 128000 }, - { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5", supportsXHighEffort: false, contextLength: 200000, maxOutputTokens: 64000 }, - { id: "claude-sonnet-4-6", name: "Claude 4.6 Sonnet", supportsXHighEffort: false, contextLength: 200000, maxOutputTokens: 64000 }, - { id: "claude-sonnet-4-5-20250929", name: "Claude 4.5 Sonnet", supportsXHighEffort: false, contextLength: 200000, maxOutputTokens: 64000 }, - { id: "claude-haiku-4-5-20251001", name: "Claude 4.5 Haiku", supportsXHighEffort: false, contextLength: 200000, maxOutputTokens: 64000 }, + { + id: "claude-opus-4-7", + name: "Claude Opus 4.7", + supportsXHighEffort: true, + contextLength: 1000000, + maxOutputTokens: 128000, + }, + { + id: "claude-opus-4-6", + name: "Claude Opus 4.6", + supportsXHighEffort: false, + contextLength: 1000000, + maxOutputTokens: 128000, + }, + { + id: "claude-opus-4-5-20251101", + name: "Claude Opus 4.5", + supportsXHighEffort: false, + contextLength: 200000, + maxOutputTokens: 64000, + }, + { + id: "claude-sonnet-4-6", + name: "Claude 4.6 Sonnet", + supportsXHighEffort: false, + contextLength: 200000, + maxOutputTokens: 64000, + }, + { + id: "claude-sonnet-4-5-20250929", + name: "Claude 4.5 Sonnet", + supportsXHighEffort: false, + contextLength: 200000, + maxOutputTokens: 64000, + }, + { + id: "claude-haiku-4-5-20251001", + name: "Claude 4.5 Haiku", + supportsXHighEffort: false, + contextLength: 200000, + maxOutputTokens: 64000, + }, ], }, @@ -466,11 +507,41 @@ export const REGISTRY: Record = { // 1.05M). Public refs : openai/codex#19208, #19319, #19464 ; // opencode#24171. max_output_tokens is stripped server-side // (litellm#21193, codex#4138) so 128K is informational only. - { id: "gpt-5.5", name: "GPT 5.5", ...GPT_5_5_CODEX_CAPABILITIES, contextLength: 400000, maxOutputTokens: 128000 }, - { id: "gpt-5.5-xhigh", name: "GPT 5.5 (xHigh)", ...GPT_5_5_CODEX_CAPABILITIES, contextLength: 400000, maxOutputTokens: 128000 }, - { id: "gpt-5.5-high", name: "GPT 5.5 (High)", ...GPT_5_5_CODEX_CAPABILITIES, contextLength: 400000, maxOutputTokens: 128000 }, - { id: "gpt-5.5-medium", name: "GPT 5.5 (Medium)", ...GPT_5_5_CODEX_CAPABILITIES, contextLength: 400000, maxOutputTokens: 128000 }, - { id: "gpt-5.5-low", name: "GPT 5.5 (Low)", ...GPT_5_5_CODEX_CAPABILITIES, contextLength: 400000, maxOutputTokens: 128000 }, + { + id: "gpt-5.5", + name: "GPT 5.5", + ...GPT_5_5_CODEX_CAPABILITIES, + contextLength: 400000, + maxOutputTokens: 128000, + }, + { + id: "gpt-5.5-xhigh", + name: "GPT 5.5 (xHigh)", + ...GPT_5_5_CODEX_CAPABILITIES, + contextLength: 400000, + maxOutputTokens: 128000, + }, + { + id: "gpt-5.5-high", + name: "GPT 5.5 (High)", + ...GPT_5_5_CODEX_CAPABILITIES, + contextLength: 400000, + maxOutputTokens: 128000, + }, + { + id: "gpt-5.5-medium", + name: "GPT 5.5 (Medium)", + ...GPT_5_5_CODEX_CAPABILITIES, + contextLength: 400000, + maxOutputTokens: 128000, + }, + { + id: "gpt-5.5-low", + name: "GPT 5.5 (Low)", + ...GPT_5_5_CODEX_CAPABILITIES, + contextLength: 400000, + maxOutputTokens: 128000, + }, { id: "gpt-5.4", name: "GPT 5.4", targetFormat: "openai-responses" }, { id: "gpt-5.4-mini", name: "GPT 5.4 Mini", targetFormat: "openai-responses" }, { id: "gpt-5.3-codex-spark", name: "GPT 5.3 Codex Spark" }, @@ -577,12 +648,48 @@ export const REGISTRY: Record = { { id: "gpt-5.4-mini", name: "GPT-5.4 Mini", targetFormat: "openai-responses" }, { id: "gpt-5.4", name: "GPT-5.4", targetFormat: "openai-responses" }, { id: "gpt-5.5", name: "GPT-5.5", ...GPT_5_5_CODEX_CAPABILITIES }, - { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", targetFormat: "openai-responses", contextLength: 200000, maxOutputTokens: 64000 }, - { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", targetFormat: "openai-responses", contextLength: 200000, maxOutputTokens: 64000 }, - { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", targetFormat: "openai-responses", contextLength: 200000, maxOutputTokens: 64000 }, - { id: "claude-opus-4-5-20251101", name: "Claude Opus 4.5 (Full ID)", targetFormat: "openai-responses", contextLength: 200000, maxOutputTokens: 64000 }, - { id: "claude-opus-4.6", name: "Claude Opus 4.6", targetFormat: "openai-responses", contextLength: 1000000, maxOutputTokens: 128000 }, - { id: "claude-opus-4.7", name: "Claude Opus 4.7", targetFormat: "openai-responses", contextLength: 1000000, maxOutputTokens: 128000 }, + { + id: "claude-haiku-4.5", + name: "Claude Haiku 4.5", + targetFormat: "openai-responses", + contextLength: 200000, + maxOutputTokens: 64000, + }, + { + id: "claude-sonnet-4.5", + name: "Claude Sonnet 4.5", + targetFormat: "openai-responses", + contextLength: 200000, + maxOutputTokens: 64000, + }, + { + id: "claude-sonnet-4.6", + name: "Claude Sonnet 4.6", + targetFormat: "openai-responses", + contextLength: 200000, + maxOutputTokens: 64000, + }, + { + id: "claude-opus-4-5-20251101", + name: "Claude Opus 4.5 (Full ID)", + targetFormat: "openai-responses", + contextLength: 200000, + maxOutputTokens: 64000, + }, + { + id: "claude-opus-4.6", + name: "Claude Opus 4.6", + targetFormat: "openai-responses", + contextLength: 1000000, + maxOutputTokens: 128000, + }, + { + id: "claude-opus-4.7", + name: "Claude Opus 4.7", + targetFormat: "openai-responses", + contextLength: 1000000, + maxOutputTokens: 128000, + }, { id: "gemini-3.1-pro-preview", name: "Gemini 3.1 Pro", targetFormat: "openai-responses" }, { id: "gemini-3-flash-preview", name: "Gemini 3 Flash", targetFormat: "openai-responses" }, { id: "oswe-vscode-prime", name: "Raptor Mini", targetFormat: "openai-responses" }, @@ -606,12 +713,37 @@ export const REGISTRY: Record = { }, models: [ { id: "auto-kiro", name: "Auto (Kiro picks best model)" }, - { id: "claude-opus-4.7", name: "Claude Opus 4.7", contextLength: 1000000, maxOutputTokens: 128000 }, - { id: "claude-opus-4.6", name: "Claude Opus 4.6", contextLength: 1000000, maxOutputTokens: 128000 }, - { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6", contextLength: 200000, maxOutputTokens: 64000 }, + { + id: "claude-opus-4.7", + name: "Claude Opus 4.7", + contextLength: 1000000, + maxOutputTokens: 128000, + }, + { + id: "claude-opus-4.6", + name: "Claude Opus 4.6", + contextLength: 1000000, + maxOutputTokens: 128000, + }, + { + id: "claude-sonnet-4.6", + name: "Claude Sonnet 4.6", + contextLength: 200000, + maxOutputTokens: 64000, + }, // models for kiro free tier - { id: "claude-sonnet-4.5", name: "Claude Sonnet 4.5", contextLength: 200000, maxOutputTokens: 64000 }, - { id: "claude-haiku-4.5", name: "Claude Haiku 4.5", contextLength: 200000, maxOutputTokens: 64000 }, + { + id: "claude-sonnet-4.5", + name: "Claude Sonnet 4.5", + contextLength: 200000, + maxOutputTokens: 64000, + }, + { + id: "claude-haiku-4.5", + name: "Claude Haiku 4.5", + contextLength: 200000, + maxOutputTokens: 64000, + }, { id: "deepseek-3.2", name: "DeepSeek V3.2" }, { id: "minimax-m2.5", name: "MiniMax M2.5" }, { id: "minimax-m2.1", name: "MiniMax M2.1" }, diff --git a/scripts/scratch/check_usage.js b/scripts/scratch/check_usage.js index 15e6cab603..9360bab904 100644 --- a/scripts/scratch/check_usage.js +++ b/scripts/scratch/check_usage.js @@ -1,34 +1,41 @@ -import Database from 'better-sqlite3'; -import path from 'path'; -import os from 'os'; +import Database from "better-sqlite3"; +import path from "path"; +import os from "os"; -const SQLITE_FILE = path.join(process.cwd(), 'data', 'storage.sqlite'); +const SQLITE_FILE = path.join(process.cwd(), "data", "storage.sqlite"); -console.log('Checking database at:', SQLITE_FILE); +console.log("Checking database at:", SQLITE_FILE); const db = new Database(SQLITE_FILE, { readonly: true }); try { - const rows = db.prepare(` + const rows = db + .prepare( + ` SELECT api_key_id, api_key_name, COUNT(*) as count, MAX(timestamp) as last_used FROM usage_history GROUP BY api_key_id, api_key_name ORDER BY count DESC - `).all(); + ` + ) + .all(); - console.log('Top Usage Entries:'); - console.table(rows); + console.log("Top Usage Entries:"); + console.table(rows); - const keys = db.prepare(` + const keys = db + .prepare( + ` SELECT id, name, key_prefix, machine_id FROM api_keys - `).all(); - - console.log('All API Keys:'); - console.table(keys); + ` + ) + .all(); + console.log("All API Keys:"); + console.table(keys); } catch (err) { - console.error('Error:', err.message); + console.error("Error:", err.message); } finally { - db.close(); + db.close(); } diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 3d3f94cd77..5ffcfaf5f9 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -17,14 +17,18 @@ import { getAllVideoModels } from "@omniroute/open-sse/config/videoRegistry"; import { getAllMusicModels } from "@omniroute/open-sse/config/musicRegistry"; import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry"; import { CODEX_NATIVE_UNPREFIXED_MODELS } from "@omniroute/open-sse/services/model"; +import { resolveNestedComboTargets } from "@omniroute/open-sse/services/combo"; import { getAllSyncedAvailableModels } from "@/lib/db/models"; import { getCompatibleFallbackModels } from "@/lib/providers/managedAvailableModels"; import { hasEligibleConnectionForModel } from "@/domain/connectionModelRules"; import { INTERNAL_PROXY_ERROR, enrichCatalogModelEntry, + getCanonicalModelMetadata, getCatalogDiagnosticsHeaders, } from "@/lib/modelMetadataRegistry"; +import { getSyncedCapability } from "@/lib/modelsDevSync"; +import { getModelSpec } from "@/shared/constants/modelSpecs"; import { isAuthRequired, isDashboardSessionAuthenticated } from "@/shared/utils/apiAuth"; import { parseModel } from "@omniroute/open-sse/services/model"; import { getTokenLimit } from "@omniroute/open-sse/services/contextManager"; @@ -54,6 +58,50 @@ const FALLBACK_ALIAS_TO_PROVIDER = { qw: "qwen", }; +type ComboCatalogTarget = { + modelStr?: string; + provider?: string | null; +}; + +type ComboTargetCatalogMetadata = { + contextLength?: number; + maxInputTokens?: number; + maxOutputTokens?: number; + inputModalities?: string[]; + outputModalities?: string[]; + capabilities: Record; +}; + +function isPositiveFiniteNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +function parseJsonStringArray(value: unknown): string[] { + if (typeof value !== "string" || value.trim().length === 0) return []; + try { + const parsed = JSON.parse(value); + return Array.isArray(parsed) + ? parsed.filter((entry): entry is string => typeof entry === "string" && entry.length > 0) + : []; + } catch { + return []; + } +} + +function intersectStringArrays(arrays: string[][]): string[] { + if (arrays.length === 0 || arrays.some((values) => values.length === 0)) return []; + const [first, ...rest] = arrays; + return first.filter((value, index) => { + if (first.indexOf(value) !== index) return false; + return rest.every((values) => values.includes(value)); + }); +} + +function minKnownNumber(values: Array): number | undefined { + if (values.length === 0 || !values.every(isPositiveFiniteNumber)) return undefined; + return Math.min(...values); +} + const VISION_MODEL_KEYWORDS = [ "gpt-4o", "gpt-4.1", @@ -320,6 +368,212 @@ export async function getUnifiedModelsResponse( ); }; + const getRegistryModel = (providerId: string, modelId: string) => { + const alias = providerIdToAlias[providerId] || PROVIDER_ID_TO_ALIAS[providerId] || providerId; + const providerModels = PROVIDER_MODELS[alias] || PROVIDER_MODELS[providerId] || []; + return providerModels.find((model) => model?.id === modelId) || null; + }; + + const getProviderPrefixes = (providerId: string, rawProvider: string) => { + const prefixes = new Set([providerId, rawProvider, providerIdToAlias[providerId]]); + for (const [alias, mappedProviderId] of Object.entries(aliasToProviderId)) { + if (mappedProviderId === providerId) prefixes.add(alias); + } + return [...prefixes].filter( + (prefix): prefix is string => typeof prefix === "string" && prefix.length > 0 + ); + }; + + const getComboTargetModelId = (target: ComboCatalogTarget) => { + const rawProvider = typeof target.provider === "string" ? target.provider.trim() : ""; + const modelStr = typeof target.modelStr === "string" ? target.modelStr.trim() : ""; + if (!rawProvider || rawProvider === "unknown" || !modelStr) return null; + + const providerId = resolveCanonicalProviderId(rawProvider); + if (!providerId || providerId === "unknown") return null; + + for (const prefix of getProviderPrefixes(providerId, rawProvider)) { + const prefixWithSlash = `${prefix}/`; + if (modelStr.startsWith(prefixWithSlash)) { + const modelId = modelStr.slice(prefixWithSlash.length).trim(); + return modelId ? { providerId, modelId } : null; + } + } + + return { providerId, modelId: modelStr }; + }; + + const getComboTargetCatalogMetadata = ( + target: ComboCatalogTarget + ): ComboTargetCatalogMetadata | null => { + const targetModel = getComboTargetModelId(target); + if (!targetModel) return null; + + const canonical = getCanonicalModelMetadata({ + provider: targetModel.providerId, + model: targetModel.modelId, + }); + if (!canonical) return null; + + const source = canonical.metadata.source; + if (!source.providerRegistry && !source.staticSpec && !source.syncedCapability) return null; + + const providerId = canonical.provider || targetModel.providerId; + const modelId = canonical.model || targetModel.modelId; + const synced = getSyncedCapability(providerId, modelId); + const spec = getModelSpec(modelId); + const registryModel = getRegistryModel(providerId, modelId); + const syncedInputModalities = parseJsonStringArray(synced?.modalities_input); + const syncedOutputModalities = parseJsonStringArray(synced?.modalities_output); + + const syncedContext = isPositiveFiniteNumber(synced?.limit_context) + ? synced.limit_context + : undefined; + const registryContext = isPositiveFiniteNumber(registryModel?.contextLength) + ? registryModel.contextLength + : undefined; + const specContext = isPositiveFiniteNumber(spec?.contextWindow) + ? spec.contextWindow + : undefined; + const contextLength = syncedContext ?? registryContext ?? specContext; + const maxInputTokens = isPositiveFiniteNumber(synced?.limit_input) + ? synced.limit_input + : contextLength; + const maxOutputTokens = isPositiveFiniteNumber(synced?.limit_output) + ? synced.limit_output + : isPositiveFiniteNumber(spec?.maxOutputTokens) + ? spec.maxOutputTokens + : undefined; + + const syncedVision = + typeof synced?.attachment === "boolean" + ? synced.attachment + : syncedInputModalities.length > 0 || syncedOutputModalities.length > 0 + ? [...syncedInputModalities, ...syncedOutputModalities].some((entry) => + entry.toLowerCase().includes("image") + ) + : undefined; + const registryVision = + typeof registryModel?.supportsVision === "boolean" + ? registryModel.supportsVision + : undefined; + const specVision = + typeof spec?.supportsVision === "boolean" ? spec.supportsVision : undefined; + const knownVision = syncedVision ?? registryVision ?? specVision; + + const inputModalities = + syncedInputModalities.length > 0 + ? syncedInputModalities + : knownVision === true + ? ["text", "image"] + : undefined; + const outputModalities = + syncedOutputModalities.length > 0 + ? syncedOutputModalities + : knownVision === true + ? ["text"] + : undefined; + + const capabilities: Record = {}; + if (typeof synced?.tool_call === "boolean") { + capabilities.tool_calling = synced.tool_call; + } else if (typeof registryModel?.toolCalling === "boolean") { + capabilities.tool_calling = registryModel.toolCalling; + } else if (typeof spec?.supportsTools === "boolean") { + capabilities.tool_calling = spec.supportsTools; + } + if (typeof synced?.reasoning === "boolean") { + capabilities.reasoning = synced.reasoning; + } else if (typeof registryModel?.supportsReasoning === "boolean") { + capabilities.reasoning = registryModel.supportsReasoning; + } else if (typeof spec?.supportsThinking === "boolean") { + capabilities.reasoning = spec.supportsThinking; + } + if (typeof knownVision === "boolean") capabilities.vision = knownVision; + if (typeof synced?.attachment === "boolean") capabilities.attachment = synced.attachment; + if (typeof synced?.structured_output === "boolean") { + capabilities.structured_output = synced.structured_output; + } + if (typeof synced?.temperature === "boolean") capabilities.temperature = synced.temperature; + if (typeof synced?.reasoning === "boolean") { + capabilities.thinking = synced.reasoning; + } else if (typeof spec?.supportsThinking === "boolean") { + capabilities.thinking = spec.supportsThinking; + } + + return { + ...(contextLength ? { contextLength } : {}), + ...(maxInputTokens ? { maxInputTokens } : {}), + ...(maxOutputTokens ? { maxOutputTokens } : {}), + ...(inputModalities && inputModalities.length > 0 ? { inputModalities } : {}), + ...(outputModalities && outputModalities.length > 0 ? { outputModalities } : {}), + capabilities, + }; + }; + + const buildComboCatalogMetadata = (combo: Record, allCombos: any[]) => { + const explicitContextLength = isPositiveFiniteNumber(combo.context_length) + ? combo.context_length + : undefined; + + const baseMetadata = explicitContextLength ? { context_length: explicitContextLength } : {}; + const targets = resolveNestedComboTargets(combo, allCombos) as ComboCatalogTarget[]; + if (targets.length === 0) return baseMetadata; + + const targetMetadata = targets.map((target) => getComboTargetCatalogMetadata(target)); + if (targetMetadata.some((metadata) => metadata === null)) return baseMetadata; + + const knownMetadata = targetMetadata as ComboTargetCatalogMetadata[]; + const contextLength = + explicitContextLength ?? + minKnownNumber(knownMetadata.map((metadata) => metadata.contextLength)); + const maxInputTokens = minKnownNumber( + knownMetadata.map((metadata) => metadata.maxInputTokens) + ); + const maxOutputTokens = minKnownNumber( + knownMetadata.map((metadata) => metadata.maxOutputTokens) + ); + + const inputModalities = knownMetadata.every( + (metadata) => Array.isArray(metadata.inputModalities) && metadata.inputModalities.length > 0 + ) + ? intersectStringArrays(knownMetadata.map((metadata) => metadata.inputModalities || [])) + : []; + const outputModalities = knownMetadata.every( + (metadata) => + Array.isArray(metadata.outputModalities) && metadata.outputModalities.length > 0 + ) + ? intersectStringArrays(knownMetadata.map((metadata) => metadata.outputModalities || [])) + : []; + + const capabilities: Record = {}; + for (const key of [ + "tool_calling", + "reasoning", + "vision", + "attachment", + "structured_output", + "temperature", + "thinking", + ]) { + const values = knownMetadata.map((metadata) => metadata.capabilities[key]); + if (values.every((value): value is boolean => typeof value === "boolean")) { + const [first] = values; + if (values.every((value) => value === first)) capabilities[key] = first; + } + } + + return { + ...baseMetadata, + ...(contextLength ? { context_length: contextLength } : {}), + ...(maxInputTokens ? { max_input_tokens: maxInputTokens } : {}), + ...(maxOutputTokens ? { max_output_tokens: maxOutputTokens } : {}), + ...(inputModalities.length > 0 ? { input_modalities: inputModalities } : {}), + ...(outputModalities.length > 0 ? { output_modalities: outputModalities } : {}), + ...(Object.keys(capabilities).length > 0 ? { capabilities } : {}), + }; + }; + // Collect models from active providers (or all if none active) const models = []; const timestamp = Math.floor(Date.now() / 1000); @@ -327,30 +581,7 @@ export async function getUnifiedModelsResponse( // Add combos first (they appear at the top) — only active ones for (const combo of combos) { if (combo.isActive === false || combo.isHidden === true) continue; - - // Calculate combo context length from its model targets. - // OpenCode and other clients read context_length from the catalog; without it - // they fall back to a conservative ~4000 token limit, causing truncation. - const comboContextLength = Array.isArray(combo.models) - ? combo.models - .filter((step): step is ComboModelStep => step?.kind === "model" && Boolean(step.model)) - .map((step) => { - const parsed = parseModel(step.model); - const rawProvider = parsed.provider || step.providerId || "unknown"; - const provider = resolveCanonicalProviderId(rawProvider); - const model = parsed.model || step.model; - return getTokenLimit(provider, model); - }) - .filter((limit): limit is number => typeof limit === "number" && limit > 0) - .reduce((min, limit) => Math.min(min, limit), Infinity) - : undefined; - - const effectiveContextLength = - typeof combo.context_length === "number" && combo.context_length > 0 - ? combo.context_length - : comboContextLength !== undefined && comboContextLength !== Infinity - ? comboContextLength - : undefined; + const comboMetadata = buildComboCatalogMetadata(combo, combos); models.push({ id: combo.name, @@ -360,7 +591,7 @@ export async function getUnifiedModelsResponse( permission: [], root: combo.name, parent: null, - ...(effectiveContextLength !== undefined ? { context_length: effectiveContextLength } : {}), + ...comboMetadata, }); } @@ -887,6 +1118,7 @@ export async function getUnifiedModelsResponse( }; const enrichedModels = finalModels.map((model) => { + if (model.owned_by === "combo") return model; const enriched = enrichCatalogModelEntry(model); const fallbackContextLength = getDefaultContextFallback(enriched); return fallbackContextLength diff --git a/src/i18n/messages/bg.json b/src/i18n/messages/bg.json index 615b046d2f..12d911c36c 100644 --- a/src/i18n/messages/bg.json +++ b/src/i18n/messages/bg.json @@ -4658,4 +4658,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/bn.json b/src/i18n/messages/bn.json index f710c34764..cbb45ce673 100644 --- a/src/i18n/messages/bn.json +++ b/src/i18n/messages/bn.json @@ -4951,4 +4951,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/cs.json b/src/i18n/messages/cs.json index 7017c186e9..ea7664394c 100644 --- a/src/i18n/messages/cs.json +++ b/src/i18n/messages/cs.json @@ -4658,4 +4658,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/da.json b/src/i18n/messages/da.json index 5336c71495..1394d2f8c8 100644 --- a/src/i18n/messages/da.json +++ b/src/i18n/messages/da.json @@ -4658,4 +4658,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 0639d215b7..5e0a11e72e 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -4674,4 +4674,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 7ca0ac2d41..6acb1bf214 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -4658,4 +4658,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/fa.json b/src/i18n/messages/fa.json index 7360b369ea..1aae9ab357 100644 --- a/src/i18n/messages/fa.json +++ b/src/i18n/messages/fa.json @@ -4951,4 +4951,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/fi.json b/src/i18n/messages/fi.json index 4e75a03fbf..de451a629a 100644 --- a/src/i18n/messages/fi.json +++ b/src/i18n/messages/fi.json @@ -4658,4 +4658,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 44326e348d..61f8cf673e 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -4658,4 +4658,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/gu.json b/src/i18n/messages/gu.json index 1204fe8a7f..6f6877fa45 100644 --- a/src/i18n/messages/gu.json +++ b/src/i18n/messages/gu.json @@ -4951,4 +4951,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/he.json b/src/i18n/messages/he.json index e733ca551a..50fe996e44 100644 --- a/src/i18n/messages/he.json +++ b/src/i18n/messages/he.json @@ -4658,4 +4658,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/hi.json b/src/i18n/messages/hi.json index 6213615420..a3030feb32 100644 --- a/src/i18n/messages/hi.json +++ b/src/i18n/messages/hi.json @@ -4658,4 +4658,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/hu.json b/src/i18n/messages/hu.json index 0695fbfa9d..dde4b75d0a 100644 --- a/src/i18n/messages/hu.json +++ b/src/i18n/messages/hu.json @@ -4658,4 +4658,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/id.json b/src/i18n/messages/id.json index 2b554d7928..78d6edc506 100644 --- a/src/i18n/messages/id.json +++ b/src/i18n/messages/id.json @@ -4662,4 +4662,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/in.json b/src/i18n/messages/in.json index 001d49628d..93d1f02fc4 100644 --- a/src/i18n/messages/in.json +++ b/src/i18n/messages/in.json @@ -4951,4 +4951,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/it.json b/src/i18n/messages/it.json index bdeb16216a..b13bbd59ef 100644 --- a/src/i18n/messages/it.json +++ b/src/i18n/messages/it.json @@ -4658,4 +4658,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 6f20f605ad..9245ca8f5b 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -4658,4 +4658,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index b0200c0c6f..0dea5885de 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -4660,4 +4660,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/mr.json b/src/i18n/messages/mr.json index a05d8fc744..1e5ce32f75 100644 --- a/src/i18n/messages/mr.json +++ b/src/i18n/messages/mr.json @@ -4951,4 +4951,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/ms.json b/src/i18n/messages/ms.json index 2745bbf818..33cc008f9b 100644 --- a/src/i18n/messages/ms.json +++ b/src/i18n/messages/ms.json @@ -4658,4 +4658,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/nl.json b/src/i18n/messages/nl.json index a72d112ff3..a9b77a8144 100644 --- a/src/i18n/messages/nl.json +++ b/src/i18n/messages/nl.json @@ -4658,4 +4658,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/no.json b/src/i18n/messages/no.json index 5b5a755318..b86e81d080 100644 --- a/src/i18n/messages/no.json +++ b/src/i18n/messages/no.json @@ -4658,4 +4658,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/i18n/messages/phi.json b/src/i18n/messages/phi.json index 35e1d22574..55f87b9e7a 100644 --- a/src/i18n/messages/phi.json +++ b/src/i18n/messages/phi.json @@ -4658,4 +4658,4 @@ "agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer", "agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000" } -} \ No newline at end of file +} diff --git a/src/lib/db/providers.ts b/src/lib/db/providers.ts index f87953903a..9a023f40e2 100644 --- a/src/lib/db/providers.ts +++ b/src/lib/db/providers.ts @@ -442,9 +442,9 @@ export async function deleteProviderConnections(ids: string[]): Promise const deletedCount = db.transaction(() => { const placeholders = ids.map(() => "?").join(","); db.prepare(`DELETE FROM quota_snapshots WHERE connection_id IN (${placeholders})`).run(...ids); - const result = db.prepare( - `DELETE FROM provider_connections WHERE id IN (${placeholders})` - ).run(...ids); + const result = db + .prepare(`DELETE FROM provider_connections WHERE id IN (${placeholders})`) + .run(...ids); return result.changes ?? 0; })(); diff --git a/src/shared/components/AutoRoutingBanner.tsx b/src/shared/components/AutoRoutingBanner.tsx index 062de5c001..9aedb528ce 100644 --- a/src/shared/components/AutoRoutingBanner.tsx +++ b/src/shared/components/AutoRoutingBanner.tsx @@ -25,7 +25,7 @@ export default function AutoRoutingBanner() { } catch { // ignore localStorage errors (private mode, quotas) } - + setIsDismissed(true); }; diff --git a/src/shared/utils/apiKeyPolicy.ts b/src/shared/utils/apiKeyPolicy.ts index 5909d6b37f..c750f93361 100644 --- a/src/shared/utils/apiKeyPolicy.ts +++ b/src/shared/utils/apiKeyPolicy.ts @@ -17,9 +17,9 @@ import * as log from "@/sse/utils/logger"; import { checkRateLimit, RateLimitRule } from "./rateLimiter"; const DEFAULT_RATE_LIMITS: RateLimitRule[] = [ - { limit: 1000, window: 86400 }, // 1000 per day - { limit: 5000, window: 604800 }, // 5000 per week - { limit: 20000, window: 2592000 } // 20000 per month + { limit: 1000, window: 86400 }, // 1000 per day + { limit: 5000, window: 604800 }, // 5000 per week + { limit: 20000, window: 2592000 }, // 20000 per month ]; interface AccessSchedule { @@ -187,7 +187,10 @@ export async function enforceApiKeyPolicy( return { apiKey, apiKeyInfo, - rejection: errorResponse(HTTP_STATUS.FORBIDDEN, "This API key is banned due to policy violations"), + rejection: errorResponse( + HTTP_STATUS.FORBIDDEN, + "This API key is banned due to policy violations" + ), }; } @@ -260,9 +263,10 @@ export async function enforceApiKeyPolicy( // ── Check 5: Generic Multi-Window Rate Limits ── if (apiKeyInfo.id) { - const rulesToApply = (apiKeyInfo.rateLimits && apiKeyInfo.rateLimits.length > 0) - ? [...apiKeyInfo.rateLimits] - : [...DEFAULT_RATE_LIMITS]; + const rulesToApply = + apiKeyInfo.rateLimits && apiKeyInfo.rateLimits.length > 0 + ? [...apiKeyInfo.rateLimits] + : [...DEFAULT_RATE_LIMITS]; // Combine with legacy limits if they exist and custom rate limits aren't set if (!apiKeyInfo.rateLimits || apiKeyInfo.rateLimits.length === 0) { @@ -276,13 +280,16 @@ export async function enforceApiKeyPolicy( const rateLimitResult = await checkRateLimit(apiKeyInfo.id, rulesToApply); if (!rateLimitResult.allowed) { - const failedWindowStr = rateLimitResult.failedWindow - ? ` (${rateLimitResult.failedWindow}s window)` + const failedWindowStr = rateLimitResult.failedWindow + ? ` (${rateLimitResult.failedWindow}s window)` : ""; return { apiKey, apiKeyInfo, - rejection: errorResponse(HTTP_STATUS.RATE_LIMITED, `Request limit exceeded${failedWindowStr}. Please try again later.`), + rejection: errorResponse( + HTTP_STATUS.RATE_LIMITED, + `Request limit exceeded${failedWindowStr}. Please try again later.` + ), }; } } diff --git a/src/shared/utils/rateLimiter.ts b/src/shared/utils/rateLimiter.ts index 14fc5a89fe..e736c3b414 100644 --- a/src/shared/utils/rateLimiter.ts +++ b/src/shared/utils/rateLimiter.ts @@ -3,11 +3,10 @@ import Redis from "ioredis"; // Reuse existing REDIS_URL if set, or local redis via default docker-compose // Use REDIS_URL from env (Docker/Production) or fallback to local redis const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379"; -if (process.env.NODE_ENV === 'production' && !process.env.REDIS_URL) { - console.warn('[REDIS] REDIS_URL is not set in production. Falling back to default.'); +if (process.env.NODE_ENV === "production" && !process.env.REDIS_URL) { + console.warn("[REDIS] REDIS_URL is not set in production. Falling back to default."); } - let redisClient: Redis | null = null; export function getRedisClient() { @@ -17,9 +16,9 @@ export function getRedisClient() { enableReadyCheck: false, retryStrategy(times) { return Math.min(times * 50, 2000); // Exponential backoff - } + }, }); - redisClient.on('error', (err) => console.error('[REDIS] Error:', err.message)); + redisClient.on("error", (err) => console.error("[REDIS] Error:", err.message)); } return redisClient; } @@ -88,14 +87,17 @@ export function setRateLimiterTestMode(enabled: boolean) { * Checks multi-window rate limits for an API key atomically via Redis. */ export async function checkRateLimit( - keyId: string, + keyId: string, rules: RateLimitRule[] ): Promise { if (!rules || rules.length === 0) return { allowed: true }; // ── In-memory mock for unit tests ── - const isTestMode = explicitTestMode || process.env.NODE_ENV === "test" || process.env.DISABLE_SQLITE_AUTO_BACKUP === "true"; - + const isTestMode = + explicitTestMode || + process.env.NODE_ENV === "test" || + process.env.DISABLE_SQLITE_AUTO_BACKUP === "true"; + if (isTestMode) { const now = Math.floor(Date.now() / 1000); for (const rule of rules) { @@ -116,27 +118,25 @@ export async function checkRateLimit( const redis = getRedisClient(); const args: (string | number)[] = [Math.floor(Date.now() / 1000)]; - + for (const rule of rules) { args.push(rule.limit, rule.window); } try { - const result = await redis.eval( - RATE_LIMIT_SCRIPT, - 1, - `rl:api_key:${keyId}`, - ...args - ) as [number, number]; + const result = (await redis.eval(RATE_LIMIT_SCRIPT, 1, `rl:api_key:${keyId}`, ...args)) as [ + number, + number, + ]; if (result[0] === 0) { return { allowed: false, failedWindow: result[1] }; } - + return { allowed: true }; } catch (error) { // Fail-open strategy if Redis goes down to prevent complete API outage console.error("[RATE_LIMITER] Redis eval failed, bypassing rate limit:", error); - return { allowed: true }; + return { allowed: true }; } } diff --git a/tests/unit/api-key-regeneration.test.ts b/tests/unit/api-key-regeneration.test.ts index d7624811ca..a492424498 100644 --- a/tests/unit/api-key-regeneration.test.ts +++ b/tests/unit/api-key-regeneration.test.ts @@ -41,15 +41,15 @@ test("regenerateApiKey creates a new key and invalidates the old one", async () const result = await apiKeysDb.regenerateApiKey(oldId); assert.ok(result?.key); const regenerated = result!.key; - + assert.notEqual(regenerated, oldKey); - + // New key should be valid assert.equal(await apiKeysDb.validateApiKey(regenerated), true); - + // Old key should be invalid assert.equal(await apiKeysDb.validateApiKey(oldKey), false); - + // Name and machineId should persist const md = await apiKeysDb.getApiKeyMetadata(regenerated); assert.equal(md?.name, "Regen Test"); diff --git a/tests/unit/base-executor-sanitize-effort.test.ts b/tests/unit/base-executor-sanitize-effort.test.ts index af9b2f3b08..e07958f47c 100644 --- a/tests/unit/base-executor-sanitize-effort.test.ts +++ b/tests/unit/base-executor-sanitize-effort.test.ts @@ -1,9 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { sanitizeReasoningEffortForProvider } = await import( - "../../open-sse/executors/base.ts" -); +const { sanitizeReasoningEffortForProvider } = await import("../../open-sse/executors/base.ts"); function makeLog() { const messages: Array<[string, string]> = []; @@ -20,12 +18,7 @@ test("sanitizeReasoningEffortForProvider: xiaomi-mimo downgrades xhigh → high" reasoning_effort: "xhigh", messages: [{ role: "user", content: "hi" }], }; - const result = sanitizeReasoningEffortForProvider( - body, - "xiaomi-mimo", - "mimo-v2.5-pro", - log - ); + const result = sanitizeReasoningEffortForProvider(body, "xiaomi-mimo", "mimo-v2.5-pro", log); assert.notEqual(result, body, "must return a new object when mutating"); assert.equal((result as any).reasoning_effort, "high"); assert.equal((result as any).model, "mimo-v2.5-pro", "other fields preserved"); diff --git a/tests/unit/models-catalog-route.test.ts b/tests/unit/models-catalog-route.test.ts index 495b730abf..e0e33cdf52 100644 --- a/tests/unit/models-catalog-route.test.ts +++ b/tests/unit/models-catalog-route.test.ts @@ -24,10 +24,7 @@ async function resetStorage() { fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); } -async function seedConnection( - provider: string, - overrides: Record = {} -) { +async function seedConnection(provider: string, overrides: Record = {}) { return providersDb.createProviderConnection({ provider, authType: (overrides.authType as string) || "apikey", @@ -40,6 +37,29 @@ async function seedConnection( }); } +function capability(overrides = {}) { + return { + tool_call: null, + reasoning: null, + attachment: null, + structured_output: null, + temperature: null, + modalities_input: JSON.stringify([]), + modalities_output: JSON.stringify([]), + knowledge_cutoff: null, + release_date: null, + last_updated: null, + status: null, + family: null, + open_weights: null, + limit_context: null, + limit_input: null, + limit_output: null, + interleaved_field: null, + ...overrides, + }; +} + test.beforeEach(async () => { await resetStorage(); }); @@ -231,6 +251,263 @@ test("v1 models catalog keeps only visible combos when no providers are active", ); }); +test("v1 models catalog derives combo metadata from known targets conservatively", async () => { + try { + modelsDevSync.saveModelsDevCapabilities({ + openai: { + "combo-alpha": capability({ + tool_call: true, + reasoning: true, + attachment: true, + structured_output: true, + temperature: false, + modalities_input: JSON.stringify(["text", "image"]), + modalities_output: JSON.stringify(["text"]), + limit_context: 1000, + limit_input: 900, + limit_output: 120, + }), + }, + gemini: { + "combo-beta": capability({ + tool_call: true, + reasoning: true, + attachment: false, + structured_output: true, + temperature: false, + modalities_input: JSON.stringify(["text"]), + modalities_output: JSON.stringify(["text"]), + limit_context: 800, + limit_input: 700, + limit_output: 90, + }), + }, + }); + + await combosDb.createCombo({ + name: "metadata-router", + strategy: "priority", + models: ["openai/combo-alpha", "gemini/combo-beta"], + }); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as any; + const combo = body.data.find((item) => item.id === "metadata-router"); + + assert.equal(response.status, 200); + assert.ok(combo); + assert.equal(combo.context_length, 800); + assert.equal(combo.max_input_tokens, 700); + assert.equal(combo.max_output_tokens, 90); + assert.deepEqual(combo.input_modalities, ["text"]); + assert.deepEqual(combo.output_modalities, ["text"]); + assert.equal(combo.capabilities.structured_output, true); + assert.equal(combo.capabilities.temperature, false); + assert.equal(combo.capabilities.tool_calling, true); + assert.equal(combo.capabilities.reasoning, true); + assert.equal(combo.capabilities.thinking, true); + assert.equal("vision" in combo.capabilities, false); + assert.equal("attachment" in combo.capabilities, false); + assert.equal("architecture" in combo, false); + assert.equal("top_provider" in combo, false); + assert.equal("supported_parameters" in combo, false); + } finally { + modelsDevSync.saveModelsDevCapabilities({}); + } +}); + +test("v1 models catalog lets explicit combo context override derived context", async () => { + try { + modelsDevSync.saveModelsDevCapabilities({ + openai: { + "context-alpha": capability({ + modalities_input: JSON.stringify(["text"]), + modalities_output: JSON.stringify(["text"]), + limit_context: 1000, + limit_input: 900, + limit_output: 120, + }), + }, + gemini: { + "context-beta": capability({ + modalities_input: JSON.stringify(["text"]), + modalities_output: JSON.stringify(["text"]), + limit_context: 800, + limit_input: 700, + limit_output: 90, + }), + }, + }); + + const combo = await combosDb.createCombo({ + name: "context-router", + strategy: "priority", + models: ["openai/context-alpha", "gemini/context-beta"], + }); + await combosDb.updateCombo((combo as any).id, { context_length: 12345 }); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as any; + const listed = body.data.find((item) => item.id === "context-router"); + + assert.equal(response.status, 200); + assert.equal(listed.context_length, 12345); + assert.equal(listed.max_input_tokens, 700); + assert.equal(listed.max_output_tokens, 90); + } finally { + modelsDevSync.saveModelsDevCapabilities({}); + } +}); + +test("v1 models catalog keeps unknown combo targets visible without guessed metadata", async () => { + await combosDb.createCombo({ + name: "unknown-router", + strategy: "priority", + models: ["openai/no-known-metadata"], + }); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as any; + const combo = body.data.find((item) => item.id === "unknown-router"); + + assert.equal(response.status, 200); + assert.ok(combo); + assert.equal("context_length" in combo, false); + assert.equal("max_input_tokens" in combo, false); + assert.equal("max_output_tokens" in combo, false); + assert.equal("input_modalities" in combo, false); + assert.equal("output_modalities" in combo, false); + assert.equal("capabilities" in combo, false); +}); + +test("v1 models catalog aggregates nested combos and keeps hidden child combos unlisted", async () => { + try { + modelsDevSync.saveModelsDevCapabilities({ + openai: { + "nested-alpha": capability({ + modalities_input: JSON.stringify(["text"]), + modalities_output: JSON.stringify(["text"]), + limit_context: 1000, + limit_input: 900, + limit_output: 120, + }), + }, + gemini: { + "nested-beta": capability({ + modalities_input: JSON.stringify(["text"]), + modalities_output: JSON.stringify(["text"]), + limit_context: 800, + limit_input: 700, + limit_output: 90, + }), + }, + }); + + await combosDb.createCombo({ + name: "hidden-child-router", + strategy: "priority", + models: ["openai/nested-alpha", "gemini/nested-beta"], + isHidden: true, + }); + await combosDb.createCombo({ + name: "parent-router", + strategy: "priority", + models: ["hidden-child-router"], + }); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as any; + const parent = body.data.find((item) => item.id === "parent-router"); + + assert.equal(response.status, 200); + assert.ok(parent); + assert.equal(parent.context_length, 800); + assert.equal(parent.max_output_tokens, 90); + assert.equal( + body.data.some((item) => item.id === "hidden-child-router"), + false + ); + } finally { + modelsDevSync.saveModelsDevCapabilities({}); + } +}); + +test("v1 models catalog resolves provider aliases without corrupting slashful model ids", async () => { + try { + modelsDevSync.saveModelsDevCapabilities({ + claude: { + "alias-model": capability({ + modalities_input: JSON.stringify(["text"]), + modalities_output: JSON.stringify(["text"]), + limit_context: 2000, + limit_input: 1900, + limit_output: 200, + }), + }, + openrouter: { + "Qwen/Qwen3-Coder": capability({ + modalities_input: JSON.stringify(["text"]), + modalities_output: JSON.stringify(["text"]), + limit_context: 1600, + limit_input: 1500, + limit_output: 150, + }), + }, + }); + + await combosDb.createCombo({ + name: "alias-and-slash-router", + strategy: "priority", + models: [ + { kind: "model", providerId: "claude", model: "cc/alias-model" }, + { kind: "model", providerId: "openrouter", model: "Qwen/Qwen3-Coder" }, + ], + }); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as any; + const combo = body.data.find((item) => item.id === "alias-and-slash-router"); + + assert.equal(response.status, 200); + assert.ok(combo); + assert.equal(combo.context_length, 1600); + assert.equal(combo.max_input_tokens, 1500); + assert.equal(combo.max_output_tokens, 150); + } finally { + modelsDevSync.saveModelsDevCapabilities({}); + } +}); + +test("v1 models catalog does not final-enrich combo names as real models", async () => { + await combosDb.createCombo({ + name: "gpt-5.5", + strategy: "priority", + models: ["openai/no-known-metadata"], + }); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as any; + const combo = body.data.find((item) => item.id === "gpt-5.5"); + + assert.equal(response.status, 200); + assert.ok(combo); + assert.equal(combo.owned_by, "combo"); + assert.equal("max_output_tokens" in combo, false); + assert.equal("capabilities" in combo, false); +}); + test("v1 models catalog exposes claude alias and provider-prefixed built-in models with vision metadata", async () => { await seedConnection("claude", { authType: "oauth", @@ -1016,7 +1293,6 @@ test("v1 models catalog falls back to getTokenLimit for models without registry ); }); - test("v1 models catalog prefers manual combo context_length over auto-calculated", async () => { await seedConnection("openai", { name: "openai-manual-context" }); diff --git a/tests/unit/proxy-connection-test.test.ts b/tests/unit/proxy-connection-test.test.ts index 358c715847..4f5c0f9a49 100644 --- a/tests/unit/proxy-connection-test.test.ts +++ b/tests/unit/proxy-connection-test.test.ts @@ -1,6 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { providerAllowsOptionalApiKey, SELF_HOSTED_CHAT_PROVIDER_IDS } from "@/shared/constants/providers"; +import { + providerAllowsOptionalApiKey, + SELF_HOSTED_CHAT_PROVIDER_IDS, +} from "@/shared/constants/providers"; // ── Import test targets from connection test route ────────────────────────── diff --git a/tests/unit/translator-claude-helper-thinking.test.ts b/tests/unit/translator-claude-helper-thinking.test.ts index ca153a44d1..5c2f460f45 100644 --- a/tests/unit/translator-claude-helper-thinking.test.ts +++ b/tests/unit/translator-claude-helper-thinking.test.ts @@ -1,12 +1,9 @@ import test from "node:test"; import assert from "node:assert/strict"; -const { prepareClaudeRequest } = await import( - "../../open-sse/translator/helpers/claudeHelper.ts" -); -const { DEFAULT_THINKING_CLAUDE_SIGNATURE } = await import( - "../../open-sse/config/defaultThinkingSignature.ts" -); +const { prepareClaudeRequest } = await import("../../open-sse/translator/helpers/claudeHelper.ts"); +const { DEFAULT_THINKING_CLAUDE_SIGNATURE } = + await import("../../open-sse/config/defaultThinkingSignature.ts"); function multiTurnBodyWithoutThinkingBlock() { return { @@ -15,9 +12,7 @@ function multiTurnBodyWithoutThinkingBlock() { { role: "user", content: [{ type: "text", text: "hi" }] }, { role: "assistant", - content: [ - { type: "tool_use", id: "call_x", name: "ls", input: { path: "." } }, - ], + content: [{ type: "tool_use", id: "call_x", name: "ls", input: { path: "." } }], }, { role: "user", @@ -72,9 +67,7 @@ test("prepareClaudeRequest: existing thinking block — redacted, signature repl }, { role: "user", - content: [ - { type: "tool_result", tool_use_id: "call_y", content: "ok" }, - ], + content: [{ type: "tool_result", tool_use_id: "call_y", content: "ok" }], }, ], }; @@ -94,15 +87,11 @@ test("prepareClaudeRequest: thinking disabled — no inject regardless of tool_u { role: "user", content: [{ type: "text", text: "hi" }] }, { role: "assistant", - content: [ - { type: "tool_use", id: "call_z", name: "ls", input: {} }, - ], + content: [{ type: "tool_use", id: "call_z", name: "ls", input: {} }], }, { role: "user", - content: [ - { type: "tool_result", tool_use_id: "call_z", content: "ok" }, - ], + content: [{ type: "tool_result", tool_use_id: "call_z", content: "ok" }], }, ], }; @@ -115,9 +104,7 @@ test("prepareClaudeRequest: thinking disabled — no inject regardless of tool_u test("prepareClaudeRequest: thinking enabled + no tool_use — no inject (single-turn text)", () => { const body = { thinking: { type: "enabled", budget_tokens: 4096 }, - messages: [ - { role: "user", content: [{ type: "text", text: "hi" }] }, - ], + messages: [{ role: "user", content: [{ type: "text", text: "hi" }] }], }; const result = prepareClaudeRequest(body as any, "kimi-coding"); const userContent = (result as any).messages[0].content;