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
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:
53
packages/model-catalog-core/package.json
Normal file
53
packages/model-catalog-core/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
139
packages/model-catalog-core/src/configured-model-refs.ts
Normal file
139
packages/model-catalog-core/src/configured-model-refs.ts
Normal 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;
|
||||
}
|
||||
9
packages/model-catalog-core/src/index.ts
Normal file
9
packages/model-catalog-core/src/index.ts
Normal 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";
|
||||
246
packages/model-catalog-core/src/model-catalog-normalize.test.ts
Normal file
246
packages/model-catalog-core/src/model-catalog-normalize.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
771
packages/model-catalog-core/src/model-catalog-normalize.ts
Normal file
771
packages/model-catalog-core/src/model-catalog-normalize.ts
Normal 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));
|
||||
}
|
||||
36
packages/model-catalog-core/src/model-catalog-refs.test.ts
Normal file
36
packages/model-catalog-core/src/model-catalog-refs.test.ts
Normal 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();
|
||||
},
|
||||
);
|
||||
});
|
||||
53
packages/model-catalog-core/src/model-catalog-refs.ts
Normal file
53
packages/model-catalog-core/src/model-catalog-refs.ts
Normal 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)}`;
|
||||
}
|
||||
301
packages/model-catalog-core/src/model-catalog-types.ts
Normal file
301
packages/model-catalog-core/src/model-catalog-types.ts
Normal 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[];
|
||||
};
|
||||
40
packages/model-catalog-core/src/provider-id.ts
Normal file
40
packages/model-catalog-core/src/provider-id.ts
Normal 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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
8
packages/model-catalog-core/tsconfig.json
Normal file
8
packages/model-catalog-core/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user