Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,53 @@
{
"name": "@openclaw/model-catalog-core",
"version": "0.0.0-private",
"private": true,
"files": [
"dist"
],
"type": "module",
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"default": "./dist/index.mjs"
},
"./configured-model-refs": {
"types": "./dist/configured-model-refs.d.mts",
"import": "./dist/configured-model-refs.mjs",
"default": "./dist/configured-model-refs.mjs"
},
"./model-catalog-refs": {
"types": "./dist/model-catalog-refs.d.mts",
"import": "./dist/model-catalog-refs.mjs",
"default": "./dist/model-catalog-refs.mjs"
},
"./model-catalog-normalize": {
"types": "./dist/model-catalog-normalize.d.mts",
"import": "./dist/model-catalog-normalize.mjs",
"default": "./dist/model-catalog-normalize.mjs"
},
"./model-catalog-types": {
"types": "./dist/model-catalog-types.d.mts",
"import": "./dist/model-catalog-types.mjs",
"default": "./dist/model-catalog-types.mjs"
},
"./provider-id": {
"types": "./dist/provider-id.d.mts",
"import": "./dist/provider-id.mjs",
"default": "./dist/provider-id.mjs"
},
"./provider-model-id-normalization": {
"types": "./dist/provider-model-id-normalization.d.mts",
"import": "./dist/provider-model-id-normalization.mjs",
"default": "./dist/provider-model-id-normalization.mjs"
},
"./provider-model-id-normalize": {
"types": "./dist/provider-model-id-normalize.d.mts",
"import": "./dist/provider-model-id-normalize.mjs",
"default": "./dist/provider-model-id-normalize.mjs"
}
}
}

View File

@@ -0,0 +1,85 @@
// Model Catalog Core tests cover configured model refs behavior.
import { describe, expect, it } from "vitest";
import {
collectConfiguredModelRefs,
collectConfiguredModelRefValues,
extractProviderFromModelRef,
} from "./configured-model-refs.js";
describe("configured model refs", () => {
it("collects agent, hook, message, and channel model refs with config paths", () => {
expect(
collectConfiguredModelRefs({
agents: {
defaults: {
model: { primary: "openai/gpt-5.5", fallbacks: ["anthropic/claude-sonnet-4-6"] },
utilityModel: "google/gemini-3.1-flash-lite-preview",
compaction: { memoryFlush: { model: "openai/gpt-5.5-mini" } },
},
list: [
{
id: "custom",
model: "xai/grok-4-fast",
utilityModel: "openai/gpt-5.5-nano",
},
],
},
hooks: {
mappings: [{ model: "openai/gpt-5.5-nano" }],
},
messages: {
tts: { summaryModel: "openai/gpt-5.5-mini" },
},
channels: {
modelByChannel: {
discord: {
guild: "anthropic/claude-opus-4-8",
},
},
},
}),
).toEqual([
{ path: "agents.defaults.model.primary", value: "openai/gpt-5.5" },
{ path: "agents.defaults.model.fallbacks.0", value: "anthropic/claude-sonnet-4-6" },
{
path: "agents.defaults.utilityModel",
value: "google/gemini-3.1-flash-lite-preview",
},
{ path: "agents.defaults.compaction.memoryFlush.model", value: "openai/gpt-5.5-mini" },
{ path: "agents.list.0.model", value: "xai/grok-4-fast" },
{ path: "agents.list.0.utilityModel", value: "openai/gpt-5.5-nano" },
{ path: "channels.modelByChannel.discord.guild", value: "anthropic/claude-opus-4-8" },
{ path: "hooks.mappings.0.model", value: "openai/gpt-5.5-nano" },
{ path: "messages.tts.summaryModel", value: "openai/gpt-5.5-mini" },
]);
});
it("can exclude channel model overrides from configured refs", () => {
expect(
collectConfiguredModelRefValues(
{
agents: { defaults: { model: "openai/gpt-5.5" } },
channels: { modelByChannel: { discord: { guild: "anthropic/claude-sonnet-4-6" } } },
},
{ includeChannelModelOverrides: false },
),
).toEqual(["openai/gpt-5.5"]);
});
it("ignores array-shaped malformed records", () => {
expect(
collectConfiguredModelRefs({
agents: {
defaults: {
models: ["openai/gpt-5.5"],
},
},
}),
).toEqual([]);
});
it("extracts normalized providers from provider-prefixed refs", () => {
expect(extractProviderFromModelRef(" OpenAI/gpt-5.5 ")).toBe("openai");
expect(extractProviderFromModelRef("gpt-5.5")).toBeNull();
});
});

View File

@@ -0,0 +1,139 @@
// Model Catalog Core module implements configured model refs behavior.
import { parseModelCatalogRef } from "./model-catalog-refs.js";
// Collects configured model references from OpenClaw config-shaped objects.
/** Narrow unknown values to plain records. */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** One configured model reference plus its config path. */
export type ConfiguredModelRef = {
path: string;
value: string;
};
/** Agent config keys that can contain direct model references. */
export const AGENT_MODEL_CONFIG_KEYS = [
"model",
"utilityModel",
"imageModel",
"imageGenerationModel",
"videoGenerationModel",
"musicGenerationModel",
"voiceModel",
"pdfModel",
] as const;
/** Collect configured model references from agents, channels, hooks, and message config. */
export function collectConfiguredModelRefs(
config: unknown,
options: { includeChannelModelOverrides?: boolean } = {},
): ConfiguredModelRef[] {
const refs: ConfiguredModelRef[] = [];
const pushModelRef = (path: string, value: unknown) => {
if (typeof value === "string" && value.trim()) {
refs.push({ path, value: value.trim() });
}
};
const collectModelConfig = (path: string, value: unknown) => {
if (typeof value === "string") {
pushModelRef(path, value);
return;
}
if (!isRecord(value)) {
return;
}
pushModelRef(`${path}.primary`, value.primary);
if (Array.isArray(value.fallbacks)) {
for (const [index, entry] of value.fallbacks.entries()) {
pushModelRef(`${path}.fallbacks.${index}`, entry);
}
}
};
const collectFromAgent = (path: string, agent: unknown) => {
if (!isRecord(agent)) {
return;
}
for (const key of AGENT_MODEL_CONFIG_KEYS) {
collectModelConfig(`${path}.${key}`, agent[key]);
}
pushModelRef(
`${path}.heartbeat.model`,
isRecord(agent.heartbeat) ? agent.heartbeat.model : undefined,
);
collectModelConfig(
`${path}.subagents.model`,
isRecord(agent.subagents) ? agent.subagents.model : undefined,
);
if (isRecord(agent.compaction)) {
pushModelRef(`${path}.compaction.model`, agent.compaction.model);
pushModelRef(
`${path}.compaction.memoryFlush.model`,
isRecord(agent.compaction.memoryFlush) ? agent.compaction.memoryFlush.model : undefined,
);
}
if (isRecord(agent.models)) {
for (const modelRef of Object.keys(agent.models)) {
pushModelRef(`${path}.models.${modelRef}`, modelRef);
}
}
};
const root = isRecord(config) ? config : {};
const agents = isRecord(root.agents) ? root.agents : {};
collectFromAgent("agents.defaults", agents.defaults);
if (Array.isArray(agents.list)) {
for (const [index, entry] of agents.list.entries()) {
collectFromAgent(`agents.list.${index}`, entry);
}
}
if (options.includeChannelModelOverrides !== false) {
const channels = isRecord(root.channels) ? root.channels : {};
const modelByChannel = isRecord(channels.modelByChannel) ? channels.modelByChannel : {};
for (const [channelId, channelMap] of Object.entries(modelByChannel)) {
if (!isRecord(channelMap)) {
continue;
}
for (const [targetId, modelRef] of Object.entries(channelMap)) {
pushModelRef(`channels.modelByChannel.${channelId}.${targetId}`, modelRef);
}
}
}
const hooks = isRecord(root.hooks) ? root.hooks : {};
if (Array.isArray(hooks.mappings)) {
for (const [index, mapping] of hooks.mappings.entries()) {
pushModelRef(`hooks.mappings.${index}.model`, isRecord(mapping) ? mapping.model : undefined);
}
}
pushModelRef("hooks.gmail.model", isRecord(hooks.gmail) ? hooks.gmail.model : undefined);
pushModelRef(
"messages.tts.summaryModel",
isRecord(root.messages) && isRecord(root.messages.tts)
? root.messages.tts.summaryModel
: undefined,
);
pushModelRef(
"channels.discord.voice.model",
isRecord(root.channels) &&
isRecord(root.channels.discord) &&
isRecord(root.channels.discord.voice)
? root.channels.discord.voice.model
: undefined,
);
return refs;
}
/** Collect only configured model reference values. */
export function collectConfiguredModelRefValues(
config: unknown,
options?: { includeChannelModelOverrides?: boolean },
): string[] {
return collectConfiguredModelRefs(config, options).map((ref) => ref.value);
}
/** Extract a normalized provider id from a provider/model reference. */
export function extractProviderFromModelRef(value: string): string | null {
return parseModelCatalogRef(value)?.provider ?? null;
}

View File

@@ -0,0 +1,9 @@
// Public barrel for model catalog normalization, ids, refs, and types.
export * from "./configured-model-refs.js";
export * from "./model-catalog-normalize.js";
export * from "./model-catalog-refs.js";
export * from "./model-catalog-types.js";
export * from "./provider-id.js";
export * from "./provider-model-id-normalization.js";
export * from "./provider-model-id-normalize.js";

