refactor(api): extract pure discovery leaves from provider-models route (#5758)

Split src/app/api/providers/[id]/models/route.ts (2511 -> 1818 LOC) by moving
the cohesive, DB-free discovery building blocks into four leaves under
discovery/:

- helpers.ts              record/string coercion, Azure + base-url helpers,
                          bearer/named-openai header builders
- normalizers.ts          Antigravity / DataRobot / OpenAI-like / SAP models
                          response normalizers
- providerModelsConfig.ts PROVIDER_MODELS_CONFIG + ProviderModelsConfigEntry
- providerSets.ts         NAMED_OPENAI_STYLE_PROVIDERS + isNamedOpenAIStyleProvider

The host keeps all request orchestration and imports the leaves back. The moved
symbols were module-private, so the route's public export set (GET) is unchanged
and no external importer needs updating. Bodies are byte-identical: the code-line
multiset of host + leaves equals the original route verbatim.

Tests:
- repoint the qwen-web source-guard in catalog-updates-v3829-kimi-qwen to the new
  config leaf (assertions unchanged)
- add provider-models-discovery-split as the split regression guard (leaf public
  surface + host wiring + the #5570 cablyai->aimlapi entry swap)
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-07-01 01:14:35 -03:00
committed by GitHub
parent 2260b31efd
commit 5d74f1d02a
7 changed files with 930 additions and 724 deletions

View File

@@ -0,0 +1,74 @@
import { isSelfHostedChatProvider } from "@/shared/constants/providers";
import type { LocalCatalogModel } from "@/lib/providers/staticModels";
export type JsonRecord = Record<string, unknown>;
export function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
export function toNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
export function getProviderBaseUrl(providerSpecificData: unknown): string | null {
const data = asRecord(providerSpecificData);
const baseUrl = data.baseUrl;
return typeof baseUrl === "string" && baseUrl.trim().length > 0 ? baseUrl : null;
}
export function normalizeAzureOpenAIBaseUrl(baseUrl: string) {
return baseUrl
.trim()
.replace(/\/+$/, "")
.replace(/\/openai$/i, "")
.replace(/\/openai\/deployments\/[^/]+\/chat\/completions.*$/i, "");
}
export function getAzureOpenAIApiVersion(providerSpecificData: unknown) {
const data = asRecord(providerSpecificData);
const apiVersion =
toNonEmptyString(data.apiVersion) || toNonEmptyString(data.validationApiVersion);
return apiVersion || "2024-12-01-preview";
}
export function isLocalOpenAIStyleProvider(provider: string): boolean {
return isSelfHostedChatProvider(provider);
}
export function mergeLocalCatalogModels<T extends LocalCatalogModel, U extends LocalCatalogModel>(
registryCatalogModels: T[],
specialtyCatalogModels: U[]
): Array<T | U> {
if (registryCatalogModels.length === 0) return specialtyCatalogModels;
const registryModelIds = new Set(registryCatalogModels.map((model) => model.id));
return [
...registryCatalogModels,
...specialtyCatalogModels.filter((model) => !registryModelIds.has(model.id)),
];
}
export function buildOptionalBearerHeaders(
token: string | null | undefined
): Record<string, string> {
return {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
export function buildNamedOpenAiStyleHeaders(
provider: string,
token: string | null | undefined
): Record<string, string> {
const headers = buildOptionalBearerHeaders(token);
if (provider === "reka" && token) {
headers["X-Api-Key"] = token;
}
return headers;
}

View File

@@ -0,0 +1,213 @@
import { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard";
import {
getAntigravityModelsDiscoveryUrls,
getAntigravityFetchAvailableModelsUrls,
} from "@omniroute/open-sse/config/antigravityUpstream.ts";
import { getAntigravityHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts";
import {
getClientVisibleAntigravityModelName,
isUserCallableAntigravityModelId,
toClientAntigravityModelId,
} from "@omniroute/open-sse/config/antigravityModelAliases.ts";
import { normalizeAntigravityClientProfile } from "@/shared/constants/antigravityClientProfile";
import { resolveAntigravityVersion } from "@omniroute/open-sse/services/antigravityVersion.ts";
import { ensureAntigravityProjectAssigned } from "@omniroute/open-sse/services/antigravityProjectBootstrap.ts";
import { asRecord, toNonEmptyString } from "./helpers";
const antigravityDiscoveryInflight = new Map<
string,
Promise<Array<{ id: string; name: string }>>
>();
export function normalizeAntigravityModelsResponse(
data: unknown
): Array<{ id: string; name: string }> {
const payload = asRecord(data).models;
if (Array.isArray(payload)) {
return payload
.map((value) => {
const item = asRecord(value);
const id =
typeof item.id === "string"
? item.id
: typeof item.name === "string"
? item.name
: typeof item.model === "string"
? item.model
: "";
const name =
typeof item.displayName === "string"
? item.displayName
: typeof item.name === "string"
? item.name
: id;
return id ? { id, name } : null;
})
.filter((value): value is { id: string; name: string } => Boolean(value));
}
const modelsById = asRecord(payload);
return Object.entries(modelsById)
.map(([id, value]) => {
const item = asRecord(value);
const name =
typeof item.displayName === "string"
? item.displayName
: typeof item.name === "string"
? item.name
: id;
return id ? { id, name } : null;
})
.filter((value): value is { id: string; name: string } => Boolean(value));
}
export function filterUserCallableAntigravityModels(models: Array<{ id: string; name: string }>) {
return models.filter((model) => isUserCallableAntigravityModelId(model.id));
}
export function mapAntigravityModelForClient(model: { id: string; name: string }): {
id: string;
name: string;
} {
const clientId = toClientAntigravityModelId(model.id);
return {
id: clientId,
name: getClientVisibleAntigravityModelName(clientId, model.name),
};
}
export async function fetchAntigravityDiscoveryModelsCached(
accessToken: string,
connectionId: string,
proxy: unknown,
providerSpecificData?: unknown
): Promise<Array<{ id: string; name: string }>> {
const profile = normalizeAntigravityClientProfile(asRecord(providerSpecificData).clientProfile);
const cacheKey = `${connectionId}:${accessToken.substring(0, 16)}:${profile}`;
const inflight = antigravityDiscoveryInflight.get(cacheKey);
if (inflight) return inflight;
const promise = (async () => {
await resolveAntigravityVersion();
await ensureAntigravityProjectAssigned(
accessToken,
fetch,
normalizeAntigravityClientProfile(asRecord(providerSpecificData).clientProfile)
);
for (const discoveryUrl of [
...getAntigravityFetchAvailableModelsUrls(),
...getAntigravityModelsDiscoveryUrls(),
]) {
try {
const response = await safeOutboundFetch(discoveryUrl, {
...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery,
guard: getProviderOutboundGuard(),
proxyConfig: proxy,
method: "POST",
headers: getAntigravityHeaders("models", accessToken),
body: JSON.stringify({}),
});
if (!response.ok) {
const errorText = await response.text();
console.warn(
`[models] antigravity discovery failed at ${discoveryUrl} (${response.status}): ${errorText}`
);
continue;
}
const models = filterUserCallableAntigravityModels(
normalizeAntigravityModelsResponse(await response.json())
).map(mapAntigravityModelForClient);
if (models.length > 0) {
return models;
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[models] antigravity discovery threw for ${discoveryUrl}: ${message}`);
}
}
return [];
})().finally(() => {
antigravityDiscoveryInflight.delete(cacheKey);
});
antigravityDiscoveryInflight.set(cacheKey, promise);
return promise;
}
export function normalizeDataRobotCatalogResponse(
data: unknown
): Array<{ id: string; name: string }> {
const items = Array.isArray(asRecord(data).data) ? (asRecord(data).data as unknown[]) : [];
return items
.map((value) => {
const item = asRecord(value);
const model =
toNonEmptyString(item.model) || toNonEmptyString(item.id) || toNonEmptyString(item.name);
if (!model) return null;
if (item.isActive === false) return null;
const name = toNonEmptyString(item.label) || toNonEmptyString(item.displayName) || model;
return { id: model, name };
})
.filter((value): value is { id: string; name: string } => Boolean(value));
}
export function normalizeOpenAiLikeModelsResponse(
data: unknown,
fallbackOwner: string
): Array<{ id: string; name: string; owned_by: string }> {
const payload = asRecord(data);
const items = Array.isArray(data)
? data
: Array.isArray(payload.data)
? (payload.data as unknown[])
: Array.isArray(payload.models)
? (payload.models as unknown[])
: [];
return items
.map((value) => {
const item = asRecord(value);
const id =
toNonEmptyString(item.id) || toNonEmptyString(item.model) || toNonEmptyString(item.name);
if (!id) return null;
const name =
toNonEmptyString(item.display_name) ||
toNonEmptyString(item.displayName) ||
toNonEmptyString(item.name) ||
id;
const ownedBy =
toNonEmptyString(item.owned_by) || toNonEmptyString(item.provider) || fallbackOwner;
return { id, name, owned_by: ownedBy };
})
.filter((value): value is { id: string; name: string; owned_by: string } => Boolean(value));
}
export function normalizeSapModelsResponse(
data: unknown
): Array<{ id: string; name: string; owned_by: string }> {
const payload = asRecord(data);
const items = Array.isArray(payload.resources) ? (payload.resources as unknown[]) : [];
return items
.map((value) => {
const item = asRecord(value);
const id =
toNonEmptyString(item.model) || toNonEmptyString(item.id) || toNonEmptyString(item.name);
if (!id) return null;
const name =
toNonEmptyString(item.displayName) ||
toNonEmptyString(item.display_name) ||
toNonEmptyString(item.name) ||
id;
const ownedBy = toNonEmptyString(item.provider) || "sap";
return { id, name, owned_by: ownedBy };
})
.filter((value): value is { id: string; name: string; owned_by: string } => Boolean(value));
}

View File

@@ -0,0 +1,377 @@
import { getAntigravityModelsDiscoveryUrls } from "@omniroute/open-sse/config/antigravityUpstream.ts";
import { getAntigravityHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts";
import { parseGeminiModelsList } from "@/lib/providerModels/geminiModelsParser";
import { normalizeOpenAiLikeModelsResponse } from "./normalizers";
export type ProviderModelsConfigEntry = {
url: string;
method: "GET" | "POST";
headers: Record<string, string>;
authHeader?: string;
authPrefix?: string;
authQuery?: string;
body?: unknown;
parseResponse: (data: any) => any;
};
const KIMI_CODING_MODELS_CONFIG: ProviderModelsConfigEntry = {
url: "https://api.kimi.com/coding/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "x-api-key",
parseResponse: (data) => data.data || data.models || [],
};
// Provider models endpoints configuration
export const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
claude: {
url: "https://api.anthropic.com/v1/models",
method: "GET",
headers: {
"Anthropic-Version": "2023-06-01",
"Content-Type": "application/json",
},
authHeader: "x-api-key",
parseResponse: (data) => data.data || [],
},
gemini: {
url: "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000",
method: "GET",
headers: { "Content-Type": "application/json" },
authQuery: "key", // Use query param for API key
parseResponse: (data) => parseGeminiModelsList(data),
},
huggingface: {
url: "https://router.huggingface.co/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => normalizeOpenAiLikeModelsResponse(data, "huggingface"),
},
qwen: {
url: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || [],
},
// #3931: qwen-web (cookie provider) was missing here, so its discovery page
// showed nothing (the OAuth fallback above only fires for provider==="qwen").
// `chat.qwen.ai/api/v2/models` is public (no auth header configured/sent);
// shape `{ data: { data: [{ id, name, owned_by }] } }`, flatter `{ data: [] }` fallback.
"qwen-web": {
url: "https://chat.qwen.ai/api/v2/models",
method: "GET",
headers: { "Content-Type": "application/json" },
parseResponse: (data) => {
const innerData = data?.data?.data || data?.data || [];
return (Array.isArray(innerData) ? innerData : [])
.map((item: any) => ({
id: item.id || item.name,
name: item.name || item.id,
owned_by: item.owned_by || "qwen",
}))
.filter((m: any) => m.id);
},
},
antigravity: {
url: getAntigravityModelsDiscoveryUrls()[0],
method: "POST",
headers: getAntigravityHeaders("models"),
authHeader: "Authorization",
authPrefix: "Bearer ",
body: {},
parseResponse: (data) => data.models || [],
},
openai: {
url: "https://api.openai.com/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || [],
},
openrouter: {
url: "https://openrouter.ai/api/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || [],
},
glhf: {
url: "https://glhf.chat/api/openai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
aimlapi: {
// #5570: AI/ML API's live catalog (400+ models) lives at the public,
// auth-free /models database endpoint (NOT /v1/models). The registry has no
// modelsUrl, so without this entry the route fell back to a stale 6-model
// seed. Response is a bare array of { id, type, info: { name } }.
url: "https://api.aimlapi.com/models",
method: "GET",
headers: { "Content-Type": "application/json" },
parseResponse: (data) => {
const all = Array.isArray(data) ? data : [];
const chat = all.filter((m) => m?.type === "chat-completion");
return (chat.length > 0 ? chat : all)
.map((m) => ({ id: m?.id, name: m?.info?.name || m?.id }))
.filter((m) => typeof m.id === "string" && m.id);
},
},
thebai: {
url: "https://api.theb.ai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
fenayai: {
url: "https://fenayai.com/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
chutes: {
url: "https://llm.chutes.ai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
clarifai: {
url: "https://api.clarifai.com/v2/ext/openai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Key ",
parseResponse: (data) => normalizeOpenAiLikeModelsResponse(data, "clarifai"),
},
kimi: {
url: "https://api.moonshot.ai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || [],
},
"kimi-coding": {
...KIMI_CODING_MODELS_CONFIG,
},
"kimi-coding-apikey": {
...KIMI_CODING_MODELS_CONFIG,
},
anthropic: {
url: "https://api.anthropic.com/v1/models",
method: "GET",
headers: {
"Anthropic-Version": "2023-06-01",
"Content-Type": "application/json",
},
authHeader: "x-api-key",
parseResponse: (data) => data.data || [],
},
deepseek: {
url: "https://api.deepseek.com/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
groq: {
url: "https://api.groq.com/openai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
blackbox: {
url: "https://api.blackbox.ai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
xai: {
url: "https://api.x.ai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
mistral: {
url: "https://api.mistral.ai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
together: {
url: "https://api.together.xyz/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
fireworks: {
url: "https://api.fireworks.ai/inference/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
cerebras: {
url: "https://api.cerebras.ai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
cohere: {
url: "https://api.cohere.com/v2/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
nvidia: {
url: "https://integrate.api.nvidia.com/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
nebius: {
url: "https://api.tokenfactory.nebius.com/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
kilocode: {
url: "https://api.kilo.ai/api/openrouter/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
"ollama-cloud": {
url: "https://api.ollama.com/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.models || data.data || [],
},
"cloudflare-ai": {
url: "https://api.cloudflare.com/client/v4/accounts/{accountId}/ai/models/search",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
// #4259: Cloudflare's `/ai/models/search` returns `{ id: "<uuid>", name: "@cf/..." }`.
// `name` is the usable model slug; `id` is an internal UUID. Map `name`→id so the
// dashboard/import surfaces callable model ids (`@cf/...`) instead of UUIDs.
parseResponse: (data) =>
(data.result || [])
.map((model: any) => {
const slug = typeof model?.name === "string" ? model.name : "";
if (!slug) return null;
return {
id: slug,
name: slug,
...(typeof model?.description === "string" && model.description
? { description: model.description }
: {}),
};
})
.filter(Boolean),
},
synthetic: {
url: "https://api.synthetic.new/openai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
"kilo-gateway": {
url: "https://api.kilo.ai/api/gateway/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
"command-code": {
url: "https://api.commandcode.ai/provider/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
"opencode-zen": {
url: "https://opencode.ai/zen/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
"opencode-go": {
url: "https://opencode.ai/zen/go/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
"glm-cn": {
url: "https://open.bigmodel.cn/api/coding/paas/v4/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
gitlawb: {
url: "https://opengateway.gitlawb.com/v1/xiaomi-mimo/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
"gitlawb-gmi": {
url: "https://opengateway.gitlawb.com/v1/gmi-cloud/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
};

View File

@@ -0,0 +1,63 @@
export const NAMED_OPENAI_STYLE_PROVIDERS = new Set([
"modal",
"reka",
"empower",
"nous-research",
"poe",
"siliconflow",
// #3976: these carry a real modelsUrl but were not classified by any live-fetch
// branch, so their hardcoded registry catalog was served instead of the live
// `<baseUrl>/models` list. Live fetch falls back to the local catalog on error.
"llm7",
"byteplus",
// #4202: zenmux is the same case — its free models (e.g. z-ai/glm-5.2-free,
// moonshotai/kimi-k2.7-code-free) live only on the upstream /models list.
"zenmux",
// #4249: vercel-ai-gateway carries a real baseUrl (.../v1/chat/completions) but
// was unclassified, so import served the 5-entry hardcoded catalog instead of the
// live `https://ai-gateway.vercel.sh/v1/models` list. Falls back to local on error.
"vercel-ai-gateway",
// #4239 / #4155 / #3841: OpenAI-compatible aggregators whose real catalog lives
// on the upstream `/v1/models` list — serve it live, fall back to the seeded
// registry catalog on error (same case as zenmux).
"openadapter",
"dit",
"tokenrouter",
// provider-model-sweep (2026-06-19): same class as #3976/#4202/#4249 — keyed
// openai-style providers with a real live `<baseUrl>/models` catalog, served
// their small hardcoded seed because unclassified. Seed stays as offline fallback.
"venice",
"deepinfra",
"wandb",
"pollinations",
"nscale",
"inference-net",
"moonshot",
// provider-model-sweep (2026-06-19) cont.: GPU-cloud / aggregator marketplaces
// hosting large, volatile OSS catalogs. The sweep confirmed each exposes a live
// `<baseUrl>/v1/models` endpoint (200 public or 401/403 = exists + keyed), so live
// fetch keeps the catalog fresh; the registry seed remains the offline fallback.
"crof",
"featherless-ai",
"ovhcloud",
"sambanova",
"orcarouter",
"uncloseai",
"opencode-go",
"baseten",
"hyperbolic",
"nebius",
"scaleway",
"together",
// escalated cmqlvxg4o: api-airforce has a live `https://api.airforce/v1/models` catalog
// but was left out of the sweep, so it served a stale hardcoded seed (grok-3, grok-2-1212,
// claude-3.7-sonnet …). Live fetch keeps it fresh; seed stays as the offline fallback.
"api-airforce",
// DGrid is an OpenAI-compatible gateway whose default seed is the free auto-router;
// the full model catalog is discovered live from https://api.dgrid.ai/v1/models.
"dgrid",
]);
export function isNamedOpenAIStyleProvider(provider: string): boolean {
return NAMED_OPENAI_STYLE_PROVIDERS.has(provider);
}

View File

@@ -3,12 +3,11 @@ import {
isClaudeCodeCompatibleProvider,
isAnthropicCompatibleProvider,
isOpenAICompatibleProvider,
isSelfHostedChatProvider,
NOAUTH_PROVIDERS,
} from "@/shared/constants/providers";
import { getRegistryEntry } from "@omniroute/open-sse/config/providerRegistry.ts";
import { getModelsByProviderId } from "@/shared/constants/models";
import { getStaticModelsForProvider, type LocalCatalogModel } from "@/lib/providers/staticModels";
import { getStaticModelsForProvider } from "@/lib/providers/staticModels";
import { isProviderBlockedByIdOrAlias } from "@/shared/utils/noAuthProviders";
import {
getProviderConnectionById,
@@ -28,19 +27,12 @@ import { getStaticQoderModels } from "@omniroute/open-sse/services/qoderCli.ts";
import { deriveConfigFromRegistryModelsUrl } from "./discoveryConfig";
import { fetchGitHubCopilotModels } from "@omniroute/open-sse/services/githubCopilotModels.ts";
import { fetchKiroAvailableModels } from "@omniroute/open-sse/services/kiroModels.ts";
import { getAntigravityHeaders } from "@omniroute/open-sse/services/antigravityHeaders.ts";
import { ensureAntigravityProjectAssigned } from "@omniroute/open-sse/services/antigravityProjectBootstrap.ts";
import {
getAntigravityModelsDiscoveryUrls,
getAntigravityFetchAvailableModelsUrls,
} from "@omniroute/open-sse/config/antigravityUpstream.ts";
import {
buildGlmCodingHeaders,
buildGlmModelsUrl,
} from "@omniroute/open-sse/config/glmProvider.ts";
import { getImageProvider } from "@omniroute/open-sse/config/imageRegistry.ts";
import { getVideoProvider } from "@omniroute/open-sse/config/videoRegistry.ts";
import { resolveAntigravityVersion } from "@omniroute/open-sse/services/antigravityVersion.ts";
import {
discoverBedrockNativeModels,
isBedrockNativeApiError,
@@ -64,12 +56,6 @@ import {
WATSONX_DEFAULT_BASE_URL,
buildWatsonxModelsUrl,
} from "@omniroute/open-sse/config/watsonx.ts";
import {
getClientVisibleAntigravityModelName,
isUserCallableAntigravityModelId,
toClientAntigravityModelId,
} from "@omniroute/open-sse/config/antigravityModelAliases.ts";
import { normalizeAntigravityClientProfile } from "@/shared/constants/antigravityClientProfile";
import { getEmbeddingProvider } from "@omniroute/open-sse/config/embeddingRegistry.ts";
import { getRerankProvider } from "@omniroute/open-sse/config/rerankRegistry.ts";
import {
@@ -87,705 +73,29 @@ import {
} from "@/lib/providerModels/geminiModelsParser";
import { getSyncedAvailableModels } from "@/lib/db/models";
import { fetchCursorAgentModels } from "@/lib/providerModels/cursorAgent";
type JsonRecord = Record<string, unknown>;
const antigravityDiscoveryInflight = new Map<
string,
Promise<Array<{ id: string; name: string }>>
>();
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
function toNonEmptyString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function getProviderBaseUrl(providerSpecificData: unknown): string | null {
const data = asRecord(providerSpecificData);
const baseUrl = data.baseUrl;
return typeof baseUrl === "string" && baseUrl.trim().length > 0 ? baseUrl : null;
}
function normalizeAzureOpenAIBaseUrl(baseUrl: string) {
return baseUrl
.trim()
.replace(/\/+$/, "")
.replace(/\/openai$/i, "")
.replace(/\/openai\/deployments\/[^/]+\/chat\/completions.*$/i, "");
}
function getAzureOpenAIApiVersion(providerSpecificData: unknown) {
const data = asRecord(providerSpecificData);
const apiVersion =
toNonEmptyString(data.apiVersion) || toNonEmptyString(data.validationApiVersion);
return apiVersion || "2024-12-01-preview";
}
function isLocalOpenAIStyleProvider(provider: string): boolean {
return isSelfHostedChatProvider(provider);
}
const NAMED_OPENAI_STYLE_PROVIDERS = new Set([
"modal",
"reka",
"empower",
"nous-research",
"poe",
"siliconflow",
// #3976: these carry a real modelsUrl but were not classified by any live-fetch
// branch, so their hardcoded registry catalog was served instead of the live
// `<baseUrl>/models` list. Live fetch falls back to the local catalog on error.
"llm7",
"byteplus",
// #4202: zenmux is the same case — its free models (e.g. z-ai/glm-5.2-free,
// moonshotai/kimi-k2.7-code-free) live only on the upstream /models list.
"zenmux",
// #4249: vercel-ai-gateway carries a real baseUrl (.../v1/chat/completions) but
// was unclassified, so import served the 5-entry hardcoded catalog instead of the
// live `https://ai-gateway.vercel.sh/v1/models` list. Falls back to local on error.
"vercel-ai-gateway",
// #4239 / #4155 / #3841: OpenAI-compatible aggregators whose real catalog lives
// on the upstream `/v1/models` list — serve it live, fall back to the seeded
// registry catalog on error (same case as zenmux).
"openadapter",
"dit",
"tokenrouter",
// provider-model-sweep (2026-06-19): same class as #3976/#4202/#4249 — keyed
// openai-style providers with a real live `<baseUrl>/models` catalog, served
// their small hardcoded seed because unclassified. Seed stays as offline fallback.
"venice",
"deepinfra",
"wandb",
"pollinations",
"nscale",
"inference-net",
"moonshot",
// provider-model-sweep (2026-06-19) cont.: GPU-cloud / aggregator marketplaces
// hosting large, volatile OSS catalogs. The sweep confirmed each exposes a live
// `<baseUrl>/v1/models` endpoint (200 public or 401/403 = exists + keyed), so live
// fetch keeps the catalog fresh; the registry seed remains the offline fallback.
"crof",
"featherless-ai",
"ovhcloud",
"sambanova",
"orcarouter",
"uncloseai",
"opencode-go",
"baseten",
"hyperbolic",
"nebius",
"scaleway",
"together",
// escalated cmqlvxg4o: api-airforce has a live `https://api.airforce/v1/models` catalog
// but was left out of the sweep, so it served a stale hardcoded seed (grok-3, grok-2-1212,
// claude-3.7-sonnet …). Live fetch keeps it fresh; seed stays as the offline fallback.
"api-airforce",
// DGrid is an OpenAI-compatible gateway whose default seed is the free auto-router;
// the full model catalog is discovered live from https://api.dgrid.ai/v1/models.
"dgrid",
]);
function isNamedOpenAIStyleProvider(provider: string): boolean {
return NAMED_OPENAI_STYLE_PROVIDERS.has(provider);
}
function mergeLocalCatalogModels<T extends LocalCatalogModel, U extends LocalCatalogModel>(
registryCatalogModels: T[],
specialtyCatalogModels: U[]
): Array<T | U> {
if (registryCatalogModels.length === 0) return specialtyCatalogModels;
const registryModelIds = new Set(registryCatalogModels.map((model) => model.id));
return [
...registryCatalogModels,
...specialtyCatalogModels.filter((model) => !registryModelIds.has(model.id)),
];
}
function buildOptionalBearerHeaders(token: string | null | undefined): Record<string, string> {
return {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
}
function buildNamedOpenAiStyleHeaders(
provider: string,
token: string | null | undefined
): Record<string, string> {
const headers = buildOptionalBearerHeaders(token);
if (provider === "reka" && token) {
headers["X-Api-Key"] = token;
}
return headers;
}
function normalizeAntigravityModelsResponse(data: unknown): Array<{ id: string; name: string }> {
const payload = asRecord(data).models;
if (Array.isArray(payload)) {
return payload
.map((value) => {
const item = asRecord(value);
const id =
typeof item.id === "string"
? item.id
: typeof item.name === "string"
? item.name
: typeof item.model === "string"
? item.model
: "";
const name =
typeof item.displayName === "string"
? item.displayName
: typeof item.name === "string"
? item.name
: id;
return id ? { id, name } : null;
})
.filter((value): value is { id: string; name: string } => Boolean(value));
}
const modelsById = asRecord(payload);
return Object.entries(modelsById)
.map(([id, value]) => {
const item = asRecord(value);
const name =
typeof item.displayName === "string"
? item.displayName
: typeof item.name === "string"
? item.name
: id;
return id ? { id, name } : null;
})
.filter((value): value is { id: string; name: string } => Boolean(value));
}
function filterUserCallableAntigravityModels(models: Array<{ id: string; name: string }>) {
return models.filter((model) => isUserCallableAntigravityModelId(model.id));
}
function mapAntigravityModelForClient(model: { id: string; name: string }): {
id: string;
name: string;
} {
const clientId = toClientAntigravityModelId(model.id);
return {
id: clientId,
name: getClientVisibleAntigravityModelName(clientId, model.name),
};
}
async function fetchAntigravityDiscoveryModelsCached(
accessToken: string,
connectionId: string,
proxy: unknown,
providerSpecificData?: unknown
): Promise<Array<{ id: string; name: string }>> {
const profile = normalizeAntigravityClientProfile(asRecord(providerSpecificData).clientProfile);
const cacheKey = `${connectionId}:${accessToken.substring(0, 16)}:${profile}`;
const inflight = antigravityDiscoveryInflight.get(cacheKey);
if (inflight) return inflight;
const promise = (async () => {
await resolveAntigravityVersion();
await ensureAntigravityProjectAssigned(
accessToken,
fetch,
normalizeAntigravityClientProfile(asRecord(providerSpecificData).clientProfile)
);
for (const discoveryUrl of [
...getAntigravityFetchAvailableModelsUrls(),
...getAntigravityModelsDiscoveryUrls(),
]) {
try {
const response = await safeOutboundFetch(discoveryUrl, {
...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery,
guard: getProviderOutboundGuard(),
proxyConfig: proxy,
method: "POST",
headers: getAntigravityHeaders("models", accessToken),
body: JSON.stringify({}),
});
if (!response.ok) {
const errorText = await response.text();
console.warn(
`[models] antigravity discovery failed at ${discoveryUrl} (${response.status}): ${errorText}`
);
continue;
}
const models = filterUserCallableAntigravityModels(
normalizeAntigravityModelsResponse(await response.json())
).map(mapAntigravityModelForClient);
if (models.length > 0) {
return models;
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`[models] antigravity discovery threw for ${discoveryUrl}: ${message}`);
}
}
return [];
})().finally(() => {
antigravityDiscoveryInflight.delete(cacheKey);
});
antigravityDiscoveryInflight.set(cacheKey, promise);
return promise;
}
function normalizeDataRobotCatalogResponse(data: unknown): Array<{ id: string; name: string }> {
const items = Array.isArray(asRecord(data).data) ? (asRecord(data).data as unknown[]) : [];
return items
.map((value) => {
const item = asRecord(value);
const model =
toNonEmptyString(item.model) || toNonEmptyString(item.id) || toNonEmptyString(item.name);
if (!model) return null;
if (item.isActive === false) return null;
const name = toNonEmptyString(item.label) || toNonEmptyString(item.displayName) || model;
return { id: model, name };
})
.filter((value): value is { id: string; name: string } => Boolean(value));
}
function normalizeOpenAiLikeModelsResponse(
data: unknown,
fallbackOwner: string
): Array<{ id: string; name: string; owned_by: string }> {
const payload = asRecord(data);
const items = Array.isArray(data)
? data
: Array.isArray(payload.data)
? (payload.data as unknown[])
: Array.isArray(payload.models)
? (payload.models as unknown[])
: [];
return items
.map((value) => {
const item = asRecord(value);
const id =
toNonEmptyString(item.id) || toNonEmptyString(item.model) || toNonEmptyString(item.name);
if (!id) return null;
const name =
toNonEmptyString(item.display_name) ||
toNonEmptyString(item.displayName) ||
toNonEmptyString(item.name) ||
id;
const ownedBy =
toNonEmptyString(item.owned_by) || toNonEmptyString(item.provider) || fallbackOwner;
return { id, name, owned_by: ownedBy };
})
.filter((value): value is { id: string; name: string; owned_by: string } => Boolean(value));
}
function normalizeSapModelsResponse(
data: unknown
): Array<{ id: string; name: string; owned_by: string }> {
const payload = asRecord(data);
const items = Array.isArray(payload.resources) ? (payload.resources as unknown[]) : [];
return items
.map((value) => {
const item = asRecord(value);
const id =
toNonEmptyString(item.model) || toNonEmptyString(item.id) || toNonEmptyString(item.name);
if (!id) return null;
const name =
toNonEmptyString(item.displayName) ||
toNonEmptyString(item.display_name) ||
toNonEmptyString(item.name) ||
id;
const ownedBy = toNonEmptyString(item.provider) || "sap";
return { id, name, owned_by: ownedBy };
})
.filter((value): value is { id: string; name: string; owned_by: string } => Boolean(value));
}
type ProviderModelsConfigEntry = {
url: string;
method: "GET" | "POST";
headers: Record<string, string>;
authHeader?: string;
authPrefix?: string;
authQuery?: string;
body?: unknown;
parseResponse: (data: any) => any;
};
const KIMI_CODING_MODELS_CONFIG: ProviderModelsConfigEntry = {
url: "https://api.kimi.com/coding/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "x-api-key",
parseResponse: (data) => data.data || data.models || [],
};
// Provider models endpoints configuration
const PROVIDER_MODELS_CONFIG: Record<string, ProviderModelsConfigEntry> = {
claude: {
url: "https://api.anthropic.com/v1/models",
method: "GET",
headers: {
"Anthropic-Version": "2023-06-01",
"Content-Type": "application/json",
},
authHeader: "x-api-key",
parseResponse: (data) => data.data || [],
},
gemini: {
url: "https://generativelanguage.googleapis.com/v1beta/models?pageSize=1000",
method: "GET",
headers: { "Content-Type": "application/json" },
authQuery: "key", // Use query param for API key
parseResponse: (data) => parseGeminiModelsList(data),
},
huggingface: {
url: "https://router.huggingface.co/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => normalizeOpenAiLikeModelsResponse(data, "huggingface"),
},
qwen: {
url: "https://dashscope-intl.aliyuncs.com/compatible-mode/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || [],
},
// #3931: qwen-web (cookie provider) was missing here, so its discovery page
// showed nothing (the OAuth fallback above only fires for provider==="qwen").
// `chat.qwen.ai/api/v2/models` is public (no auth header configured/sent);
// shape `{ data: { data: [{ id, name, owned_by }] } }`, flatter `{ data: [] }` fallback.
"qwen-web": {
url: "https://chat.qwen.ai/api/v2/models",
method: "GET",
headers: { "Content-Type": "application/json" },
parseResponse: (data) => {
const innerData = data?.data?.data || data?.data || [];
return (Array.isArray(innerData) ? innerData : [])
.map((item: any) => ({
id: item.id || item.name,
name: item.name || item.id,
owned_by: item.owned_by || "qwen",
}))
.filter((m: any) => m.id);
},
},
antigravity: {
url: getAntigravityModelsDiscoveryUrls()[0],
method: "POST",
headers: getAntigravityHeaders("models"),
authHeader: "Authorization",
authPrefix: "Bearer ",
body: {},
parseResponse: (data) => data.models || [],
},
openai: {
url: "https://api.openai.com/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || [],
},
openrouter: {
url: "https://openrouter.ai/api/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || [],
},
glhf: {
url: "https://glhf.chat/api/openai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
aimlapi: {
// #5570: AI/ML API's live catalog (400+ models) lives at the public,
// auth-free /models database endpoint (NOT /v1/models). The registry has no
// modelsUrl, so without this entry the route fell back to a stale 6-model
// seed. Response is a bare array of { id, type, info: { name } }.
url: "https://api.aimlapi.com/models",
method: "GET",
headers: { "Content-Type": "application/json" },
parseResponse: (data) => {
const all = Array.isArray(data) ? data : [];
const chat = all.filter((m) => m?.type === "chat-completion");
return (chat.length > 0 ? chat : all)
.map((m) => ({ id: m?.id, name: m?.info?.name || m?.id }))
.filter((m) => typeof m.id === "string" && m.id);
},
},
thebai: {
url: "https://api.theb.ai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
fenayai: {
url: "https://fenayai.com/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
chutes: {
url: "https://llm.chutes.ai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
clarifai: {
url: "https://api.clarifai.com/v2/ext/openai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Key ",
parseResponse: (data) => normalizeOpenAiLikeModelsResponse(data, "clarifai"),
},
kimi: {
url: "https://api.moonshot.ai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || [],
},
"kimi-coding": {
...KIMI_CODING_MODELS_CONFIG,
},
"kimi-coding-apikey": {
...KIMI_CODING_MODELS_CONFIG,
},
anthropic: {
url: "https://api.anthropic.com/v1/models",
method: "GET",
headers: {
"Anthropic-Version": "2023-06-01",
"Content-Type": "application/json",
},
authHeader: "x-api-key",
parseResponse: (data) => data.data || [],
},
deepseek: {
url: "https://api.deepseek.com/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
groq: {
url: "https://api.groq.com/openai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
blackbox: {
url: "https://api.blackbox.ai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
xai: {
url: "https://api.x.ai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
mistral: {
url: "https://api.mistral.ai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
together: {
url: "https://api.together.xyz/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
fireworks: {
url: "https://api.fireworks.ai/inference/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
cerebras: {
url: "https://api.cerebras.ai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
cohere: {
url: "https://api.cohere.com/v2/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
nvidia: {
url: "https://integrate.api.nvidia.com/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
nebius: {
url: "https://api.tokenfactory.nebius.com/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
kilocode: {
url: "https://api.kilo.ai/api/openrouter/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
"ollama-cloud": {
url: "https://api.ollama.com/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.models || data.data || [],
},
"cloudflare-ai": {
url: "https://api.cloudflare.com/client/v4/accounts/{accountId}/ai/models/search",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
// #4259: Cloudflare's `/ai/models/search` returns `{ id: "<uuid>", name: "@cf/..." }`.
// `name` is the usable model slug; `id` is an internal UUID. Map `name`→id so the
// dashboard/import surfaces callable model ids (`@cf/...`) instead of UUIDs.
parseResponse: (data) =>
(data.result || [])
.map((model: any) => {
const slug = typeof model?.name === "string" ? model.name : "";
if (!slug) return null;
return {
id: slug,
name: slug,
...(typeof model?.description === "string" && model.description
? { description: model.description }
: {}),
};
})
.filter(Boolean),
},
synthetic: {
url: "https://api.synthetic.new/openai/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
"kilo-gateway": {
url: "https://api.kilo.ai/api/gateway/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
"command-code": {
url: "https://api.commandcode.ai/provider/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
"opencode-zen": {
url: "https://opencode.ai/zen/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
"opencode-go": {
url: "https://opencode.ai/zen/go/v1/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
"glm-cn": {
url: "https://open.bigmodel.cn/api/coding/paas/v4/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
gitlawb: {
url: "https://opengateway.gitlawb.com/v1/xiaomi-mimo/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
"gitlawb-gmi": {
url: "https://opengateway.gitlawb.com/v1/gmi-cloud/models",
method: "GET",
headers: { "Content-Type": "application/json" },
authHeader: "Authorization",
authPrefix: "Bearer ",
parseResponse: (data) => data.data || data.models || [],
},
};
import {
type JsonRecord,
asRecord,
toNonEmptyString,
getProviderBaseUrl,
normalizeAzureOpenAIBaseUrl,
getAzureOpenAIApiVersion,
isLocalOpenAIStyleProvider,
mergeLocalCatalogModels,
buildOptionalBearerHeaders,
buildNamedOpenAiStyleHeaders,
} from "./discovery/helpers";
import {
fetchAntigravityDiscoveryModelsCached,
normalizeDataRobotCatalogResponse,
normalizeOpenAiLikeModelsResponse,
normalizeSapModelsResponse,
} from "./discovery/normalizers";
import { isNamedOpenAIStyleProvider } from "./discovery/providerSets";
import {
type ProviderModelsConfigEntry,
PROVIDER_MODELS_CONFIG,
} from "./discovery/providerModelsConfig";
/**
* GET /api/providers/[id]/models - Get models list from provider
@@ -946,8 +256,7 @@ export async function GET(
...((model as Record<string, unknown>).supportedEndpoints
? {
supportedEndpoints: (model as Record<string, unknown>).supportedEndpoints as
| string[]
| undefined,
string[] | undefined,
}
: {}),
...(registryCatalogModels.length > 0 ? { owned_by: provider } : {}),
@@ -1066,8 +375,7 @@ export async function GET(
...((model as Record<string, unknown>).supportedEndpoints
? {
supportedEndpoints: (model as Record<string, unknown>).supportedEndpoints as
| string[]
| undefined,
string[] | undefined,
}
: {}),
...(freshRegistry.length > 0 ? { owned_by: provider } : {}),
@@ -2341,8 +1649,7 @@ export async function GET(
...((m as Record<string, unknown>).supportedEndpoints
? {
supportedEndpoints: (m as Record<string, unknown>).supportedEndpoints as
| string[]
| undefined,
string[] | undefined,
}
: {}),
...(registryCatalogModels.length > 0 ? { owned_by: provider } : {}),

View File

@@ -43,19 +43,31 @@ test("kmca still exposes kimi-k2.6 and kimi-k2.6-thinking alongside the new mode
// ── Bug #3 / issue #3931: qwen-web in PROVIDER_MODELS_CONFIG ──────────────────
const ROUTE_FILE = path.join("src", "app", "api", "providers", "[id]", "models", "route.ts");
// PROVIDER_MODELS_CONFIG was extracted from the discovery route into the
// discovery/ leaf (refactor: split provider-models discovery route). The
// source-guard follows the config to its new home.
const CONFIG_FILE = path.join(
"src",
"app",
"api",
"providers",
"[id]",
"models",
"discovery",
"providerModelsConfig.ts"
);
test("PROVIDER_MODELS_CONFIG contains a qwen-web entry (issue #3931 bug #3)", () => {
const src = fs.readFileSync(ROUTE_FILE, "utf-8");
const src = fs.readFileSync(CONFIG_FILE, "utf-8");
assert.match(
src,
/"qwen-web"\s*:/,
'"qwen-web" key missing from PROVIDER_MODELS_CONFIG in models/route.ts'
'"qwen-web" key missing from PROVIDER_MODELS_CONFIG in discovery/providerModelsConfig.ts'
);
});
test("qwen-web PROVIDER_MODELS_CONFIG entry targets chat.qwen.ai/api/v2/models", () => {
const src = fs.readFileSync(ROUTE_FILE, "utf-8");
const src = fs.readFileSync(CONFIG_FILE, "utf-8");
assert.match(
src,
/chat\.qwen\.ai\/api\/v2\/models/,

View File

@@ -0,0 +1,160 @@
// Split-guard for the provider-models discovery route decomposition
// (refactor: extract 4 pure leaves — helpers / normalizers / providerModelsConfig /
// providerSets — out of src/app/api/providers/[id]/models/route.ts). The leaves are
// DB-free and state-free; this guard pins their public surface, the host wiring, and
// the #5570 cablyai→aimlapi swap so a future edit that silently breaks the split fails.
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import {
asRecord,
toNonEmptyString,
buildOptionalBearerHeaders,
buildNamedOpenAiStyleHeaders,
isLocalOpenAIStyleProvider,
mergeLocalCatalogModels,
getAzureOpenAIApiVersion,
} from "../../src/app/api/providers/[id]/models/discovery/helpers.ts";
import { normalizeOpenAiLikeModelsResponse } from "../../src/app/api/providers/[id]/models/discovery/normalizers.ts";
import {
NAMED_OPENAI_STYLE_PROVIDERS,
isNamedOpenAIStyleProvider,
} from "../../src/app/api/providers/[id]/models/discovery/providerSets.ts";
import { PROVIDER_MODELS_CONFIG } from "../../src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts";
// ── helpers leaf ─────────────────────────────────────────────────────────────
test("helpers.asRecord returns plain objects untouched, non-objects as {}", () => {
assert.deepEqual(asRecord({ a: 1 }), { a: 1 });
assert.deepEqual(asRecord([1, 2]), {});
assert.deepEqual(asRecord(null), {});
assert.deepEqual(asRecord("x"), {});
});
test("helpers.toNonEmptyString trims and rejects blanks/non-strings", () => {
assert.equal(toNonEmptyString(" hi "), "hi");
assert.equal(toNonEmptyString(" "), null);
assert.equal(toNonEmptyString(7), null);
});
test("helpers.buildOptionalBearerHeaders only adds Authorization when a token is present", () => {
assert.deepEqual(buildOptionalBearerHeaders("tok"), {
"Content-Type": "application/json",
Authorization: "Bearer tok",
});
assert.deepEqual(buildOptionalBearerHeaders(null), { "Content-Type": "application/json" });
});
test("helpers.buildNamedOpenAiStyleHeaders adds the reka X-Api-Key only for reka", () => {
assert.equal(buildNamedOpenAiStyleHeaders("reka", "tok")["X-Api-Key"], "tok");
assert.equal(buildNamedOpenAiStyleHeaders("openai", "tok")["X-Api-Key"], undefined);
});
test("helpers.mergeLocalCatalogModels dedupes by id, registry wins", () => {
const merged = mergeLocalCatalogModels(
[{ id: "a", name: "A" }],
[
{ id: "a", name: "dup" },
{ id: "b", name: "B" },
]
);
assert.deepEqual(
merged.map((m) => m.id),
["a", "b"]
);
assert.equal(merged.find((m) => m.id === "a")?.name, "A");
});
test("helpers.getAzureOpenAIApiVersion falls back to the pinned default", () => {
assert.equal(getAzureOpenAIApiVersion({}), "2024-12-01-preview");
assert.equal(getAzureOpenAIApiVersion({ apiVersion: "2025-01-01" }), "2025-01-01");
});
test("helpers.isLocalOpenAIStyleProvider is false for a hosted provider", () => {
assert.equal(isLocalOpenAIStyleProvider("openai"), false);
});
// ── normalizers leaf ─────────────────────────────────────────────────────────
test("normalizers.normalizeOpenAiLikeModelsResponse maps ids and applies the fallback owner", () => {
const out = normalizeOpenAiLikeModelsResponse(
{ data: [{ id: "m1" }, { id: "m2", display_name: "M2", owned_by: "x" }] },
"acme"
);
assert.deepEqual(out, [
{ id: "m1", name: "m1", owned_by: "acme" },
{ id: "m2", name: "M2", owned_by: "x" },
]);
});
test("normalizers.normalizeOpenAiLikeModelsResponse drops entries without an id", () => {
const out = normalizeOpenAiLikeModelsResponse({ data: [{}, { id: "ok" }] }, "acme");
assert.deepEqual(
out.map((m) => m.id),
["ok"]
);
});
// ── providerSets leaf ────────────────────────────────────────────────────────
test("providerSets.NAMED_OPENAI_STYLE_PROVIDERS is a populated Set with known members", () => {
assert.ok(NAMED_OPENAI_STYLE_PROVIDERS instanceof Set);
assert.ok(NAMED_OPENAI_STYLE_PROVIDERS.size >= 30);
for (const p of ["zenmux", "api-airforce", "together", "reka"]) {
assert.ok(NAMED_OPENAI_STYLE_PROVIDERS.has(p), `${p} must be a named OpenAI-style provider`);
}
});
test("providerSets.isNamedOpenAIStyleProvider matches Set membership", () => {
assert.equal(isNamedOpenAIStyleProvider("zenmux"), true);
assert.equal(isNamedOpenAIStyleProvider("definitely-not-a-provider"), false);
});
// ── providerModelsConfig leaf ────────────────────────────────────────────────
test("providerModelsConfig.PROVIDER_MODELS_CONFIG keeps core provider entries", () => {
assert.equal(PROVIDER_MODELS_CONFIG.claude.url, "https://api.anthropic.com/v1/models");
assert.equal(PROVIDER_MODELS_CONFIG["qwen-web"].url, "https://chat.qwen.ai/api/v2/models");
});
test("providerModelsConfig aimlapi entry replaced the removed cablyai entry (#5570)", () => {
assert.equal(PROVIDER_MODELS_CONFIG.aimlapi.url, "https://api.aimlapi.com/models");
assert.equal(PROVIDER_MODELS_CONFIG.cablyai, undefined);
});
test("providerModelsConfig aimlapi.parseResponse keeps only chat-completion models when present", () => {
const parsed = PROVIDER_MODELS_CONFIG.aimlapi.parseResponse([
{ id: "chat-1", type: "chat-completion", info: { name: "Chat 1" } },
{ id: "img-1", type: "image" },
]);
assert.deepEqual(parsed, [{ id: "chat-1", name: "Chat 1" }]);
});
// ── host wiring guard ────────────────────────────────────────────────────────
test("route.ts imports the discovery leaves and no longer declares the moved consts", () => {
const route = fs.readFileSync(
path.join("src", "app", "api", "providers", "[id]", "models", "route.ts"),
"utf-8"
);
for (const leaf of ["helpers", "normalizers", "providerSets", "providerModelsConfig"]) {
assert.match(
route,
new RegExp(`from "\\./discovery/${leaf}"`),
`route must import ./discovery/${leaf}`
);
}
assert.doesNotMatch(
route,
/const PROVIDER_MODELS_CONFIG\s*:/,
"PROVIDER_MODELS_CONFIG must live in the leaf, not route.ts"
);
assert.doesNotMatch(
route,
/const NAMED_OPENAI_STYLE_PROVIDERS\s*=/,
"NAMED_OPENAI_STYLE_PROVIDERS must live in the leaf, not route.ts"
);
});