Skip to content

Commit 85472f0

Browse files
abienAlph4d0g
andauthored
Release v1.2.0 enrich model capabilities via models.dev
* feat: enrich model capabilities via models.dev and fix provider alias resolution - Add env var fallback for API key in config hook (OMNIROUTE_API_KEY) - Generate modelMetadata from enriched models so OpenCode applies capabilities - Add top-level capability fields (temperature, reasoning, attachment, tool_call, modalities) to provider model output - Generate reasoning effort variants (low/medium/high) for reasoning models - Add provider aliases: glmt/glm→zai-coding-plan, kimi-coding/kmc→moonshotai, gh/github→google - Add subscription fallback: zai-coding-plan→zai, kimi-for-coding→moonshotai - Add model aliases for naming mismatches (kimi-k2.6-thinking→kimi-k2-thinking) - Strip reasoning effort variant suffixes (gpt-5.5-xhigh→gpt-5.5) for lookup - Close 19 of 132 models with 4096 default context limits * fix: address model metadata review feedback Preserve user modelMetadata while merging generated capabilities, propagate new capability fields through models.dev and combo paths, and consolidate alias resolution. * fix: preserve combo attachment metadata Treat missing attachment capability as unknown instead of false when folding combo model capabilities, while still honoring explicit false values. * fix: respect explicit attachment false Avoid falling back to vision support when models.dev or combo metadata explicitly marks attachments unsupported. * fix: address code review issues from PR #18 - Fix temperature AND-chain bug in calculateLowestCommonCapabilities by tracking hasTemperatureMetadata - Fix reasoning AND-chain bug in calculateLowestCommonCapabilities by tracking hasReasoningMetadata - Respect explicit supportsAttachment=false for vision models in toProviderModel - Add normalized key candidates to getModelLookupCandidates for better alias/variant matching Addresses feedback from Copilot, Gemini Code Assist, and Kilo Code Bot reviews. * fix: use nullish coalescing for API key fallback * fix: add runtime validation for modelMetadata merge * fix: improve modelMetadata validation coverage * fix: include modelsDev config in cache key * refactor: extract splitModelId to eliminate DRY violation --------- Co-authored-by: Sebastian Rumpf <alp4d0g007@googlemail.com>
1 parent fd69858 commit 85472f0

8 files changed

Lines changed: 1501 additions & 94 deletions

File tree

docs/superpowers/plans/2026-05-16-pr18-review-fixes.md

Lines changed: 950 additions & 0 deletions
Large diffs are not rendered by default.

src/models-dev.ts

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,18 @@ export function modelsDevToMetadata(model: ModelsDevModel): OmniRouteModelMetada
246246
metadata.maxTokens = model.limit.output;
247247
}
248248

249+
if (model.temperature !== undefined) {
250+
metadata.supportsTemperature = model.temperature;
251+
}
252+
253+
if (model.reasoning !== undefined) {
254+
metadata.supportsReasoning = model.reasoning;
255+
}
256+
257+
if (model.attachment !== undefined) {
258+
metadata.supportsAttachment = model.attachment;
259+
}
260+
249261
// Derive vision support from modalities
250262
if (model.modalities?.input?.includes('image')) {
251263
metadata.supportsVision = true;
@@ -256,8 +268,6 @@ export function modelsDevToMetadata(model: ModelsDevModel): OmniRouteModelMetada
256268
metadata.supportsTools = true;
257269
}
258270