View File

@@ -0,0 +1,246 @@
// Model Catalog Core tests cover model catalog normalize behavior.
import { describe, expect, it } from "vitest";
import { normalizeModelCatalog, normalizeModelCatalogRows } from "./index.js";
import { buildModelCatalogMergeKey, buildModelCatalogRef } from "./model-catalog-refs.js";
describe("model catalog normalization", () => {
it("normalizes catalog ownership, aliases, suppressions, and row fields", () => {
const catalog = normalizeModelCatalog(
{
providers: {
OpenAI: {
baseUrl: "https://api.openai.com/v1",
api: "openai-responses",
headers: {
"x-provider": "openai",
},
models: [
{
id: "gpt-5.4",
name: "GPT-5.4",
api: "openai-completions",
baseUrl: "https://proxy.example/v1",
headers: {
"x-model": "gpt-5.4",
},
input: ["text", "image", "document", "audio"],
reasoning: true,
contextWindow: 256000,
contextTokens: 200000,
maxTokens: 128000,
thinkingLevelMap: {
off: null,
minimal: " low ",
max: "max",
adaptive: "high",
},
cost: {
input: 1.25,
output: 10,
cacheRead: 0.125,
tieredPricing: [
{
input: 1.25,
output: 10,
cacheRead: 0.125,
cacheWrite: 1.25,
range: [0, 256000],
},
{
input: 1,
output: 2,
range: [0, 1000],
},
],
},
compat: {
supportsTools: true,
openRouterRouting: {
only: ["anthropic", 1],
allow_fallbacks: false,
require_parameters: "no",
},
vercelGatewayRouting: { order: ["anthropic", 1], only: "openai" },
zaiToolStream: true,
cacheControlFormat: "anthropic",
sendSessionAffinityHeaders: true,
sendSessionIdHeader: false,
supportsEagerToolInputStreaming: false,
supportsLongCacheRetention: true,
supportsStore: "yes",
thinkingFormat: "together",
unknownFlag: true,
},
status: "preview",
statusReason: "rolling out",
replaces: ["gpt-5.3"],
replacedBy: "gpt-5.5",
tags: ["default"],
},
{
id: "",
},
],
},
anthropic: {
models: [{ id: "claude-sonnet-4.6" }],
},
},
aliases: {
"Azure-OpenAI-Responses": {
provider: "OpenAI",
api: "azure-openai-responses",
},
"anthropic-alias": {
provider: "anthropic",
},
},
suppressions: [
{
provider: "Azure-OpenAI-Responses",
model: "gpt-5.3-codex-spark",
reason: "not available",
when: {
baseUrlHosts: ["CODING-INTL.DASHSCOPE.ALIYUNCS.COM"],
providerConfigApiIn: ["Qwen", "ModelStudio"],
},
},
],
discovery: {
OpenAI: "static",
anthropic: "static",
bad: "unknown",
},
runtimeAugment: true,
},
{ ownedProviders: new Set(["OpenAI"]) },
);
expect(catalog).toEqual({
providers: {
openai: {
baseUrl: "https://api.openai.com/v1",
api: "openai-responses",
headers: {
"x-provider": "openai",
},
models: [
{
id: "gpt-5.4",
name: "GPT-5.4",
api: "openai-completions",
baseUrl: "https://proxy.example/v1",
headers: {
"x-model": "gpt-5.4",
},
input: ["text", "image", "document"],
reasoning: true,
contextWindow: 256000,
contextTokens: 200000,
maxTokens: 128000,
thinkingLevelMap: { off: null, minimal: "low", max: "max" },
cost: {
input: 1.25,
output: 10,
cacheRead: 0.125,
tieredPricing: [
{
input: 1.25,
output: 10,
cacheRead: 0.125,
cacheWrite: 1.25,
range: [0, 256000],
},
],
},
compat: {
supportsTools: true,
openRouterRouting: { only: ["anthropic"], allow_fallbacks: false },
vercelGatewayRouting: { order: ["anthropic"] },
zaiToolStream: true,
cacheControlFormat: "anthropic",
sendSessionAffinityHeaders: true,
sendSessionIdHeader: false,
supportsEagerToolInputStreaming: false,
supportsLongCacheRetention: true,
thinkingFormat: "together",
},
status: "preview",
statusReason: "rolling out",
replaces: ["gpt-5.3"],
replacedBy: "gpt-5.5",
tags: ["default"],
},
],
},
},
aliases: {
"azure-openai-responses": {
provider: "openai",
api: "azure-openai-responses",
},
},
suppressions: [
{
provider: "azure-openai-responses",
model: "gpt-5.3-codex-spark",
reason: "not available",
when: {
baseUrlHosts: ["coding-intl.dashscope.aliyuncs.com"],
providerConfigApiIn: ["qwen", "modelstudio"],
},
},
],
discovery: {
openai: "static",
},
runtimeAugment: true,
});
});
it("builds normalized rows with provider defaults and stable refs", () => {
const rows = normalizeModelCatalogRows({
source: "manifest",
providers: {
OpenAI: {
baseUrl: "https://api.openai.com/v1",
api: "openai-responses",
headers: {
"x-provider": "openai",
},
models: [
{
id: "GPT-5.4",
headers: {
"x-model": "gpt-5.4",
},
input: ["image"],
},
],
},
},
});
expect(rows).toEqual([
{
provider: "openai",
id: "GPT-5.4",
ref: "openai/GPT-5.4",
mergeKey: "openai::gpt-5.4",
name: "GPT-5.4",
source: "manifest",
input: ["image"],
reasoning: false,
status: "available",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
headers: {
"x-provider": "openai",
"x-model": "gpt-5.4",
},
},
]);
expect(buildModelCatalogRef("OpenAI", "GPT-5.4")).toBe("openai/GPT-5.4");
expect(buildModelCatalogMergeKey("OpenAI", "GPT-5.4")).toBe("openai::gpt-5.4");
});
});

View File

