-
Notifications
You must be signed in to change notification settings - Fork 37
Bundle models.dev catalog for AI credits model resolution #4589
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a5f0c2a
d8cf2b1
6b11737
c200251
d05a9b8
542a169
3ff4fae
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| 'use strict'; | ||
|
|
||
| const bundledCatalog = require('./models.dev.catalog.json'); | ||
|
|
||
| const DOLLARS_PER_TOKEN_TO_DOLLARS_PER_MILLION = 1_000_000; | ||
|
|
||
| function canonicalizeModel(model) { | ||
| if (!model || typeof model !== 'string') return ''; | ||
| const bare = model.includes('/') ? model.slice(model.indexOf('/') + 1) : model; | ||
| const withoutDateSuffix = bare.replace(/(-alpha)?-(\d{4}-\d{2}-\d{2}|\d{8})$/, ''); | ||
| return withoutDateSuffix.replace(/[._]/g, '-'); | ||
| } | ||
|
|
||
| function parseDollarsPerToken(value) { | ||
| const parsed = Number(value); | ||
| if (!Number.isFinite(parsed) || parsed < 0) return null; | ||
| return parsed * DOLLARS_PER_TOKEN_TO_DOLLARS_PER_MILLION; | ||
| } | ||
|
|
||
| function normalizePricing(pricing) { | ||
| if (!pricing || typeof pricing !== 'object') return null; | ||
|
|
||
| const input = parseDollarsPerToken(pricing.prompt); | ||
| const output = parseDollarsPerToken(pricing.completion); | ||
| if (input === null || output === null) return null; | ||
|
|
||
| const cachedInput = pricing.input_cache_read === undefined | ||
| ? input * 0.1 | ||
| : parseDollarsPerToken(pricing.input_cache_read); | ||
| if (cachedInput === null) return null; | ||
|
|
||
| const cacheWrite = pricing.input_cache_write === undefined | ||
| ? null | ||
| : parseDollarsPerToken(pricing.input_cache_write); | ||
| if (pricing.input_cache_write !== undefined && cacheWrite === null) return null; | ||
|
|
||
| return { | ||
| input, | ||
| cachedInput, | ||
| cacheWrite, | ||
| output, | ||
| }; | ||
| } | ||
|
|
||
| function isZeroCostPricing(pricing) { | ||
| if (!pricing) return false; | ||
| return pricing.input === 0 && | ||
| pricing.cachedInput === 0 && | ||
| (pricing.cacheWrite === null || pricing.cacheWrite === 0) && | ||
| pricing.output === 0; | ||
| } | ||
|
|
||
| function buildCatalogIndex(entries) { | ||
| const knownModels = new Set(); | ||
| const pricingByModel = new Map(); | ||
|
|
||
| for (const entry of entries) { | ||
| const normalizedPricing = normalizePricing(entry?.pricing); | ||
| for (const candidate of [entry?.id, entry?.canonical_slug]) { | ||
| const canonical = canonicalizeModel(candidate); | ||
| if (!canonical) continue; | ||
| knownModels.add(canonical); | ||
| if (normalizedPricing && !pricingByModel.has(canonical)) { | ||
| pricingByModel.set(canonical, normalizedPricing); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return { knownModels, pricingByModel }; | ||
| } | ||
|
|
||
| const { knownModels, pricingByModel } = buildCatalogIndex(Array.isArray(bundledCatalog?.data) ? bundledCatalog.data : []); | ||
|
|
||
| function resolveCatalogModel(model) { | ||
| const canonical = canonicalizeModel(model); | ||
| if (!canonical) { | ||
| return { exists: false, pricing: null, zeroCost: false }; | ||
| } | ||
|
|
||
| const exactPricing = pricingByModel.get(canonical); | ||
| if (exactPricing) { | ||
| return { exists: true, pricing: exactPricing, zeroCost: isZeroCostPricing(exactPricing) }; | ||
| } | ||
|
|
||
| let stripped = canonical; | ||
| while (stripped.includes('-')) { | ||
| stripped = stripped.slice(0, stripped.lastIndexOf('-')); | ||
| const pricing = pricingByModel.get(stripped); | ||
| if (pricing) { | ||
| return { exists: true, pricing, zeroCost: isZeroCostPricing(pricing) }; | ||
| } | ||
| } | ||
|
|
||
| return { exists: knownModels.has(canonical), pricing: null, zeroCost: false }; | ||
| } | ||
|
|
||
| module.exports = { | ||
| resolveCatalogModel, | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| const { resolveCatalogModel } = require('./models-dev-catalog'); | ||
|
|
||
| describe('models-dev-catalog', () => { | ||
| it('resolves bundled pricing for catalog models outside the curated pricing table', () => { | ||
| expect(resolveCatalogModel('openai/gpt-5.5-pro')).toEqual({ | ||
| exists: true, | ||
| pricing: { | ||
| input: 30, | ||
| cachedInput: 3, | ||
| cacheWrite: null, | ||
| output: 180, | ||
| }, | ||
| zeroCost: false, | ||
| }); | ||
| }); | ||
|
|
||
| it('treats catalog entries with negative sentinel pricing as unpriced', () => { | ||
| // "openrouter/pareto-code" has prompt/completion "-1" sentinel values in the bundled catalog | ||
| expect(resolveCatalogModel('openrouter/pareto-code')).toEqual({ | ||
| exists: true, | ||
| pricing: null, | ||
| zeroCost: false, | ||
| }); | ||
| }); | ||
|
|
||
| it('recognizes zero-cost catalog models', () => { | ||
| expect(resolveCatalogModel('google/gemma-4-31b-it:free')).toEqual({ | ||
| exists: true, | ||
| pricing: { | ||
| input: 0, | ||
| cachedInput: 0, | ||
| cacheWrite: null, | ||
| output: 0, | ||
| }, | ||
| zeroCost: true, | ||
| }); | ||
| }); | ||
| }); | ||
|
Comment on lines
+26
to
+38
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added regression test in commit |
||
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -719,13 +719,22 @@ and error type `ai_credits_limit_exceeded`. | |
|
|
||
| ### 10.7.1 Model Name Resolution for Pricing | ||
|
|
||
| The AI credits guard resolves model names against a built-in pricing table. | ||
| The AI credits guard resolves model names using a two-step lookup: | ||
|
|
||
| 1. **Curated pricing table** — a built-in table of known models with exact pricing. | ||
| 2. **Bundled models.dev catalog** — a bundled snapshot of the models.dev catalog used as a fallback when the model is not found in the curated table. | ||
|
|
||
| Model names are **canonicalized** before lookup: provider prefixes | ||
| (e.g. `copilot/`) are stripped, and separators (`.`, `_`, `-`) are treated | ||
| as interchangeable. For example, `copilot/claude-sonnet-4.6`, | ||
| `claude_sonnet_4_6`, and `claude-sonnet-4-6` all resolve to the same pricing | ||
| entry. | ||
|
|
||
| If neither source resolves the model, the `defaultAiCreditsPricing` fallback | ||
| (if configured) is used. If that is also absent, the request is rejected. | ||
| Models whose catalog entry carries zero-cost pricing are recognized as known | ||
| models with zero AI credit impact, so they are never rejected as "unknown". | ||
|
|
||
| ### 10.7.2 Default AI Credits Pricing (Fallback) | ||
|
|
||
| `defaultAiCreditsPricing` is an optional object with `input` and `output` | ||
|
|
@@ -736,13 +745,15 @@ It is supplied via the AWF config file and maps to the | |
| `AWF_DEFAULT_AI_CREDITS_PRICING` environment variable (JSON string) injected | ||
| into the api-proxy container. | ||
|
|
||
| When configured, any model not found in the built-in pricing table uses | ||
| these rates as a fallback for AI credits calculation. | ||
| When configured, any model not found in the curated built-in pricing table or | ||
| the bundled models.dev catalog uses these rates as a fallback for AI credits | ||
| calculation. | ||
|
Comment on lines
+748
to
+750
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updated in commit |
||
|
|
||
| ### 10.7.3 Unknown Model Rejection | ||
|
|
||
| When `maxAiCredits` is active and the proxy encounters a request whose model | ||
| cannot be resolved from the built-in pricing table: | ||
| cannot be resolved from the curated built-in pricing table or the bundled | ||
| models.dev catalog: | ||
|
|
||
| 1. **If `defaultAiCreditsPricing` is configured**: the fallback rates are used | ||
| and the request proceeds normally. | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.