259-
260-
261271
// Pricing
262272
if (model.cost?.input !== undefined || model.cost?.output !== undefined) {
263273
metadata.pricing = {};
@@ -292,6 +302,12 @@ export function calculateLowestCommonCapabilities(
292302
let minMaxTokens: number | undefined;
293303
let allSupportVision = true;
294304
let allSupportTools = true;
305+
let allSupportTemperature = true;
306+
let hasTemperatureMetadata = false;
307+
let allSupportReasoning = true;
308+
let hasReasoningMetadata = false;
309+
let allSupportAttachment = true;
310+
let hasAttachmentMetadata = false;
295311
let allSupportStreaming = true;
296312

297313
for (const model of models) {
@@ -314,6 +330,21 @@ export function calculateLowestCommonCapabilities(
314330
// Tools: all must support it
315331
const supportsTools = model.tool_call === true;
316332
allSupportTools = allSupportTools && supportsTools;
333+
334+
if (model.temperature !== undefined) {
335+
hasTemperatureMetadata = true;
336+
allSupportTemperature = allSupportTemperature && model.temperature;
337+
}
338+
339+
if (model.reasoning !== undefined) {
340+
hasReasoningMetadata = true;
341+
allSupportReasoning = allSupportReasoning && model.reasoning;
342+
}
343+
344+
if (model.attachment !== undefined) {
345+
hasAttachmentMetadata = true;
346+
allSupportAttachment = allSupportAttachment && model.attachment;
347+
}
317348
}
318349

319350
const result: OmniRouteModelMetadata = {};
@@ -334,6 +365,24 @@ export function calculateLowestCommonCapabilities(
334365
result.supportsTools = true;
335366
}
336367

368+
if (hasTemperatureMetadata && allSupportTemperature) {
369+
result.supportsTemperature = true;
370+
} else if (hasTemperatureMetadata) {
371+
result.supportsTemperature = false;
372+
}
373+
374+
if (hasReasoningMetadata && allSupportReasoning) {
375+
result.supportsReasoning = true;
376+
} else if (hasReasoningMetadata) {
377+
result.supportsReasoning = false;
378+
}
379+
380+
if (hasAttachmentMetadata && allSupportAttachment) {
381+
result.supportsAttachment = true;
382+
} else if (hasAttachmentMetadata) {
383+
result.supportsAttachment = false;
384+
}
385+
337386
// Streaming is generally supported by all modern models
338387
if (allSupportStreaming) {
339388
result.supportsStreaming = true;
@@ -343,6 +392,31 @@ export function calculateLowestCommonCapabilities(
343392
}
344393

345394

395+
/**
396+
* Subscription → public provider fallback map.
397+
* When a subscription provider (e.g. zai-coding-plan) lacks a model,
398+
* try its public counterpart (e.g. zai) before giving up.
399+
*/
400+
export const SUBSCRIPTION_FALLBACKS: Record<string, string> = {
401+
'zai-coding-plan': 'zai',
402+
'kimi-for-coding': 'moonshotai',
403+
'github-models': 'google',
404+
};
405+
406+
/**
407+
* Known model ID mismatches between OmniRoute and models.dev.
408+
* Maps OmniRoute model names to their models.dev equivalents.
409+
*/
410+
export const MODEL_ALIASES: Record<string, string> = {
411+
'kimi-k2.6-thinking': 'kimi-k2-thinking',
412+
'kimi-k2.6-thinking-turbo': 'kimi-k2-thinking-turbo',
413+
};
414+
415+
export function resolveModelAlias(modelKey: string): string {
416+
const lower = modelKey.toLowerCase();
417+
return MODEL_ALIASES[lower] ?? MODEL_ALIASES[normalizeModelKey(lower)] ?? modelKey;
418+
}
419+
346420
/**
347421
* Resolve provider alias using config and defaults
348422
*/
@@ -372,8 +446,35 @@ export function resolveProviderAlias(
372446
openrouter: 'openrouter',
373447
perplexity: 'perplexity',
374448
cohere: 'cohere',
449+
glmt: 'zai-coding-plan',
450+
glm: 'zai-coding-plan',
451+
'kimi-coding': 'moonshotai',
452+
kmc: 'moonshotai',
453+
gh: 'google',
454+
github: 'google',
375455
...config?.modelsDev?.providerAliases,
376456
};
377457

378458
return aliases[lower] ?? lower;
379-
}
459+
}
460+
461+
/**
462+
* Get the public fallback provider for a subscription provider.
463+
* Returns null if no fallback exists.
464+
*/
465+
export function getSubscriptionFallback(provider: string): string | null {
466+
return SUBSCRIPTION_FALLBACKS[provider.toLowerCase()] ?? null;
467+
}
468+
469+
/**
470+
* Strip reasoning effort variant suffix from a model name.
471+
* Returns the base model name and true if a suffix was stripped.
472+
*/
473+
export function stripVariantSuffix(modelKey: string): { base: string; stripped: boolean } {
474+
const variantPattern = /-(low|medium|high|xhigh)$/i;
475+
const match = modelKey.match(variantPattern);
476+
if (match) {
477+
return { base: modelKey.slice(0, match.index), stripped: true };
478+
}
479+
return { base: modelKey, stripped: false };
480+
}

src/models.ts

Lines changed: 87 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,20 @@
1-
import type { OmniRouteConfig, OmniRouteModel, OmniRouteModelMetadata, OmniRouteModelsResponse } from './types.js';
1+
import type { OmniRouteConfig, OmniRouteModel, OmniRouteModelsResponse } from './types.js';
22
import {
33
OMNIROUTE_DEFAULT_MODELS,
44
OMNIROUTE_ENDPOINTS,
55
MODEL_CACHE_TTL,
66
REQUEST_TIMEOUT,
77
} from './constants.js';
8-
import { getModelsDevIndex, normalizeModelKey } from './models-dev.js';
9-
import type { ModelsDevIndex } from './models-dev.js';
10-
import { enrichComboModels, clearComboCache } from './omniroute-combos.js';
8+
import {
9+
getModelsDevIndex,
10+
normalizeModelKey,
11+
getSubscriptionFallback,
12+
stripVariantSuffix,
13+
resolveProviderAlias,
14+
resolveModelAlias,
15+
} from './models-dev.js';
16+
import type { ModelsDevIndex, ModelsDevModel } from './models-dev.js';
17+
import { enrichComboModels, clearComboCache, splitModelId } from './omniroute-combos.js';
1118
import { warn, debug } from './logger.js';
1219

1320
/**
@@ -28,7 +35,17 @@ const modelCache = new Map<string, ModelCache>();
2835
*/
2936
function getCacheKey(config: OmniRouteConfig, apiKey: string): string {
3037
const baseUrl = config.baseUrl || OMNIROUTE_ENDPOINTS.BASE_URL;
31-
return `${baseUrl}:${apiKey}`;
38+
39+
// Include modelsDev config in cache key to prevent stale data
40+
const modelsDevHash = config.modelsDev
41+
? JSON.stringify({
42+
enabled: config.modelsDev.enabled,
43+
url: config.modelsDev.url,
44+
providerAliases: config.modelsDev.providerAliases,
45+
})
46+
: '';
47+
48+
return `${baseUrl}:${apiKey}:${modelsDevHash}`;
3249
}
3350

3451
/**
@@ -116,6 +133,9 @@ export async function fetchModels(
116133
supportsStreaming: model.supportsStreaming,
117134
supportsVision: model.supportsVision,
118135
supportsTools: model.supportsTools,
136+
supportsTemperature: model.supportsTemperature,
137+
supportsReasoning: model.supportsReasoning,
138+
supportsAttachment: model.supportsAttachment,
119139
}));
120140

121141
// Enrich with models.dev and combo capabilities
@@ -234,32 +254,17 @@ function applyModelsDevMetadata(
234254
config: OmniRouteConfig,
235255
index: ModelsDevIndex,
236256
): OmniRouteModel {
237-
const { providerKey, modelKey } = splitOmniRouteModelForLookup(model.id);
257+
const { providerKey, modelKey } = splitModelId(model.id);
238258
const providerAlias = resolveProviderAlias(providerKey, config);
239-
const lookupKey = modelKey.toLowerCase();
240-
const normalizedKey = normalizeModelKey(modelKey);
241-
242-
// Try provider-specific exact match first
243-
const providerExact = providerAlias
244-
? index.exactByProvider.get(providerAlias)?.get(lookupKey)
245-
: undefined;
246-
247-
// Try provider-specific normalized match
248-
const providerNorm = providerAlias
249-
? index.normalizedByProvider.get(providerAlias)?.get(normalizedKey)
250-
: undefined;
251-
252-
// Try global exact match (only if single match to avoid ambiguity)
253-
const globalExactList = index.exactGlobal.get(lookupKey);
254-
const globalExact = globalExactList?.length === 1 ? globalExactList[0] : undefined;
255-
256-
// Try global normalized match (only if single match to avoid ambiguity)
257-
const globalNormList = index.normalizedGlobal.get(normalizedKey);
258-
const globalNorm = globalNormList?.length === 1 ? globalNormList[0] : undefined;
259-
260-
// Pick the best match (provider-specific preferred over global)
261-
const best = providerExact ?? providerNorm ?? globalExact ?? globalNorm;
262-
259+
const candidates = getModelLookupCandidates(modelKey);
260+
const providerCandidates = [
261+
...(providerAlias ? [providerAlias] : []),
262+
...(providerAlias
263+
? [getSubscriptionFallback(providerAlias)].filter((p): p is string => p !== null)
264+
: []),
265+
];
266+
267+
const best = lookupModelsDevModel(index, providerCandidates, candidates);
263268
if (!best) return model;
264269

265270
// Merge capabilities (only fill in missing values)
@@ -278,66 +283,65 @@ function applyModelsDevMetadata(
278283
? { supportsTools: true }
279284
: {}),
280285
...(model.supportsStreaming === undefined
281-
? { supportsStreaming: true } // Assume streaming is supported by default
286+
? { supportsStreaming: true }
287+
: {}),
288+
...(model.supportsTemperature === undefined && best.temperature !== undefined
289+
? { supportsTemperature: best.temperature }
290+
: {}),
291+
...(model.supportsReasoning === undefined && best.reasoning !== undefined
292+
? { supportsReasoning: best.reasoning }
293+
: {}),
294+
...(model.supportsAttachment === undefined && best.attachment !== undefined
295+
? { supportsAttachment: best.attachment }
282296
: {}),
283297
};
284298
}
285299

286-
/**
287-
* Split model ID for models.dev lookup
288-
*/
289-
function splitOmniRouteModelForLookup(
290-
modelId: string,
291-
): { providerKey: string | null; modelKey: string } {
292-
const trimmed = modelId.trim();
293-
294-
// Remove omniroute prefix if present
295-
const withoutPrefix = trimmed.replace(/^omniroute\//, '');
296-
297-
// Split by /
298-
const parts = withoutPrefix.split('/').filter((p) => p.trim() !== '');
299-
300-
if (parts.length >= 2) {
301-
return {
302-
providerKey: parts[0] ?? null,
303-
modelKey: parts.slice(1).join('/'),
304-
};
300+
function getModelLookupCandidates(modelKey: string): string[] {
301+
const candidates = new Set<string>();
302+
const addCandidate = (key: string): void => {
303+
candidates.add(key.toLowerCase());
304+
candidates.add(resolveModelAlias(key).toLowerCase());
305+
candidates.add(normalizeModelKey(key));
306+
candidates.add(normalizeModelKey(resolveModelAlias(key)));
307+
};
308+
309+
addCandidate(modelKey);
310+
311+
const { base, stripped } = stripVariantSuffix(modelKey);
312+
if (stripped) {
313+
addCandidate(base);
305314
}
306315

307-
return { providerKey: null, modelKey: withoutPrefix };
316+
return [...candidates];
308317
}
309318

310-
/**
311-
* Resolve provider alias using config
312-
*/
313-
function resolveProviderAlias(
314-
providerKey: string | null,
315-
config: OmniRouteConfig,
316-
): string | null {
317-
if (!providerKey) return null;
318-
319-
const lower = providerKey.toLowerCase();
320-
321-
// Default aliases
322-
const aliases: Record<string, string> = {
323-
oai: 'openai',
324-
openai: 'openai',
325-
cx: 'openai',
326-
codex: 'openai',
327-
anthropic: 'anthropic',
328-
claude: 'anthropic',
329-
gemini: 'google',
330-
google: 'google',
331-
deepseek: 'deepseek',
332-
mistral: 'mistral',
333-
xai: 'xai',
334-
groq: 'groq',
335-
together: 'together',
336-
openrouter: 'openrouter',
337-
perplexity: 'perplexity',
338-
cohere: 'cohere',
339-
...config.modelsDev?.providerAliases,
340-
};
319+
function lookupModelsDevModel(
320+
index: ModelsDevIndex,
321+
providerCandidates: string[],
322+
modelCandidates: string[],
323+
): ModelsDevModel | undefined {
324+
for (const provider of providerCandidates) {
325+
for (const candidate of modelCandidates) {
326+
const exact = index.exactByProvider.get(provider)?.get(candidate);
327+
if (exact) return exact;
328+
329+
const normalized = index.normalizedByProvider
330+
.get(provider)
331+
?.get(normalizeModelKey(candidate));
332+
if (normalized) return normalized;
333+
}
334+
}
341335

342-
return aliases[lower] ?? lower;
336+
for (const candidate of modelCandidates) {
337+
const exactList = index.exactGlobal.get(candidate);
338+
if (exactList?.length === 1) return exactList[0];
339+
340+
const normalizedList = index.normalizedGlobal.get(normalizeModelKey(candidate));
341+
if (normalizedList?.length === 1) return normalizedList[0];
342+
}
343+
344+
return undefined;
343345
}
346+
347+

src/omniroute-combos.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,7 @@ export function lookupModelInIndex(
224224
* Split a model ID into provider and model key
225225
* Handles formats like "provider/model", "omniroute/provider/model", etc.
226226
*/
227-
function splitModelId(modelId: string): { providerKey: string | null; modelKey: string } {
227+
export function splitModelId(modelId: string): { providerKey: string | null; modelKey: string } {
228228
const trimmed = modelId.trim();
229229

230230
// Remove omniroute prefix if present
@@ -366,6 +366,15 @@ export async function enrichComboModels(
366366
...(capabilities.maxTokens !== undefined ? { maxTokens: capabilities.maxTokens } : {}),
367367
...(capabilities.supportsVision !== undefined ? { supportsVision: capabilities.supportsVision } : {}),
368368
...(capabilities.supportsTools !== undefined ? { supportsTools: capabilities.supportsTools } : {}),
369+
...(capabilities.supportsTemperature !== undefined
370+
? { supportsTemperature: capabilities.supportsTemperature }
371+
: {}),
372+
...(capabilities.supportsReasoning !== undefined
373+
? { supportsReasoning: capabilities.supportsReasoning }
374+
: {}),
375+
...(capabilities.supportsAttachment !== undefined
376+
? { supportsAttachment: capabilities.supportsAttachment }
377+
: {}),
369378
...(capabilities.supportsStreaming !== undefined ? { supportsStreaming: capabilities.supportsStreaming } : {}),
370379
...(capabilities.pricing !== undefined ? { pricing: { ...model.pricing, ...capabilities.pricing } } : {}),
371380
};

0 commit comments

Comments
 (0)