@@ -0,0 +1,771 @@
// Model Catalog Core helper module supports model catalog normalize behavior.
import {
buildModelCatalogMergeKey,
buildModelCatalogRef,
normalizeModelCatalogProviderId,
} from "./model-catalog-refs.js";
import {
MODEL_CATALOG_APIS,
MODEL_CATALOG_THINKING_LEVELS,
isModelCatalogThinkingFormat,
type ModelCatalog,
type ModelCatalogAlias,
type ModelCatalogApi,
type ModelCatalogCompatConfig,
type ModelCatalogCost,
type ModelCatalogDiscovery,
type ModelCatalogImageInputConfig,
type ModelCatalogInput,
type ModelCatalogMediaInputConfig,
type ModelCatalogModel,
type ModelCatalogOpenRouterRouting,
type ModelCatalogProvider,
type ModelCatalogSource,
type ModelCatalogStatus,
type ModelCatalogSuppression,
type ModelCatalogThinkingLevelMap,
type ModelCatalogTieredCost,
type ModelCatalogVercelGatewayRouting,
type NormalizedModelCatalogRow,
} from "./model-catalog-types.js";
// Normalizes raw provider model catalogs into stable rows for lookup and merging.
const MODEL_CATALOG_INPUTS = new Set(["text", "image", "document"]);
const MODEL_CATALOG_DISCOVERY_MODES = new Set(["static", "refreshable", "runtime"]);
const MODEL_CATALOG_STATUSES = new Set(["available", "preview", "deprecated", "disabled"]);
const MODEL_CATALOG_API_SET = new Set<string>(MODEL_CATALOG_APIS);
const DEFAULT_MODEL_INPUT: ModelCatalogInput[] = ["text"];
const DEFAULT_MODEL_STATUS: ModelCatalogStatus = "available";
/** Narrow unknown catalog payloads to plain records. */
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/** Reject object keys that can mutate prototypes when copied into records. */
function isBlockedObjectKey(key: string): boolean {
return key === "__proto__" || key === "prototype" || key === "constructor";
}
/** Normalize optional catalog strings. */
function normalizeOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
}
/** Normalize arrays of trimmed strings, dropping invalid entries. */
function normalizeTrimmedStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.flatMap((entry) => {
const normalized = normalizeOptionalString(entry);
return normalized ? [normalized] : [];
});
}
function normalizeOptionalTrimmedStringList(value: unknown): string[] | undefined {
const normalized = normalizeTrimmedStringList(value);
return normalized.length > 0 ? normalized : undefined;
}
function normalizeModelCatalogThinkingLevelMap(
value: unknown,
): ModelCatalogThinkingLevelMap | undefined {
if (!isRecord(value)) {
return undefined;
}
const normalized: ModelCatalogThinkingLevelMap = {};
for (const level of MODEL_CATALOG_THINKING_LEVELS) {
const mapped = value[level];
if (mapped === null) {
normalized[level] = null;
continue;
}
const normalizedValue = normalizeOptionalString(mapped);
if (normalizedValue !== undefined) {
normalized[level] = normalizedValue;
}
}
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
function normalizeSafeRecordKey(value: unknown): string {
const key = normalizeOptionalString(value) ?? "";
return key && !isBlockedObjectKey(key) ? key : "";
}
function normalizeOwnedProviderSet(providers: ReadonlySet<string>): ReadonlySet<string> {
const normalized = new Set<string>();
for (const provider of providers) {
const providerId = normalizeModelCatalogProviderId(provider);
if (providerId) {
normalized.add(providerId);
}
}
return normalized;
}
function normalizeStringMap(value: unknown): Record<string, string> | undefined {
if (!isRecord(value)) {
return undefined;
}
const normalized: Record<string, string> = {};
for (const [rawKey, rawValue] of Object.entries(value)) {
const key = normalizeSafeRecordKey(rawKey);
const mapValue = normalizeOptionalString(rawValue) ?? "";
if (key && mapValue) {
normalized[key] = mapValue;
}
}
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
function mergeStringMaps(
base: Record<string, string> | undefined,
override: Record<string, string> | undefined,
): Record<string, string> | undefined {
if (!base && !override) {
return undefined;
}
return { ...base, ...override };
}
function normalizeModelCatalogApi(value: unknown): ModelCatalogApi | undefined {
const api = normalizeOptionalString(value) ?? "";
return MODEL_CATALOG_API_SET.has(api) ? (api as ModelCatalogApi) : undefined;
}
function normalizeModelCatalogInputs(value: unknown): ModelCatalogInput[] | undefined {
const inputs = normalizeTrimmedStringList(value).filter((input): input is ModelCatalogInput =>
MODEL_CATALOG_INPUTS.has(input),
);
return inputs.length > 0 ? inputs : undefined;
}
function normalizeNonNegativeNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
}
function normalizeFiniteNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}
function normalizeStringOrNumber(value: unknown): string | number | undefined {
return normalizeOptionalString(value) ?? normalizeFiniteNumber(value);
}
function normalizePositiveNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
}
function normalizePositiveInteger(value: unknown): number | undefined {
return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
}
function normalizeModelCatalogTieredCost(value: unknown): ModelCatalogTieredCost[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const normalized: ModelCatalogTieredCost[] = [];
for (const entry of value) {
if (!isRecord(entry) || !Array.isArray(entry.range)) {
continue;
}
const input = normalizeNonNegativeNumber(entry.input);
const output = normalizeNonNegativeNumber(entry.output);
const cacheRead = normalizeNonNegativeNumber(entry.cacheRead);
const cacheWrite = normalizeNonNegativeNumber(entry.cacheWrite);
if (
input === undefined ||
output === undefined ||
cacheRead === undefined ||
cacheWrite === undefined ||
entry.range.length < 1 ||
entry.range.length > 2
) {
continue;
}
const rangeValues = entry.range.map((rangeValue) => normalizeNonNegativeNumber(rangeValue));
if (rangeValues.some((rangeValue) => rangeValue === undefined)) {
continue;
}
normalized.push({
input,
output,
cacheRead,
cacheWrite,
range:
rangeValues.length === 1
? ([rangeValues[0]] as [number])
: ([rangeValues[0], rangeValues[1]] as [number, number]),
});
}
return normalized.length > 0 ? normalized : undefined;
}
function normalizeModelCatalogCost(value: unknown): ModelCatalogCost | undefined {
if (!isRecord(value)) {
return undefined;
}
const input = normalizeNonNegativeNumber(value.input);
const output = normalizeNonNegativeNumber(value.output);
const cacheRead = normalizeNonNegativeNumber(value.cacheRead);
const cacheWrite = normalizeNonNegativeNumber(value.cacheWrite);
const tieredPricing = normalizeModelCatalogTieredCost(value.tieredPricing);
const cost = {
...(input !== undefined ? { input } : {}),
...(output !== undefined ? { output } : {}),
...(cacheRead !== undefined ? { cacheRead } : {}),
...(cacheWrite !== undefined ? { cacheWrite } : {}),
...(tieredPricing ? { tieredPricing } : {}),
} satisfies ModelCatalogCost;
return Object.keys(cost).length > 0 ? cost : undefined;
}
function normalizeOpenRouterPrice(value: unknown): ModelCatalogOpenRouterRouting["max_price"] {
if (!isRecord(value)) {
return undefined;
}
const maxPrice = {
...(normalizeStringOrNumber(value.prompt) !== undefined
? { prompt: normalizeStringOrNumber(value.prompt) }
: {}),
...(normalizeStringOrNumber(value.completion) !== undefined
? { completion: normalizeStringOrNumber(value.completion) }
: {}),
...(normalizeStringOrNumber(value.image) !== undefined
? { image: normalizeStringOrNumber(value.image) }
: {}),
...(normalizeStringOrNumber(value.audio) !== undefined
? { audio: normalizeStringOrNumber(value.audio) }
: {}),
...(normalizeStringOrNumber(value.request) !== undefined
? { request: normalizeStringOrNumber(value.request) }
: {}),
} satisfies NonNullable<ModelCatalogOpenRouterRouting["max_price"]>;
return Object.keys(maxPrice).length > 0 ? maxPrice : undefined;
}
function normalizeOpenRouterPercentileCutoffs(
value: unknown,
):
| NonNullable<Exclude<ModelCatalogOpenRouterRouting["preferred_min_throughput"], number>>
| undefined {
if (!isRecord(value)) {
return undefined;
}
const normalized = {
...(normalizeFiniteNumber(value.p50) !== undefined
? { p50: normalizeFiniteNumber(value.p50) }
: {}),
...(normalizeFiniteNumber(value.p75) !== undefined
? { p75: normalizeFiniteNumber(value.p75) }
: {}),
...(normalizeFiniteNumber(value.p90) !== undefined
? { p90: normalizeFiniteNumber(value.p90) }
: {}),
...(normalizeFiniteNumber(value.p99) !== undefined
? { p99: normalizeFiniteNumber(value.p99) }
: {}),
};
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
function normalizeOpenRouterMetricPreference(
value: unknown,
): ModelCatalogOpenRouterRouting["preferred_min_throughput"] {
return normalizeFiniteNumber(value) ?? normalizeOpenRouterPercentileCutoffs(value);
}
function normalizeOpenRouterSort(value: unknown): ModelCatalogOpenRouterRouting["sort"] {
const sort = normalizeOptionalString(value);
if (sort) {
return sort;
}
if (!isRecord(value)) {
return undefined;
}
const by = normalizeOptionalString(value.by);
const partition =
value.partition === null ? null : (normalizeOptionalString(value.partition) ?? undefined);
const normalized = {
...(by ? { by } : {}),
...(partition !== undefined ? { partition } : {}),
} satisfies NonNullable<Exclude<ModelCatalogOpenRouterRouting["sort"], string>>;
return Object.keys(normalized).length > 0 ? normalized : undefined;
}
function normalizeOpenRouterRouting(value: unknown): ModelCatalogOpenRouterRouting | undefined {
if (!isRecord(value)) {
return undefined;
}
const routing = {
...(typeof value.allow_fallbacks === "boolean"
? { allow_fallbacks: value.allow_fallbacks }
: {}),
...(typeof value.require_parameters === "boolean"
? { require_parameters: value.require_parameters }
: {}),
...(value.data_collection === "deny" || value.data_collection === "allow"
? { data_collection: value.data_collection }
: {}),
...(typeof value.zdr === "boolean" ? { zdr: value.zdr } : {}),
...(typeof value.enforce_distillable_text === "boolean"
? { enforce_distillable_text: value.enforce_distillable_text }
: {}),
...(normalizeOptionalTrimmedStringList(value.order)
? { order: normalizeOptionalTrimmedStringList(value.order) }
: {}),
...(normalizeOptionalTrimmedStringList(value.only)
? { only: normalizeOptionalTrimmedStringList(value.only) }
: {}),
...(normalizeOptionalTrimmedStringList(value.ignore)
? { ignore: normalizeOptionalTrimmedStringList(value.ignore) }
: {}),
...(normalizeOptionalTrimmedStringList(value.quantizations)
? { quantizations: normalizeOptionalTrimmedStringList(value.quantizations) }
: {}),
...(normalizeOpenRouterSort(value.sort) ? { sort: normalizeOpenRouterSort(value.sort) } : {}),
...(normalizeOpenRouterPrice(value.max_price)
? { max_price: normalizeOpenRouterPrice(value.max_price) }
: {}),
...(normalizeOpenRouterMetricPreference(value.preferred_min_throughput) !== undefined
? {
preferred_min_throughput: normalizeOpenRouterMetricPreference(
value.preferred_min_throughput,
),
}
: {}),
...(normalizeOpenRouterMetricPreference(value.preferred_max_latency) !== undefined
? { preferred_max_latency: normalizeOpenRouterMetricPreference(value.preferred_max_latency) }
: {}),
} satisfies ModelCatalogOpenRouterRouting;
return Object.keys(routing).length > 0 ? routing : undefined;
}
function normalizeVercelGatewayRouting(
value: unknown,
): ModelCatalogVercelGatewayRouting | undefined {
if (!isRecord(value)) {
return undefined;
}
const routing = {
...(normalizeOptionalTrimmedStringList(value.only)
? { only: normalizeOptionalTrimmedStringList(value.only) }
: {}),
...(normalizeOptionalTrimmedStringList(value.order)
? { order: normalizeOptionalTrimmedStringList(value.order) }
: {}),
} satisfies ModelCatalogVercelGatewayRouting;
return Object.keys(routing).length > 0 ? routing : undefined;
}
function normalizeModelCatalogCompat(value: unknown): ModelCatalogCompatConfig | undefined {
if (!isRecord(value)) {
return undefined;
}
const compat: Record<string, unknown> = {};
const booleanFields = [
"supportsStore",
"supportsPromptCacheKey",
"supportsDeveloperRole",
"supportsReasoningEffort",
"supportsUsageInStreaming",
"supportsTools",
"supportsStrictMode",
"requiresStringContent",
"strictMessageKeys",
"requiresToolResultName",
"requiresAssistantAfterToolResult",
"requiresThinkingAsText",
"zaiToolStream",
"sendSessionAffinityHeaders",
"sendSessionIdHeader",
"supportsEagerToolInputStreaming",
"supportsLongCacheRetention",
"nativeWebSearchTool",
"requiresMistralToolIds",
"requiresOpenAiAnthropicToolPayload",
] as const;
for (const field of booleanFields) {
if (typeof value[field] === "boolean") {
compat[field] = value[field];
}
}
const stringFields = ["toolSchemaProfile", "toolCallArgumentsEncoding"] as const;
for (const field of stringFields) {
const normalized = normalizeOptionalString(value[field]) ?? "";
if (normalized) {
compat[field] = normalized;
}
}
const stringListFields = [
"visibleReasoningDetailTypes",
"supportedReasoningEfforts",
"unsupportedToolSchemaKeywords",
] as const;
for (const field of stringListFields) {
const normalized = normalizeTrimmedStringList(value[field]);
if (normalized.length > 0) {
compat[field] = normalized;
}
}
if (isRecord(value.reasoningEffortMap)) {
const reasoningEffortMap = Object.fromEntries(
Object.entries(value.reasoningEffortMap)
.map(([key, mapped]) => [key.trim(), typeof mapped === "string" ? mapped.trim() : ""])
.filter(([key, mapped]) => key.length > 0 && mapped.length > 0),
);
if (Object.keys(reasoningEffortMap).length > 0) {
compat.reasoningEffortMap = reasoningEffortMap;
}
}
const maxTokensField = normalizeOptionalString(value.maxTokensField) ?? "";
if (maxTokensField === "max_completion_tokens" || maxTokensField === "max_tokens") {
compat.maxTokensField = maxTokensField;
}
const thinkingFormat = normalizeOptionalString(value.thinkingFormat) ?? "";
if (isModelCatalogThinkingFormat(thinkingFormat)) {
compat.thinkingFormat = thinkingFormat;
}
if (value.cacheControlFormat === "anthropic") {
compat.cacheControlFormat = "anthropic";
}
const openRouterRouting = normalizeOpenRouterRouting(value.openRouterRouting);
if (openRouterRouting) {
compat.openRouterRouting = openRouterRouting;
}
const vercelGatewayRouting = normalizeVercelGatewayRouting(value.vercelGatewayRouting);
if (vercelGatewayRouting) {
compat.vercelGatewayRouting = vercelGatewayRouting;
}
return Object.keys(compat).length > 0 ? (compat as ModelCatalogCompatConfig) : undefined;
}
function normalizeModelCatalogStatus(value: unknown): ModelCatalogStatus | undefined {
const status = normalizeOptionalString(value) ?? "";
return MODEL_CATALOG_STATUSES.has(status) ? (status as ModelCatalogStatus) : undefined;
}
function normalizeModelCatalogImageTokenMode(
value: unknown,
): ModelCatalogImageInputConfig["tokenMode"] {
const tokenMode = normalizeOptionalString(value) ?? "";
if (tokenMode === "tile" || tokenMode === "detail" || tokenMode === "provider") {
return tokenMode;
}
return undefined;
}
function normalizeModelCatalogMediaInput(value: unknown): ModelCatalogMediaInputConfig | undefined {
if (!isRecord(value) || !isRecord(value.image)) {
return undefined;
}
const maxBytes = normalizePositiveInteger(value.image.maxBytes);
const maxPixels = normalizePositiveInteger(value.image.maxPixels);
const maxSidePx = normalizePositiveInteger(value.image.maxSidePx);
const preferredSidePx = normalizePositiveInteger(value.image.preferredSidePx);
const tokenMode = normalizeModelCatalogImageTokenMode(value.image.tokenMode);
const normalizedImage = {
...(maxBytes !== undefined ? { maxBytes } : {}),
...(maxPixels !== undefined ? { maxPixels } : {}),
...(maxSidePx !== undefined ? { maxSidePx } : {}),
...(preferredSidePx !== undefined ? { preferredSidePx } : {}),
...(tokenMode ? { tokenMode } : {}),
};
return Object.keys(normalizedImage).length > 0 ? { image: normalizedImage } : undefined;
}
function normalizeModelCatalogModel(value: unknown): ModelCatalogModel | undefined {
if (!isRecord(value)) {
return undefined;
}
const id = normalizeOptionalString(value.id) ?? "";
if (!id) {
return undefined;
}
const name = normalizeOptionalString(value.name) ?? "";
const api = normalizeModelCatalogApi(value.api);
const baseUrl = normalizeOptionalString(value.baseUrl) ?? "";
const headers = normalizeStringMap(value.headers);
const input = normalizeModelCatalogInputs(value.input);
const reasoning = typeof value.reasoning === "boolean" ? value.reasoning : undefined;
const contextWindow = normalizePositiveNumber(value.contextWindow);
const contextTokens = normalizePositiveInteger(value.contextTokens);
const maxTokens = normalizePositiveNumber(value.maxTokens);
const thinkingLevelMap = normalizeModelCatalogThinkingLevelMap(value.thinkingLevelMap);
const cost = normalizeModelCatalogCost(value.cost);
const compat = normalizeModelCatalogCompat(value.compat);
const mediaInput = normalizeModelCatalogMediaInput(value.mediaInput);
const status = normalizeModelCatalogStatus(value.status);
const statusReason = normalizeOptionalString(value.statusReason) ?? "";
const replaces = normalizeTrimmedStringList(value.replaces);
const replacedBy = normalizeOptionalString(value.replacedBy) ?? "";
const tags = normalizeTrimmedStringList(value.tags);
return {
id,
...(name ? { name } : {}),
...(api ? { api } : {}),
...(baseUrl ? { baseUrl } : {}),
...(headers ? { headers } : {}),
...(input ? { input } : {}),
...(reasoning !== undefined ? { reasoning } : {}),
...(contextWindow !== undefined ? { contextWindow } : {}),
...(contextTokens !== undefined ? { contextTokens } : {}),
...(maxTokens !== undefined ? { maxTokens } : {}),
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
...(cost ? { cost } : {}),
...(compat ? { compat } : {}),
...(mediaInput ? { mediaInput } : {}),
...(status ? { status } : {}),
...(statusReason ? { statusReason } : {}),
...(replaces.length > 0 ? { replaces } : {}),
...(replacedBy ? { replacedBy } : {}),
...(tags.length > 0 ? { tags } : {}),
};
}
function normalizeModelCatalogProvider(value: unknown): ModelCatalogProvider | undefined {
if (!isRecord(value)) {
return undefined;
}
const models = Array.isArray(value.models)
? value.models
.map((entry) => normalizeModelCatalogModel(entry))
.filter((entry): entry is ModelCatalogModel => Boolean(entry))
: [];
if (models.length === 0) {
return undefined;
}
const baseUrl = normalizeOptionalString(value.baseUrl) ?? "";
const api = normalizeModelCatalogApi(value.api);
const headers = normalizeStringMap(value.headers);
return {
...(baseUrl ? { baseUrl } : {}),
...(api ? { api } : {}),
...(headers ? { headers } : {}),
models,
};
}
function normalizeModelCatalogProviders(
value: unknown,
ownedProviders: ReadonlySet<string>,
): Record<string, ModelCatalogProvider> | undefined {
if (!isRecord(value)) {
return undefined;
}
const providers: Record<string, ModelCatalogProvider> = {};
for (const [rawProviderId, rawProvider] of Object.entries(value)) {
const providerId = normalizeModelCatalogProviderId(rawProviderId);
if (!providerId || !ownedProviders.has(providerId)) {
continue;
}
const provider = normalizeModelCatalogProvider(rawProvider);
if (provider) {
providers[providerId] = provider;
}
}
return Object.keys(providers).length > 0 ? providers : undefined;
}
function normalizeModelCatalogAliases(
value: unknown,
ownedProviders: ReadonlySet<string>,
): Record<string, ModelCatalogAlias> | undefined {
if (!isRecord(value)) {
return undefined;
}
const aliases: Record<string, ModelCatalogAlias> = {};
for (const [rawAlias, rawTarget] of Object.entries(value)) {
const alias = normalizeModelCatalogProviderId(rawAlias);
if (!alias || !isRecord(rawTarget)) {
continue;
}
const provider = normalizeModelCatalogProviderId(
normalizeOptionalString(rawTarget.provider) ?? "",
);
if (!provider || !ownedProviders.has(provider)) {
continue;
}
const api = normalizeModelCatalogApi(rawTarget.api);
const baseUrl = normalizeOptionalString(rawTarget.baseUrl) ?? "";
aliases[alias] = {
provider,
...(api ? { api } : {}),
...(baseUrl ? { baseUrl } : {}),
};
}
return Object.keys(aliases).length > 0 ? aliases : undefined;
}
function normalizeModelCatalogSuppressions(value: unknown): ModelCatalogSuppression[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const suppressions: ModelCatalogSuppression[] = [];
for (const entry of value) {
if (!isRecord(entry)) {
continue;
}
const provider = normalizeModelCatalogProviderId(normalizeOptionalString(entry.provider) ?? "");
const model = normalizeOptionalString(entry.model) ?? "";
if (!provider || !model) {
continue;
}
const reason = normalizeOptionalString(entry.reason) ?? "";
const rawWhen = isRecord(entry.when) ? entry.when : undefined;
const baseUrlHosts = normalizeTrimmedStringList(rawWhen?.baseUrlHosts).map((host) =>
host.toLowerCase(),
);
const providerConfigApiIn = normalizeTrimmedStringList(rawWhen?.providerConfigApiIn).map(
(api) => api.toLowerCase(),
);
const when =
baseUrlHosts.length > 0 || providerConfigApiIn.length > 0
? {
...(baseUrlHosts.length > 0 ? { baseUrlHosts } : {}),
...(providerConfigApiIn.length > 0 ? { providerConfigApiIn } : {}),
}
: undefined;
suppressions.push({
provider,
model,
...(reason ? { reason } : {}),
...(when ? { when } : {}),
});
}
return suppressions.length > 0 ? suppressions : undefined;
}
function normalizeModelCatalogDiscovery(
value: unknown,
ownedProviders: ReadonlySet<string>,
): Record<string, ModelCatalogDiscovery> | undefined {
if (!isRecord(value)) {
return undefined;
}
const discovery: Record<string, ModelCatalogDiscovery> = {};
for (const [rawProviderId, rawMode] of Object.entries(value)) {
const providerId = normalizeModelCatalogProviderId(rawProviderId);
const mode = normalizeOptionalString(rawMode) ?? "";
if (providerId && ownedProviders.has(providerId) && MODEL_CATALOG_DISCOVERY_MODES.has(mode)) {
discovery[providerId] = mode as ModelCatalogDiscovery;
}
}
return Object.keys(discovery).length > 0 ? discovery : undefined;
}
/** Normalize a raw model catalog object for the set of providers owned by a plugin/manifest. */
export function normalizeModelCatalog(
value: unknown,
params: { ownedProviders: ReadonlySet<string> },
): ModelCatalog | undefined {
if (!isRecord(value)) {
return undefined;
}
const ownedProviders = normalizeOwnedProviderSet(params.ownedProviders);
const providers = normalizeModelCatalogProviders(value.providers, ownedProviders);
const aliases = normalizeModelCatalogAliases(value.aliases, ownedProviders);
const suppressions = normalizeModelCatalogSuppressions(value.suppressions);
const discovery = normalizeModelCatalogDiscovery(value.discovery, ownedProviders);
const runtimeAugment = value.runtimeAugment === true;
const catalog = {
...(providers ? { providers } : {}),
...(aliases ? { aliases } : {}),
...(suppressions ? { suppressions } : {}),
...(discovery ? { discovery } : {}),
...(runtimeAugment ? { runtimeAugment } : {}),
} satisfies ModelCatalog;
return Object.keys(catalog).length > 0 ? catalog : undefined;
}
/** Normalize one provider catalog into sorted runtime rows. */
export function normalizeModelCatalogProviderRows(params: {
provider: string;
providerCatalog: ModelCatalogProvider;
source: ModelCatalogSource;
}): NormalizedModelCatalogRow[] {
const provider = normalizeModelCatalogProviderId(params.provider);
if (!provider || !Array.isArray(params.providerCatalog.models)) {
return [];
}
const providerApi = normalizeModelCatalogApi(params.providerCatalog.api);
const providerBaseUrl = normalizeOptionalString(params.providerCatalog.baseUrl) ?? "";
const providerHeaders = normalizeStringMap(params.providerCatalog.headers);
const rows: NormalizedModelCatalogRow[] = [];
for (const model of params.providerCatalog.models) {
const id = normalizeOptionalString(model.id) ?? "";
if (!id) {
continue;
}
const api = normalizeModelCatalogApi(model.api) ?? providerApi;
const baseUrl = normalizeOptionalString(model.baseUrl) ?? providerBaseUrl;
const headers = mergeStringMaps(providerHeaders, normalizeStringMap(model.headers));
const contextWindow = normalizePositiveNumber(model.contextWindow);
const contextTokens = normalizePositiveInteger(model.contextTokens);
const maxTokens = normalizePositiveNumber(model.maxTokens);
const thinkingLevelMap = normalizeModelCatalogThinkingLevelMap(model.thinkingLevelMap);
const cost = normalizeModelCatalogCost(model.cost);
const compat = normalizeModelCatalogCompat(model.compat);
const mediaInput = normalizeModelCatalogMediaInput(model.mediaInput);
const statusReason = normalizeOptionalString(model.statusReason) ?? "";
const replacedBy = normalizeOptionalString(model.replacedBy) ?? "";
const replaces = normalizeOptionalTrimmedStringList(model.replaces);
const tags = normalizeOptionalTrimmedStringList(model.tags);
rows.push({
provider,
id,
ref: buildModelCatalogRef(provider, id),
mergeKey: buildModelCatalogMergeKey(provider, id),
name: normalizeOptionalString(model.name) || id,
source: params.source,
input: normalizeModelCatalogInputs(model.input) ?? [...DEFAULT_MODEL_INPUT],
reasoning: typeof model.reasoning === "boolean" ? model.reasoning : false,
status: normalizeModelCatalogStatus(model.status) ?? DEFAULT_MODEL_STATUS,
...(api ? { api } : {}),
...(baseUrl ? { baseUrl } : {}),
...(headers ? { headers } : {}),
...(contextWindow !== undefined ? { contextWindow } : {}),
...(contextTokens !== undefined ? { contextTokens } : {}),
...(maxTokens !== undefined ? { maxTokens } : {}),
...(thinkingLevelMap ? { thinkingLevelMap } : {}),
...(cost ? { cost } : {}),
...(compat ? { compat } : {}),
...(mediaInput ? { mediaInput } : {}),
...(statusReason ? { statusReason } : {}),
...(replaces ? { replaces } : {}),
...(replacedBy ? { replacedBy } : {}),
...(tags ? { tags } : {}),
});
}
return rows.toSorted((a, b) => a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id));
}
/** Normalize all provider catalogs into sorted runtime rows. */
export function normalizeModelCatalogRows(params: {
providers: Record<string, ModelCatalogProvider>;
source: ModelCatalogSource;
}): NormalizedModelCatalogRow[] {
return Object.entries(params.providers)
.flatMap(([provider, providerCatalog]) =>
normalizeModelCatalogProviderRows({ provider, providerCatalog, source: params.source }),
)
.toSorted((a, b) => a.provider.localeCompare(b.provider) || a.id.localeCompare(b.id));
}

