Files
OmniRoute/src/lib/providerModels/modelDiscovery.ts
Lance Woodson 821d02ba13 fix(providers): parse per-vendor-route reasoning.effort_values in discovery (#12730)
OpenAI-compatible model discovery does not recognize per-vendor-route
reasoning vocabularies declared under vendors.<vendor>.capabilities.reasoning
in GET /v1/models (Merge Gateway's documented catalog shape), so synced
models carry no supportedThinkingEfforts/defaultThinkingEffort and operator
effort data resets on every model sync; models whose upstream accepts a
native max tier cannot be used with forced-max reasoning rules.

Parse the shape into the existing supportedThinkingEfforts pipeline,
intersected across vendor routes: the same canonical model declares
different vocabularies per route and unpinned requests self-narrow to a
route honoring the requested level, so a synced tier must be honored on
every route the model can land on. Routes without effort_values declare
no effort control and are excluded; disjoint vocabularies produce an
authoritative empty list (no fall-through to generic tier shapes).

detectDefaultThinkingEffort falls back to the intersection's highest tier
ranked by the canonical effort order — only when the vendors shape is the
record's winning vocabulary source, never escaping a flat or nested
declared list.

Detection is shape-gated, not provider-gated; Zod-validated (Hard Rule #7)
with malformed vendor and tier entries dropped individually (discarding a
whole route would widen the intersection, fail-open). Precedence: flat
field > reasoning.supported_efforts / metadata (#7694) > vendor-route
intersection > capabilities.effort_tiers (#9160) / supported_reasoning_levels
/ thinking.levels (#8347).
2026-09-17 16:24:39 -03:00

520 lines
24 KiB
TypeScript

import { z } from "zod";
import {
getSyncedAvailableModelsForConnection,
replaceSyncedAvailableModelsForConnection,
type SyncedAvailableModel,
} from "@/lib/db/models";
import type { VertexModelMetadataProvenance } from "@/lib/providerModels/vertexModelMetadata";
import { CANONICAL_EFFORT_VALUES } from "@/shared/reasoning/effortStandardization";
import { isObsoleteKiroModelAlias } from "@omniroute/open-sse/services/kiroModels.ts";
import { filterSelectableModels } from "@omniroute/open-sse/services/modelLifecycle.ts";
type JsonRecord = Record<string, unknown>;
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function toNonEmptyString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
/**
* Resolve a positive integer token limit from a list of candidate values.
* Used to fall back across the differently-named context/output fields that
* upstream catalogs expose (e.g. OpenRouter uses `context_length` /
* `top_provider.context_length` instead of `inputTokenLimit`). See #3202.
*/
function firstPositiveNumber(...candidates: unknown[]): number | undefined {
for (const candidate of candidates) {
if (typeof candidate === "number" && Number.isFinite(candidate) && candidate > 0) {
return candidate;
}
}
return undefined;
}
function modalitiesIncludeImage(value: unknown): boolean {
return (
Array.isArray(value) &&
value.some((entry) => toNonEmptyString(entry)?.toLowerCase() === "image")
);
}
/**
* #4264: detect image-input (vision) capability from a discovered model record.
* Handles the common upstream shapes: an explicit `supportsVision` flag, the
* OpenRouter `architecture.input_modalities` array and string `architecture.modality`
* ("text+image->text" — the input side is everything before "->"), and a top-level
* `input_modalities` array. Returns false when the upstream exposes no modality info.
*/
export function detectVisionInput(record: JsonRecord): boolean {
if (record.supportsVision === true) return true;
const architecture = asRecord(record.architecture);
if (modalitiesIncludeImage(architecture.input_modalities)) return true;
if (modalitiesIncludeImage(record.input_modalities)) return true;
const modality = toNonEmptyString(architecture.modality) || toNonEmptyString(record.modality);
if (modality) {
const [inputPart] = modality.toLowerCase().split("->");
if ((inputPart || "").includes("image")) return true;
}
return false;
}
// #7694: nested `reasoning.supported_efforts` shape some OpenAI-compatible upstreams
// expose (as opposed to the flat `supportedThinkingEfforts` field OmniRoute's own
// import format already emits). Hard Rule #7 — validate the untrusted upstream
// payload with Zod before it is trusted/stored; a malformed shape degrades to
// `undefined` instead of throwing, so one bad record never fails the whole sync.
// The same nesting also carries `default_effort` (e.g. OpenRouter
// `reasoning:{mandatory, default_enabled, default_effort, supported_efforts}`) —
// captured by `detectDefaultThinkingEffort` below and threaded through the
// EXISTING `defaultThinkingEffort` plumbing (`SyncedAvailableModel`,
// RuntimeModelMeta, #6879 `applyDefaultReasoningEffort`), so a model that only
// produces usable output with an explicit effort (measured: OpenRouter stealth
// reasoning models returning `upstream_empty_response` without one) gets the
// vendor-declared default injected instead of failing.
const reasoningDefaultEffortSchema = z
.object({ default_effort: z.string().optional() })
.partial()
.nullable()
.optional();
const reasoningSupportedEffortsSchema = z
.object({ supported_efforts: z.array(z.string()).optional() })
.partial()
.nullable()
.optional();
// #8347: CLIProxyAPI-style upstreams expose reasoning tiers as a top-level
// `supported_reasoning_levels` array, or nested under `thinking.levels`. Both accept
// entries that are either plain strings or `{ effort: string }` objects (the report shows
// the object form; `discovery/codex.ts:140` reads the same `supported_reasoning_levels`
// key as a bare existence check). Validate with Zod (Hard Rule #7): a malformed ENTRY is
// dropped individually rather than failing the whole array/record.
const effortEntrySchema = z.union([z.string(), z.object({ effort: z.string() })]);
const effortListSchema = z.array(z.unknown());
const supportedReasoningLevelsSchema = z.object({ supported_reasoning_levels: z.unknown() });
const thinkingLevelsSchema = z.object({ thinking: z.object({ levels: z.unknown() }).partial() });
// Vendor-route catalogs (e.g. Merge Gateway's `/v1/models`) nest per-route
// reasoning capability under `vendors.<vendor>.capabilities.reasoning` — the
// route's accepted effort levels live in `effort_values` (docs.merge.dev,
// "Effort levels per route"): the SAME canonical model lists different
// vocabularies per vendor route, and a request naming an effort level is
// served by a route that honors it when one exists (unpinned requests
// self-narrow). So the safe synced vocabulary is the INTERSECTION across
// vendor routes (a synced level must be honored on every route the model can
// land on), not the union. A route without `effort_values` (absent or empty)
// declares "no effort control" for that vendor and is excluded from the
// intersection. Like the other shapes in this file, detection is shape-gated,
// not provider-gated: a record that declares this structure is declaring its
// effort vocabulary. Validate with Zod (Hard Rule #7); a malformed ENTRY is
// dropped individually (never the whole route — discarding a route would
// WIDEN the intersection, fail-open).
const vendorRouteReasoningCapabilitySchema = z.object({
effort_values: z.array(z.unknown()).optional(),
});
const vendorRoutesSchema = z.record(z.string(), z.unknown());
function parseVendorRouteEffortValues(record: JsonRecord): string[][] {
const vendorsParsed = vendorRoutesSchema.safeParse(record.vendors);
if (!vendorsParsed.success) return [];
const perVendor: string[][] = [];
for (const vendorValue of Object.values(vendorsParsed.data)) {
const vendorRecord = asRecord(vendorValue);
const reasoningParsed = vendorRouteReasoningCapabilitySchema.safeParse(
asRecord(vendorRecord.capabilities).reasoning
);
if (!reasoningParsed.success || !reasoningParsed.data) continue;
const efforts = Array.from(
new Set(
(reasoningParsed.data.effort_values ?? [])
.filter((effort): effort is string => typeof effort === "string" && effort.length > 0)
.map(normalizeSupportedEffort)
)
);
if (efforts.length > 0) perVendor.push(efforts);
}
return perVendor;
}
/**
* Intersect `effort_values` across the record's vendor routes. Returns
* `undefined` when no vendor route declares a list (shape not present);
* returns an EMPTY array when routes declare disjoint vocabularies — that
* emptiness is authoritative (no tier works on every route) and must not
* fall through to lower-precedence generic shapes.
*/
function vendorRouteSharedEfforts(record: JsonRecord): string[] | undefined {
const perVendor = parseVendorRouteEffortValues(record);
if (perVendor.length === 0) return undefined;
return perVendor.reduce((acc, efforts) => acc.filter((effort) => efforts.includes(effort)));
}
// Maps common upstream synonyms onto OmniRoute's canonical effort vocabulary
// (`src/shared/reasoning/effortStandardization.ts`). Values already in
// `CANONICAL_EFFORT_VALUES`, and any unrecognized provider-native tier (e.g.
// Codex's own "ultra"), pass through unchanged — only known synonyms are mapped.
const EFFORT_SYNONYMS: Record<string, string> = { extra: "xhigh" };
// CrofAI's live `/v1/models` exposes a boolean reasoning capability rather than
// the supported tiers. Keep this provider-specific fallback explicit so the same
// boolean is never interpreted for other discovery sources.
// Live request testing confirms Crof accepts `max` as a distinct top tier.
const CROF_REASONING_EFFORTS = ["none", "low", "medium", "high", "max"] as const;
// Command Code's provider API accepts the documented low/medium/high/xhigh/max
// reasoning_effort values for its reasoning-capable model catalog, but its
// /models response does not declare them. Keep this fallback provider-scoped.
const COMMAND_CODE_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
function normalizeSupportedEffort(effort: string): string {
if ((CANONICAL_EFFORT_VALUES as readonly string[]).includes(effort)) return effort;
return EFFORT_SYNONYMS[effort.toLowerCase()] || effort;
}
/**
* #8347: shared parser for the two new upstream shapes (`supported_reasoning_levels`,
* `thinking.levels`). Accepts a list whose entries are either plain strings or
* `{ effort: string }` objects, drops malformed entries individually (never throws), and
* normalizes survivors onto the canonical vocabulary. Returns `undefined` when nothing
* usable remains, mirroring `detectSupportedThinkingEfforts`'s existing contract.
*/
function parseEffortList(rawList: unknown): string[] | undefined {
const listParsed = effortListSchema.safeParse(rawList);
if (!listParsed.success) return undefined;
const efforts = Array.from(
new Set(
listParsed.data
.map((entry) => {
const entryParsed = effortEntrySchema.safeParse(entry);
if (!entryParsed.success) return null;
const raw =
typeof entryParsed.data === "string" ? entryParsed.data : entryParsed.data.effort;
return raw.length > 0 ? normalizeSupportedEffort(raw) : null;
})
.filter((effort): effort is string => effort !== null)
)
);
return efforts.length > 0 ? efforts : undefined;
}
/**
* Read the nested `record.reasoning.default_effort` shape (OpenRouter declares
* `reasoning:{mandatory, default_enabled, default_effort, supported_efforts}`)
* and normalize it onto the canonical vocabulary (`max` → `xhigh`, same mapping
* `detectSupportedThinkingEfforts` applies to the tier list). Returns `undefined`
* (never throws) when the field is absent or malformed.
*
* A flat top-level `defaultThinkingEffort` (OmniRoute's own import format, and
* kimi-style upstreams) stays authoritative — the nested shape is a fallback.
*/
export function detectDefaultThinkingEffort(record: JsonRecord): string | undefined {
if (typeof record.defaultThinkingEffort === "string" && record.defaultThinkingEffort.length > 0) {
return normalizeSupportedEffort(record.defaultThinkingEffort);
}
const parsed = reasoningDefaultEffortSchema.safeParse(record.reasoning);
if (parsed.success && parsed.data) {
const raw = parsed.data.default_effort;
if (typeof raw === "string" && raw.length > 0) return normalizeSupportedEffort(raw);
}
// Vendor-route fallback — only when the `vendors` shape IS the record's
// winning effort-vocabulary source. If a higher-precedence declared shape
// (`reasoning.supported_efforts`, `metadata.reasoning.supported_efforts`)
// produced a usable list, the record's default must never escape that
// winning list. Highest shared tier wins, ranked by the canonical order
// (vendor arrays are not guaranteed sorted).
const mergeShared = vendorRouteSharedEfforts(record);
if (mergeShared && mergeShared.length > 0 && !hasUsableDeclaredEffortList(record)) {
const ranked = mergeShared
.map((tier) => ({ tier, rank: CANONICAL_EFFORT_VALUES.indexOf(tier as never) }))
.filter((x) => x.rank >= 0)
.sort((a, b) => b.rank - a.rank);
if (ranked.length > 0) return ranked[0].tier;
}
return undefined;
}
/**
* Whether a higher-precedence declared shape (the flat import field, or either
* #7694 nested `supported_efforts` shape) yields a usable tier list — the
* exact "usable" semantics the vocabulary detection applies (non-empty after
* filtering + normalization). Used to decide whether the Merge vendors shape
* is the record's winning vocabulary source for default-effort derivation.
*/
function hasUsableDeclaredEffortList(record: JsonRecord): boolean {
if (
Array.isArray(record.supportedThinkingEfforts) &&
record.supportedThinkingEfforts.some((e) => typeof e === "string" && e.length > 0)
) {
return true;
}
for (const holder of [record.reasoning, asRecord(record.metadata).reasoning]) {
const shapeParsed = reasoningSupportedEffortsSchema.safeParse(holder);
if (!shapeParsed.success || !shapeParsed.data) continue;
const rawEfforts = shapeParsed.data.supported_efforts;
if (
Array.isArray(rawEfforts) &&
rawEfforts.some((e) => typeof e === "string" && e.length > 0)
) {
return true;
}
}
return false;
}
/**
* #7694: read the nested `record.reasoning.supported_efforts` shape and normalize each
* tier onto the canonical vocabulary. Returns `undefined` (never throws) when the field
* is absent or malformed, so it can be used as a fallback alongside the pre-existing flat
* `record.supportedThinkingEfforts` field without disturbing that field's current
* pass-through behavior.
*/
export function detectSupportedThinkingEfforts(record: JsonRecord): string[] | undefined {
const parsed = reasoningSupportedEffortsSchema.safeParse(record.reasoning);
if (parsed.success && parsed.data) {
const rawEfforts = parsed.data.supported_efforts;
if (Array.isArray(rawEfforts)) {
const efforts = Array.from(
new Set(
rawEfforts
.filter((effort): effort is string => typeof effort === "string" && effort.length > 0)
.map(normalizeSupportedEffort)
)
);
if (efforts.length > 0) return efforts;
}
}
// neuralwatt-style upstreams wrap the same tier data one level deeper under
// `metadata.reasoning.supported_efforts` (their /v1/models nests capabilities
// and reasoning under a `metadata` object). Same semantics and validation as
// the top-level #7694 shape; placed right after it so a top-level declaration
// still wins when both are present.
const metadataRecord = asRecord(record.metadata);
const metadataParsed = reasoningSupportedEffortsSchema.safeParse(metadataRecord.reasoning);
if (metadataParsed.success && metadataParsed.data) {
const rawEfforts = metadataParsed.data.supported_efforts;
if (Array.isArray(rawEfforts)) {
const efforts = Array.from(
new Set(
rawEfforts
.filter((effort): effort is string => typeof effort === "string" && effort.length > 0)
.map(normalizeSupportedEffort)
)
);
if (efforts.length > 0) return efforts;
}
}
// Vendor-route catalogs: intersect `effort_values` across vendor routes.
// Placed after the flat import field handling (caller) and the
// #7694/#9160 nested shapes so those explicit per-model declarations keep
// precedence; runs before the generic `capabilities.effort_tiers` fallback
// because per-route vocabularies are strictly more specific than a flat
// tier list. An empty intersection (disjoint routes) is authoritative —
// nothing works on every route — and must not fall through to a generic
// tier list.
const mergeShared = vendorRouteSharedEfforts(record);
if (mergeShared !== undefined) {
return mergeShared.length > 0 ? mergeShared : [];
}
// #9160: fall back to `capabilities.effort_tiers` before the legacy fields.
// OmniRoute's own catalog surfaces effort tiers inside `capabilities.effort_tiers`,
// which the existing `parseEffortList` already handles (string arrays).
const capabilitiesRecord = asRecord(record.capabilities);
const capabilitiesParsed = effortListSchema.safeParse(capabilitiesRecord.effort_tiers);
if (capabilitiesParsed.success) {
const fromCapabilities = parseEffortList(capabilitiesRecord.effort_tiers);
if (fromCapabilities) return fromCapabilities;
}
// #8347: fall back to `supported_reasoning_levels`, then `thinking.levels` — in that
// order, per the regression guard for #7694 (the flat field and `reasoning.supported_efforts`
// both take precedence over these two and are handled above / by the caller).
const levelsParsed = supportedReasoningLevelsSchema.safeParse(record);
if (levelsParsed.success) {
const fromLevels = parseEffortList(levelsParsed.data.supported_reasoning_levels);
if (fromLevels) return fromLevels;
}
const thinkingParsed = thinkingLevelsSchema.safeParse(record);
if (thinkingParsed.success) {
const fromThinking = parseEffortList(thinkingParsed.data.thinking?.levels);
if (fromThinking) return fromThinking;
}
return undefined;
}
function hasDeclaredEffortList(record: JsonRecord): boolean {
if (Array.isArray(record.supportedThinkingEfforts)) return true;
if (Array.isArray(asRecord(record.reasoning).supported_efforts)) return true;
if (Array.isArray(asRecord(record.capabilities).effort_tiers)) return true;
if (Array.isArray(record.supported_reasoning_levels)) return true;
if (Array.isArray(asRecord(record.thinking).levels)) return true;
// `vendors.<v>.capabilities.reasoning.effort_values` counts as a declared
// list so the fallback chain in `normalizeDiscoveredModels` stops here
// instead of applying provider-specific heuristics to a record that already
// declares its vocabulary explicitly (mirrors the other declared shapes:
// detect returning undefined means "declared, nothing usable").
return parseVendorRouteEffortValues(record).length > 0;
}
export function isAutoFetchModelsEnabled(providerSpecificData: unknown): boolean {
// Remote discovery writes its response into the shared synced-model cache, so
// it must be an explicit per-connection opt-in rather than the default.
return asRecord(providerSpecificData).autoFetchModels === true;
}
export function normalizeDiscoveredModels(
models: unknown,
providerId?: string
): SyncedAvailableModel[] {
const items = Array.isArray(models) ? models : [];
const deduped = new Map<string, SyncedAvailableModel>();
for (const item of items) {
const record = asRecord(item);
const id =
toNonEmptyString(record.id) ||
toNonEmptyString(record.name) ||
toNonEmptyString(record.model);
if (!id) continue;
const isCrofReasoningModel = providerId === "crof" && record.reasoning_effort === true;
const isCommandCodeModel = providerId === "command-code";
const supportedThinkingEfforts = (() => {
// The flat import field and every recognized upstream tier array remain
// authoritative over the provider fallback, including an explicit empty list.
if (Array.isArray(record.supportedThinkingEfforts)) {
return record.supportedThinkingEfforts.filter(
(effort): effort is string => typeof effort === "string" && effort.length > 0
);
}
const detected = detectSupportedThinkingEfforts(record);
if (detected || hasDeclaredEffortList(record)) return detected;
if (isCrofReasoningModel) return [...CROF_REASONING_EFFORTS];
return isCommandCodeModel ? [...COMMAND_CODE_REASONING_EFFORTS] : undefined;
})();
// Vendor-declared default effort (OpenRouter `reasoning.default_effort`, or the
// flat import field). Normalized onto the canonical vocabulary (`max` → `xhigh`).
const defaultThinkingEffort = detectDefaultThinkingEffort(record);
const name =
toNonEmptyString(record.name) ||
toNonEmptyString(record.displayName) ||
toNonEmptyString(record.model) ||
id;
const supportedEndpoints = Array.isArray(record.supportedEndpoints)
? Array.from(
new Set(
record.supportedEndpoints
.map((endpoint) => toNonEmptyString(endpoint))
.filter((endpoint): endpoint is string => Boolean(endpoint))
)
).sort()
: undefined;
const topProvider = asRecord(record.top_provider);
// Keep the total context window distinct from an explicit maximum-input limit. Existing
// providers historically stored context_length as inputTokenLimit, so retain that compatibility
// outside Vertex while persisting the separate contextWindow field for new consumers.
const contextWindow = firstPositiveNumber(
record.context_length,
record.contextLength,
record.contextWindow,
topProvider.context_length
);
const isVertexProvider = providerId === "vertex" || providerId === "vertex-partner";
const inputTokenLimit = firstPositiveNumber(
record.inputTokenLimit,
...(isVertexProvider ? [] : [contextWindow])
);
const outputTokenLimit = firstPositiveNumber(
record.outputTokenLimit,
topProvider.max_completion_tokens
);
// #4264: capture image-input (vision) capability at sync time. OpenRouter (and
// similar passthrough catalogs) declare it via `architecture.input_modalities`
// (e.g. ["text","image"]) or the string `architecture.modality` ("text+image->text");
// some providers expose a top-level `input_modalities`. Without this, synced
// models reached the catalog with no vision flag and vision-capable models
// (which work at request time) showed up as non-vision after import.
const supportsVision = detectVisionInput(record);
deduped.set(id, {
id,
name,
source: "imported",
...(toNonEmptyString(record.apiFormat)
? { apiFormat: toNonEmptyString(record.apiFormat)! }
: {}),
...(toNonEmptyString(record.targetFormat)
? { targetFormat: toNonEmptyString(record.targetFormat)! }
: {}),
...(toNonEmptyString(record.upstreamProtocol)
? { upstreamProtocol: toNonEmptyString(record.upstreamProtocol)! }
: {}),
...(supportedEndpoints && supportedEndpoints.length > 0 ? { supportedEndpoints } : {}),
...(supportedThinkingEfforts !== undefined ? { supportedThinkingEfforts } : {}),
...(defaultThinkingEffort !== undefined ? { defaultThinkingEffort } : {}),
...(typeof inputTokenLimit === "number" ? { inputTokenLimit } : {}),
...(isVertexProvider && typeof contextWindow === "number" ? { contextWindow } : {}),
...(typeof outputTokenLimit === "number" ? { outputTokenLimit } : {}),
// The narrowed `object` is not assignable to VertexModelMetadataProvenance; the
// read path (src/lib/db/models/synced.ts) casts the same field the same way, so
// keep both sides of the round-trip identical rather than only one of them typed.
...(record.metadataProvenance && typeof record.metadataProvenance === "object"
? { metadataProvenance: record.metadataProvenance as VertexModelMetadataProvenance }
: {}),
...(typeof record.description === "string" ? { description: record.description } : {}),
...(typeof record.supportsThinking === "boolean"
? { supportsThinking: record.supportsThinking }
: isCrofReasoningModel || isCommandCodeModel
? { supportsThinking: true }
: {}),
...(record.alwaysThinking === true ? { alwaysThinking: true } : {}),
...(typeof record.supportsTools === "boolean" ? { supportsTools: record.supportsTools } : {}),
...(typeof record.supportsVideo === "boolean" ? { supportsVideo: record.supportsVideo } : {}),
...(supportsVision ? { supportsVision: true } : {}),
});
}
return Array.from(deduped.values());
}
export async function getCachedDiscoveredModels(
providerId: string,
connectionId: string
): Promise<SyncedAvailableModel[]> {
const models = await getSyncedAvailableModelsForConnection(providerId, connectionId);
return providerId === "kiro"
? models.filter((model) => !isObsoleteKiroModelAlias(model.id))
: models;
}
export async function persistDiscoveredModels(
providerId: string,
connectionId: string,
models: unknown
): Promise<SyncedAvailableModel[]> {
// #11088 (option 1): the synced store is endpoint-agnostic — images/embeddings
// models must persist so per-connection endpoint routing (#11088) and the
// /v1/models catalog can see them. Chat selectability is applied at read time
// (auto-pool expansion, chat projections), not at write time.
const normalized = filterSelectableModels(
providerId,
normalizeDiscoveredModels(models, providerId)
);
await replaceSyncedAvailableModelsForConnection(providerId, connectionId, normalized);
return normalized;
}