diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 69841c055c..df443a6f7b 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -181,6 +181,29 @@ export function getRegistryEntry(provider: string): RegistryEntry | null { return REGISTRY[provider] || _byAlias.get(provider) || null; } +/** Resolve only a model's explicit reasoning vocabulary. */ +export function getRegistryModelThinkingEfforts( + provider: string, + modelId: string +): readonly string[] | undefined { + const entry = getRegistryEntry(provider); + if (!entry) return undefined; + const model = entry.models.find((candidate) => candidate.id === modelId); + return model?.supportedThinkingEfforts; +} + +/** Resolve a model's explicit reasoning vocabulary before its provider fallback. */ +export function getRegistryThinkingEfforts( + provider: string, + modelId: string +): readonly string[] | undefined { + const entry = getRegistryEntry(provider); + if (!entry) return undefined; + const modelEfforts = getRegistryModelThinkingEfforts(provider, modelId); + if (modelEfforts !== undefined) return modelEfforts; + return entry.defaultSupportedThinkingEfforts; +} + /** * Decide whether a non-empty live catalog may exclude omitted static models * during request routing and wildcard expansion. diff --git a/open-sse/config/providers/registry/ollama-cloud/index.ts b/open-sse/config/providers/registry/ollama-cloud/index.ts index 4cf020263a..fb68aae61e 100644 --- a/open-sse/config/providers/registry/ollama-cloud/index.ts +++ b/open-sse/config/providers/registry/ollama-cloud/index.ts @@ -9,6 +9,7 @@ export const ollama_cloudProvider: RegistryEntry = { modelsUrl: "https://ollama.com/api/tags", authType: "apikey", authHeader: "bearer", + defaultSupportedThinkingEfforts: ["none", "low", "medium", "high", "max"], // Note: rate limits vary by plan (free = "Light usage", Pro = more, Max = 5x Pro). // Users can generate API keys at https://ollama.com/settings/keys models: [ @@ -24,23 +25,20 @@ export const ollama_cloudProvider: RegistryEntry = { supportsReasoning: true, supportedThinkingEfforts: ["low", "medium", "high"], }, - // #10788: Ollama Cloud accepts low|medium|high|max|none uniformly across - // its reasoning-capable models (see supportsMaxEffortForProvider's - // isOllamaCloud comment in open-sse/executors/base/reasoningEffort.ts) — - // declare supportedThinkingEfforts so appendSyncedEffortVariants() (which - // runs before static-model capability enrichment) can synthesize the - // catalog's selectable -low/-high/-max variant ids for these models. + // #10788: these models accept none|low|medium|high|max. Keep their explicit + // declarations aligned with the provider fallback so the static and synced + // catalog paths expose the same native vocabulary. { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", supportsReasoning: true, - supportedThinkingEfforts: ["low", "medium", "high", "max"], + supportedThinkingEfforts: ["none", "low", "medium", "high", "max"], }, { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", supportsReasoning: true, - supportedThinkingEfforts: ["low", "medium", "high", "max"], + supportedThinkingEfforts: ["none", "low", "medium", "high", "max"], }, { id: "kimi-k2.6", name: "Kimi K2.6" }, // Ollama Cloud accepts low|medium|high|max|none and rejects xhigh, so the @@ -50,14 +48,14 @@ export const ollama_cloudProvider: RegistryEntry = { name: "GLM 5.1", supportsReasoning: true, supportsXHighEffort: false, - supportedThinkingEfforts: ["low", "medium", "high", "max"], + supportedThinkingEfforts: ["none", "low", "medium", "high", "max"], }, { id: "glm-5.2", name: "GLM 5.2", supportsReasoning: true, supportsXHighEffort: false, - supportedThinkingEfforts: ["low", "medium", "high", "max"], + supportedThinkingEfforts: ["none", "low", "medium", "high", "max"], }, // #3110: MiniMax M3 via Ollama { id: "minimax-m3", name: "MiniMax M3", contextLength: 1048576, supportsVision: true }, diff --git a/open-sse/config/providers/shared.ts b/open-sse/config/providers/shared.ts index db4b9a4d5b..16c0c09b41 100644 --- a/open-sse/config/providers/shared.ts +++ b/open-sse/config/providers/shared.ts @@ -139,6 +139,9 @@ export interface RegistryEntry { requestDefaults?: ProviderRequestDefaults; oauth?: RegistryOAuth; models: RegistryModel[]; + /** Provider-native reasoning vocabulary for reasoning-capable passthrough models + * that do not have an explicit per-model declaration. */ + defaultSupportedThinkingEfforts?: readonly string[]; modelsUrl?: string; /** Prefix to prepend to model IDs before upstream API calls (e.g. "accounts/fireworks/models/") */ modelIdPrefix?: string; diff --git a/src/app/api/v1/models/catalog.ts b/src/app/api/v1/models/catalog.ts index 78fd327009..ee01af6098 100644 --- a/src/app/api/v1/models/catalog.ts +++ b/src/app/api/v1/models/catalog.ts @@ -28,7 +28,11 @@ import { getAllAudioModels } from "@omniroute/open-sse/config/audioRegistry"; import { getAllModerationModels } from "@omniroute/open-sse/config/moderationRegistry"; import { getAllVideoModels } from "@omniroute/open-sse/config/videoRegistry"; import { getAllMusicModels } from "@omniroute/open-sse/config/musicRegistry"; -import { REGISTRY } from "@omniroute/open-sse/config/providerRegistry"; +import { + getRegistryModelThinkingEfforts, + getRegistryThinkingEfforts, + REGISTRY, +} from "@omniroute/open-sse/config/providerRegistry"; import { CODEX_NATIVE_UNPREFIXED_MODELS } from "@omniroute/open-sse/services/model"; import { isModelSelectable } from "@omniroute/open-sse/services/modelLifecycle"; import { resolveNestedComboTargets } from "@omniroute/open-sse/services/combo"; @@ -585,7 +589,9 @@ async function buildUnifiedModelsResponseCore( modelId, target, eligibleConnectionIds, - connectionCatalog || {} + connectionCatalog || {}, + getRegistryModelThinkingEfforts(providerId, modelId), + getRegistryThinkingEfforts(providerId, modelId) ); if ( connectionEfforts === undefined && @@ -669,7 +675,7 @@ async function buildUnifiedModelsResponseCore( providerId, modelId, canonical.capabilities.supportsThinking, - registryModel?.supportedThinkingEfforts, + getRegistryThinkingEfforts(providerId, modelId), true ) : getThinkingCapabilityFields( diff --git a/src/app/api/v1/models/catalogHelpers.ts b/src/app/api/v1/models/catalogHelpers.ts index 42d24fb1d8..40ecf25e85 100644 --- a/src/app/api/v1/models/catalogHelpers.ts +++ b/src/app/api/v1/models/catalogHelpers.ts @@ -36,6 +36,7 @@ export type ComboCatalogTarget = { type ConnectionScopedReasoningModel = { id: string; + supportsThinking?: boolean; supportedThinkingEfforts?: string[]; }; @@ -106,7 +107,9 @@ export function getConnectionScopedEffortTiers( modelId: string, target: Pick, eligibleConnectionIds: readonly string[] | undefined, - modelsByConnection: ConnectionScopedReasoningCatalog + modelsByConnection: ConnectionScopedReasoningCatalog, + explicitThinkingEfforts?: readonly string[], + fallbackThinkingEfforts?: readonly string[] ): string[] | undefined { const eligible = eligibleConnectionIds ? new Set(eligibleConnectionIds) : undefined; if (target.connectionId && eligible && !eligible.has(target.connectionId)) return []; @@ -139,7 +142,16 @@ export function getConnectionScopedEffortTiers( ); if (matching.some((model) => model === undefined)) return []; - const efforts = matching.map((model) => model?.supportedThinkingEfforts || []); + const efforts = matching.map((model) => { + const resolved = model?.supportedThinkingEfforts?.length + ? model.supportedThinkingEfforts + : model?.supportsThinking === true && fallbackThinkingEfforts + ? [...fallbackThinkingEfforts] + : []; + return explicitThinkingEfforts + ? explicitThinkingEfforts.filter((effort) => resolved.includes(effort)) + : resolved; + }); return intersectStringArrays(efforts); } diff --git a/src/app/api/v1/models/syncedCapabilities.ts b/src/app/api/v1/models/syncedCapabilities.ts index bf82f0f980..529753a8d9 100644 --- a/src/app/api/v1/models/syncedCapabilities.ts +++ b/src/app/api/v1/models/syncedCapabilities.ts @@ -27,9 +27,14 @@ // refactors. (Confirmed convention: grep "from \"@omniroute/open-sse" src/app/api/v1/models/) import { getLearnedReasoningEffortForModel } from "@omniroute/open-sse/services/learnedReasoningEffortCaps.ts"; import { isSkippedEffortProvider } from "@omniroute/open-sse/utils/syncedEffortVariants.ts"; +import { + getRegistryModelThinkingEfforts, + getRegistryThinkingEfforts, +} from "@omniroute/open-sse/config/providerRegistry.ts"; interface SyncedCapabilityFlags { id?: string; + supportsThinking?: boolean; supportsVision?: boolean; supportedThinkingEfforts?: string[]; } @@ -37,10 +42,23 @@ interface SyncedCapabilityFlags { function effectiveEffortTiers(sm: SyncedCapabilityFlags, ownedBy: string): string[] | undefined { if (isSkippedEffortProvider(ownedBy)) return undefined; const learned = sm.id ? getLearnedReasoningEffortForModel(sm.id) : null; + const synced = + Array.isArray(sm.supportedThinkingEfforts) && sm.supportedThinkingEfforts.length > 0 + ? sm.supportedThinkingEfforts + : null; + const explicit = sm.id ? getRegistryModelThinkingEfforts(ownedBy, sm.id) : undefined; + if (explicit) { + const observed = learned ? [...learned] : synced; + const narrowed = observed + ? explicit.filter((effort) => observed.includes(effort)) + : [...explicit]; + return narrowed.length > 0 ? narrowed : undefined; + } if (learned) return [...learned]; - return Array.isArray(sm.supportedThinkingEfforts) && sm.supportedThinkingEfforts.length > 0 - ? sm.supportedThinkingEfforts - : undefined; + if (synced) return synced; + if (!sm.supportsThinking || !sm.id) return undefined; + const registryEfforts = getRegistryThinkingEfforts(ownedBy, sm.id); + return registryEfforts && registryEfforts.length > 0 ? [...registryEfforts] : undefined; } /** Build the `capabilities` object for a fresh synced-model catalog entry, or `undefined` when neither flag applies. */ diff --git a/tests/unit/models-catalog-combo-metadata.test.ts b/tests/unit/models-catalog-combo-metadata.test.ts index c80253c314..1ab884f3d3 100644 --- a/tests/unit/models-catalog-combo-metadata.test.ts +++ b/tests/unit/models-catalog-combo-metadata.test.ts @@ -567,3 +567,64 @@ test("mixed DeepSeek combos advertise the efforts accepted by every V4 target", ]); } }); + +test("Ollama Cloud projects native efforts for base, tagged, and combo models", async () => { + const provider = "ollama-cloud"; + const baseModel = "deepseek-v4-flash"; + const taggedModel = "deepseek-v4-flash:0731"; + const narrowModel = "gpt-oss:20b"; + const nativeEfforts = ["none", "low", "medium", "high", "max"]; + const narrowEfforts = ["low", "medium", "high"]; + const connection = await providersDb.createProviderConnection({ + provider, + authType: "apikey", + name: "ollama-cloud-native-efforts", + apiKey: "ollama-cloud-test-key", + isActive: true, + testStatus: "active", + }); + await modelsDb.replaceSyncedAvailableModelsForConnection(provider, connection.id, [ + { id: baseModel, name: "DeepSeek V4 Flash", supportsThinking: true }, + { id: taggedModel, name: "DeepSeek V4 Flash 0731", supportsThinking: true }, + { + id: narrowModel, + name: "GPT-OSS 20B", + supportsThinking: true, + supportedThinkingEfforts: nativeEfforts, + }, + ]); + await combosDb.createCombo({ + name: "ollama-cloud-native-efforts-combo", + strategy: "auto", + models: [`${provider}/${baseModel}`, `${provider}/${taggedModel}`], + }); + await combosDb.createCombo({ + name: "ollama-cloud-narrow-efforts-combo", + strategy: "auto", + models: [`${provider}/${narrowModel}`], + }); + + const response = await catalog.getUnifiedModelsResponse( + new Request("http://localhost/api/v1/models") + ); + const body = (await response.json()) as { data: Array> }; + const capabilitiesFor = (modelId: string) => { + const model = body.data.find((item) => item.id === modelId); + assert.ok(model, modelId); + return model.capabilities as Record; + }; + + assert.equal(response.status, 200); + for (const modelId of [ + `ollamacloud/${baseModel}`, + `ollamacloud/${taggedModel}`, + "ollama-cloud-native-efforts-combo", + ]) { + const effortTiers = capabilitiesFor(modelId).effort_tiers; + assert.deepEqual(effortTiers, nativeEfforts, modelId); + assert.equal((effortTiers as string[]).includes("xhigh"), false, modelId); + } + for (const modelId of [`ollamacloud/${narrowModel}`, "ollama-cloud-narrow-efforts-combo"]) { + assert.deepEqual(capabilitiesFor(modelId).effort_tiers, narrowEfforts, modelId); + } +}); diff --git a/tests/unit/ollama-cloud-reasoning-effort-tiers-10788.test.ts b/tests/unit/ollama-cloud-reasoning-effort-tiers-10788.test.ts index ae7506cbf1..599408ce67 100644 --- a/tests/unit/ollama-cloud-reasoning-effort-tiers-10788.test.ts +++ b/tests/unit/ollama-cloud-reasoning-effort-tiers-10788.test.ts @@ -1,6 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { ollama_cloudProvider } from "../../open-sse/config/providers/registry/ollama-cloud/index.ts"; +import { getRegistryThinkingEfforts } from "../../open-sse/config/providerRegistry.ts"; // #10788: ollama-cloud declared supportsReasoning:true on several models // (glm-5.1/5.2, deepseek-v4-pro/flash) but never declared @@ -19,6 +20,7 @@ test("#10788: ollama-cloud reasoning-capable models declare supportedThinkingEff Array.isArray(control?.supportedThinkingEfforts) && control.supportedThinkingEfforts.length > 0, "control: gpt-oss:20b should already declare supportedThinkingEfforts" ); + assert.deepEqual(control.supportedThinkingEfforts, ["low", "medium", "high"]); const reasoningModelIds = ["glm-5.1", "glm-5.2", "deepseek-v4-pro", "deepseek-v4-flash"]; for (const id of reasoningModelIds) { @@ -33,8 +35,29 @@ test("#10788: ollama-cloud reasoning-capable models declare supportedThinkingEff // low|medium|high|max|none — xhigh is rejected and mapped to max. assert.deepEqual( [...(model?.supportedThinkingEfforts ?? [])], - ["low", "medium", "high", "max"], - `${id} should declare Ollama Cloud's documented low/medium/high/max vocabulary` + ["none", "low", "medium", "high", "max"], + `${id} should declare Ollama Cloud's documented none/low/medium/high/max vocabulary` ); } }); + +test("#10788: provider fallback preserves explicit and unrelated vocabularies", () => { + assert.deepEqual(getRegistryThinkingEfforts("ollama-cloud", "deepseek-v4-flash:0731"), [ + "none", + "low", + "medium", + "high", + "max", + ]); + assert.deepEqual(getRegistryThinkingEfforts("ollama-cloud", "gpt-oss:20b"), [ + "low", + "medium", + "high", + ]); + assert.deepEqual(getRegistryThinkingEfforts("deepseek", "deepseek-v4-flash"), [ + "none", + "low", + "high", + "max", + ]); +});