Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/sync-models.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }}
Expand Down
20 changes: 20 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,26 @@ 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:

- 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.

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.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
31 changes: 4 additions & 27 deletions scripts/convert-openrouter-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { toModelConstName } from './model-sync/ids'

type InputModality = 'text' | 'image' | 'audio' | 'video' | 'document'

Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -338,9 +316,8 @@ function generateModelMetaString(model: OpenRouterModel): string {
return lines.join('\n')
}

function convertModels(models: Array<OpenRouterModel>): string {
const modelStrings = models.map(generateModelMetaString)
return modelStrings.join('\n')
function convertModels(sourceModels: Array<OpenRouterModel>): string {
return sourceModels.map(generateModelMetaString).join('\n')
}

// ============================================================
Expand Down
50 changes: 50 additions & 0 deletions scripts/model-sync/ids.test.ts
Original file line number Diff line number Diff line change
@@ -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('maps a leading tilde so an OpenRouter alias is a valid JS identifier', () => {
expect(toModelConstName('~anthropic/claude-haiku-latest')).toBe(
'_ANTHROPIC_CLAUDE_HAIKU_LATEST',
)
})

it('fails loud when the id cannot become a JS identifier', () => {
expect(() => toModelConstName('openai/gpt-5.5!')).toThrow(
/valid JS identifier/i,
)
})
})
42 changes: 42 additions & 0 deletions scripts/model-sync/ids.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Shared OpenRouter id helpers for `convert-openrouter-models.ts` and
* `sync-provider-models.ts`.
*
* 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_]*$/

export function isRoutingAlias(modelId: string): boolean {
return modelId.startsWith('~')
}

export function rejectRoutingAliases<T extends { id: string }>(
models: Array<T>,
): Array<T> {
return models.filter((model) => !isRoutingAlias(model.id))
}

export function toModelConstName(modelId: string): string {
const constName = modelId
.replaceAll('~', '_')
.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
}
57 changes: 57 additions & 0 deletions scripts/model-sync/provider-supports.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
82 changes: 82 additions & 0 deletions scripts/model-sync/provider-supports.ts
Original file line number Diff line number Diff line change
@@ -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<string>
supportedParameters?: Array<string>
}

function hasParam(params: Array<string>, names: Array<string>): boolean {
return names.some((name) => params.includes(name))
}

function quoteList(values: Array<string>): 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<string> = []
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<string> = []
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')
}
}
}
Loading
Loading