View File

@@ -0,0 +1,36 @@
// Model Catalog Core tests cover model catalog refs behavior.
import { describe, expect, it } from "vitest";
import {
buildModelCatalogMergeKey,
buildModelCatalogRef,
parseModelCatalogRef,
parseProviderModelRef,
} from "./model-catalog-refs.js";
describe("model catalog refs", () => {
it("normalizes provider ids without lowercasing model ids in refs", () => {
expect(buildModelCatalogRef("OpenAI", "GPT-5.4")).toBe("openai/GPT-5.4");
expect(buildModelCatalogMergeKey("OpenAI", "GPT-5.4")).toBe("openai::gpt-5.4");
});
it("parses strict refs while preserving nested model ids", () => {
expect(parseModelCatalogRef(" OpenRouter / meta-llama/llama-3.3 ")).toEqual({
provider: "openrouter",
modelId: "meta-llama/llama-3.3",
});
});
it("parses strict refs without normalizing provider or model casing", () => {
expect(parseProviderModelRef(" OpenRouter / Meta-Llama/Llama-3.3 ")).toEqual({
provider: "OpenRouter",
model: "Meta-Llama/Llama-3.3",
});
});
it.each(["", "openai", "/gpt-5.4", "openai/", " / gpt-5.4 "])(
"rejects incomplete ref %j",
(value) => {
expect(parseModelCatalogRef(value)).toBeNull();
},
);
});

