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
2 changes: 1 addition & 1 deletion containers/api-proxy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ COPY server.js logging.js metrics.js rate-limiter.js \
oidc-token-provider-base.js \
github-oidc.js aws-oidc-token-provider.js gcp-oidc-token-provider.js \
anthropic-oidc-token-provider.js \
ai-credits-pricing.js \
ai-credits-pricing.js models-dev-catalog.js models.dev.catalog.json \
oidc-refresh-utils.js body-transform.js body-utils.js rate-limit.js websocket-proxy.js \
deprecated-header-tracker.js billing-headers.js upstream-response.js \
anthropic-cache.js otel.js token-budget-log.js ./
Expand Down
5 changes: 3 additions & 2 deletions containers/api-proxy/ai-credits-pricing.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
'use strict';

// Per-model pricing in dollars per 1M tokens.
// Source: https://models.dev/catalog.json
// Curated per-model pricing in dollars per 1M tokens.
// These provider-agnostic aliases take precedence over the bundled models.dev
// catalog fallback.
module.exports = Object.freeze({
'gpt-5-mini': { input: 0.25, cachedInput: 0.025, cacheWrite: null, output: 2.00 },
'gpt-5-codex-mini': { input: 0.25, cachedInput: 0.025, cacheWrite: null, output: 2.00 },
Expand Down
4 changes: 4 additions & 0 deletions containers/api-proxy/guards/ai-credits-guard.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const { logRequest, sanitizeForLog } = require('../logging');
const pricingByModel = require('../ai-credits-pricing');
const { resolveCatalogModel } = require('../models-dev-catalog');
const { parsePositiveNumber } = require('./guard-utils');

const TOKENS_PER_MILLION = 1_000_000;
Expand Down Expand Up @@ -102,6 +103,9 @@ function resolveModelPricing(model, state = aiCreditsState) {
}
if (prefixMatch) return prefixMatch.pricing;

const catalogModel = resolveCatalogModel(model);
if (catalogModel.pricing) return catalogModel.pricing;

if (!state.warnedUnknownModels.has(model)) {
logRequest('warn', 'unknown_model_ai_credits_pricing', {
model: sanitizeForLog(model),
Expand Down
28 changes: 28 additions & 0 deletions containers/api-proxy/guards/ai-credits-guard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,34 @@ describe('ai-credits-guard', () => {
}));
});

it('uses bundled models.dev pricing for known catalog models', () => {
const usage = applyAiCreditsUsage({
input_tokens: 100,
output_tokens: 10,
}, 'perceptron/perceptron-mk1');

expect(usage).toMatchObject({
aiCreditsThisResponse: 0.003,
totalAiCredits: 0.003,
});
});

it('treats zero-cost catalog models as free instead of unknown', () => {
process.env.AWF_MAX_AI_CREDITS = '10';
resetAiCreditsGuardForTests();

const usage = applyAiCreditsUsage({
input_tokens: 1000,
output_tokens: 500,
}, 'google/gemma-4-31b-it:free');

expect(usage).toMatchObject({
aiCreditsThisResponse: 0,
totalAiCredits: 0,
});
expect(checkUnknownModelRejection('google/gemma-4-31b-it:free')).toBeNull();
});

it('reports block state when max ai credits is configured and exceeded', () => {
process.env.AWF_MAX_AI_CREDITS = '0.1';
applyAiCreditsUsage({
Expand Down
99 changes: 99 additions & 0 deletions containers/api-proxy/models-dev-catalog.js
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;
}
Comment thread
Copilot marked this conversation as resolved.

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,
};
38 changes: 38 additions & 0 deletions containers/api-proxy/models-dev-catalog.test.js
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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added regression test in commit c200251openrouter/pareto-code (which carries prompt/completion: "-1" in the bundled catalog) asserts pricing: null, zeroCost: false.

1 change: 1 addition & 0 deletions containers/api-proxy/models.dev.catalog.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions containers/api-proxy/token-budget-log.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe('computeTokenBudgetUsage', () => {
const result = computeTokenBudgetUsage(
{ logRequest, requestId: 'req-1', provider: 'openai' },
{ input_tokens: 10, output_tokens: 5 },
'gpt-4o',
'brand-new-model-xyz',
);
expect(result).toBeUndefined();
expect(logRequest).not.toHaveBeenCalled();
Expand All @@ -34,7 +34,7 @@ describe('computeTokenBudgetUsage', () => {
const result = computeTokenBudgetUsage(
{ logRequest, requestId: 'req-1', provider: 'openai' },
{ input_tokens: 10, output_tokens: 5 },
'gpt-4o',
'brand-new-model-xyz',
);
expect(result).toMatchObject({
effective_tokens_this_response: expect.any(Number),
Expand Down
19 changes: 15 additions & 4 deletions docs/awf-config-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in commit c200251 — section 10.7.1 now describes the curated-table → bundled-catalog two-step lookup, the defaultAiCreditsPricing fallback, and zero-cost model handling.


### 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.
Expand Down
Loading