diff --git a/changelog.d/features/8347-cliproxy-reasoning-levels.md b/changelog.d/features/8347-cliproxy-reasoning-levels.md new file mode 100644 index 0000000000..b75cb72687 --- /dev/null +++ b/changelog.d/features/8347-cliproxy-reasoning-levels.md @@ -0,0 +1 @@ +- feat(providers): map upstream reasoning-level metadata in openai-compatible discovery (#8347) diff --git a/src/app/api/providers/[id]/models/discoveryClientVersion.ts b/src/app/api/providers/[id]/models/discoveryClientVersion.ts new file mode 100644 index 0000000000..b359e59999 --- /dev/null +++ b/src/app/api/providers/[id]/models/discoveryClientVersion.ts @@ -0,0 +1,57 @@ +// #8347: CLIProxyAPI-style upstreams gate a richer catalog behind a `client_version` query +// param on the model-list request (mirroring `discovery/codex.ts::buildCodexModelsUrl`). +// This is a per-connection, default-OFF opt-in — appending an unexpected query param to +// every generic OpenAI-compatible upstream's model-list call is the stated regression +// vector, so this MUST default to false and MUST never be applied to inference URLs. + +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +export type DiscoveryClientVersionOptions = { + discoveryClientVersionEnabled?: boolean; + discoveryClientVersion?: string; +}; + +/** + * Reads the opt-in flag + optional version string off a connection's + * `providerSpecificData`. Default is OFF: absent, falsy, or malformed data never enables + * the query param. + */ +export function getDiscoveryClientVersionOptions( + providerSpecificData: unknown +): DiscoveryClientVersionOptions { + const record = asRecord(providerSpecificData); + const enabled = record.discoveryClientVersionEnabled === true; + const version = + typeof record.discoveryClientVersion === "string" && record.discoveryClientVersion.trim() + ? record.discoveryClientVersion.trim() + : undefined; + return { discoveryClientVersionEnabled: enabled, discoveryClientVersion: version }; +} + +/** + * Appends `client_version` to a model-LIST URL only, and only when the connection has + * explicitly opted in. Never call this for an inference/chat-completions URL. Returns the + * URL unchanged (byte-identical) when the opt-in is absent or off, so unrelated generic + * OpenAI-compatible upstreams see no behavior change. + */ +export function buildProviderModelsUrl( + modelsUrl: string, + options: DiscoveryClientVersionOptions | undefined +): string { + if (!options?.discoveryClientVersionEnabled || !options.discoveryClientVersion) { + return modelsUrl; + } + try { + const url = new URL(modelsUrl); + url.searchParams.set("client_version", options.discoveryClientVersion); + return url.toString(); + } catch { + // Malformed base URL — fall back to the untouched string rather than throwing; + // the caller's own fetch will surface the real error. + return modelsUrl; + } +} diff --git a/src/app/api/providers/[id]/models/route.ts b/src/app/api/providers/[id]/models/route.ts index b24c4b7cf5..c7d74a1f9d 100755 --- a/src/app/api/providers/[id]/models/route.ts +++ b/src/app/api/providers/[id]/models/route.ts @@ -83,6 +83,10 @@ import { isAutoFetchModelsEnabled, persistDiscoveredModels, } from "@/lib/providerModels/modelDiscovery"; +import { + buildProviderModelsUrl, + getDiscoveryClientVersionOptions, +} from "./discoveryClientVersion"; import { parseGeminiModelsList, type GeminiDiscoveryModel, @@ -802,8 +806,16 @@ export async function GET( `${baseUrl.replace(/\/$/, "")}/models`, // Original fallback ]; + // #8347: opt-in `client_version` query param on the model-LIST request only (never on + // any inference URL, and never on this path by default) — see discoveryClientVersion.ts. + const discoveryClientVersionOptions = getDiscoveryClientVersionOptions( + connection.providerSpecificData + ); + // Remove duplicates - const uniqueEndpoints = [...new Set(endpoints)]; + const uniqueEndpoints = [...new Set(endpoints)].map((endpoint) => + buildProviderModelsUrl(endpoint, discoveryClientVersionOptions) + ); let models = null; let lastErrorStatus = null; const token = apiKey || accessToken; diff --git a/src/lib/providerModels/modelDiscovery.ts b/src/lib/providerModels/modelDiscovery.ts index 7e7d91927a..b463f7fac3 100644 --- a/src/lib/providerModels/modelDiscovery.ts +++ b/src/lib/providerModels/modelDiscovery.ts @@ -71,6 +71,18 @@ const reasoningSupportedEffortsSchema = z .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() }); + // 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. @@ -82,6 +94,32 @@ function normalizeSupportedEffort(effort: string): string { 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; +} + /** * #7694: read the nested `record.reasoning.supported_efforts` shape and normalize each * tier onto the canonical vocabulary. Returns `undefined` (never throws) when the field @@ -91,19 +129,36 @@ function normalizeSupportedEffort(effort: string): string { */ export function detectSupportedThinkingEfforts(record: JsonRecord): string[] | undefined { const parsed = reasoningSupportedEffortsSchema.safeParse(record.reasoning); - if (!parsed.success || !parsed.data) return undefined; + 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; + } + } - const rawEfforts = parsed.data.supported_efforts; - if (!Array.isArray(rawEfforts)) return undefined; + // #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 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; + const thinkingParsed = thinkingLevelsSchema.safeParse(record); + if (thinkingParsed.success) { + const fromThinking = parseEffortList(thinkingParsed.data.thinking?.levels); + if (fromThinking) return fromThinking; + } + + return undefined; } export function isAutoFetchModelsEnabled(providerSpecificData: unknown): boolean { @@ -177,10 +232,11 @@ export function normalizeDiscoveredModels(models: unknown): SyncedAvailableModel : {}), ...(supportedEndpoints && supportedEndpoints.length > 0 ? { supportedEndpoints } : {}), ...(() => { - // #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. + // #7694/#8347: the flat field (OmniRoute's own import format) wins verbatim when + // present, unchanged from its current pass-through behavior. Otherwise + // `detectSupportedThinkingEfforts` falls back in order: `reasoning.supported_efforts` + // → `supported_reasoning_levels` → `thinking.levels` (all normalized onto the + // canonical vocabulary) — never disturbing the flat field's precedence. if (Array.isArray(record.supportedThinkingEfforts)) { return { supportedThinkingEfforts: record.supportedThinkingEfforts.filter( diff --git a/tests/unit/model-discovery-reasoning-levels.test.ts b/tests/unit/model-discovery-reasoning-levels.test.ts new file mode 100644 index 0000000000..1e0bd34518 --- /dev/null +++ b/tests/unit/model-discovery-reasoning-levels.test.ts @@ -0,0 +1,129 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + detectSupportedThinkingEfforts, + normalizeDiscoveredModels, +} from "@/lib/providerModels/modelDiscovery"; +import { buildProviderModelsUrl } from "@/app/api/providers/[id]/models/discoveryClientVersion"; + +// #8347: generic openai-compatible / custom-node discovery had no metadata-mapping layer +// for `supported_reasoning_levels` (CLIProxyAPI-style upstreams) or `thinking.levels`. This +// suite proves the two new shapes are parsed onto the existing `supportedThinkingEfforts` +// pipeline, that the #7694 precedence order is preserved, and that the `client_version` +// opt-in touches the model-list URL only — never inference URLs — and defaults off. + +test("supported_reasoning_levels as {effort} objects is parsed into supportedThinkingEfforts", () => { + const record = { + id: "model-a", + supported_reasoning_levels: [{ effort: "low" }, { effort: "high" }], + }; + assert.deepEqual(detectSupportedThinkingEfforts(record), ["low", "high"]); +}); + +test("supported_reasoning_levels as plain strings is parsed into supportedThinkingEfforts", () => { + const record = { + id: "model-b", + supported_reasoning_levels: ["low", "medium"], + }; + assert.deepEqual(detectSupportedThinkingEfforts(record), ["low", "medium"]); +}); + +test("thinking.levels is parsed into supportedThinkingEfforts", () => { + const record = { + id: "model-c", + thinking: { levels: ["medium", "high"] }, + }; + assert.deepEqual(detectSupportedThinkingEfforts(record), ["medium", "high"]); +}); + +test("duplicates are deduped, max canonicalizes to xhigh, unknown native tier retained", () => { + const record = { + id: "model-d", + supported_reasoning_levels: [{ effort: "max" }, { effort: "max" }, { effort: "ultra" }], + }; + assert.deepEqual(detectSupportedThinkingEfforts(record), ["xhigh", "ultra"]); +}); + +test("a malformed entry inside an otherwise-valid array is dropped, the rest survive, no throw", () => { + const record = { + id: "model-e", + supported_reasoning_levels: [{ effort: "low" }, { effort: 42 }, null, "high", 7], + }; + assert.doesNotThrow(() => detectSupportedThinkingEfforts(record)); + assert.deepEqual(detectSupportedThinkingEfforts(record), ["low", "high"]); +}); + +test("a fully malformed shape degrades to undefined instead of throwing", () => { + const record = { id: "model-f", supported_reasoning_levels: "not-an-array" }; + assert.doesNotThrow(() => detectSupportedThinkingEfforts(record)); + assert.equal(detectSupportedThinkingEfforts(record), undefined); +}); + +test("precedence: flat supportedThinkingEfforts wins over reasoning.supported_efforts and both new shapes (#7694 regression guard)", () => { + const models = normalizeDiscoveredModels([ + { + id: "model-g", + supportedThinkingEfforts: ["none"], + reasoning: { supported_efforts: ["low"] }, + supported_reasoning_levels: ["medium"], + thinking: { levels: ["high"] }, + }, + ]); + assert.deepEqual(models[0].supportedThinkingEfforts, ["none"]); +}); + +test("precedence: reasoning.supported_efforts wins over supported_reasoning_levels and thinking.levels", () => { + const models = normalizeDiscoveredModels([ + { + id: "model-h", + reasoning: { supported_efforts: ["low"] }, + supported_reasoning_levels: ["medium"], + thinking: { levels: ["high"] }, + }, + ]); + assert.deepEqual(models[0].supportedThinkingEfforts, ["low"]); +}); + +test("precedence: supported_reasoning_levels wins over thinking.levels when both present", () => { + const models = normalizeDiscoveredModels([ + { + id: "model-i", + supported_reasoning_levels: ["medium"], + thinking: { levels: ["high"] }, + }, + ]); + assert.deepEqual(models[0].supportedThinkingEfforts, ["medium"]); +}); + +test("thinking.levels alone still populates supportedThinkingEfforts via normalizeDiscoveredModels", () => { + const models = normalizeDiscoveredModels([ + { + id: "model-j", + thinking: { levels: ["high"] }, + }, + ]); + assert.deepEqual(models[0].supportedThinkingEfforts, ["high"]); +}); + +test("client_version is absent from the model-list URL by default", () => { + const url = buildProviderModelsUrl("https://example.com/v1/models", undefined); + assert.equal(url, "https://example.com/v1/models"); + assert.ok(!url.includes("client_version")); +}); + +test("client_version is present on the model-list URL only when the connection opts in", () => { + const url = buildProviderModelsUrl("https://example.com/v1/models", { + discoveryClientVersionEnabled: true, + discoveryClientVersion: "1.2.3", + }); + const parsed = new URL(url); + assert.equal(parsed.searchParams.get("client_version"), "1.2.3"); +}); + +test("client_version opt-in without an explicit version still defaults off (no gate silently mutates every request)", () => { + const url = buildProviderModelsUrl("https://example.com/v1/models", { + discoveryClientVersionEnabled: false, + discoveryClientVersion: "1.2.3", + }); + assert.ok(!url.includes("client_version")); +});