View File

@@ -0,0 +1,53 @@
// Model Catalog Core module implements model catalog refs behavior.
import { normalizeLowercaseStringOrEmpty } from "./provider-id.js";
// Stable model catalog ref and merge-key builders.
export type ModelCatalogRef = {
provider: string;
modelId: string;
};
export type ProviderModelRef = {
provider: string;
model: string;
};
/** Normalize provider ids for catalog refs. */
export function normalizeModelCatalogProviderId(provider: string): string {
return normalizeLowercaseStringOrEmpty(provider);
}
/** Build a provider/model catalog reference. */
export function buildModelCatalogRef(provider: string, modelId: string): string {
return `${normalizeModelCatalogProviderId(provider)}/${modelId}`;
}
/** Parse a strict provider/model reference without normalizing either segment. */
export function parseProviderModelRef(value: string): ProviderModelRef | null {
const trimmed = value.trim();
const slashIndex = trimmed.indexOf("/");
if (slashIndex <= 0 || slashIndex >= trimmed.length - 1) {
return null;
}
const provider = trimmed.slice(0, slashIndex).trim();
const model = trimmed.slice(slashIndex + 1).trim();
return provider && model ? { provider, model } : null;
}
/** Parse a strict provider/model catalog reference. */
export function parseModelCatalogRef(value: string): ModelCatalogRef | null {
const parsed = parseProviderModelRef(value);
if (!parsed) {
return null;
}
return {
provider: normalizeModelCatalogProviderId(parsed.provider),
modelId: parsed.model,
};
}
/** Build a case-insensitive merge key for provider/model rows. */
export function buildModelCatalogMergeKey(provider: string, modelId: string): string {
return `${normalizeModelCatalogProviderId(provider)}::${normalizeLowercaseStringOrEmpty(modelId)}`;
}

