feat(providers): map upstream reasoning-level metadata in openai-compatible discovery (#8347) (#8363)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-25 02:51:15 -03:00
committed by GitHub
parent 9dcbbd18c8
commit 5d9ace6778
5 changed files with 271 additions and 16 deletions

View File

@@ -0,0 +1 @@
- feat(providers): map upstream reasoning-level metadata in openai-compatible discovery (#8347)

View File

@@ -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<string, unknown>;
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;
}
}

View File

@@ -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;

View File

@@ -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(

View File

@@ -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"));
});