blob: bf31492690d0e68f655271e41ec3344f37d6d975 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* Turning a models.dev response into `ModelMetadata`, for the build-time
* generator and the Runtime Host's startup refresh alike. One projection means
* a model cannot mean one thing in the committed snapshot and another when the
* Host reads it live.
*
* `scripts/sync-model-metadata.mjs` loads this file through Node's type
* stripping, which erases type-only imports but cannot resolve a relative
* value import. Every relative import here must stay `import type`.
*
* Validation fails loud and whole. A caller that cannot accept a rejected
* catalog keeps whatever it had.
*/
import type { ProviderType } from './llm-connections.js';
import type { ModelMetadata } from './model-metadata.js';
export const MODELS_DEV_SOURCE_URL = 'https://models.dev/api.json';
/** Every provider access path Maka serves, and the models.dev provider it reads. */
export const MODELS_DEV_PROVIDERS = {
anthropic: 'anthropic',
alibaba: 'alibaba',
'alibaba-cn': 'alibaba-cn',
'alibaba-coding-plan-cn': 'alibaba-coding-plan-cn',
'alibaba-coding-plan': 'alibaba-coding-plan',
'alibaba-token-plan-cn': 'alibaba-token-plan-cn',
'alibaba-token-plan': 'alibaba-token-plan',
cerebras: 'cerebras',
cohere: 'cohere',
'cloudflare-workers-ai': 'cloudflare-workers-ai',
deepinfra: 'deepinfra',
deepseek: 'deepseek',
'fireworks-ai': 'fireworks-ai',
'github-copilot': 'github-copilot',
google: 'google',
groq: 'groq',
huggingface: 'huggingface',
'kimi-coding-plan': 'kimi-for-coding',
MiniMax: 'minimax',
'MiniMax-cn': 'minimax-cn',
'minimax-coding-plan': 'minimax-coding-plan',
mistral: 'mistral',
moonshot: 'moonshotai-cn',
nvidia: 'nvidia',
'ollama-cloud': 'ollama-cloud',
openai: 'openai',
opencode: 'opencode',
'opencode-go': 'opencode-go',
openrouter: 'openrouter',
siliconflow: 'siliconflow',
stepfun: 'stepfun',
'stepfun-ai': 'stepfun-ai',
'stepfun-ai-step-plan': 'stepfun-ai-step-plan',
'stepfun-step-plan': 'stepfun-step-plan',
togetherai: 'togetherai',
'tencent-coding-plan': 'tencent-coding-plan',
'tencent-token-plan': 'tencent-token-plan',
'tencent-tokenhub': 'tencent-tokenhub',
vercel: 'vercel',
xai: 'xai',
xiaomi: 'xiaomi',
'xiaomi-token-plan-cn': 'xiaomi-token-plan-cn',
'xiaomi-token-plan-sgp': 'xiaomi-token-plan-sgp',
'xiaomi-token-plan-ams': 'xiaomi-token-plan-ams',
zai: 'zai',
'zai-coding-plan': 'zai-coding-plan',
zenmux: 'zenmux',
} as const satisfies Partial<Record<ProviderType, string>>;
export interface ModelsDevProvider {
readonly id: string;
readonly name: string;
readonly api?: string;
readonly doc: string;
readonly models: Readonly<Record<string, ModelsDevModel>>;
}
export interface ModelsDevModel {
readonly name: string;
readonly description?: string;
readonly knowledge?: string;
readonly last_updated?: string;
readonly status?: string;
readonly reasoning: boolean;
readonly tool_call: boolean;
readonly structured_output?: boolean;
readonly limit: { readonly context: number; readonly output: number; readonly input?: number };
readonly modalities?: { readonly input: string[]; readonly output: string[] };
readonly reasoning_options?: ReadonlyArray<{ readonly type?: string; readonly values?: unknown }>;
readonly cost?: Readonly<Record<string, unknown>>;
readonly provider?: { readonly npm?: string; readonly api?: string };
}
export type ModelsDevCatalog = Readonly<Record<string, ModelsDevProvider>>;
type ModelModality = NonNullable<ModelMetadata['modalities']>['input'][number];
// Every member must be listed, so a modality added to `ModelInfo` cannot reach
// the wire without a decision here.
const KNOWN_MODALITIES: Record<ModelModality, true> = {
text: true,
image: true,
audio: true,
pdf: true,
video: true,
};
function requireModalities(
providerId: string,
modelId: string,
values: readonly string[],
): ModelModality[] {
for (const value of values) {
if (!Object.hasOwn(KNOWN_MODALITIES, value)) {
throw new Error(`models.dev model ${providerId}/${modelId} has unsupported modalities`);
}
}
return values as ModelModality[];
}
/**
* The providers Maka reads, taken from a models.dev response and checked to
* the depth every downstream projection depends on. A missing or shapeless
* provider rejects the whole response: a partial catalog would read as an
* upstream removal.
*/
export function selectModelsDevCatalog(catalog: unknown): ModelsDevCatalog {
if (!catalog || typeof catalog !== 'object' || Array.isArray(catalog)) {
throw new Error('models.dev response is not an object');
}
const source = catalog as Record<string, unknown>;
const selected: Record<string, ModelsDevProvider> = {};
for (const sourceId of [...new Set(Object.values(MODELS_DEV_PROVIDERS))].sort()) {
const provider = source[sourceId];
assertModelsDevProvider(sourceId, provider);
selected[sourceId] = provider;
}
return selected;
}
/**
* The provider shape every downstream projection depends on. A caller that
* reports rather than rejects — the drift report names what upstream broke —
* checks one provider at a time instead of taking the whole catalog.
*/
export function assertModelsDevProvider(
sourceId: string,
provider: unknown,
): asserts provider is ModelsDevProvider {
if (!provider || typeof provider !== 'object' || Array.isArray(provider)) {
throw new Error(`models.dev provider ${sourceId} is missing`);
}
const candidate = provider as Record<string, unknown>;
if (
!candidate.models ||
typeof candidate.models !== 'object' ||
Array.isArray(candidate.models) ||
Object.keys(candidate.models).length === 0
) {
throw new Error(`models.dev provider ${sourceId} has no non-empty models object`);
}
if (
typeof candidate.id !== 'string' ||
typeof candidate.name !== 'string' ||
typeof candidate.doc !== 'string' ||
(candidate.api !== undefined && typeof candidate.api !== 'string')
) {
throw new Error(`models.dev provider ${sourceId} has an unsupported shape`);
}
}
/** Every access path's models, keyed as Maka names the provider. */
export function projectModelsDevMetadata(
catalog: ModelsDevCatalog,
): Partial<Record<ProviderType, Record<string, ModelMetadata>>> {
const metadata: Partial<Record<ProviderType, Record<string, ModelMetadata>>> = {};
for (const [providerType, sourceId] of Object.entries(MODELS_DEV_PROVIDERS)) {
const provider = catalog[sourceId];
if (!provider) throw new Error(`models.dev provider ${sourceId} is missing`);
metadata[providerType as ProviderType] = Object.fromEntries(
Object.entries(provider.models)
.sort(([left], [right]) => left.localeCompare(right))
.map(([id, model]) => [id, projectModelsDevModel(sourceId, id, provider, model)]),
);
}
return metadata;
}
export function projectModelsDevModel(
providerId: string,
modelId: string,
provider: ModelsDevProvider,
model: ModelsDevModel,
): ModelMetadata {
if (
typeof provider.doc !== 'string' ||
typeof model?.name !== 'string' ||
(model.modalities !== undefined && !Array.isArray(model.modalities?.input)) ||
(model.modalities !== undefined && !Array.isArray(model.modalities?.output)) ||
typeof model.limit?.context !== 'number' ||
typeof model.limit?.output !== 'number' ||
typeof model.reasoning !== 'boolean' ||
typeof model.tool_call !== 'boolean'
) {
throw new Error(`models.dev model ${providerId}/${modelId} has an unsupported shape`);
}
const modalities =
model.modalities === undefined
? undefined
: {
input: requireModalities(providerId, modelId, model.modalities.input),
output: requireModalities(providerId, modelId, model.modalities.output),
};
if (
(model.description !== undefined && typeof model.description !== 'string') ||
(model.knowledge !== undefined && typeof model.knowledge !== 'string') ||
(model.limit?.input !== undefined &&
(typeof model.limit.input !== 'number' || !Number.isFinite(model.limit.input))) ||
(model.structured_output !== undefined && typeof model.structured_output !== 'boolean') ||
(model.last_updated !== undefined && typeof model.last_updated !== 'string')
) {
throw new Error(`models.dev model ${providerId}/${modelId} has an unsupported shape`);
}
const lifecycle = lifecycleForStatus(providerId, modelId, model.status);
const reasoningOptions = model.reasoning_options ?? [];
if (!Array.isArray(reasoningOptions)) {
throw new Error(`models.dev model ${providerId}/${modelId} has an unsupported shape`);
}
let efforts: string[] | undefined;
let toggle = false;
for (const entry of reasoningOptions) {
if (entry?.type === 'effort') {
const values = entry.values;
if (!Array.isArray(values) || values.some((value) => typeof value !== 'string')) {
throw new Error(`models.dev model ${providerId}/${modelId} has an unsupported shape`);
}
efforts = values;
} else if (entry?.type === 'toggle') {
toggle = true;
} else if (entry?.type !== 'budget_tokens') {
// budget_tokens is a known models.dev option type with no wire consumer
// yet; any other unknown type fails loudly so a models.dev schema change
// is a conscious decision, not silent drift.
throw new Error(`models.dev model ${providerId}/${modelId} has an unsupported shape`);
}
}
return {
displayName: model.name,
...(model.description !== undefined ? { description: model.description } : {}),
lifecycle,
contextWindow: model.limit?.context,
...(model.limit?.input !== undefined ? { inputLimit: model.limit.input } : {}),
maxOutputTokens: model.limit?.output,
...(model.knowledge !== undefined ? { knowledgeCutoff: model.knowledge } : {}),
...(model.structured_output !== undefined ? { structuredOutput: model.structured_output } : {}),
...(model.last_updated !== undefined ? { lastUpdated: model.last_updated } : {}),
...(model.cost?.input === 0 ? { isFree: true } : {}),
capabilities: {
...(modalities ? { vision: modalities.input.includes('image') } : {}),
reasoning: model.reasoning === true,
functionCalling: model.tool_call === true,
},
...(efforts?.length || toggle
? {
thinkingOptions: {
...(efforts?.length ? { efforts } : {}),
...(toggle ? { toggle: true } : {}),
},
}
: {}),
...(modalities
? {
modalities: {
input: [...modalities.input],
output: [...modalities.output],
},
}
: {}),
};
}
function lifecycleForStatus(
providerId: string,
modelId: string,
status: string | undefined,
): NonNullable<ModelMetadata['lifecycle']> {
if (status === undefined) return 'active';
if (status === 'active' || status === 'beta' || status === 'alpha' || status === 'deprecated') {
return status;
}
throw new Error(`models.dev model ${providerId}/${modelId} has an unsupported status`);
}
/**
* Paths present in `previous` that `next` no longer carries, in JSON Pointer
* form. Both arguments are whole projections, so a caller comparing only one
* section passes it under the same key each side and reads the same paths the
* generator reports.
*
* Upstream removing a fact is not upstream correcting one. A model that
* stopped declaring its effort set does not stop having a knob, and a build
* that silently adopts the shorter list drops a level the user had already
* chosen. What the caller does about it is policy: the generator refuses the
* refresh until a human acknowledges it, the Host records it and carries on.
*/
export function collectProjectionRemovals(previous: unknown, next: unknown): string[] {
const removals: string[] = [];
collectRemovals(previous, next, [], removals);
return removals.sort();
}
function collectRemovals(
previous: unknown,
next: unknown,
path: (string | number)[],
removals: string[],
): void {
if (Array.isArray(previous)) {
if (!Array.isArray(next)) {
removals.push(projectionPath(path));
return;
}
if (path.length === 1 && path[0] === 'pricing') {
const nextByModelKey = new Map(
next.map((entry) => [(entry as { modelKey?: unknown })?.modelKey, entry]),
);
for (const entry of previous) {
const modelKey = (entry as { modelKey?: unknown })?.modelKey;
const modelPath = [...path, String(modelKey)];
const nextEntry = nextByModelKey.get(modelKey);
if (!nextEntry) removals.push(projectionPath(modelPath));
else collectRemovals(entry, nextEntry, modelPath, removals);
}
return;
}
for (const value of previous) {
if (!next.some((candidate) => Object.is(candidate, value))) {
removals.push(`${projectionPath(path)} value ${JSON.stringify(value)}`);
}
}
return;
}
if (!previous || typeof previous !== 'object') {
// A capability withdrawn is a removal even though the key survives.
if (
previous === true &&
next === false &&
path.length === 5 &&
path[0] === 'metadata' &&
path[3] === 'capabilities'
) {
removals.push(projectionPath(path));
}
return;
}
if (!next || typeof next !== 'object' || Array.isArray(next)) {
removals.push(projectionPath(path));
return;
}
for (const [key, value] of Object.entries(previous)) {
const childPath = [...path, key];
if (!Object.hasOwn(next, key)) removals.push(projectionPath(childPath));
else collectRemovals(value, (next as Record<string, unknown>)[key], childPath, removals);
}
}
function projectionPath(path: readonly (string | number)[]): string {
return `/${path.map((segment) => String(segment).replaceAll('~', '~0').replaceAll('/', '~1')).join('/')}`;
}