View File

@@ -0,0 +1,301 @@
// Shared model catalog data contracts for provider manifests and normalized rows.
/** Supported API protocols for model catalog entries. */
export const MODEL_CATALOG_APIS = [
"openai-completions",
"openai-responses",
"openai-chatgpt-responses",
"anthropic-messages",
"google-generative-ai",
"google-vertex",
"github-copilot",
"bedrock-converse-stream",
"ollama",
"azure-openai-responses",
] as const;
/** API protocol for a model catalog entry. */
export type ModelCatalogApi = (typeof MODEL_CATALOG_APIS)[number];
/** Supported model thinking/reasoning wire formats. */
export const MODEL_CATALOG_THINKING_FORMATS = [
"openai",
"openrouter",
"deepseek",
"together",
"qwen",
"qwen-chat-template",
"zai",
] as const;
/** Thinking/reasoning wire format for model compatibility. */
export type ModelCatalogThinkingFormat = (typeof MODEL_CATALOG_THINKING_FORMATS)[number];
/** Narrow a string to a supported model catalog thinking format. */
export function isModelCatalogThinkingFormat(value: string): value is ModelCatalogThinkingFormat {
return (MODEL_CATALOG_THINKING_FORMATS as readonly string[]).includes(value);
}
/** Compatibility flags and provider-specific routing metadata for one model. */
export type ModelCatalogCompatConfig = {
supportsStore?: boolean;
supportsDeveloperRole?: boolean;
supportsReasoningEffort?: boolean;
supportsUsageInStreaming?: boolean;
supportsStrictMode?: boolean;
maxTokensField?: "max_completion_tokens" | "max_tokens";
requiresToolResultName?: boolean;
requiresAssistantAfterToolResult?: boolean;
requiresThinkingAsText?: boolean;
openRouterRouting?: ModelCatalogOpenRouterRouting;
vercelGatewayRouting?: ModelCatalogVercelGatewayRouting;
zaiToolStream?: boolean;
cacheControlFormat?: "anthropic";
sendSessionAffinityHeaders?: boolean;
sendSessionIdHeader?: boolean;
supportsEagerToolInputStreaming?: boolean;
supportsLongCacheRetention?: boolean;
supportsPromptCacheKey?: boolean;
supportsTools?: boolean;
requiresStringContent?: boolean;
strictMessageKeys?: boolean;
toolSchemaProfile?: string;
unsupportedToolSchemaKeywords?: string[];
nativeWebSearchTool?: boolean;
toolCallArgumentsEncoding?: string;
requiresMistralToolIds?: boolean;
requiresOpenAiAnthropicToolPayload?: boolean;
thinkingFormat?: ModelCatalogThinkingFormat;
supportedReasoningEfforts?: string[];
reasoningEffortMap?: Record<string, string>;
visibleReasoningDetailTypes?: string[];
};
/** OpenRouter routing preferences copied into request metadata. */
export type ModelCatalogOpenRouterRouting = {
allow_fallbacks?: boolean;
require_parameters?: boolean;
data_collection?: "deny" | "allow";
zdr?: boolean;
enforce_distillable_text?: boolean;
order?: string[];
only?: string[];
ignore?: string[];
quantizations?: string[];
sort?:
| string
| {
by?: string;
partition?: string | null;
};
max_price?: {
prompt?: number | string;
completion?: number | string;
image?: number | string;
audio?: number | string;
request?: number | string;
};
preferred_min_throughput?:
| number
| {
p50?: number;
p75?: number;
p90?: number;
p99?: number;
};
preferred_max_latency?:
| number
| {
p50?: number;
p75?: number;
p90?: number;
p99?: number;
};
};
/** Vercel AI Gateway routing preferences. */
export type ModelCatalogVercelGatewayRouting = {
only?: string[];
order?: string[];
};
/** Image input limits for a model. */
export type ModelCatalogImageInputConfig = {
maxBytes?: number;
maxPixels?: number;
maxSidePx?: number;
preferredSidePx?: number;
tokenMode?: "tile" | "detail" | "provider";
};
/** Media input limits for a model. */
export type ModelCatalogMediaInputConfig = {
image?: ModelCatalogImageInputConfig;
};
/** Supported input modality for a model. */
export type ModelCatalogInput = "text" | "image" | "document";
/** Model-level thinking settings carried by provider catalog metadata. */
export const MODEL_CATALOG_THINKING_LEVELS = [
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"max",
] as const;
export type ModelCatalogThinkingLevel = (typeof MODEL_CATALOG_THINKING_LEVELS)[number];
export type ModelCatalogThinkingLevelMap = Partial<
Record<ModelCatalogThinkingLevel, string | null>
>;
/** Discovery lifecycle for a provider catalog. */
export type ModelCatalogDiscovery = "static" | "refreshable" | "runtime";
/** Availability state for a model. */
export type ModelCatalogStatus = "available" | "preview" | "deprecated" | "disabled";
/** Source of a model catalog row. */
export type ModelCatalogSource =
| "manifest"
| "provider-index"
| "cache"
| "config"
| "runtime-refresh";
/** Unified catalog kind across text and generated media models. */
export type UnifiedModelCatalogKind =
| "text"
| "voice"
| "image_generation"
| "video_generation"
| "music_generation";
/** Source for unified model catalog entries. */
export type UnifiedModelCatalogSource =
| "manifest"
| "provider-index"
| "static"
| "live"
| "cache"
| "configured"
| "runtime-refresh";
/** Unified model catalog entry for provider/model pickers. */
export type UnifiedModelCatalogEntry<TCapabilities = unknown> = {
kind: UnifiedModelCatalogKind;
provider: string;
model: string;
label?: string;
source: UnifiedModelCatalogSource;
default?: boolean;
configured?: boolean;
capabilities?: TCapabilities;
modes?: readonly string[];
authEnvVars?: readonly string[];
docsPath?: string;
fetchedAt?: number;
expiresAt?: number;
warnings?: readonly string[];
};
/** Tiered token cost row. */
export type ModelCatalogTieredCost = {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
range: [number, number] | [number];
};
/** Token cost metadata for one model. */
export type ModelCatalogCost = {
input?: number;
output?: number;
cacheRead?: number;
cacheWrite?: number;
tieredPricing?: ModelCatalogTieredCost[];
};
/** Provider manifest model entry. */
export type ModelCatalogModel = {
id: string;
name?: string;
api?: ModelCatalogApi;
baseUrl?: string;
headers?: Record<string, string>;
input?: ModelCatalogInput[];
reasoning?: boolean;
contextWindow?: number;
contextTokens?: number;
maxTokens?: number;
thinkingLevelMap?: ModelCatalogThinkingLevelMap;
cost?: ModelCatalogCost;
compat?: ModelCatalogCompatConfig;
mediaInput?: ModelCatalogMediaInputConfig;
status?: ModelCatalogStatus;
statusReason?: string;
replaces?: string[];
replacedBy?: string;
tags?: string[];
};
/** Provider manifest catalog entry. */
export type ModelCatalogProvider = {
baseUrl?: string;
api?: ModelCatalogApi;
headers?: Record<string, string>;
models: ModelCatalogModel[];
};
/** Provider alias entry. */
export type ModelCatalogAlias = {
provider: string;
api?: ModelCatalogApi;
baseUrl?: string;
};
/** Suppression rule for hiding a provider/model under matching config. */
export type ModelCatalogSuppression = {
provider: string;
model: string;
reason?: string;
when?: {
baseUrlHosts?: string[];
providerConfigApiIn?: string[];
};
};
/** Raw model catalog manifest shape. */
export type ModelCatalog = {
providers?: Record<string, ModelCatalogProvider>;
aliases?: Record<string, ModelCatalogAlias>;
suppressions?: ModelCatalogSuppression[];
discovery?: Record<string, ModelCatalogDiscovery>;
runtimeAugment?: boolean;
};
/** Normalized model catalog row used by runtime lookup and UI surfaces. */
export type NormalizedModelCatalogRow = {
provider: string;
id: string;
ref: string;
mergeKey: string;
name: string;
source: ModelCatalogSource;
input: ModelCatalogInput[];
reasoning: boolean;
status: ModelCatalogStatus;
api?: ModelCatalogApi;
baseUrl?: string;
headers?: Record<string, string>;
contextWindow?: number;
contextTokens?: number;
maxTokens?: number;
thinkingLevelMap?: ModelCatalogThinkingLevelMap;
cost?: ModelCatalogCost;
compat?: ModelCatalogCompatConfig;
mediaInput?: ModelCatalogMediaInputConfig;
statusReason?: string;
replaces?: string[];
replacedBy?: string;
tags?: string[];
};

View File

