mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 01:32:22 +03:00
* fix(ci): drop unused RadarReferrals type export — dead-code ratchet back to 227 baseline The radar referral-links feature (#9697) exported the inferred type RadarReferrals from feedSchema.ts but nothing imports it (the singular RadarReferral is the consumed type). knip counts it as a new dead export, pushing the dead-code ratchet to 228 > 227 and failing Fast Quality Gates on every PR born after the merge. RadarReferralsSchema itself stays — it is used by RadarFeedSchema. Refs #9737 * fix(ci): clear the 08-08 base-red layer — prod crash in chat.ts, Responses API payload regression, born-red stdio test, gate drifts Six independent base-reds from the 08-07 evening merge batch, each verified against the pure release/v3.8.50 tip: - src/sse/handlers/chat.ts: #9467's squash carried a refactor hunk that renamed the all-rate-limited breaker guard to an UNDEFINED variable (isAllRateLimited) — a production ReferenceError on the all-accounts-429 path (chat.ts is outside typecheck:core scope, so only tests caught it). Restore credentials?.allRateLimited. Guard: chat-rate-limit-body-lock (2/2), also un-breaks batch_api and chat-combo-live-test. - open-sse/utils/stream.ts: #9315 switched providerPayload summaries to the accumulated responseBody, but in passthrough paths that body is synthesized in chat-completion shape — Responses API lost its `response` object in the dashboard payload. Keep the events-derived summary for OPENAI_RESPONSES only. Guard: stream-utils + stream-collector-9315 suites (51/51). - tests/unit/mcp-stdio-json-purity.test.ts: born red — the full CLI chain takes ~10s (2x tsx import + DB init) and the test slept a fixed 4s. Poll for the first stdout line with a 60s deadline instead. - tests/unit/plugins-route-error-sanitization.test.ts: register #9445's new marketplace/install route in PLUGIN_ROUTES (route already sanitizes) (33/33). - tests/unit/provider-models-route-codex.test.ts: realign pinned GPT-5.6 input limit to #9432's deliberate 272000→922000 bump (7/7). - lint: fix 11 no-explicit-any errors in repro-9630 + specialty-9293 tests, prune 1 orphaned suppression, allowlist the opencode-ai devDependency (#8869, publisher-verified), and reword a doc line the fabricated-docs gate misread as an env var. Gates re-verified locally: lint:json --max-warnings 0 exit 0, dead-code 227, typecheck:core clean, check:deps OK, check:fabricated-docs OK. Refs #9737 * fix(ci): clear the third 08-08 base-red layer — invalid ru rule pack, stale event pin, orphaned UI repro test, pack/mutation/file-size drifts Follow-up to the previous layer: the serial fast-gates chain unmasked one more stratum after file-size/dead-code went green, all verified against the merged release/v3.8.50 tip: - compression rules ru/ultra.json (#9581): two rules shipped minIntensity "notes", which is not a valid CavemanIntensity (lite|full|ultra) — loading ANY language pack list threw and killed the rtk-loader suite. Mapped both to "ultra" (they are the most aggressive punctuation/case rules, matching the en pack tiers). 2/2. - plugins-welcome-banner-e2e: #9668 added the onStreamComplete builtin event (real emission path via runOnStreamCompleteHooks) and missed this pinned-list sibling. 35/35. - tests/unit/free-pool-frontend-repro (#9046): landed as .tsx with node:test semantics — no runner collects tests/unit/*.tsx, so it NEVER ran (test-discovery NEW-orphan). It contains zero JSX; renamed to .test.ts so the unit runner's existing glob collects it. 5/5 (first real run). - pack-policy: allow + require bin/mcpStdioConsoleGuard.mjs (#9281) — it is preloaded via node --import by bin/mcp-server.mjs, so a published artifact without it crashes 'omniroute --mcp' at startup. - stryker.conf.json: add 5 covering unit tests from the batch (#8779/#9204/ #9330/#9630/openrouter-passthrough) to tap.testFiles (--strict drift). - file-size-baseline: consolidate the base-drift rebaseline for the 12 files grown by the 08-06..08-08 batches (#9616's entries never reached the base; measured on this branch's tree — this PR's own source edits add zero lines to any frozen file). Local battery: file-size/deps/test-discovery/mutation/pack-policy/dead-code/ duplication/docs-all/secrets/vuln/workflows ratchets all exit 0; full lint gate --max-warnings 0 exit 0. Refs #9737 * fix(types): clear the 3 uncovered open-sse-typecheck regressions + realign combo skip-code siblings Fourth base-red layer unmasked by the serial gates. The other 4 typecheck regressions (codex.ts, kiro.ts, tierResolver.test.ts, translator/index.ts) already have dedicated open [TS7] PRs (#9748/#9753/#9742/#9747) — not duplicated here. This commit covers only what no open PR owns: - devin-agentic/serializer.ts TS2367: drop the dead 'role === "system"' branch — the guard above already narrows role to user|assistant (system throws unsupported_role). Devin suites 104/104. - raycast.ts TS2416: the buildHeaders 'override' never matched the base signature (2nd param is the signed payload string, not the stream boolean) — renamed to a private buildRaycastRequestHeaders helper so a polymorphic buildHeaders(credentials, true) call can never bind here. - modelMetadataRegistry.ts TS2352: PricingByProvider → nested-record cast now goes through unknown (shape is runtime-guarded by findInsensitive). - combo-routing-engine.test.ts: realign 2 pre-dispatch-skip expectations to #9630's deliberate ALL_TARGETS_SKIPPED contract (87/87). Refs #9737 * fix(ci): clear the fifth 08-08 base-red layer — reasoning-placeholder contract sweep, GPT-5.6 limits sweep, vi key parity The 08-08 merges (#9610 reasoning replay, #9432 GPT-5.6 limits, #9630 combo skip codes, #9336 provider key links) each changed a contract and left sibling tests pinning the old one. Full grep sweep per contract, not just the shard that happened to go red: - reasoning placeholder (#9573/#9610): the fix DELIBERATELY removed NON_ANTHROPIC_THINKING_PLACEHOLDER injection on cache miss — the model echoed the placeholder as its own reasoning (empty stop) and re-poisoned cache + client history; DeepSeek's 400 is specific to an EMPTY STRING, not an absent field. Realigned reasoning-cache (2 cases, renamed to describe omission) + tool-request-sanitization (1 case + dead import). 60/60. - GPT-5.6 Codex limits (#9432, 272000 -> 1050000 ctx / 922000 input): realigned vscode-token-routes-gpt56 (2) + vscode-token-routes (3). 43/43 together with t23-t24. - combo skip codes (#9630): t23-t24-fallback-resilience T24 now expects ALL_TARGETS_SKIPPED like the combo-routing-engine siblings. - vi.json key parity: #9336 added providers.getApiKey/getApiKeyDescription to en.json without syncing vi (the only locale with a parity gate). Translated both; providers block reordered to match en key order. 5/5. - pack-artifact-policy.test.ts: sibling of this PR's own required-paths change (bin/mcpStdioConsoleGuard.mjs). 10/10. - combo-routing-engine.test.ts: dropped the 6 comment lines added in the previous commit so the frozen test file-size stays at its baseline (the rationale lives in that commit message, not the test body). Gates: file-size, test-discovery, mutation-test-coverage, pack-policy, open-sse-typecheck, dead-code all exit 0. Refs #9737 * fix(translator): keep the reasoning_content placeholder for Xiaomi MiMo — #9610 traded one live 400 for another The xiaomi-mimo replay test (9router#1321) went red on the base after #9610 removed the NON_ANTHROPIC_THINKING_PLACEHOLDER injection globally. That test is NOT stale — it guards a documented upstream 400 ('Param Incorrect: The reasoning_content in the thinking mode must be passed back to the API'), so realigning it would have masked a reintroduced production bug. Two real bugs conflict here: - #9573: forwarding the placeholder makes the model continue its chain of thought FROM that text (echo -> empty stop) and re-poisons cache/history. - 9router#1321/#1337: omitting reasoning_content on a plain replay turn makes Xiaomi MiMo reject the request outright. #9610's evidence for omitting is provider-specific — it verified that deepseek-v4-flash accepts an ABSENT field. It does not extend to MiMo. So the omission stays for every provider #9610 covered, and the placeholder survives the cache miss only for xiaomi-mimo (new requiresReasoningContentPresence predicate next to isReasoningOnlyReplayTarget). The echo that comes back is still stripped on the way in by isInternalReasoningPlaceholder(), so #9573's cache/history poisoning stays fixed for MiMo too. Both contracts now hold simultaneously: xiaomi-mimo replay + reasoning-cache + tool-request-sanitization 61/61; placeholder-strip/responses/translator/combo regression sweep 168/168. Gates: file-size, open-sse-typecheck, dead-code, mutation-test-coverage exit 0; typecheck:core clean. A live check on the VPS (Hard Rule #18 path 2) is the only way to confirm the DeepSeek half of #9610's empirical claim; flagging it in the PR rather than widening this fix on speculation. Refs #9737 * test(translator): pin the reasoning-placeholder provider scope so neither half of the conflict can silently re-break #9610 removed the placeholder globally on the strength of ONE provider's observed behavior (deepseek-v4-flash accepting an absent reasoning_content), which re-opened the MiMo 400 (9router#1321). The previous commit scoped the placeholder to xiaomi-mimo; this pins BOTH directions in one test so the next global edit fails loudly instead of trading the bugs again: - xiaomi-mimo plain replay turn, cache miss -> reasoning_content present (narrowing the scope away from MiMo re-opens 9router#1321) - deepseek plain replay turn, cache miss -> reasoning_content absent (widening it back to DeepSeek re-opens the #9573 echo bug) Guard verified by mutation: forcing requiresReasoningContentPresence() to return true makes the DeepSeek half fail (1 pass / 1 fail), and the file was restored from the pre-probe copy before committing. Also checked kimi-coding/kimi-coding-apikey, the other strict-contract entries in REASONING_REPLAY_PROVIDERS: their originating PR (#7673) fixes capture and replay of REAL reasoning and documents no 400 on an absent field, so they stay out of the placeholder scope — evidence-scoped, not speculatively widened. Reasoning suites together: 87/87. Gates: file-size, test-discovery, mutation-test-coverage, dead-code exit 0; eslint clean. Refs #9737 --------- Co-authored-by: diegosouzapw <diegosouzapw@users.noreply.github.com>
759 lines
27 KiB
TypeScript
759 lines
27 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
||
import { parseModel } from "@omniroute/open-sse/services/model.ts";
|
||
import { getModelInfo } from "@/sse/services/model";
|
||
import { getModelAliases } from "@/lib/db/models";
|
||
import {
|
||
getResolvedModelCapabilities,
|
||
getResolvedModelContextOverride,
|
||
isNonChatCatalogSurface,
|
||
} from "@/lib/modelCapabilities";
|
||
import {
|
||
getAuthoritativeContextWindow,
|
||
getAuthoritativeProviderContextWindow,
|
||
getModelSpec,
|
||
resolveModelAlias as resolveStaticModelAlias,
|
||
} from "@/shared/constants/modelSpecs";
|
||
import { AI_PROVIDERS } from "@/shared/constants/providers";
|
||
import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "@/shared/constants/models";
|
||
import { getSyncStatus, getSyncedCapability, getModelsDevPricing } from "@/lib/modelsDevSync";
|
||
import { getSyncedPricing } from "@/lib/pricingSync";
|
||
import { getPricingForModel as getDefaultPricingForModel } from "@/shared/constants/pricing";
|
||
import {
|
||
CANONICAL_EFFORT_VALUES,
|
||
extendCodexGpt56EffortValues,
|
||
} from "@/shared/reasoning/effortStandardization";
|
||
|
||
const MODEL_METADATA_SCHEMA_VERSION = "model-metadata-v1";
|
||
|
||
export const MODEL_ALIAS_AMBIGUOUS = "MODEL_ALIAS_AMBIGUOUS";
|
||
export const MODEL_NOT_MAPPED = "MODEL_NOT_MAPPED";
|
||
export const INTERNAL_PROXY_ERROR = "INTERNAL_PROXY_ERROR";
|
||
|
||
type JsonRecord = Record<string, unknown>;
|
||
|
||
interface CatalogDiagnosticsOptions {
|
||
request?: Request | null;
|
||
requestId?: string | null;
|
||
resolvedAlias?: string | null;
|
||
}
|
||
|
||
export interface CanonicalModelMetadata {
|
||
provider: string | null;
|
||
providerAlias: string | null;
|
||
providerLabel: string | null;
|
||
model: string;
|
||
qualifiedId: string | null;
|
||
displayName: string;
|
||
aliases: string[];
|
||
capabilities: {
|
||
toolCalling: boolean;
|
||
reasoning: boolean;
|
||
supportsThinking: boolean | null;
|
||
supportsTools: boolean | null;
|
||
vision: boolean | null;
|
||
attachment: boolean | null;
|
||
structuredOutput: boolean | null;
|
||
temperature: boolean | null;
|
||
};
|
||
limits: {
|
||
contextWindow: number | null;
|
||
maxInputTokens: number | null;
|
||
maxOutputTokens: number;
|
||
defaultThinkingBudget: number;
|
||
thinkingBudgetCap: number | null;
|
||
thinkingOverhead: number | null;
|
||
adaptiveMaxTokens: number | null;
|
||
};
|
||
metadata: {
|
||
family: string | null;
|
||
status: string | null;
|
||
knowledgeCutoff: string | null;
|
||
releaseDate: string | null;
|
||
lastUpdated: string | null;
|
||
openWeights: boolean | null;
|
||
source: {
|
||
providerRegistry: boolean;
|
||
staticSpec: boolean;
|
||
syncedCapability: boolean;
|
||
};
|
||
};
|
||
modalities: {
|
||
input: string[];
|
||
output: string[];
|
||
interleavedField: string | null;
|
||
};
|
||
}
|
||
|
||
export interface ResolvedAliasLookup {
|
||
alias: string;
|
||
resolvedAlias: string;
|
||
source: "stored_alias" | "direct_model" | "catalog_match";
|
||
provider: string;
|
||
providerAlias: string;
|
||
model: string;
|
||
target: unknown;
|
||
metadata: CanonicalModelMetadata;
|
||
}
|
||
|
||
export interface AliasResolutionError {
|
||
status: number;
|
||
code: string;
|
||
message: string;
|
||
candidates?: string[];
|
||
}
|
||
|
||
function asNonEmptyString(value: unknown): string | null {
|
||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||
}
|
||
|
||
function uniqueStrings(values: Array<string | null | undefined>) {
|
||
return [
|
||
...new Set(values.filter((value): value is string => Boolean(value && value.length > 0))),
|
||
];
|
||
}
|
||
|
||
function toQualifiedId(
|
||
providerAlias: string | null,
|
||
provider: string | null,
|
||
model: string | null
|
||
) {
|
||
if (!model) return null;
|
||
if (providerAlias) return `${providerAlias}/${model}`;
|
||
if (provider) return `${provider}/${model}`;
|
||
return model;
|
||
}
|
||
|
||
function getRegistryModel(providerOrAlias: string | null, modelId: string | null) {
|
||
if (!providerOrAlias || !modelId) return null;
|
||
const alias = PROVIDER_ID_TO_ALIAS[providerOrAlias] || providerOrAlias;
|
||
const models = PROVIDER_MODELS[alias] || PROVIDER_MODELS[providerOrAlias] || [];
|
||
return models.find((entry) => entry?.id === modelId) || null;
|
||
}
|
||
|
||
function buildModalities(
|
||
input: string[],
|
||
output: string[],
|
||
supportsVision: boolean | null
|
||
): { input: string[]; output: string[] } {
|
||
if (input.length > 0 || output.length > 0) {
|
||
return { input, output };
|
||
}
|
||
if (supportsVision) {
|
||
return { input: ["text", "image"], output: ["text"] };
|
||
}
|
||
return { input: [], output: [] };
|
||
}
|
||
|
||
function extractCandidateMatches(modelId: string) {
|
||
const matches: Array<{ provider: string; providerAlias: string; model: string }> = [];
|
||
for (const [providerAlias, models] of Object.entries(PROVIDER_MODELS)) {
|
||
for (const entry of models || []) {
|
||
if (entry?.id !== modelId) continue;
|
||
const providerId =
|
||
Object.entries(PROVIDER_ID_TO_ALIAS).find(([, alias]) => alias === providerAlias)?.[0] ||
|
||
providerAlias;
|
||
matches.push({
|
||
provider: providerId,
|
||
providerAlias: PROVIDER_ID_TO_ALIAS[providerId] || providerAlias,
|
||
model: entry.id,
|
||
});
|
||
}
|
||
}
|
||
return matches;
|
||
}
|
||
|
||
function getResolvedRequestId(request?: Request | null, requestId?: string | null) {
|
||
const incomingId =
|
||
asNonEmptyString(requestId) || asNonEmptyString(request?.headers.get("x-request-id"));
|
||
return incomingId || randomUUID();
|
||
}
|
||
|
||
export function getModelCatalogVersion() {
|
||
const syncStatus = getSyncStatus();
|
||
return syncStatus.lastSync
|
||
? `${MODEL_METADATA_SCHEMA_VERSION}:${syncStatus.lastSync}`
|
||
: `${MODEL_METADATA_SCHEMA_VERSION}:static`;
|
||
}
|
||
|
||
export function getCatalogDiagnosticsHeaders(
|
||
options: CatalogDiagnosticsOptions = {}
|
||
): Record<string, string> {
|
||
const resolvedRequestId = getResolvedRequestId(options.request, options.requestId);
|
||
return {
|
||
"X-Request-Id": resolvedRequestId,
|
||
"X-Model-Catalog-Version": getModelCatalogVersion(),
|
||
...(options.resolvedAlias ? { "X-Model-Alias-Resolved": options.resolvedAlias } : {}),
|
||
};
|
||
}
|
||
|
||
export function getCanonicalModelMetadata(input: {
|
||
provider?: string | null;
|
||
model?: string | null;
|
||
}): CanonicalModelMetadata | null {
|
||
const modelId = asNonEmptyString(input.model);
|
||
if (!modelId) return null;
|
||
|
||
const resolved = getResolvedModelCapabilities({
|
||
provider: input.provider || null,
|
||
model: modelId,
|
||
});
|
||
const provider = resolved.provider;
|
||
const providerAlias = provider ? PROVIDER_ID_TO_ALIAS[provider] || provider : null;
|
||
const registryModel = getRegistryModel(providerAlias || provider, resolved.model || modelId);
|
||
const staticSpec = getModelSpec(resolved.model || modelId);
|
||
const syncedCapability =
|
||
provider && resolved.model ? getSyncedCapability(provider, resolved.model) : null;
|
||
const canonicalStaticAlias = resolveStaticModelAlias(resolved.model || modelId);
|
||
const modalities = buildModalities(
|
||
resolved.modalitiesInput,
|
||
resolved.modalitiesOutput,
|
||
resolved.supportsVision
|
||
);
|
||
|
||
return {
|
||
provider,
|
||
providerAlias,
|
||
providerLabel:
|
||
(provider && (AI_PROVIDERS as Record<string, JsonRecord>)[provider]?.name?.toString()) ||
|
||
null,
|
||
model: resolved.model || modelId,
|
||
qualifiedId: toQualifiedId(providerAlias, provider, resolved.model || modelId),
|
||
displayName: registryModel?.name || resolved.model || modelId,
|
||
aliases: uniqueStrings([
|
||
canonicalStaticAlias !== (resolved.model || modelId) ? canonicalStaticAlias : null,
|
||
...(staticSpec?.aliases || []),
|
||
]),
|
||
capabilities: {
|
||
toolCalling: resolved.toolCalling,
|
||
reasoning: resolved.reasoning,
|
||
supportsThinking: resolved.supportsThinking,
|
||
supportsTools: resolved.supportsTools,
|
||
vision: resolved.supportsVision,
|
||
attachment: resolved.attachment,
|
||
structuredOutput: resolved.structuredOutput,
|
||
temperature: resolved.temperature,
|
||
},
|
||
limits: {
|
||
contextWindow: resolved.contextWindow,
|
||
maxInputTokens: resolved.maxInputTokens,
|
||
maxOutputTokens: resolved.maxOutputTokens,
|
||
defaultThinkingBudget: resolved.defaultThinkingBudget,
|
||
thinkingBudgetCap: resolved.thinkingBudgetCap,
|
||
thinkingOverhead: resolved.thinkingOverhead,
|
||
adaptiveMaxTokens: resolved.adaptiveMaxTokens,
|
||
},
|
||
metadata: {
|
||
family: resolved.family,
|
||
status: resolved.status,
|
||
knowledgeCutoff: resolved.knowledgeCutoff,
|
||
releaseDate: resolved.releaseDate,
|
||
lastUpdated: resolved.lastUpdated,
|
||
openWeights: resolved.openWeights,
|
||
source: {
|
||
providerRegistry: Boolean(registryModel),
|
||
staticSpec: Boolean(staticSpec),
|
||
syncedCapability: Boolean(syncedCapability),
|
||
},
|
||
},
|
||
modalities: {
|
||
input: modalities.input,
|
||
output: modalities.output,
|
||
interleavedField: resolved.interleavedField,
|
||
},
|
||
};
|
||
}
|
||
|
||
// #8697 second bottleneck (after getModelsDevPricing memoization above): findInsensitive
|
||
// rebuilt a full Object.entries() scan on every miss, twice per model (provider lookup +
|
||
// model lookup) — ~6091 models × ~180-210 entries ≈ 1.2-1.3M allocations per catalog
|
||
// rebuild. Replaced with a lowercase-key index built once per distinct object and cached
|
||
// by identity (WeakMap) — getModelsDevPricing() returns the same object reference while
|
||
// its cache is warm, so the index is reused across every resolveCatalogPricing() call in
|
||
// a rebuild instead of rebuilt per lookup.
|
||
const lowercaseIndexCache = new WeakMap<object, Map<string, unknown>>();
|
||
|
||
function findInsensitive<T>(obj: Record<string, T> | null | undefined, key: string): T | undefined {
|
||
if (!obj || !key) return undefined;
|
||
if (key in obj) return obj[key];
|
||
let index = lowercaseIndexCache.get(obj);
|
||
if (!index) {
|
||
index = new Map();
|
||
for (const [k, v] of Object.entries(obj)) {
|
||
const lowerKey = k.toLowerCase();
|
||
// Warn once at index-build time (not per-lookup) if two keys collide
|
||
// case-insensitively — a real data-quality signal from an upstream sync (e.g.
|
||
// models.dev returning both "OpenAI" and "openai" as distinct provider keys).
|
||
// Matches the pre-fix scan's silent first-match-wins behavior, just surfaced
|
||
// instead of swallowed.
|
||
if (index.has(lowerKey)) {
|
||
console.warn(
|
||
`[modelMetadataRegistry] findInsensitive: case-insensitive key collision on "${lowerKey}" — keeping first-seen value, later one discarded`
|
||
);
|
||
continue;
|
||
}
|
||
index.set(lowerKey, v);
|
||
}
|
||
lowercaseIndexCache.set(obj, index);
|
||
}
|
||
return index.get(key.toLowerCase()) as T | undefined;
|
||
}
|
||
|
||
function resolveCatalogPricing(
|
||
provider: string | null,
|
||
model: string | null
|
||
): Record<string, number> | null {
|
||
if (!provider || !model) return null;
|
||
|
||
// Prefer models.dev synced pricing when present; fall back to hardcoded defaults.
|
||
try {
|
||
const modelsDev = getModelsDevPricing() as Record<
|
||
string,
|
||
Record<string, Record<string, number>>
|
||
>;
|
||
const providerPricing =
|
||
findInsensitive(modelsDev, provider) ||
|
||
findInsensitive(modelsDev, provider.replace(/-cn$/, ""));
|
||
if (providerPricing) {
|
||
const modelPricing =
|
||
findInsensitive(providerPricing, model) ||
|
||
findInsensitive(providerPricing, model.replace(/\./g, "-")) ||
|
||
findInsensitive(
|
||
providerPricing,
|
||
model.includes("/") ? model.split("/").pop() || model : model
|
||
);
|
||
if (modelPricing && typeof modelPricing === "object") {
|
||
const input = modelPricing.input;
|
||
const output = modelPricing.output;
|
||
if (typeof input === "number" || typeof output === "number") {
|
||
const pricing: Record<string, number> = {};
|
||
if (typeof input === "number") pricing.input = input;
|
||
if (typeof output === "number") pricing.output = output;
|
||
if (typeof modelPricing.cached === "number") pricing.cached = modelPricing.cached;
|
||
if (typeof modelPricing.cache_creation === "number") {
|
||
pricing.cache_creation = modelPricing.cache_creation;
|
||
}
|
||
return pricing;
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// pricing lookup must never break catalog assembly
|
||
}
|
||
|
||
// LiteLLM-synced pricing (`pricing_synced` namespace) — Layer 3 in the
|
||
// documented resolution order (user > models.dev > LiteLLM > defaults).
|
||
// Consulted only when models.dev returned nothing, matching the order
|
||
// already implemented in db/settings/pricing.ts::getPricing().
|
||
try {
|
||
const litellm = getSyncedPricing() as unknown as Record<
|
||
string,
|
||
Record<string, Record<string, number>>
|
||
>;
|
||
const providerPricing =
|
||
findInsensitive(litellm, provider) || findInsensitive(litellm, provider.replace(/-cn$/, ""));
|
||
if (providerPricing) {
|
||
const modelPricing =
|
||
findInsensitive(providerPricing, model) ||
|
||
findInsensitive(providerPricing, model.replace(/\./g, "-")) ||
|
||
findInsensitive(
|
||
providerPricing,
|
||
model.includes("/") ? model.split("/").pop() || model : model
|
||
);
|
||
if (modelPricing && typeof modelPricing === "object") {
|
||
const input = modelPricing.input;
|
||
const output = modelPricing.output;
|
||
if (typeof input === "number" || typeof output === "number") {
|
||
const pricing: Record<string, number> = {};
|
||
if (typeof input === "number") pricing.input = input;
|
||
if (typeof output === "number") pricing.output = output;
|
||
if (typeof modelPricing.cached === "number") pricing.cached = modelPricing.cached;
|
||
if (typeof modelPricing.cache_creation === "number") {
|
||
pricing.cache_creation = modelPricing.cache_creation;
|
||
}
|
||
return pricing;
|
||
}
|
||
}
|
||
}
|
||
} catch {
|
||
// pricing lookup must never break catalog assembly
|
||
}
|
||
|
||
try {
|
||
const defaults = getDefaultPricingForModel(provider, model) as Record<string, number> | null;
|
||
if (defaults && (typeof defaults.input === "number" || typeof defaults.output === "number")) {
|
||
return defaults;
|
||
}
|
||
} catch {
|
||
// ignore
|
||
}
|
||
return null;
|
||
}
|
||
|
||
export function enrichCatalogModelEntry<T extends JsonRecord>(
|
||
entry: T,
|
||
input?: { provider?: string | null; model?: string | null }
|
||
): T {
|
||
const provider =
|
||
input?.provider ||
|
||
(typeof entry.owned_by === "string" && entry.owned_by !== "combo" ? entry.owned_by : null);
|
||
const model =
|
||
input?.model ||
|
||
asNonEmptyString(entry.root) ||
|
||
(() => {
|
||
const id = asNonEmptyString(entry.id);
|
||
if (!id) return null;
|
||
if (id.includes("/")) return id.slice(id.indexOf("/") + 1);
|
||
return id;
|
||
})();
|
||
|
||
const metadata = getCanonicalModelMetadata({ provider, model });
|
||
if (!metadata) return entry;
|
||
const registryModel = getRegistryModel(
|
||
metadata.providerAlias || metadata.provider,
|
||
metadata.model
|
||
);
|
||
|
||
const nextEntry: JsonRecord = { ...entry };
|
||
const existingName = asNonEmptyString(entry.name);
|
||
const authoritativeContextWindow =
|
||
getAuthoritativeProviderContextWindow(metadata.provider, metadata.model) ??
|
||
getAuthoritativeProviderContextWindow(provider, model) ??
|
||
getAuthoritativeContextWindow(metadata.model) ??
|
||
getAuthoritativeContextWindow(model);
|
||
const specialtySurface = isNonChatCatalogSurface(entry.type);
|
||
const persistedContextWindow = getResolvedModelContextOverride({ provider, model });
|
||
const capabilityFields = {
|
||
...(typeof metadata.capabilities.vision === "boolean"
|
||
? { vision: metadata.capabilities.vision }
|
||
: {}),
|
||
// #8016: never invent chat tool/reasoning defaults onto specialty surfaces.
|
||
// Only copy boolean true when authoritative metadata says so; specialty rows
|
||
// default to false instead of optimistic chat heuristics.
|
||
tool_calling: specialtySurface
|
||
? metadata.capabilities.supportsTools === true || metadata.capabilities.toolCalling === true
|
||
? true
|
||
: false
|
||
: metadata.capabilities.toolCalling,
|
||
reasoning: specialtySurface
|
||
? metadata.capabilities.supportsThinking === true || metadata.capabilities.reasoning === true
|
||
? true
|
||
: false
|
||
: metadata.capabilities.reasoning,
|
||
// #6241: surface thinking support + the canonical effort tiers so the frontend can
|
||
// render the effort/thinking toggles. `thinking` is kept for back-compat; `supportsThinking`
|
||
// is the explicit flag and `effort_tiers` lists the selectable reasoning levels
|
||
// (only when the model actually supports thinking).
|
||
...(typeof metadata.capabilities.supportsThinking === "boolean"
|
||
? {
|
||
thinking: metadata.capabilities.supportsThinking,
|
||
supportsThinking: metadata.capabilities.supportsThinking,
|
||
...(metadata.capabilities.supportsThinking
|
||
? {
|
||
effort_tiers:
|
||
registryModel?.supportedThinkingEfforts &&
|
||
registryModel.supportedThinkingEfforts.length > 0
|
||
? [...registryModel.supportedThinkingEfforts]
|
||
: extendCodexGpt56EffortValues(
|
||
metadata.provider,
|
||
metadata.model,
|
||
CANONICAL_EFFORT_VALUES
|
||
),
|
||
}
|
||
: {}),
|
||
}
|
||
: {}),
|
||
...(typeof metadata.capabilities.attachment === "boolean"
|
||
? { attachment: metadata.capabilities.attachment }
|
||
: {}),
|
||
...(typeof metadata.capabilities.structuredOutput === "boolean"
|
||
? { structured_output: metadata.capabilities.structuredOutput }
|
||
: {}),
|
||
...(typeof metadata.capabilities.temperature === "boolean"
|
||
? { temperature: metadata.capabilities.temperature }
|
||
: {}),
|
||
};
|
||
|
||
nextEntry.capabilities = {
|
||
...(entry.capabilities && typeof entry.capabilities === "object"
|
||
? (entry.capabilities as JsonRecord)
|
||
: {}),
|
||
...capabilityFields,
|
||
};
|
||
|
||
if (!Array.isArray(entry.input_modalities) && metadata.modalities.input.length > 0) {
|
||
nextEntry.input_modalities = metadata.modalities.input;
|
||
}
|
||
|
||
if (!Array.isArray(entry.output_modalities) && metadata.modalities.output.length > 0) {
|
||
nextEntry.output_modalities = metadata.modalities.output;
|
||
}
|
||
|
||
if (
|
||
!specialtySurface &&
|
||
(typeof nextEntry.context_length !== "number" ||
|
||
authoritativeContextWindow !== null ||
|
||
persistedContextWindow !== null) &&
|
||
typeof metadata.limits.contextWindow === "number"
|
||
) {
|
||
nextEntry.context_length = metadata.limits.contextWindow;
|
||
} else if (specialtySurface && persistedContextWindow !== null) {
|
||
// Exact persisted overrides are authoritative for every surface of the model.
|
||
nextEntry.context_length = persistedContextWindow;
|
||
} else if (
|
||
specialtySurface &&
|
||
authoritativeContextWindow !== null &&
|
||
typeof authoritativeContextWindow === "number"
|
||
) {
|
||
// Only authoritative static windows may otherwise decorate specialty rows.
|
||
nextEntry.context_length = authoritativeContextWindow;
|
||
} else if (specialtySurface && typeof nextEntry.context_length === "number") {
|
||
// Keep an explicit source-provided context if the emitter already set one.
|
||
} else if (specialtySurface) {
|
||
delete nextEntry.context_length;
|
||
}
|
||
|
||
if (typeof metadata.limits.maxOutputTokens === "number" && metadata.limits.maxOutputTokens > 0) {
|
||
nextEntry.max_output_tokens = metadata.limits.maxOutputTokens;
|
||
}
|
||
|
||
if (
|
||
typeof metadata.limits.maxInputTokens === "number" &&
|
||
(typeof nextEntry.max_input_tokens !== "number" || authoritativeContextWindow !== null)
|
||
) {
|
||
nextEntry.max_input_tokens = metadata.limits.maxInputTokens;
|
||
}
|
||
|
||
if (metadata.metadata.family) nextEntry.family = metadata.metadata.family;
|
||
if (metadata.metadata.status) nextEntry.status = metadata.metadata.status;
|
||
if (metadata.metadata.knowledgeCutoff)
|
||
nextEntry.knowledge_cutoff = metadata.metadata.knowledgeCutoff;
|
||
if (metadata.metadata.releaseDate) nextEntry.release_date = metadata.metadata.releaseDate;
|
||
if (metadata.metadata.lastUpdated) nextEntry.last_updated = metadata.metadata.lastUpdated;
|
||
if (typeof metadata.metadata.openWeights === "boolean") {
|
||
nextEntry.open_weights = metadata.metadata.openWeights;
|
||
}
|
||
if (!existingName && metadata.displayName) {
|
||
nextEntry.name = metadata.displayName;
|
||
}
|
||
|
||
if (nextEntry.pricing == null) {
|
||
const pricing = resolveCatalogPricing(provider, model);
|
||
if (pricing) nextEntry.pricing = pricing;
|
||
}
|
||
|
||
return nextEntry as T;
|
||
}
|
||
|
||
function buildAliasCandidates(alias: string) {
|
||
const parsed = parseModel(alias);
|
||
const modelId = asNonEmptyString(parsed.model) || asNonEmptyString(alias);
|
||
if (!modelId) return [];
|
||
const canonicalModel = resolveStaticModelAlias(modelId);
|
||
const candidateModelIds = uniqueStrings([modelId, canonicalModel]);
|
||
const candidates = new Map<string, { provider: string; providerAlias: string; model: string }>();
|
||
|
||
for (const candidateModelId of candidateModelIds) {
|
||
for (const match of extractCandidateMatches(candidateModelId)) {
|
||
candidates.set(`${match.provider}/${match.model}`, match);
|
||
}
|
||
}
|
||
|
||
return [...candidates.values()];
|
||
}
|
||
|
||
function normalizeAliasCandidates(candidates: string[] | undefined) {
|
||
return uniqueStrings(
|
||
(candidates || []).map((candidate) => {
|
||
const parsed = parseModel(candidate);
|
||
if (!parsed.provider || !parsed.model) return candidate;
|
||
const providerAlias = PROVIDER_ID_TO_ALIAS[parsed.provider] || parsed.provider;
|
||
return `${providerAlias}/${parsed.model}`;
|
||
})
|
||
);
|
||
}
|
||
|
||
export async function resolveModelAliasLookup(
|
||
alias: string
|
||
): Promise<{ ok: true; value: ResolvedAliasLookup } | { ok: false; error: AliasResolutionError }> {
|
||
const normalizedAlias = asNonEmptyString(alias);
|
||
if (!normalizedAlias) {
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
status: 400,
|
||
code: MODEL_NOT_MAPPED,
|
||
message: "Alias is required",
|
||
},
|
||
};
|
||
}
|
||
|
||
const aliases = await getModelAliases();
|
||
const explicitTarget = aliases[normalizedAlias];
|
||
|
||
if (explicitTarget !== undefined) {
|
||
const modelInfo = await getModelInfo(normalizedAlias);
|
||
if (!modelInfo.provider || !modelInfo.model) {
|
||
const candidates = normalizeAliasCandidates(
|
||
(modelInfo as JsonRecord).candidateAliases as string[]
|
||
);
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
status: 409,
|
||
code: MODEL_ALIAS_AMBIGUOUS,
|
||
message:
|
||
(modelInfo as JsonRecord).errorMessage?.toString() ||
|
||
`Alias '${normalizedAlias}' is ambiguous.`,
|
||
...(candidates.length > 0 ? { candidates } : {}),
|
||
},
|
||
};
|
||
}
|
||
|
||
const providerAlias = PROVIDER_ID_TO_ALIAS[modelInfo.provider] || modelInfo.provider;
|
||
return {
|
||
ok: true,
|
||
value: {
|
||
alias: normalizedAlias,
|
||
resolvedAlias: `${providerAlias}/${modelInfo.model}`,
|
||
source: "stored_alias",
|
||
provider: modelInfo.provider,
|
||
providerAlias,
|
||
model: modelInfo.model,
|
||
target: explicitTarget,
|
||
metadata: getCanonicalModelMetadata({
|
||
provider: modelInfo.provider,
|
||
model: modelInfo.model,
|
||
})!,
|
||
},
|
||
};
|
||
}
|
||
|
||
const parsed = parseModel(normalizedAlias);
|
||
if (parsed.provider && parsed.model) {
|
||
const modelInfo = await getModelInfo(normalizedAlias);
|
||
if (!modelInfo.provider || !modelInfo.model) {
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
status: 404,
|
||
code: MODEL_NOT_MAPPED,
|
||
message: `Model '${normalizedAlias}' is not mapped.`,
|
||
},
|
||
};
|
||
}
|
||
|
||
const providerAlias = PROVIDER_ID_TO_ALIAS[modelInfo.provider] || modelInfo.provider;
|
||
return {
|
||
ok: true,
|
||
value: {
|
||
alias: normalizedAlias,
|
||
resolvedAlias: `${providerAlias}/${modelInfo.model}`,
|
||
source: "direct_model",
|
||
provider: modelInfo.provider,
|
||
providerAlias,
|
||
model: modelInfo.model,
|
||
target: `${providerAlias}/${modelInfo.model}`,
|
||
metadata: getCanonicalModelMetadata({
|
||
provider: modelInfo.provider,
|
||
model: modelInfo.model,
|
||
})!,
|
||
},
|
||
};
|
||
}
|
||
|
||
const candidates = buildAliasCandidates(normalizedAlias);
|
||
if (candidates.length === 0) {
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
status: 404,
|
||
code: MODEL_NOT_MAPPED,
|
||
message: `Alias '${normalizedAlias}' is not mapped.`,
|
||
},
|
||
};
|
||
}
|
||
|
||
if (candidates.length > 1) {
|
||
return {
|
||
ok: false,
|
||
error: {
|
||
status: 409,
|
||
code: MODEL_ALIAS_AMBIGUOUS,
|
||
message: `Alias '${normalizedAlias}' is ambiguous. Use provider/model prefix.`,
|
||
candidates: candidates.map((candidate) => `${candidate.providerAlias}/${candidate.model}`),
|
||
},
|
||
};
|
||
}
|
||
|
||
const match = candidates[0];
|
||
const metadata = getCanonicalModelMetadata({
|
||
provider: match.provider,
|
||
model: match.model,
|
||
});
|
||
|
||
return {
|
||
ok: true,
|
||
value: {
|
||
alias: normalizedAlias,
|
||
resolvedAlias: `${match.providerAlias}/${match.model}`,
|
||
source: "catalog_match",
|
||
provider: match.provider,
|
||
providerAlias: match.providerAlias,
|
||
model: match.model,
|
||
target: `${match.providerAlias}/${match.model}`,
|
||
metadata: metadata!,
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Qualify duplicate model display names with a provider prefix so users can
|
||
* distinguish between providers that serve the same model.
|
||
*
|
||
* Problem: when multiple providers (e.g. `gh`, `cx`, `opencode-zen`) all serve
|
||
* `gpt-5.5`, each catalog entry gets `name: "GPT-5.5"`. A client like OpenCode
|
||
* that renders the `name` field (src/plugin.ts: `name: model.name || model.id`)
|
||
* shows an identical "GPT-5.5" for every provider variant, giving the user no
|
||
* way to know which provider they are selecting.
|
||
*
|
||
* Fix: make a single O(n) pass over the enriched catalog. For any base name that
|
||
* appears on entries from two or more distinct providers, replace each entry's
|
||
* `name` with `<providerPrefix>/<baseName>` (e.g. "gh/GPT-5.5", "cx/GPT-5.5").
|
||
* Entries with a unique name are left untouched, preserving the clean display for
|
||
* the common single-provider case. The provider prefix is the first segment of
|
||
* the model id (e.g. `gh` from `gh/gpt-5.5`).
|
||
*
|
||
* The modification is purely cosmetic — it only affects the catalog `name` field
|
||
* used for display. Model IDs, routing, and request parsing are unchanged.
|
||
*/
|
||
export function disambiguateCatalogModelNames<T extends JsonRecord>(models: T[]): T[] {
|
||
// Count how many distinct provider prefixes use each display name.
|
||
const nameToProviders = new Map<string, Set<string>>();
|
||
for (const model of models) {
|
||
const name = asNonEmptyString(model.name);
|
||
if (!name) continue;
|
||
const id = asNonEmptyString(model.id);
|
||
const prefix = id && id.includes("/") ? id.slice(0, id.indexOf("/")) : null;
|
||
if (!prefix) continue;
|
||
const existing = nameToProviders.get(name) || new Set<string>();
|
||
existing.add(prefix);
|
||
nameToProviders.set(name, existing);
|
||
}
|
||
|
||
// Only rewrite names that are shared across 2+ providers.
|
||
const ambiguous = new Set<string>();
|
||
for (const [name, providers] of nameToProviders) {
|
||
if (providers.size > 1) ambiguous.add(name);
|
||
}
|
||
if (ambiguous.size === 0) return models;
|
||
|
||
return models.map((model) => {
|
||
const name = asNonEmptyString(model.name);
|
||
if (!name || !ambiguous.has(name)) return model;
|
||
const id = asNonEmptyString(model.id);
|
||
const prefix = id && id.includes("/") ? id.slice(0, id.indexOf("/")) : null;
|
||
if (!prefix) return model;
|
||
return { ...model, name: `${prefix}/${name}` };
|
||
});
|
||
}
|