diff --git a/changelog.d/features/7694-reasoning-supported-efforts.md b/changelog.d/features/7694-reasoning-supported-efforts.md new file mode 100644 index 0000000000..9d0656de29 --- /dev/null +++ b/changelog.d/features/7694-reasoning-supported-efforts.md @@ -0,0 +1 @@ +- Generic OpenAI-compatible model sync now captures a provider's upstream `reasoning.supported_efforts` (in addition to the pre-existing flat `supportedThinkingEfforts` shape), surfaces it in the model catalog as `capabilities.effort_tiers`, and advertises `/-{effort}` request-time alias ids for catalog-only clients — mirroring the existing per-model reasoning-effort plumbing (#6879). Codex and Kimi are excluded since they already own their own effort-suffix mechanism. diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 19eb81bcca..87d1e4650f 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -2189,9 +2189,16 @@ export async function handleChatCore({ // carries no reasoning field of any shape — an explicit client/combo-leg value // always wins. Scoped to the OpenAI Chat Completions dispatch shape (the shape // `reasoning_effort` is native to); unset ModelSpec.defaultReasoningEffort is a - // no-op. See open-sse/services/defaultReasoningEffort.ts. + // no-op. #7694: `modelInfo.resolvedThinkingEffort` — set when the request's model + // id carried a `/-{effort}` synced-model alias suffix + // (`src/sse/services/model.ts`) — takes priority over the static per-model default. + // See open-sse/services/defaultReasoningEffort.ts. if (targetFormat === FORMATS.OPENAI) { - translatedBody = applyDefaultReasoningEffort(translatedBody, finalModelToUpstream); + translatedBody = applyDefaultReasoningEffort( + translatedBody, + finalModelToUpstream, + (modelInfo as { resolvedThinkingEffort?: string })?.resolvedThinkingEffort + ); } } diff --git a/open-sse/services/defaultReasoningEffort.ts b/open-sse/services/defaultReasoningEffort.ts index 75ab833bbb..156312a24c 100644 --- a/open-sse/services/defaultReasoningEffort.ts +++ b/open-sse/services/defaultReasoningEffort.ts @@ -24,18 +24,24 @@ function hasExplicitReasoningField(body: Record): boolean { } /** - * Inject the resolved model's `defaultReasoningEffort` as `reasoning_effort` when the - * request has no reasoning field. Returns `body` unchanged (same reference) when there - * is nothing to inject, so callers can chain it without extra guards. + * Inject a resolved reasoning effort as `reasoning_effort` when the request has no + * reasoning field. Returns `body` unchanged (same reference) when there is nothing to + * inject, so callers can chain it without extra guards. + * + * `suffixEffort` (#7694) is the tier a `/-{effort}` synced-model alias + * resolved to (`src/sse/services/model.ts`'s `resolveSyncedModelIdAndEffort`) — an + * explicit, request-time model selection, so it takes priority over the static + * `ModelSpec.defaultReasoningEffort` fleet-wide default (#6879) when both are present. */ export function applyDefaultReasoningEffort>( body: T, - modelId: string + modelId: string, + suffixEffort?: string | null ): T { if (!body || typeof body !== "object") return body; if (hasExplicitReasoningField(body)) return body; - const defaultEffort = getModelSpec(modelId)?.defaultReasoningEffort; + const defaultEffort = suffixEffort || getModelSpec(modelId)?.defaultReasoningEffort; if (!defaultEffort) return body; return { ...body, reasoning_effort: defaultEffort }; diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index e7699be4b7..a4685696b2 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -425,6 +425,34 @@ export function parseModel(modelStr: string | null | undefined): ParsedModel { return { provider: null, model: cleanStr, isAlias: true, providerAlias: null, extendedContext }; } +/** + * Generic `-{effort}` suffix split for a synced model (#7694), sibling to Codex's own + * `splitCodexReasoningSuffix` (`open-sse/executors/codex.ts`) but not tied to any single + * provider. `knownEfforts` must be the CANDIDATE base model's own declared + * `supportedThinkingEfforts` — the caller is responsible for only invoking this once it + * already has a specific synced-model candidate in hand (e.g. by trying each synced model + * for the provider), so a suffix is only ever stripped when it is an EXACT, known tier for + * THAT model — never a blind string match. This avoids colliding with a model id that + * legitimately ends in an effort-like token, and keeps parsing pure/synchronous (no DB + * access here — the DB-backed candidate lookup lives in `src/sse/services/model.ts`). + */ +export function splitSyncedEffortSuffix( + modelId: string, + knownEfforts: readonly string[] | null | undefined +): { baseModel: string; effort: string | null } { + if (typeof modelId !== "string" || !modelId || !Array.isArray(knownEfforts)) { + return { baseModel: modelId, effort: null }; + } + for (const effort of knownEfforts) { + if (typeof effort !== "string" || !effort) continue; + const suffix = `-${effort}`; + if (modelId.length > suffix.length && modelId.endsWith(suffix)) { + return { baseModel: modelId.slice(0, -suffix.length), effort }; + } + } + return { baseModel: modelId, effort: null }; +} + /** * Resolve model alias from aliases object * Format: { "alias": "provider/model" } diff --git a/open-sse/utils/syncedEffortVariants.ts b/open-sse/utils/syncedEffortVariants.ts new file mode 100644 index 0000000000..2a2c7f0d68 --- /dev/null +++ b/open-sse/utils/syncedEffortVariants.ts @@ -0,0 +1,109 @@ +/** + * Synced-model reasoning-effort catalog variants (#7694). + * + * Generic OpenAI-compatible model discovery (`src/lib/providerModels/modelDiscovery.ts`) + * captures upstream `reasoning.supported_efforts` (or OmniRoute's own flat + * `supportedThinkingEfforts` import field) into `SyncedAvailableModel.supportedThinkingEfforts`, + * and the catalog builder (`src/app/api/v1/models/catalog.ts`) surfaces it as + * `capabilities.effort_tiers`. Catalog-only clients (OpenCode, plain OpenAI-SDK model + * pickers) can only choose a model by its `id`, so — mirroring `claudeEffortVariants.ts`'s + * `/-` pattern for Claude — this module synthesizes one catalog + * entry per declared tier: + * + * /- e.g. myprovider/my-model-high + * + * The gateway already resolves these ids at request time + * (`splitSyncedEffortSuffix` in `open-sse/services/model.ts`, wired through + * `src/sse/services/model.ts`'s synced-metadata lookup): it strips the `-` suffix + * back to the real base model and surfaces the tier as `reasoning_effort` before dispatch, + * only when the base model's own `supportedThinkingEfforts` actually declares that tier — + * never a blind string match. + * + * Skipped entirely for `codex` and `kimi`-owned models: both already own a conflicting + * native `-{effort}` suffix mechanism (`splitCodexReasoningSuffix` / + * `getKimiCodeStaticThinkingPolicy`), so double-registering here would collide with their + * own alias resolution. Also skipped for any model whose id already ends in a token that + * matches a canonical effort value, to avoid colliding with a model that legitimately ends + * in an effort-like token (e.g. a model literally named "...-high"). + */ +import { CANONICAL_EFFORT_VALUES } from "@/shared/reasoning/effortStandardization.ts"; + +/** Provider ids that already own a native `-{effort}` suffix mechanism — never double-register. */ +export const SYNCED_EFFORT_SKIP_PROVIDERS = new Set(["codex"]); +/** Provider-id prefixes covering that mechanism's multiple connection variants (kimi-coding, kimi-coding-apikey). */ +const SYNCED_EFFORT_SKIP_PROVIDER_PREFIXES = ["kimi"]; + +function isSkippedEffortProvider(ownedBy: string): boolean { + return ( + SYNCED_EFFORT_SKIP_PROVIDERS.has(ownedBy) || + SYNCED_EFFORT_SKIP_PROVIDER_PREFIXES.some((prefix) => ownedBy.startsWith(prefix)) + ); +} + +interface CatalogModelEntry { + id?: unknown; + owned_by?: unknown; + root?: unknown; + name?: unknown; + capabilities?: { effort_tiers?: unknown; [key: string]: unknown }; + [key: string]: unknown; +} + +function endsWithKnownEffortToken(id: string): boolean { + return CANONICAL_EFFORT_VALUES.some((value) => id.endsWith(`-${value}`)); +} + +function extractEffortTiers(model: CatalogModelEntry): string[] { + const tiers = model.capabilities?.effort_tiers; + if (!Array.isArray(tiers)) return []; + return tiers.filter((tier): tier is string => typeof tier === "string" && tier.length > 0); +} + +/** + * Whether the catalog should advertise reasoning-effort variants for this entry. + * + * Rule: a synced model that declares `capabilities.effort_tiers`, is not owned by a + * provider that already owns its own suffix mechanism, is not a virtual combo entry, and + * whose id does not already end in a token that collides with a canonical effort value. + */ +export function shouldExposeSyncedEffortVariants( + model: CatalogModelEntry +): model is CatalogModelEntry & { id: string } { + if (!model || typeof model !== "object") return false; + const id = model.id; + if (typeof id !== "string" || id.length === 0) return false; + if (model.owned_by === "combo") return false; + if (typeof model.owned_by === "string" && isSkippedEffortProvider(model.owned_by)) { + return false; + } + if (endsWithKnownEffortToken(id)) return false; + return extractEffortTiers(model).length > 0; +} + +/** + * Append reasoning-effort variants for every eligible synced model. Returns the original + * array reference unchanged when nothing is eligible (no allocation in the common case). + * Derived from the already key-filtered/hidden-filtered catalog list, so a variant's base + * fields (visibility, capabilities, pricing) are inherited by spreading the base entry + * rather than re-running any filter — an alias never bypasses a filter the base model was + * already subject to. + */ +export function appendSyncedEffortVariants(models: T[]): T[] { + if (!Array.isArray(models)) return models; + const variants: T[] = []; + const existingIds = new Set(models.map((model) => model.id)); + + for (const model of models) { + if (!shouldExposeSyncedEffortVariants(model)) continue; + const baseRoot = typeof model.root === "string" && model.root ? model.root : model.id; + + for (const tier of extractEffortTiers(model)) { + const variantId = `${model.id}-${tier}`; + if (existingIds.has(variantId)) continue; + existingIds.add(variantId); + variants.push({ ...model, id: variantId, root: `${baseRoot}-${tier}` }); + } + } + + return variants.length > 0 ? [...models, ...variants] : models; +} diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 31a5277e48..2fd9c0a57e 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -12,6 +12,8 @@ import { import { extractAliasBackedModels } from "./aliasBackedModels"; import { appendNoThinkingVariants } from "@omniroute/open-sse/utils/noThinkingAlias"; import { appendClaudeEffortVariants } from "@omniroute/open-sse/utils/claudeEffortVariants"; +import { appendSyncedEffortVariants } from "@omniroute/open-sse/utils/syncedEffortVariants"; +import { buildSyncedCapabilities, mergeSyncedCapabilities } from "./syncedCapabilities"; import { getAllEmbeddingModels } from "@omniroute/open-sse/config/embeddingRegistry"; import { getAllImageModels, @@ -865,22 +867,14 @@ async function buildUnifiedModelsResponseCore( ...(endpoints.length > 1 || !endpoints.includes("chat") ? { supported_endpoints: endpoints } : {}), - // #4264: surface the vision flag captured at sync time so imported - // image-capable models (e.g. OpenRouter) aren't shown as text-only. - ...(sm.supportsVision ? { capabilities: { vision: true } } : {}), + // #4264/#7694: vision + reasoning-effort-tier flags captured at sync time, + // merged into a single capabilities object (see ./syncedCapabilities.ts). + ...(buildSyncedCapabilities(sm) ? { capabilities: buildSyncedCapabilities(sm) } : {}), }; const existingAliasModel = models.find((model) => model.id === aliasId); if (existingAliasModel) { - // Merge (not clobber) capabilities so syncing a vision flag onto a - // registry/combo model that already declares other capabilities keeps both. - const mergedCapabilities = - sm.supportsVision || existingAliasModel.capabilities - ? { - ...(existingAliasModel.capabilities || {}), - ...(sm.supportsVision ? { vision: true } : {}), - } - : undefined; + const mergedCapabilities = mergeSyncedCapabilities(existingAliasModel.capabilities, sm); Object.assign(existingAliasModel, syncedFields); if (mergedCapabilities) existingAliasModel.capabilities = mergedCapabilities; continue; @@ -1489,6 +1483,11 @@ async function buildUnifiedModelsResponseCore( prefixMode === "canonical" ? aliasToProviderId : undefined ); + // #7694: advertise `/-` variants for synced models that + // captured `reasoning.supported_efforts` at sync time (capabilities.effort_tiers). + // Derived from the already key-filtered list; skips codex/kimi (own suffix mechanism). + finalModels = appendSyncedEffortVariants(finalModels); + // #4424 follow-up — drop exact-duplicate ids that slip through the per-source push // guards (e.g. `codex/gpt-5.5`, `veo-free/seedance` listed twice). Keyed by listing // identity (id, type, subtype) so the intentional same-id audio transcription/speech diff --git a/src/app/api/v1/models/syncedCapabilities.ts b/src/app/api/v1/models/syncedCapabilities.ts new file mode 100644 index 0000000000..a42671b331 --- /dev/null +++ b/src/app/api/v1/models/syncedCapabilities.ts @@ -0,0 +1,47 @@ +/** + * Synced-model catalog `capabilities` builder (#7694). + * + * Extracted from catalog.ts (frozen file-size baseline — `config/quality/file-size-baseline.json`) + * to keep the vision (#4264) and reasoning-effort-tier (#7694) flags merged into a SINGLE + * `capabilities` object rather than two separate spreads that would silently overwrite one + * another via object-spread order. A model can be both vision- and reasoning-capable. + */ + +interface SyncedCapabilityFlags { + supportsVision?: boolean; + supportedThinkingEfforts?: string[]; +} + +function hasEffortTiers(sm: SyncedCapabilityFlags): boolean { + return Array.isArray(sm.supportedThinkingEfforts) && sm.supportedThinkingEfforts.length > 0; +} + +/** Build the `capabilities` object for a fresh synced-model catalog entry, or `undefined` when neither flag applies. */ +export function buildSyncedCapabilities( + sm: SyncedCapabilityFlags +): Record | undefined { + const effortTiers = hasEffortTiers(sm); + if (!sm.supportsVision && !effortTiers) return undefined; + return { + ...(sm.supportsVision ? { vision: true } : {}), + ...(effortTiers ? { effort_tiers: sm.supportedThinkingEfforts! } : {}), + }; +} + +/** + * Merge (not clobber) capabilities onto an already-catalogued entry so syncing a + * vision/effort-tier flag onto a registry/combo model that already declares other + * capabilities keeps both. Returns `undefined` when there is nothing to merge. + */ +export function mergeSyncedCapabilities( + existing: Record | undefined, + sm: SyncedCapabilityFlags +): Record | undefined { + const effortTiers = hasEffortTiers(sm); + if (!sm.supportsVision && !effortTiers && !existing) return undefined; + return { + ...(existing || {}), + ...(sm.supportsVision ? { vision: true } : {}), + ...(effortTiers ? { effort_tiers: sm.supportedThinkingEfforts! } : {}), + }; +} diff --git a/src/lib/providerModels/modelDiscovery.ts b/src/lib/providerModels/modelDiscovery.ts index 48e4caf0e6..7e7d91927a 100644 --- a/src/lib/providerModels/modelDiscovery.ts +++ b/src/lib/providerModels/modelDiscovery.ts @@ -1,8 +1,10 @@ +import { z } from "zod"; import { getSyncedAvailableModelsForConnection, replaceSyncedAvailableModelsForConnection, type SyncedAvailableModel, } from "@/lib/db/models"; +import { CANONICAL_EFFORT_VALUES } from "@/shared/reasoning/effortStandardization"; type JsonRecord = Record; @@ -58,6 +60,52 @@ export function detectVisionInput(record: JsonRecord): boolean { 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. +const reasoningSupportedEffortsSchema = z + .object({ supported_efforts: z.array(z.string()).optional() }) + .partial() + .nullable() + .optional(); + +// 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 = { max: "xhigh" }; + +function normalizeSupportedEffort(effort: string): string { + if ((CANONICAL_EFFORT_VALUES as readonly string[]).includes(effort)) return effort; + return EFFORT_SYNONYMS[effort.toLowerCase()] || effort; +} + +/** + * #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) return undefined; + + const rawEfforts = parsed.data.supported_efforts; + if (!Array.isArray(rawEfforts)) return undefined; + + const efforts = Array.from( + new Set( + rawEfforts + .filter((effort): effort is string => typeof effort === "string" && effort.length > 0) + .map(normalizeSupportedEffort) + ) + ); + return efforts.length > 0 ? efforts : undefined; +} + export function isAutoFetchModelsEnabled(providerSpecificData: unknown): boolean { return asRecord(providerSpecificData).autoFetchModels !== false; } @@ -128,13 +176,21 @@ export function normalizeDiscoveredModels(models: unknown): SyncedAvailableModel ? { upstreamProtocol: toNonEmptyString(record.upstreamProtocol)! } : {}), ...(supportedEndpoints && supportedEndpoints.length > 0 ? { supportedEndpoints } : {}), - ...(Array.isArray(record.supportedThinkingEfforts) - ? { + ...(() => { + // #7694: the flat field (OmniRoute's own import format) wins verbatim when + // present, unchanged from its current pass-through behavior; only fall back to + // the nested `reasoning.supported_efforts` shape (normalized onto the canonical + // vocabulary) when the flat field is absent. + if (Array.isArray(record.supportedThinkingEfforts)) { + return { supportedThinkingEfforts: record.supportedThinkingEfforts.filter( (effort): effort is string => typeof effort === "string" && effort.length > 0 ), - } - : {}), + }; + } + const nested = detectSupportedThinkingEfforts(record); + return nested ? { supportedThinkingEfforts: nested } : {}; + })(), ...(toNonEmptyString(record.defaultThinkingEffort) ? { defaultThinkingEffort: toNonEmptyString(record.defaultThinkingEffort)! } : {}), diff --git a/src/sse/services/model.ts b/src/sse/services/model.ts index 2f1f417eb0..858cb375cb 100644 --- a/src/sse/services/model.ts +++ b/src/sse/services/model.ts @@ -9,7 +9,11 @@ import { } from "@/lib/localDb"; import { getCachedSettings } from "@/lib/localDb"; import { getSyncedAvailableModels } from "@/lib/db/models"; -import { parseModel, getModelInfoCore } from "@omniroute/open-sse/services/model.ts"; +import { + parseModel, + getModelInfoCore, + splitSyncedEffortSuffix, +} from "@omniroute/open-sse/services/model.ts"; import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry.ts"; export { parseModel }; @@ -101,8 +105,56 @@ type RuntimeModelMeta = { alwaysThinking?: boolean; supportedThinkingEfforts?: string[]; defaultThinkingEffort?: string; + // #7694: set when `modelId` carried a `-{effort}` suffix resolved against a synced + // model's own `supportedThinkingEfforts` (see `resolveSyncedModelIdAndEffort` below). + // Threaded through to `handleChatCore` -> `applyDefaultReasoningEffort` so the suffix + // becomes `reasoning_effort` only when the request itself carries no reasoning field. + resolvedThinkingEffort?: string; }; +// Providers that already own a native `-{effort}` suffix mechanism — never +// double-resolve the generic synced suffix on top of theirs (#7694, mirrors the +// catalog-side skip list in `open-sse/utils/syncedEffortVariants.ts`). +const SYNCED_EFFORT_SKIP_PROVIDER_PREFIXES = ["codex", "kimi"]; + +function isSyncedEffortSkippedProvider(providerId: string): boolean { + return SYNCED_EFFORT_SKIP_PROVIDER_PREFIXES.some((prefix) => providerId.startsWith(prefix)); +} + +/** + * #7694: when `modelId` has no direct synced-model match, try stripping a trailing + * `-{effort}` token by testing it against each candidate synced model's OWN declared + * `supportedThinkingEfforts` (`splitSyncedEffortSuffix`) — never a blind/global effort + * list, so a model id that legitimately ends in an effort-like token is left untouched + * unless some synced model's real tier list says otherwise. Returns the original + * `modelId` with `effort: null` when nothing matches or the provider already owns its + * own suffix mechanism. + */ +function resolveSyncedModelIdAndEffort( + providerId: string, + modelId: string, + syncedModels: unknown +): { modelId: string; effort: string | null } { + if (isSyncedEffortSkippedProvider(providerId) || !Array.isArray(syncedModels)) { + return { modelId, effort: null }; + } + if (findSyncedModelMeta(syncedModels, modelId)) return { modelId, effort: null }; + + for (const candidate of syncedModels as Array<{ id?: unknown; supportedThinkingEfforts?: unknown }>) { + if (typeof candidate?.id !== "string" || !Array.isArray(candidate.supportedThinkingEfforts)) { + continue; + } + const attempt = splitSyncedEffortSuffix( + modelId, + candidate.supportedThinkingEfforts as string[] + ); + if (attempt.effort && attempt.baseModel === candidate.id) { + return { modelId: attempt.baseModel, effort: attempt.effort }; + } + } + return { modelId, effort: null }; +} + function findCustomModelMeta(models: unknown, modelId: string): any { if (!Array.isArray(models)) return undefined; return ( @@ -151,19 +203,32 @@ function buildRuntimeModelMeta(customMatch: any, syncedMatch: any): RuntimeModel return metadata; } -async function lookupModelMeta(providerId: string, modelId: string): Promise { +async function lookupModelMeta( + providerId: string, + modelId: string +): Promise<{ modelId: string; metadata: RuntimeModelMeta }> { try { const [customModels, syncedModels] = await Promise.all([ getCustomModels(providerId), getSyncedAvailableModels(providerId), ]); + // #7694: no direct match on the raw modelId? try a synced-declared `-{effort}` + // suffix before falling back to the literal id, so `/-` + // resolves to the real base model + a resolved effort. + const { modelId: resolvedModelId, effort } = resolveSyncedModelIdAndEffort( + providerId, + modelId, + syncedModels + ); // #7364: exact match first; retain the case-insensitive custom-model fallback // while also consulting the API-synced catalog for Kimi runtime metadata. - const customMatch = findCustomModelMeta(customModels, modelId); - const syncedMatch = findSyncedModelMeta(syncedModels, modelId); - return buildRuntimeModelMeta(customMatch, syncedMatch); + const customMatch = findCustomModelMeta(customModels, resolvedModelId); + const syncedMatch = findSyncedModelMeta(syncedModels, resolvedModelId); + const metadata = buildRuntimeModelMeta(customMatch, syncedMatch); + if (effort) metadata.resolvedThinkingEffort = effort; + return { modelId: resolvedModelId, metadata }; } catch { - return {}; + return { modelId, metadata: {} }; } } @@ -192,8 +257,9 @@ export async function getModelInfo(modelStr) { const attachRuntimeModelMeta = async (info: any) => { if (!info?.provider || !info?.model) return info; - const metadata = await lookupModelMeta(String(info.provider), String(info.model)); - return Object.keys(metadata).length > 0 ? { ...info, ...metadata } : info; + const { modelId, metadata } = await lookupModelMeta(String(info.provider), String(info.model)); + const resolvedInfo = modelId !== info.model ? { ...info, model: modelId } : info; + return Object.keys(metadata).length > 0 ? { ...resolvedInfo, ...metadata } : resolvedInfo; }; // Check custom provider nodes first (for both alias and non-alias formats) @@ -225,13 +291,13 @@ export async function getModelInfo(modelStr) { parsed.model as string, matchedOpenAI.prefix ); - const metadata = await lookupModelMeta( + const { modelId, metadata } = await lookupModelMeta( matchedOpenAI.id as string, normalizedModel ); return { provider: matchedOpenAI.id, - model: normalizedModel, + model: modelId, extendedContext, ...metadata, }; @@ -247,13 +313,13 @@ export async function getModelInfo(modelStr) { parsed.model as string, matchedAnthropic.prefix ); - const metadata = await lookupModelMeta( + const { modelId, metadata } = await lookupModelMeta( matchedAnthropic.id as string, normalizedModel ); return { provider: matchedAnthropic.id, - model: normalizedModel, + model: modelId, extendedContext, ...metadata, }; diff --git a/tests/unit/sync-reasoning-supported-efforts-7694.test.ts b/tests/unit/sync-reasoning-supported-efforts-7694.test.ts new file mode 100644 index 0000000000..f81440ea10 --- /dev/null +++ b/tests/unit/sync-reasoning-supported-efforts-7694.test.ts @@ -0,0 +1,379 @@ +/** + * #7694 — capture upstream `reasoning.supported_efforts` at sync, surface it as + * `capabilities.effort_tiers` in the catalog, and support `/-{effort}` + * request-time alias resolution. + * + * Mirrors the #6879 per-model reasoning-effort precedent + * (open-sse/services/defaultReasoningEffort.ts): captures/exposes what the provider + * already declared and threads it through the EXISTING `supportedThinkingEfforts` + * plumbing (`SyncedAvailableModel`, `src/sse/services/model.ts` `RuntimeModelMeta`) — + * no parallel storage mechanism. + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-7694-effort-sync-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "catalog-test-secret-7694"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const modelsDb = await import("../../src/lib/db/models.ts"); +const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts"); +const { getModelInfo } = await import("../../src/sse/services/model.ts"); +const { normalizeDiscoveredModels, detectSupportedThinkingEfforts } = await import( + "../../src/lib/providerModels/modelDiscovery.ts" +); +const { splitSyncedEffortSuffix } = await import("../../open-sse/services/model.ts"); +const { + appendSyncedEffortVariants, + shouldExposeSyncedEffortVariants, + SYNCED_EFFORT_SKIP_PROVIDERS, +} = await import("../../open-sse/utils/syncedEffortVariants.ts"); +const { applyDefaultReasoningEffort } = await import( + "../../open-sse/services/defaultReasoningEffort.ts" +); + +async function resetStorage() { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + v1ModelsCatalog.__resetCatalogBuilderRunsForTest(); + await resetStorage(); +}); + +test.after(() => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +async function seedProviderConnection(provider: string) { + return providersDb.createProviderConnection({ + provider, + authType: "apikey", + name: `${provider}-${Math.random().toString(16).slice(2, 8)}`, + apiKey: `${provider}-key`, + isActive: true, + testStatus: "active", + }); +} + +// --------------------------------------------------------------------------- +// Step 1+2: sync-time capture of the nested `reasoning.supported_efforts` shape, +// normalized onto the canonical vocabulary. Hard Rule #7 — Zod-validated. +// --------------------------------------------------------------------------- + +test("normalizeDiscoveredModels: captures nested reasoning.supported_efforts (no flat field) and normalizes 'max' -> 'xhigh'", () => { + const [model] = normalizeDiscoveredModels([ + { + id: "some/model-7694", + reasoning: { supported_efforts: ["low", "medium", "max"] }, + }, + ]); + assert.deepEqual(model.supportedThinkingEfforts, ["low", "medium", "xhigh"]); +}); + +test("normalizeDiscoveredModels: pre-existing flat supportedThinkingEfforts field wins verbatim over nested (regression)", () => { + const [model] = normalizeDiscoveredModels([ + { + id: "some/model-flat", + supportedThinkingEfforts: ["low", "max"], // byte-identical pass-through, no normalization + reasoning: { supported_efforts: ["high"] }, // must be ignored — flat field wins + }, + ]); + assert.deepEqual(model.supportedThinkingEfforts, ["low", "max"]); +}); + +test("normalizeDiscoveredModels: an unrecognized provider-native tier passes through unchanged (e.g. codex-style 'ultra')", () => { + const [model] = normalizeDiscoveredModels([ + { id: "some/model-ultra", reasoning: { supported_efforts: ["ultra"] } }, + ]); + assert.deepEqual(model.supportedThinkingEfforts, ["ultra"]); +}); + +test("normalizeDiscoveredModels: graceful degradation — reasoning:{} (no supported_efforts key) produces no supportedThinkingEfforts field, does not throw", () => { + const [model] = normalizeDiscoveredModels([{ id: "some/model-empty-reasoning", reasoning: {} }]); + assert.equal("supportedThinkingEfforts" in model, false); +}); + +test("normalizeDiscoveredModels: graceful degradation — reasoning entirely absent produces no supportedThinkingEfforts field, does not throw", () => { + const [model] = normalizeDiscoveredModels([{ id: "some/model-no-reasoning" }]); + assert.equal("supportedThinkingEfforts" in model, false); +}); + +test("detectSupportedThinkingEfforts: Hard Rule #7 — malformed upstream payload (supported_efforts is a string, not an array) is rejected by Zod and degrades to undefined without throwing", () => { + assert.doesNotThrow(() => { + const result = detectSupportedThinkingEfforts({ + reasoning: { supported_efforts: "high" as unknown }, + }); + assert.equal(result, undefined); + }); +}); + +test("detectSupportedThinkingEfforts: malformed upstream payload (reasoning is a string, not an object) is rejected by Zod and degrades to undefined without throwing", () => { + assert.doesNotThrow(() => { + const result = detectSupportedThinkingEfforts({ reasoning: "not-an-object" as unknown }); + assert.equal(result, undefined); + }); +}); + +// --------------------------------------------------------------------------- +// Step 3: catalog surfaces capabilities.effort_tiers for synced models. +// --------------------------------------------------------------------------- + +test("#7694 catalog: GET /api/v1/models surfaces capabilities.effort_tiers for a synced model with nested reasoning.supported_efforts", async () => { + const connection = await seedProviderConnection("huggingface"); + + await modelsDb.replaceSyncedAvailableModelsForConnection("huggingface", connection.id, [ + { + id: "some-org/reasoning-model-7694", + name: "Reasoning Model 7694", + supportedThinkingEfforts: ["low", "medium", "high"], + }, + ]); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + assert.equal(response.status, 200); + + const body = (await response.json()) as { + data: Array<{ id: string; capabilities?: { effort_tiers?: string[] } }>; + }; + const entry = body.data.find((m) => m.id.endsWith("some-org/reasoning-model-7694")); + assert.ok(entry, "synced reasoning model must appear in the catalog"); + assert.deepEqual(entry!.capabilities?.effort_tiers, ["low", "medium", "high"]); +}); + +test("#7694 catalog: a synced model with no supportedThinkingEfforts gets no capabilities.effort_tiers (graceful degradation)", async () => { + const connection = await seedProviderConnection("huggingface"); + + await modelsDb.replaceSyncedAvailableModelsForConnection("huggingface", connection.id, [ + { id: "some-org/no-reasoning-model-7694", name: "No Reasoning Model 7694" }, + ]); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as { + data: Array<{ id: string; capabilities?: { effort_tiers?: string[] } }>; + }; + const entry = body.data.find((m) => m.id.endsWith("some-org/no-reasoning-model-7694")); + assert.ok(entry); + assert.equal(entry!.capabilities?.effort_tiers, undefined); +}); + +// --------------------------------------------------------------------------- +// Step 4: catalog generates `-{effort}` alias ids per declared tier. +// --------------------------------------------------------------------------- + +test("#7694 catalog: GET /api/v1/models advertises a - alias id per declared effort tier", async () => { + const connection = await seedProviderConnection("huggingface"); + + await modelsDb.replaceSyncedAvailableModelsForConnection("huggingface", connection.id, [ + { + id: "some-org/reasoning-model-alias-7694", + name: "Reasoning Model Alias 7694", + supportedThinkingEfforts: ["low", "high"], + }, + ]); + + const response = await v1ModelsCatalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as { data: Array<{ id: string }> }; + const baseEntry = body.data.find((m) => m.id.endsWith("some-org/reasoning-model-alias-7694")); + assert.ok(baseEntry, "base model must appear in the catalog"); + + const highVariant = body.data.find((m) => m.id === `${baseEntry!.id}-high`); + const lowVariant = body.data.find((m) => m.id === `${baseEntry!.id}-low`); + assert.ok(highVariant, `expected a "${baseEntry!.id}-high" alias entry`); + assert.ok(lowVariant, `expected a "${baseEntry!.id}-low" alias entry`); +}); + +// --------------------------------------------------------------------------- +// shouldExposeSyncedEffortVariants / appendSyncedEffortVariants — pure unit tests. +// --------------------------------------------------------------------------- + +test("shouldExposeSyncedEffortVariants: skips codex/kimi-owned models (own suffix mechanism) — Kimi/codex regression guard", () => { + assert.equal( + shouldExposeSyncedEffortVariants({ + id: "codex/gpt-5.5", + owned_by: "codex", + capabilities: { effort_tiers: ["low", "high"] }, + }), + false + ); + assert.equal( + shouldExposeSyncedEffortVariants({ + id: "kimi/kimi-k3", + owned_by: "kimi-coding", + capabilities: { effort_tiers: ["low", "high"] }, + }), + false + ); + assert.equal( + shouldExposeSyncedEffortVariants({ + id: "kimi/kimi-k3", + owned_by: "kimi-coding-apikey", + capabilities: { effort_tiers: ["low", "high"] }, + }), + false + ); + assert.ok(SYNCED_EFFORT_SKIP_PROVIDERS.has("codex")); +}); + +test("shouldExposeSyncedEffortVariants: skips a model id that already ends in a token matching a canonical effort value (collision guard)", () => { + assert.equal( + shouldExposeSyncedEffortVariants({ + id: "someprovider/my-model-high", // legitimately ends in "-high" + owned_by: "someprovider", + capabilities: { effort_tiers: ["low", "high"] }, + }), + false + ); +}); + +test("shouldExposeSyncedEffortVariants: exposes a plain synced model carrying effort_tiers", () => { + assert.equal( + shouldExposeSyncedEffortVariants({ + id: "someprovider/my-model", + owned_by: "someprovider", + capabilities: { effort_tiers: ["low", "high"] }, + }), + true + ); +}); + +test("appendSyncedEffortVariants: generates one variant per tier, inheriting the base model's fields", () => { + const base = { + id: "someprovider/my-model", + owned_by: "someprovider", + root: "my-model", + capabilities: { effort_tiers: ["low", "high"] }, + hidden: false, + }; + const result = appendSyncedEffortVariants([base]); + assert.equal(result.length, 3); + const high = result.find((m) => m.id === "someprovider/my-model-high"); + assert.ok(high); + assert.equal(high!.root, "my-model-high"); + assert.equal(high!.hidden, false); // inherited, not re-derived +}); + +// --------------------------------------------------------------------------- +// Step 5: request-time `/-{effort}` alias resolution. +// --------------------------------------------------------------------------- + +test("splitSyncedEffortSuffix: strips a known trailing effort token for a candidate's own declared tiers", () => { + const result = splitSyncedEffortSuffix("my-model-high", ["low", "medium", "high"]); + assert.deepEqual(result, { baseModel: "my-model", effort: "high" }); +}); + +test("splitSyncedEffortSuffix: leaves an unsupported effort token untouched (not a known tier for this model)", () => { + const result = splitSyncedEffortSuffix("my-model-ultrahigh", ["low", "medium", "high"]); + assert.deepEqual(result, { baseModel: "my-model-ultrahigh", effort: null }); +}); + +test("splitSyncedEffortSuffix: no known efforts list -> untouched", () => { + const result = splitSyncedEffortSuffix("my-model-high", undefined); + assert.deepEqual(result, { baseModel: "my-model-high", effort: null }); +}); + +const EFFORT_CONN_ID = "openai-compatible-chat-7694-effort-probe"; +const EFFORT_PREFIX = "pfx7694"; +const EFFORT_BASE_MODEL = "reasoning-model-7694"; + +async function seedEffortSuffixNode() { + await providersDb.createProviderNode({ + id: EFFORT_CONN_ID, + type: "openai-compatible", + name: "7694 effort-suffix probe", + prefix: EFFORT_PREFIX, + baseUrl: "https://proxy.example.com", + chatPath: "/v1/chat/completions", + modelsPath: "/v1/models", + }); + await modelsDb.replaceSyncedAvailableModelsForConnection(EFFORT_CONN_ID, EFFORT_CONN_ID, [ + { + id: EFFORT_BASE_MODEL, + name: "Reasoning Model 7694", + supportedThinkingEfforts: ["low", "medium", "high"], + }, + ]); +} + +test("#7694 RED->GREEN: /-high resolves to the base model id and derives 'high' as the effort", async () => { + await seedEffortSuffixNode(); + + const info = (await getModelInfo(`${EFFORT_PREFIX}/${EFFORT_BASE_MODEL}-high`)) as { + provider?: string; + model?: string; + resolvedThinkingEffort?: string; + }; + assert.equal(info.provider, EFFORT_CONN_ID); + assert.equal(info.model, EFFORT_BASE_MODEL, "suffix must be stripped back to the base model id"); + assert.equal(info.resolvedThinkingEffort, "high"); +}); + +test("#7694: an unsupported effort suffix is NOT stripped (treated as a literal/unknown model id — collision-risk guard holds)", async () => { + await seedEffortSuffixNode(); + + const info = (await getModelInfo(`${EFFORT_PREFIX}/${EFFORT_BASE_MODEL}-ultrahigh`)) as { + provider?: string; + model?: string; + resolvedThinkingEffort?: string; + }; + assert.equal(info.provider, EFFORT_CONN_ID); + assert.equal( + info.model, + `${EFFORT_BASE_MODEL}-ultrahigh`, + "unknown suffix must pass through literally, not be silently stripped" + ); + assert.equal(info.resolvedThinkingEffort, undefined); +}); + +test("#7694: the base model id (no suffix) still resolves normally and carries its full tier list", async () => { + await seedEffortSuffixNode(); + + const info = (await getModelInfo(`${EFFORT_PREFIX}/${EFFORT_BASE_MODEL}`)) as { + provider?: string; + model?: string; + supportedThinkingEfforts?: string[]; + resolvedThinkingEffort?: string; + }; + assert.equal(info.provider, EFFORT_CONN_ID); + assert.equal(info.model, EFFORT_BASE_MODEL); + assert.deepEqual(info.supportedThinkingEfforts, ["low", "medium", "high"]); + assert.equal(info.resolvedThinkingEffort, undefined); +}); + +// --------------------------------------------------------------------------- +// applyDefaultReasoningEffort: the suffix-resolved effort (#7694) takes priority over +// the static per-model ModelSpec default (#6879), and an explicit client value still +// always wins over both. +// --------------------------------------------------------------------------- + +test("applyDefaultReasoningEffort: a suffix-resolved effort (#7694) is injected as reasoning_effort", () => { + const body = { model: "some-model", messages: [] }; + const result = applyDefaultReasoningEffort(body, "some-model", "high"); + assert.equal(result.reasoning_effort, "high"); +}); + +test("applyDefaultReasoningEffort: an explicit client reasoning_effort still wins over the suffix-resolved effort", () => { + const body = { model: "some-model", messages: [], reasoning_effort: "low" }; + const result = applyDefaultReasoningEffort(body, "some-model", "high"); + assert.equal(result.reasoning_effort, "low"); +}); + +test("applyDefaultReasoningEffort: no suffix effort and no ModelSpec default -> no injection (regression, same reference)", () => { + const body = { model: "some-model-untouched", messages: [] }; + const result = applyDefaultReasoningEffort(body, "some-model-untouched", null); + assert.equal(result, body); +});