@@ -0,0 +1,40 @@
// Model Catalog Core module implements provider id behavior.
export function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
export function normalizeProviderId(provider: string): string {
return normalizeLowercaseStringOrEmpty(provider);
}
/** Normalize provider ID before manifest-owned auth alias lookup. */
export function normalizeProviderIdForAuth(provider: string): string {
return normalizeProviderId(provider);
}
export function findNormalizedProviderValue<T>(
entries: Record<string, T> | undefined,
provider: string,
): T | undefined {
if (!entries) {
return undefined;
}
const providerKey = normalizeProviderId(provider);
for (const [key, value] of Object.entries(entries)) {
if (normalizeProviderId(key) === providerKey) {
return value;
}
}
return undefined;
}
export function findNormalizedProviderKey(
entries: Record<string, unknown> | undefined,
provider: string,
): string | undefined {
if (!entries) {
return undefined;
}
const providerKey = normalizeProviderId(provider);
return Object.keys(entries).find((key) => normalizeProviderId(key) === providerKey);
}

View File

@@ -0,0 +1,122 @@
// Model Catalog Core tests cover provider model id normalization behavior.
import { describe, expect, it } from "vitest";
import {
collectManifestModelIdNormalizationPolicies,
normalizeConfiguredProviderCatalogModelId,
normalizeStaticProviderModelIdWithPolicies,
stripSelfProviderModelPrefix,
} from "./provider-model-id-normalization.js";
describe("provider model id policy normalization", () => {
it("applies manifest policies before built-in provider normalization", () => {
const policies = collectManifestModelIdNormalizationPolicies([
{
modelIdNormalization: {
providers: {
"Google-Vertex": {
aliases: {
pro: "gemini-3-pro",
},
},
},
},
},
]);
expect(normalizeStaticProviderModelIdWithPolicies("google-vertex", "pro", policies)).toBe(
"gemini-3.1-pro-preview",
);
});
it("normalizes provider-prefixed Google catalog refs behind gateway prefixes", () => {
expect(
normalizeConfiguredProviderCatalogModelId(
"openrouter",
"openrouter/google/gemini-3-pro-preview",
),
).toBe("openrouter/google/gemini-3.1-pro-preview");
expect(
normalizeConfiguredProviderCatalogModelId("openrouter", "openrouter/google/gemma-4-26b"),
).toBe("openrouter/google/gemma-4-26b-a4b-it");
});
it("normalizes native Anthropic catalog refs without retaining the provider prefix", () => {
expect(
normalizeStaticProviderModelIdWithPolicies("anthropic", "anthropic/claude-haiku-4-5"),
).toBe("claude-haiku-4-5");
expect(
normalizeConfiguredProviderCatalogModelId("anthropic", "anthropic/claude-haiku-4-5"),
).toBe("claude-haiku-4-5");
});
it("normalizes provider-prefixed native catalog refs without stripping catalog prefixes", () => {
expect(normalizeStaticProviderModelIdWithPolicies("google", "google/gemini-2.0-flash")).toBe(
"google/gemini-2.0-flash",
);
expect(
normalizeStaticProviderModelIdWithPolicies(
"google-gemini-cli",
"google-gemini-cli/gemini-2.0-flash",
),
).toBe("google-gemini-cli/gemini-2.0-flash");
expect(
normalizeStaticProviderModelIdWithPolicies(
"google-vertex",
"google-vertex/gemini-3-pro-preview",
),
).toBe("google-vertex/gemini-3-pro-preview");
expect(normalizeStaticProviderModelIdWithPolicies("xai", "xai/grok-4-fast-reasoning")).toBe(
"xai/grok-4-fast-reasoning",
);
expect(normalizeStaticProviderModelIdWithPolicies("openai", "openai/gpt-5.4")).toBe(
"openai/gpt-5.4",
);
expect(
normalizeStaticProviderModelIdWithPolicies("vercel-ai-gateway", "vercel-ai-gateway/opus-4.6"),
).toBe("vercel-ai-gateway/opus-4.6");
});
it("strips self provider model prefixes before runtime provider calls", () => {
expect(stripSelfProviderModelPrefix("google", "google/gemini-2.0-flash")).toBe(
"gemini-2.0-flash",
);
expect(stripSelfProviderModelPrefix("xai", "xai/grok-4-fast-reasoning")).toBe(
"grok-4-fast-reasoning",
);
expect(stripSelfProviderModelPrefix("openai", "openai/gpt-5.4")).toBe("gpt-5.4");
expect(stripSelfProviderModelPrefix("vercel-ai-gateway", "vercel-ai-gateway/opus-4.6")).toBe(
"opus-4.6",
);
});
});
describe("manifest stripPrefixes matches and slices on the same normalized value", () => {
function stripWith(stripPrefixes: string[], modelId: string): string {
const policies = collectManifestModelIdNormalizationPolicies([
{
modelIdNormalization: {
providers: {
openai: { stripPrefixes },
},
},
},
]);
return normalizeStaticProviderModelIdWithPolicies("openai", modelId, policies);
}
it("strips a whitespace-free prefix exactly (control: no regression)", () => {
expect(stripWith(["openai/"], "openai/gpt-4")).toBe("gpt-4");
});
it("strips by the matched length when the manifest prefix has a leading space", () => {
expect(stripWith([" openai/"], "openai/gpt-4")).toBe("gpt-4");
});
it("strips by the matched length when the manifest prefix has a trailing space", () => {
expect(stripWith(["openai/ "], "openai/gpt-4")).toBe("gpt-4");
});
it("strips by the matched length when the manifest prefix differs in case and spacing", () => {
expect(stripWith([" OpenAI/ "], "openai/gpt-4")).toBe("gpt-4");
});
});

View File

