mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(providers): map upstream reasoning-level metadata in openai-compatible discovery (#8347) (#8363)
This commit is contained in:
committed by
GitHub
parent
9dcbbd18c8
commit
5d9ace6778
1
changelog.d/features/8347-cliproxy-reasoning-levels.md
Normal file
1
changelog.d/features/8347-cliproxy-reasoning-levels.md
Normal file
@@ -0,0 +1 @@
|
||||
- feat(providers): map upstream reasoning-level metadata in openai-compatible discovery (#8347)
|
||||
57
src/app/api/providers/[id]/models/discoveryClientVersion.ts
Normal file
57
src/app/api/providers/[id]/models/discoveryClientVersion.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
129
tests/unit/model-discovery-reasoning-levels.test.ts
Normal file
129
tests/unit/model-discovery-reasoning-levels.test.ts
Normal 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"));
|
||||
});
|
||||
Reference in New Issue
Block a user