From 0c601404ffd6eec41dcb600b8d2c875f58564c5b Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 14 Aug 2026 15:15:46 +0200 Subject: [PATCH 1/2] fix(scripts): skip OpenRouter aliases and stop copying tool templates The model sync generator treated ~routing aliases as models and stamped every new native-provider model with another model's tool list. Skip aliases, share one identifier sanitiser, and infer only features that OpenRouter reports. --- .github/workflows/sync-models.yml | 3 + CONTRIBUTING.md | 19 +++++ package.json | 2 +- scripts/convert-openrouter-models.ts | 37 +++------ scripts/model-sync/ids.test.ts | 50 ++++++++++++ scripts/model-sync/ids.ts | 45 +++++++++++ scripts/model-sync/provider-supports.test.ts | 57 ++++++++++++++ scripts/model-sync/provider-supports.ts | 82 ++++++++++++++++++++ scripts/sync-provider-models.ts | 47 +++++------ 9 files changed, 289 insertions(+), 53 deletions(-) create mode 100644 scripts/model-sync/ids.test.ts create mode 100644 scripts/model-sync/ids.ts create mode 100644 scripts/model-sync/provider-supports.test.ts create mode 100644 scripts/model-sync/provider-supports.ts diff --git a/.github/workflows/sync-models.yml b/.github/workflows/sync-models.yml index 35e7f80111..740ee6dae2 100644 --- a/.github/workflows/sync-models.yml +++ b/.github/workflows/sync-models.yml @@ -46,6 +46,9 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" git add packages/ scripts/openrouter.models.json scripts/openrouter.video-models.json scripts/vercel-gateway.models.json scripts/.sync-models-last-run .changeset/ git commit -m "chore: sync model metadata" + # GITHUB_TOKEN pushes do not start the PR Test / E2E workflows. + # After this job, a maintainer must run those checks from the + # Actions tab or push an empty commit to automated/sync-models. git push --force origin HEAD:automated/sync-models env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5d9edd7580..ecba9a9dda 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,6 +39,25 @@ scripts/ # Repo-level scripts (doc generation, model sync, link v For deeper architecture details (adapter system, isomorphic tools, framework integrations), see `CLAUDE.md` at the repo root. +## Syncing model metadata + +`pnpm generate:models` is the maintainer command behind the daily **Sync Model Metadata** workflow (branch `automated/sync-models`). It: + +1. Fetches OpenRouter and Vercel AI Gateway catalogs. +2. Regenerates `packages/ai-openrouter/src/model-meta.ts` and the Vercel Gateway model list. +3. Inserts **new** native-provider models into `packages/ai-openai`, `ai-anthropic`, `ai-gemini`, and `ai-grok`. +4. Writes a patch changeset for the packages that changed. + +Rules the generator follows: + +- Skip OpenRouter routing aliases (ids that start with `~`). Those ids move under you and cannot become JS identifiers. +- For a new native-provider model, write id, modalities, and pricing. Infer features from OpenRouter `supported_parameters` when that field exists. Do **not** copy another model's tool list (`computer_use`, `google_search`, `x_search`, and similar). +- Leave curated tools and flags on existing models alone. Edit those by hand after the sync PR opens. + +Do not rebase or hand-edit `automated/sync-models`. The next scheduled run force-pushes that branch from `main`. Merge generator fixes to `main` first, then let the workflow rebuild the sync PR. + +The workflow pushes with `GITHUB_TOKEN`, so GitHub does not start Test / E2E on that push. After a sync, a maintainer with write access can run the PR checks from the Actions tab, or push an empty commit to `automated/sync-models`. + ## Day-to-day commands All commands are run from the repo root. Nx handles affected detection and caching. diff --git a/package.json b/package.json index 4c0e711f38..26d907dd40 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "test:knip": "knip", "test:docs": "tsx scripts/verify-links.ts", "test:dts": "node scripts/scan-dangling-dts.mjs", - "test:maintainer": "vitest run --root scripts/maintainer", + "test:maintainer": "vitest run --root scripts/maintainer && vitest run scripts/model-sync", "maintainer:sweep": "tsx scripts/maintainer/sweep.ts", "maintainer:scorecard": "tsx scripts/maintainer/scorecard.ts", "test:kiira": "kiira check", diff --git a/scripts/convert-openrouter-models.ts b/scripts/convert-openrouter-models.ts index 126c837869..d71c291514 100644 --- a/scripts/convert-openrouter-models.ts +++ b/scripts/convert-openrouter-models.ts @@ -12,6 +12,7 @@ import { models } from './openrouter.models' import { videoModels as videoApiModels } from './openrouter.video-models' import type { OpenRouterModel } from './openrouter.models' import type { OpenRouterVideoApiModel } from './openrouter.video-models' +import { rejectRoutingAliases, toModelConstName } from './model-sync/ids' type InputModality = 'text' | 'image' | 'audio' | 'video' | 'document' @@ -187,30 +188,7 @@ function generateModelMetaString(model: OpenRouterModel): string { const outputModalities = model.architecture.output_modalities .map(mapInputModality) .filter((m): m is InputModality => m !== null) - // OpenRouter uses `~prefix/name` to denote routing aliases (e.g. - // `~anthropic/claude-haiku-latest`). The model ID itself is preserved as a - // string literal so users can pass it to `chat({ model: ... })`. The leading - // `~` is mapped to `_` only for the derived constant name so it's a valid - // JavaScript identifier. - const constName = model.id - .replaceAll('~', '_') - .replaceAll('/', '-') - .replaceAll('-', '_') - .replaceAll('.', '_') - .replaceAll(':', '_') - .toUpperCase() - // Safety net: if a future OpenRouter ID quirk produces a non-identifier - // constant name, fail loudly here instead of letting prettier choke on the - // generated file later in the pipeline. - if (!/^[A-Z_][A-Z0-9_]*$/.test(constName)) { - throw new Error( - `Generated constant name is not a valid JS identifier: ${JSON.stringify( - constName, - )} (from OpenRouter model.id ${JSON.stringify( - model.id, - )}). Extend the constName sanitiser to handle this case.`, - ) - } + const constName = toModelConstName(model.id) // Ensure at least 'text' is present if (!inputModalities.includes('text')) { inputModalities.unshift('text') @@ -338,8 +316,15 @@ function generateModelMetaString(model: OpenRouterModel): string { return lines.join('\n') } -function convertModels(models: Array): string { - const modelStrings = models.map(generateModelMetaString) +function convertModels(sourceModels: Array): string { + const stableModels = rejectRoutingAliases(sourceModels) + const skipped = sourceModels.length - stableModels.length + if (skipped > 0) { + console.log( + `Skipped ${skipped} OpenRouter routing-alias model(s) (\`~prefix/...\`)`, + ) + } + const modelStrings = stableModels.map(generateModelMetaString) return modelStrings.join('\n') } diff --git a/scripts/model-sync/ids.test.ts b/scripts/model-sync/ids.test.ts new file mode 100644 index 0000000000..06c5882b87 --- /dev/null +++ b/scripts/model-sync/ids.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { isRoutingAlias, rejectRoutingAliases, toModelConstName } from './ids' + +describe('isRoutingAlias', () => { + it('treats a leading tilde as a routing alias', () => { + expect(isRoutingAlias('~anthropic/claude-haiku-latest')).toBe(true) + }) + + it('does not treat a stable model id as an alias', () => { + expect(isRoutingAlias('anthropic/claude-opus-4.6')).toBe(false) + }) +}) + +describe('rejectRoutingAliases', () => { + it('drops alias ids and keeps stable ids', () => { + const kept = rejectRoutingAliases([ + { id: '~anthropic/claude-haiku-latest' }, + { id: 'openai/gpt-5.5' }, + { id: 'anthropic/claude-opus-4.6' }, + ]) + expect(kept.map((m) => m.id)).toEqual([ + 'openai/gpt-5.5', + 'anthropic/claude-opus-4.6', + ]) + }) +}) + +describe('toModelConstName', () => { + it('turns a stable OpenRouter id into a JS identifier', () => { + expect(toModelConstName('anthropic/claude-opus-4.6')).toBe( + 'ANTHROPIC_CLAUDE_OPUS_4_6', + ) + }) + + it('turns a stripped provider id into a JS identifier', () => { + expect(toModelConstName('gpt-5.6-luna-pro')).toBe('GPT_5_6_LUNA_PRO') + }) + + it('refuses to name a routing alias', () => { + expect(() => toModelConstName('~anthropic/claude-haiku-latest')).toThrow( + /routing alias/i, + ) + }) + + it('fails loud when the id cannot become a JS identifier', () => { + expect(() => toModelConstName('openai/gpt-5.5!')).toThrow( + /valid JS identifier/i, + ) + }) +}) diff --git a/scripts/model-sync/ids.ts b/scripts/model-sync/ids.ts new file mode 100644 index 0000000000..7a7bbe0379 --- /dev/null +++ b/scripts/model-sync/ids.ts @@ -0,0 +1,45 @@ +/** + * Shared OpenRouter id helpers for `convert-openrouter-models.ts` and + * `sync-provider-models.ts`. + * + * OpenRouter marks unstable routing aliases with a leading `~` + * (`~anthropic/claude-haiku-latest`). Those aliases are not stable model + * ids and they cannot become JS identifiers, so the generators skip them. + */ + +const CONST_NAME_RE = /^[A-Z_][A-Z0-9_]*$/ + +export function isRoutingAlias(modelId: string): boolean { + return modelId.startsWith('~') +} + +export function rejectRoutingAliases( + models: Array, +): Array { + return models.filter((model) => !isRoutingAlias(model.id)) +} + +export function toModelConstName(modelId: string): string { + if (isRoutingAlias(modelId)) { + throw new Error( + `Refusing to name a routing alias ${JSON.stringify(modelId)}. Filter aliases with rejectRoutingAliases() first.`, + ) + } + + const constName = modelId + .replaceAll('/', '_') + .replaceAll('-', '_') + .replaceAll('.', '_') + .replaceAll(':', '_') + .toUpperCase() + + if (!CONST_NAME_RE.test(constName)) { + throw new Error( + `Generated constant name is not a valid JS identifier: ${JSON.stringify( + constName, + )} (from model id ${JSON.stringify(modelId)}).`, + ) + } + + return constName +} diff --git a/scripts/model-sync/provider-supports.test.ts b/scripts/model-sync/provider-supports.test.ts new file mode 100644 index 0000000000..fb7a14f876 --- /dev/null +++ b/scripts/model-sync/provider-supports.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { buildProviderSupportsBody } from './provider-supports' + +describe('buildProviderSupportsBody', () => { + it('does not copy OpenAI computer_use or local_shell onto a new model', () => { + const body = buildProviderSupportsBody({ + provider: 'openai', + inputModalities: ['text', 'image'], + supportedParameters: ['temperature', 'tools', 'response_format'], + }) + expect(body).not.toContain('computer_use') + expect(body).not.toContain('local_shell') + expect(body).not.toContain('apply_patch') + expect(body).toContain('tools: []') + expect(body).toContain("endpoints: ['chat', 'chat-completions']") + expect(body).toContain('function_calling') + expect(body).toContain('structured_outputs') + }) + + it('does not invent Anthropic tools or priority_tier', () => { + const body = buildProviderSupportsBody({ + provider: 'anthropic', + inputModalities: ['text', 'image', 'document'], + supportedParameters: ['max_tokens', 'tools'], + }) + expect(body).not.toContain('web_fetch') + expect(body).not.toContain('computer_use') + expect(body).not.toContain('priority_tier') + expect(body).not.toContain('extended_thinking') + expect(body).toContain('tools: []') + }) + + it('does not invent Gemini google_search or url_context', () => { + const body = buildProviderSupportsBody({ + provider: 'gemini', + inputModalities: ['text'], + supportedParameters: ['tools', 'include_reasoning'], + }) + expect(body).not.toContain('google_search') + expect(body).not.toContain('url_context') + expect(body).toContain('tools: []') + expect(body).toContain('function_calling') + expect(body).toContain('thinking') + }) + + it('does not invent Grok x_search', () => { + const body = buildProviderSupportsBody({ + provider: 'grok', + inputModalities: ['text', 'image'], + supportedParameters: ['tools', 'include_reasoning'], + }) + expect(body).not.toContain('x_search') + expect(body).toContain('tools: []') + expect(body).toContain('tool_calling') + expect(body).toContain('reasoning') + }) +}) diff --git a/scripts/model-sync/provider-supports.ts b/scripts/model-sync/provider-supports.ts new file mode 100644 index 0000000000..0c85d7cb1d --- /dev/null +++ b/scripts/model-sync/provider-supports.ts @@ -0,0 +1,82 @@ +/** + * Conservative `supports` blocks for newly synced native-provider models. + * + * The generator only writes facts it can see: input modalities from + * OpenRouter, plus features inferred from `supported_parameters`. + * It does not copy a reference model's tool list (computer_use, x_search, + * google_search, …) onto every new id. + */ + +export type SyncedProvider = 'openai' | 'anthropic' | 'gemini' | 'grok' + +export interface ProviderSupportsInput { + provider: SyncedProvider + inputModalities: Array + supportedParameters?: Array +} + +function hasParam(params: Array, names: Array): boolean { + return names.some((name) => params.includes(name)) +} + +function quoteList(values: Array): string { + return `[${values.map((value) => `'${value}'`).join(', ')}]` +} + +export function buildProviderSupportsBody( + input: ProviderSupportsInput, +): string { + const params = input.supportedParameters ?? [] + const inputList = quoteList(input.inputModalities) + const hasTools = hasParam(params, ['tools', 'tool_choice']) + const hasStructured = hasParam(params, [ + 'response_format', + 'structured_outputs', + ]) + const hasReasoning = hasParam(params, [ + 'include_reasoning', + 'reasoning', + 'reasoning_effort', + ]) + + switch (input.provider) { + case 'openai': { + const features = ['streaming'] + if (hasTools) features.push('function_calling') + if (hasStructured) features.push('structured_outputs') + return [ + ` input: ${inputList},`, + ` output: ['text'],`, + ` endpoints: ['chat', 'chat-completions'],`, + ` features: ${quoteList(features)},`, + ` tools: [],`, + ].join('\n') + } + case 'anthropic': + return [` input: ${inputList},`, ` tools: [],`].join('\n') + case 'gemini': { + const capabilities: Array = [] + if (hasTools) capabilities.push('function_calling') + if (hasStructured) capabilities.push('structured_output') + if (hasReasoning) capabilities.push('thinking') + const lines = [` input: ${inputList},`, ` output: ['text'],`] + if (capabilities.length > 0) { + lines.push(` capabilities: ${quoteList(capabilities)},`) + } + lines.push(` tools: [],`) + return lines.join('\n') + } + case 'grok': { + const capabilities: Array = [] + if (hasReasoning) capabilities.push('reasoning') + if (hasStructured) capabilities.push('structured_outputs') + if (hasTools) capabilities.push('tool_calling') + const lines = [` input: ${inputList},`, ` output: ['text'],`] + if (capabilities.length > 0) { + lines.push(` capabilities: ${quoteList(capabilities)},`) + } + lines.push(` tools: [],`) + return lines.join('\n') + } + } +} diff --git a/scripts/sync-provider-models.ts b/scripts/sync-provider-models.ts index 56468a16bc..5457baacf1 100644 --- a/scripts/sync-provider-models.ts +++ b/scripts/sync-provider-models.ts @@ -37,6 +37,9 @@ import { execFileSync } from 'node:child_process' import { readFile, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { isRoutingAlias, toModelConstName } from './model-sync/ids' +import { buildProviderSupportsBody } from './model-sync/provider-supports' +import type { SyncedProvider } from './model-sync/provider-supports' import { models } from './openrouter.models' import type { OpenRouterModel } from './openrouter.models' @@ -72,8 +75,8 @@ interface ProviderConfig { * (issue #849); other providers treat token limits as optional and omit it. */ maxOutputTokensMapName?: string - /** The supports block template (minus input modalities, which come from OpenRouter) */ - referenceSupportsBody: string + /** Provider key for conservative supports generation */ + kind: SyncedProvider /** Valid input modality types for this provider's ModelMeta interface */ validInputModalities: Array /** The satisfies type clause (after 'as const satisfies') */ @@ -98,10 +101,7 @@ const PROVIDER_MAP: Record = { providerOptionsTypeName: 'OpenAIChatModelProviderOptionsByName', inputModalitiesTypeName: 'OpenAIModelInputModalitiesByName', validInputModalities: ['text', 'image', 'audio', 'video'], - referenceSupportsBody: ` output: ['text'], - endpoints: ['chat', 'chat-completions'], - features: ['streaming', 'function_calling', 'structured_outputs', 'distillation'], - tools: ['web_search', 'web_search_preview', 'file_search', 'image_generation', 'code_interpreter', 'mcp', 'computer_use', 'local_shell', 'shell', 'apply_patch'],`, + kind: 'openai', referenceSatisfies: 'ModelMeta', referenceProviderOptionsEntry: @@ -126,9 +126,7 @@ const PROVIDER_MAP: Record = { inputModalitiesTypeName: 'AnthropicModelInputModalitiesByName', maxOutputTokensMapName: 'ANTHROPIC_MODEL_MAX_OUTPUT_TOKENS', validInputModalities: ['text', 'image', 'audio', 'video', 'document'], - referenceSupportsBody: ` extended_thinking: true, - priority_tier: true, - tools: ['web_search', 'web_fetch', 'code_execution', 'computer_use', 'bash', 'text_editor', 'memory'],`, + kind: 'anthropic', referenceSatisfies: 'ModelMeta', referenceProviderOptionsEntry: @@ -146,9 +144,7 @@ const PROVIDER_MAP: Record = { providerOptionsTypeName: 'GeminiChatModelProviderOptionsByName', inputModalitiesTypeName: 'GeminiModelInputModalitiesByName', validInputModalities: ['text', 'image', 'audio', 'video', 'document'], - referenceSupportsBody: ` output: ['text'], - capabilities: ['batch_api', 'caching', 'function_calling', 'structured_output', 'thinking'], - tools: ['code_execution', 'file_search', 'google_search', 'url_context'],`, + kind: 'gemini', referenceSatisfies: 'ModelMeta', referenceProviderOptionsEntry: @@ -168,9 +164,7 @@ const PROVIDER_MAP: Record = { providerOptionsTypeName: 'GrokChatModelProviderOptionsByName', inputModalitiesTypeName: 'GrokModelInputModalitiesByName', validInputModalities: ['text', 'image', 'audio', 'video', 'document'], - referenceSupportsBody: ` output: ['text'], - capabilities: ['reasoning', 'structured_outputs', 'tool_calling'], - tools: [],`, + kind: 'grok', referenceSatisfies: 'ModelMeta', referenceProviderOptionsEntry: 'GrokProviderOptions', hasBothNameAndId: false, @@ -219,13 +213,7 @@ function stripPrefix(prefix: string, modelId: string): string { * E.g. 'gpt-6' -> 'GPT_6', 'grok-4.20-multi-agent' -> 'GROK_4_20_MULTI_AGENT' */ function toConstName(prefix: string, modelId: string): string { - const stripped = stripPrefix(prefix, modelId) - return stripped - .replace(/[-]/g, '_') - .replace(/[.]/g, '_') - .replace(/[:]/g, '_') - .replace(/[/]/g, '_') - .toUpperCase() + return toModelConstName(stripPrefix(prefix, modelId)) } /** @@ -386,7 +374,6 @@ function generateModelConstant( const inputModalities = mapInputModalities( model.architecture.input_modalities, ).filter((m) => config.validInputModalities.includes(m)) - const inputModalitiesStr = inputModalities.map((m) => `'${m}'`).join(', ') const lines: Array = [] lines.push(`const ${constName} = {`) @@ -413,10 +400,14 @@ function generateModelConstant( ) } - // supports block (actual input modalities + reference capabilities) lines.push(` supports: {`) - lines.push(` input: [${inputModalitiesStr}],`) - lines.push(config.referenceSupportsBody) + lines.push( + buildProviderSupportsBody({ + provider: config.kind, + inputModalities, + supportedParameters: model.supported_parameters, + }), + ) lines.push(` },`) // pricing @@ -645,6 +636,10 @@ async function main() { }> = [] for (const model of providerModels) { + if (isRoutingAlias(model.id)) { + continue + } + const strippedId = stripPrefix(prefix, model.id) const constName = toConstName(prefix, model.id) From 394ac3b4128abaf7402d9774fae2564fb030a16c Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Fri, 14 Aug 2026 15:36:42 +0200 Subject: [PATCH 2/2] fix(scripts): keep OpenRouter ~ aliases, skip them on native providers OpenRouter catalog still emits ~anthropic/claude-haiku-latest (const name maps ~ to _). Native openai/anthropic/gemini/grok sync still skips those ids. --- CONTRIBUTING.md | 3 ++- scripts/convert-openrouter-models.ts | 12 ++---------- scripts/model-sync/ids.test.ts | 6 +++--- scripts/model-sync/ids.ts | 15 ++++++--------- 4 files changed, 13 insertions(+), 23 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ecba9a9dda..021698caec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -50,7 +50,8 @@ For deeper architecture details (adapter system, isomorphic tools, framework int Rules the generator follows: -- Skip OpenRouter routing aliases (ids that start with `~`). Those ids move under you and cannot become JS identifiers. +- Keep OpenRouter routing aliases (ids that start with `~`) in the OpenRouter catalog. Users can pass `chat({ model: '~anthropic/claude-haiku-latest' })`. The generated constant name maps `~` to `_`. +- Do **not** copy those aliases into native provider files (`ai-openai`, `ai-anthropic`, `ai-gemini`, `ai-grok`). Those adapters only accept the provider's own ids. - For a new native-provider model, write id, modalities, and pricing. Infer features from OpenRouter `supported_parameters` when that field exists. Do **not** copy another model's tool list (`computer_use`, `google_search`, `x_search`, and similar). - Leave curated tools and flags on existing models alone. Edit those by hand after the sync PR opens. diff --git a/scripts/convert-openrouter-models.ts b/scripts/convert-openrouter-models.ts index d71c291514..1ea245ec1b 100644 --- a/scripts/convert-openrouter-models.ts +++ b/scripts/convert-openrouter-models.ts @@ -12,7 +12,7 @@ import { models } from './openrouter.models' import { videoModels as videoApiModels } from './openrouter.video-models' import type { OpenRouterModel } from './openrouter.models' import type { OpenRouterVideoApiModel } from './openrouter.video-models' -import { rejectRoutingAliases, toModelConstName } from './model-sync/ids' +import { toModelConstName } from './model-sync/ids' type InputModality = 'text' | 'image' | 'audio' | 'video' | 'document' @@ -317,15 +317,7 @@ function generateModelMetaString(model: OpenRouterModel): string { } function convertModels(sourceModels: Array): string { - const stableModels = rejectRoutingAliases(sourceModels) - const skipped = sourceModels.length - stableModels.length - if (skipped > 0) { - console.log( - `Skipped ${skipped} OpenRouter routing-alias model(s) (\`~prefix/...\`)`, - ) - } - const modelStrings = stableModels.map(generateModelMetaString) - return modelStrings.join('\n') + return sourceModels.map(generateModelMetaString).join('\n') } // ============================================================ diff --git a/scripts/model-sync/ids.test.ts b/scripts/model-sync/ids.test.ts index 06c5882b87..2f0c1159af 100644 --- a/scripts/model-sync/ids.test.ts +++ b/scripts/model-sync/ids.test.ts @@ -36,9 +36,9 @@ describe('toModelConstName', () => { expect(toModelConstName('gpt-5.6-luna-pro')).toBe('GPT_5_6_LUNA_PRO') }) - it('refuses to name a routing alias', () => { - expect(() => toModelConstName('~anthropic/claude-haiku-latest')).toThrow( - /routing alias/i, + it('maps a leading tilde so an OpenRouter alias is a valid JS identifier', () => { + expect(toModelConstName('~anthropic/claude-haiku-latest')).toBe( + '_ANTHROPIC_CLAUDE_HAIKU_LATEST', ) }) diff --git a/scripts/model-sync/ids.ts b/scripts/model-sync/ids.ts index 7a7bbe0379..ee43570aa4 100644 --- a/scripts/model-sync/ids.ts +++ b/scripts/model-sync/ids.ts @@ -2,9 +2,11 @@ * Shared OpenRouter id helpers for `convert-openrouter-models.ts` and * `sync-provider-models.ts`. * - * OpenRouter marks unstable routing aliases with a leading `~` - * (`~anthropic/claude-haiku-latest`). Those aliases are not stable model - * ids and they cannot become JS identifiers, so the generators skip them. + * OpenRouter marks routing aliases with a leading `~` + * (`~anthropic/claude-haiku-latest`). The OpenRouter catalog keeps those + * ids so `chat({ model: '~anthropic/claude-haiku-latest' })` type-checks. + * Native provider sync skips them. The `~` is mapped to `_` only in the + * generated constant name so the file is valid JS. */ const CONST_NAME_RE = /^[A-Z_][A-Z0-9_]*$/ @@ -20,13 +22,8 @@ export function rejectRoutingAliases( } export function toModelConstName(modelId: string): string { - if (isRoutingAlias(modelId)) { - throw new Error( - `Refusing to name a routing alias ${JSON.stringify(modelId)}. Filter aliases with rejectRoutingAliases() first.`, - ) - } - const constName = modelId + .replaceAll('~', '_') .replaceAll('/', '_') .replaceAll('-', '_') .replaceAll('.', '_')