feat(api): aggregate combo model metadata in catalog (#2166)

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).
This commit is contained in:
Dohyun Jung
2026-05-12 09:14:25 +09:00
committed by GitHub
parent b3fe7ddcb1
commit dcb32a6ba0
36 changed files with 839 additions and 203 deletions

View File

@@ -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

View File

@@ -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<string, RegistryModel[]> = {
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<string, RegistryEntry> = {
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<string, RegistryEntry> = {
// 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<string, RegistryEntry> = {
{ 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<string, RegistryEntry> = {
},
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" },

View File

@@ -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();
}

View File

@@ -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<string, boolean>;
};
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>): 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<string>([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<string, boolean> = {};
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<string, any>, 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<string, boolean> = {};
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

View File

@@ -4658,4 +4658,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4951,4 +4951,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4658,4 +4658,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4658,4 +4658,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4674,4 +4674,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4658,4 +4658,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4951,4 +4951,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4658,4 +4658,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4658,4 +4658,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4951,4 +4951,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4658,4 +4658,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4658,4 +4658,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4658,4 +4658,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4662,4 +4662,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4951,4 +4951,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4658,4 +4658,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4658,4 +4658,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4660,4 +4660,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4951,4 +4951,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4658,4 +4658,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4658,4 +4658,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4658,4 +4658,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -4658,4 +4658,4 @@
"agentFeaturesContextLengthErrorInteger": "Context length must be a valid integer",
"agentFeaturesContextLengthErrorRange": "Context length must be between 1000 and 2000000"
}
}
}

View File

@@ -442,9 +442,9 @@ export async function deleteProviderConnections(ids: string[]): Promise<number>
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;
})();

View File

@@ -25,7 +25,7 @@ export default function AutoRoutingBanner() {
} catch {
// ignore localStorage errors (private mode, quotas)
}
setIsDismissed(true);
};

View File

@@ -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.`
),
};
}
}

View File

@@ -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<RateLimitResult> {
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 };
}
}

View File

@@ -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");

View File

@@ -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");

View File

@@ -24,10 +24,7 @@ async function resetStorage() {
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
async function seedConnection(
provider: string,
overrides: Record<string, unknown> = {}
) {
async function seedConnection(provider: string, overrides: Record<string, unknown> = {}) {
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" });

View File

@@ -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 ──────────────────────────

View File

@@ -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;