@@ -0,0 +1,239 @@
// Model Catalog Core module implements provider model id normalization behavior.
import { parseModelCatalogRef } from "./model-catalog-refs.js";
import { normalizeLowercaseStringOrEmpty } from "./provider-id.js";
import {
normalizeGooglePreviewModelId,
normalizeTogetherModelId,
} from "./provider-model-id-normalize.js";
// Provider model-id normalization policies from manifests plus built-in provider rules.
/** Manifest-defined normalization rules for one provider. */
export type ManifestModelIdNormalizationProvider = {
aliases?: Record<string, string>;
stripPrefixes?: string[];
prefixWhenBare?: string;
prefixWhenBareAfterAliasStartsWith?: {
modelPrefix: string;
prefix: string;
}[];
};
/** Manifest fragment that can define provider model-id normalization policies. */
export type ManifestModelIdNormalizationRecord = {
modelIdNormalization?: {
providers?: Record<string, ManifestModelIdNormalizationProvider>;
};
};
let currentManifestModelIdNormalizationPolicies:
| ReadonlyMap<string, ManifestModelIdNormalizationProvider>
| undefined;
/** Collect provider model-id normalization policies from plugin manifests. */
export function collectManifestModelIdNormalizationPolicies(
plugins: readonly ManifestModelIdNormalizationRecord[],
): Map<string, ManifestModelIdNormalizationProvider> {
const policies = new Map<string, ManifestModelIdNormalizationProvider>();
for (const plugin of plugins) {
for (const [provider, policy] of Object.entries(plugin.modelIdNormalization?.providers ?? {})) {
policies.set(normalizeLowercaseStringOrEmpty(provider), policy);
}
}
return policies;
}
/** Replace the process-local manifest normalization policy snapshot. */
export function setCurrentManifestModelIdNormalizationRecords(
plugins: readonly ManifestModelIdNormalizationRecord[] | undefined,
): void {
currentManifestModelIdNormalizationPolicies = plugins
? collectManifestModelIdNormalizationPolicies(plugins)
: undefined;
}
/** Return the current process-local manifest normalization policy snapshot. */
export function getCurrentManifestModelIdNormalizationPolicies():
| ReadonlyMap<string, ManifestModelIdNormalizationProvider>
| undefined {
return currentManifestModelIdNormalizationPolicies;
}
/** Return true when a model id already includes a provider namespace. */
function hasProviderPrefix(modelId: string): boolean {
return modelId.includes("/");
}
/** Join a provider prefix and model id with exactly one slash. */
function formatPrefixedModelId(prefix: string, modelId: string): string {
return `${prefix.replace(/\/+$/u, "")}/${modelId.replace(/^\/+/u, "")}`;
}
/** Strip a duplicated self-provider prefix from a model id. */
export function stripSelfProviderModelPrefix(provider: string, model: string): string {
const prefix = `${normalizeLowercaseStringOrEmpty(provider)}/`;
const trimmed = model.trim();
return normalizeLowercaseStringOrEmpty(trimmed).startsWith(prefix)
? trimmed.slice(prefix.length)
: model;
}
/** Apply manifest normalization policies for one provider/model id. */
export function normalizeProviderModelIdWithPolicies(params: {
provider: string;
policies: ReadonlyMap<string, ManifestModelIdNormalizationProvider>;
context: {
modelId: string;
};
}): string | undefined {
const policy = params.policies.get(normalizeLowercaseStringOrEmpty(params.provider));
if (!policy) {
return undefined;
}
let modelId = params.context.modelId.trim();
if (!modelId) {
return modelId;
}
for (const prefix of policy.stripPrefixes ?? []) {
const normalizedPrefix = normalizeLowercaseStringOrEmpty(prefix);
if (normalizedPrefix && normalizeLowercaseStringOrEmpty(modelId).startsWith(normalizedPrefix)) {
modelId = modelId.slice(normalizedPrefix.length);
break;
}
}
modelId = policy.aliases?.[normalizeLowercaseStringOrEmpty(modelId)] ?? modelId;
if (!hasProviderPrefix(modelId)) {
for (const rule of policy.prefixWhenBareAfterAliasStartsWith ?? []) {
if (normalizeLowercaseStringOrEmpty(modelId).startsWith(rule.modelPrefix.toLowerCase())) {
return formatPrefixedModelId(rule.prefix, modelId);
}
}
if (policy.prefixWhenBare) {
return formatPrefixedModelId(policy.prefixWhenBare, modelId);
}
}
return modelId;
}
/** Apply built-in provider-specific model id normalization rules. */
export function normalizeBuiltInProviderModelId(provider: string, model: string): string {
const normalizedProvider = normalizeLowercaseStringOrEmpty(provider);
if (
normalizedProvider === "google" ||
normalizedProvider === "google-gemini-cli" ||
normalizedProvider === "google-vertex"
) {
return normalizeGooglePreviewModelId(model);
}
if (normalizedProvider === "openrouter") {
const trimmed = model.trim();
return trimmed && !trimmed.includes("/") ? `openrouter/${trimmed}` : model;
}
if (normalizedProvider === "anthropic") {
const anthropicAliases: Record<string, string> = {
"opus-4.8": "claude-opus-4-8",
opus: "claude-opus-4-8",
"opus-4.6": "claude-opus-4-6",
"sonnet-4.6": "claude-sonnet-4-6",
};
const anthropicPrefix = "anthropic/";
const normalizedModel = normalizeLowercaseStringOrEmpty(model);
const providerModel = normalizedModel.startsWith(anthropicPrefix)
? model.trim().slice(anthropicPrefix.length)
: model;
return anthropicAliases[normalizeLowercaseStringOrEmpty(providerModel)] ?? providerModel;
}
if (normalizedProvider === "vercel-ai-gateway") {
const vercelAliases: Record<string, string> = {
"opus-4.6": "claude-opus-4-6",
"sonnet-4.6": "claude-sonnet-4-6",
};
const aliased = vercelAliases[normalizeLowercaseStringOrEmpty(model)] ?? model;
return normalizeLowercaseStringOrEmpty(aliased).startsWith("claude-")
? `anthropic/${aliased}`
: aliased;
}
if (normalizedProvider === "huggingface") {
const prefix = "huggingface/";
return normalizeLowercaseStringOrEmpty(model).startsWith(prefix)
? model.slice(prefix.length)
: model;
}
if (normalizedProvider === "nvidia") {
const trimmed = model.trim();
return trimmed && !trimmed.includes("/") ? `nvidia/${trimmed}` : model;
}
if (normalizedProvider === "xai") {
const xaiAliases: Record<string, string> = {
"grok-4-fast-reasoning": "grok-4-fast",
"grok-4-1-fast-reasoning": "grok-4-1-fast",
"grok-4.20-experimental-beta-0304-reasoning": "grok-4.20-beta-latest-reasoning",
"grok-4.20-experimental-beta-0304-non-reasoning": "grok-4.20-beta-latest-non-reasoning",
"grok-4.20-reasoning": "grok-4.20-beta-latest-reasoning",
"grok-4.20-non-reasoning": "grok-4.20-beta-latest-non-reasoning",
};
return xaiAliases[normalizeLowercaseStringOrEmpty(model)] ?? model;
}
if (normalizedProvider === "openai") {
return model;
}
if (normalizedProvider === "together") {
return normalizeTogetherModelId(model);
}
return model;
}
/** Apply manifest policies and built-in normalization to a static provider/model id. */
export function normalizeStaticProviderModelIdWithPolicies(
provider: string,
model: string,
policies?: ReadonlyMap<string, ManifestModelIdNormalizationProvider>,
): string {
const normalizedProvider = normalizeLowercaseStringOrEmpty(provider);
const manifestModelId = policies
? (normalizeProviderModelIdWithPolicies({
provider: normalizedProvider,
policies,
context: {
modelId: model,
},
}) ?? model)
: model;
return normalizeBuiltInProviderModelId(normalizedProvider, manifestModelId);
}
/** Normalize a configured provider/model catalog reference using current policies. */
export function normalizeConfiguredProviderCatalogModelId(
provider: string,
model: string,
policies = getCurrentManifestModelIdNormalizationPolicies(),
): string {
const providerModel = normalizeStaticProviderModelIdWithPolicies(provider, model, policies);
return normalizeConfiguredProviderCatalogModelRef(providerModel);
}
/** Normalize embedded Google model aliases inside provider/model catalog refs. */
export function normalizeConfiguredProviderCatalogModelRef(providerModel: string): string {
const googlePrefix = "google/";
if (!providerModel.startsWith(googlePrefix)) {
const parsed = parseModelCatalogRef(providerModel);
if (!parsed) {
return providerModel;
}
if (!parsed.modelId.startsWith(googlePrefix)) {
return providerModel;
}
const normalizedModelId = normalizeGooglePreviewModelId(parsed.modelId);
return normalizedModelId === parsed.modelId
? providerModel
: `${parsed.provider}/${normalizedModelId}`;
}
const modelId = providerModel.slice(googlePrefix.length);
const normalizedModelId = normalizeGooglePreviewModelId(modelId);
return normalizedModelId === modelId ? providerModel : `${googlePrefix}${normalizedModelId}`;
}

View File

@@ -0,0 +1,40 @@
// Model Catalog Core tests cover provider model id normalize behavior.
import { describe, expect, it } from "vitest";
import { normalizeGooglePreviewModelId } from "./provider-model-id-normalize.js";
describe("provider model id normalization", () => {
it("routes bare Gemini 3 Pro to the current Gemini 3.1 Pro preview", () => {
expect(normalizeGooglePreviewModelId("gemini-3-pro")).toBe("gemini-3.1-pro-preview");
expect(normalizeGooglePreviewModelId("gemini-3-pro-preview")).toBe("gemini-3.1-pro-preview");
expect(normalizeGooglePreviewModelId("gemini-3.1-pro")).toBe("gemini-3.1-pro-preview");
});
it("routes provider-prefixed Gemini 3 Pro to the current Gemini 3.1 Pro preview", () => {
expect(normalizeGooglePreviewModelId("google/gemini-3-pro-preview")).toBe(
"google/gemini-3.1-pro-preview",
);
});
it("does not rewrite already-current Gemini replacement ids", () => {
expect(normalizeGooglePreviewModelId("gemini-3.1-pro-preview")).toBe("gemini-3.1-pro-preview");
expect(normalizeGooglePreviewModelId("gemini-2.5-flash")).toBe("gemini-2.5-flash");
});
it("maps deprecated flash-lite-preview to GA flash-lite", () => {
expect(normalizeGooglePreviewModelId("gemini-3.1-flash-lite-preview")).toBe(
"gemini-3.1-flash-lite",
);
expect(normalizeGooglePreviewModelId("google/gemini-3.1-flash-lite-preview")).toBe(
"google/gemini-3.1-flash-lite",
);
});
it("does not rewrite stable GA flash-lite", () => {
expect(normalizeGooglePreviewModelId("gemini-3.1-flash-lite")).toBe("gemini-3.1-flash-lite");
});
it("routes Gemma 4 26B shorthand to Google's canonical API id", () => {
expect(normalizeGooglePreviewModelId("gemma-4-26b")).toBe("gemma-4-26b-a4b-it");
expect(normalizeGooglePreviewModelId("google/gemma-4-26b")).toBe("google/gemma-4-26b-a4b-it");
});
});

View File

@@ -0,0 +1,47 @@
// Model Catalog Core helper module supports provider model id normalize behavior.
const ANTIGRAVITY_BARE_PRO_IDS = new Set(["gemini-3-pro", "gemini-3.1-pro", "gemini-3-1-pro"]);
const GOOGLE_PROVIDER_PREFIX = "google/";
export function normalizeGooglePreviewModelId(id: string): string {
if (id.startsWith(GOOGLE_PROVIDER_PREFIX)) {
const modelId = id.slice(GOOGLE_PROVIDER_PREFIX.length);
const normalizedModelId = normalizeGooglePreviewModelId(modelId);
return normalizedModelId === modelId ? id : `${GOOGLE_PROVIDER_PREFIX}${normalizedModelId}`;
}
if (id === "gemini-3-pro" || id === "gemini-3-pro-preview") {
return "gemini-3.1-pro-preview";
}
if (id === "gemini-3-flash") {
return "gemini-3-flash-preview";
}
if (id === "gemini-3.1-pro") {
return "gemini-3.1-pro-preview";
}
// Gemini 3.1 Flash Lite graduated to GA on 2026-05-07; the -preview
// endpoint is deprecated (shutdown 2026-05-25). Map old preview name
// to the stable GA id.
if (id === "gemini-3.1-flash-lite-preview") {
return "gemini-3.1-flash-lite";
}
if (id === "gemini-3.1-flash" || id === "gemini-3.1-flash-preview") {
return "gemini-3-flash-preview";
}
if (id === "gemma-4-26b") {
return "gemma-4-26b-a4b-it";
}
return id;
}
export function normalizeTogetherModelId(id: string): string {
if (id === "moonshotai/Kimi-K2.5") {
return "moonshotai/Kimi-K2.6";
}
return id;
}
export function normalizeAntigravityPreviewModelId(id: string): string {
if (ANTIGRAVITY_BARE_PRO_IDS.has(id)) {
return `${id}-low`;
}
return id;
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*"]
}