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
1 change: 1 addition & 0 deletions containers/api-proxy/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ COPY server.js logging.js metrics.js rate-limiter.js rate-limiter-window.js \
github-oidc.js aws-oidc-token-provider.js gcp-oidc-token-provider.js \
anthropic-oidc-token-provider.js \
ai-credits-pricing.js models-dev-catalog.js models.dev.catalog.json \
provider-pricing-overlays.js runtime-model-catalog.js \
oidc-refresh-utils.js body-transform.js body-utils.js rate-limit.js websocket-proxy.js \
websocket-guards.js websocket-tunnel.js \
deprecated-header-tracker.js billing-headers.js upstream-response.js \
Expand Down
4 changes: 4 additions & 0 deletions containers/api-proxy/ai-credits-pricing.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ module.exports = Object.freeze({
'gpt-5.4-mini': { input: 0.75, cachedInput: 0.075, cacheWrite: null, output: 4.50 },
'gpt-5.4-nano': { input: 0.20, cachedInput: 0.02, cacheWrite: null, output: 1.25 },
'gpt-5.5': { input: 5.00, cachedInput: 0.50, cacheWrite: null, output: 30.00 },
'gpt-5.6-luna': { input: 1.00, cachedInput: 0.10, cacheWrite: null, output: 6.00 },
'gpt-5.6-sol': { input: 5.00, cachedInput: 0.50, cacheWrite: null, output: 30.00 },
'gpt-5.6-terra': { input: 2.50, cachedInput: 0.25, cacheWrite: null, output: 15.00 },
'claude-haiku-4-5': { input: 1.00, cachedInput: 0.10, cacheWrite: 1.25, output: 5.00 },
'claude-sonnet-4': { input: 3.00, cachedInput: 0.30, cacheWrite: 3.75, output: 15.00 },
'claude-sonnet-4-5': { input: 3.00, cachedInput: 0.30, cacheWrite: 3.75, output: 15.00 },
Expand All @@ -28,6 +31,7 @@ module.exports = Object.freeze({
'claude-opus-4-6': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, output: 25.00 },
'claude-opus-4-7': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, output: 25.00 },
'claude-opus-4-8': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, output: 25.00 },
'claude-opus-5': { input: 5.00, cachedInput: 0.50, cacheWrite: 6.25, output: 25.00 },
'claude-fable-5': { input: 10.00, cachedInput: 1.00, cacheWrite: 12.50, output: 50.00 },
'claude-mythos-5': { input: 10.00, cachedInput: 1.00, cacheWrite: 12.50, output: 50.00 },
'gemini-2.5-pro': { input: 1.25, cachedInput: 0.125, cacheWrite: null, output: 10.00 },
Expand Down
9 changes: 9 additions & 0 deletions containers/api-proxy/copilot-byok.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,15 @@ describe('createCopilotAdapter — BYOK getAuthHeaders', () => {
expect(headers['Authorization']).toBe(bearerGithubToken);
});

it('requests versioned runtime pricing metadata from the standard Copilot models endpoint', () => {
const adapter = createCopilotAdapter({ COPILOT_GITHUB_TOKEN: githubToken });
const config = adapter.getModelsFetchConfig();

expect(config.opts.headers['X-GitHub-Api-Version']).toBe('2026-07-01');
expect(config.modelMetadataFormat).toBe('copilot');
expect(config.apiVersion).toBe('2026-07-01');
});

it('uses COPILOT_PROVIDER_API_KEY (not COPILOT_GITHUB_TOKEN) for inference in BYOK+token mode', () => {
const adapter = createCopilotAdapter({
COPILOT_GITHUB_TOKEN: githubToken,
Expand Down
103 changes: 84 additions & 19 deletions containers/api-proxy/guards/ai-credits-guard.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
const { logRequest, sanitizeForLog } = require('../logging');
const pricingByModel = require('../ai-credits-pricing');
const { resolveCatalogModel } = require('../models-dev-catalog');
const { resolveRuntimePricing } = require('../runtime-model-catalog');
const { resolveProviderPricingOverlay } = require('../provider-pricing-overlays');
const { parsePositiveNumber } = require('./guard-utils');
const { PROVIDER_ANTHROPIC, PROVIDER_COPILOT } = require('../provider-names');

Expand Down Expand Up @@ -91,15 +93,41 @@ function canonicalizeModel(model) {
return withoutDateSuffix.replace(/[._]/g, '-');
}

function resolveModelPricing(model, state = aiCreditsState) {
if (Object.hasOwn(pricingByModel, model)) return pricingByModel[model];
function resolveModelPricing(model, state = aiCreditsState, provider = undefined, inputTokens = 0) {
const operatorPricing = provider ? resolveProviderPricingOverlay(provider, model) : null;
if (operatorPricing) return operatorPricing;

const runtime = provider ? resolveRuntimePricing(provider, model, inputTokens) : null;
if (runtime && ['input', 'cachedInput', 'cacheWrite', 'output']
.every(field => Object.hasOwn(runtime.pricing, field))) {
return runtime;
}
const fallback = resolveLowerPriorityPricing(model, state);
if (!runtime) return fallback;
const mergedPricing = {};
for (const field of ['input', 'cachedInput', 'cacheWrite', 'output']) {
if (Object.hasOwn(runtime.pricing, field)) {
mergedPricing[field] = runtime.pricing[field];
} else if (fallback?.pricing && Object.hasOwn(fallback.pricing, field)) {
mergedPricing[field] = fallback.pricing[field];
} else {
return null;
}
}
return { ...runtime, pricing: mergedPricing };
}

function resolveLowerPriorityPricing(model, state) {
if (Object.hasOwn(pricingByModel, model)) {
return { pricing: pricingByModel[model], source: 'curated', tier: 'default' };
}

const canonical = canonicalizeModel(model);

// Try canonical form against canonicalized pricing keys
for (const [configuredModel, pricing] of Object.entries(pricingByModel)) {
const canonicalKey = canonicalizeModel(configuredModel);
if (canonical === canonicalKey) return pricing;
if (canonical === canonicalKey) return { pricing, source: 'curated', tier: 'default' };
}

// Prefix match: canonical model starts with a canonical pricing key
Expand All @@ -112,10 +140,12 @@ function resolveModelPricing(model, state = aiCreditsState) {
}
}
}
if (prefixMatch) return prefixMatch.pricing;
if (prefixMatch) return { pricing: prefixMatch.pricing, source: 'curated', tier: 'default' };

const catalogModel = resolveCatalogModel(model);
if (catalogModel.pricing) return catalogModel.pricing;
if (catalogModel.pricing) {
return { pricing: catalogModel.pricing, source: 'models.dev', tier: 'default' };
}

if (!state.warnedUnknownModels.has(model)) {
logRequest('warn', 'unknown_model_ai_credits_pricing', {
Expand All @@ -126,13 +156,17 @@ function resolveModelPricing(model, state = aiCreditsState) {

// Fall back to configured default pricing if available
const config = getAiCreditsConfig();
if (config.defaultPricing) return config.defaultPricing;
if (config.defaultPricing) {
return { pricing: config.defaultPricing, source: 'configured_default', tier: 'default' };
}

// When the model is the 'unknown' sentinel (response omitted model and
// request body didn't contain one either), use conservative fallback pricing
// so AI credits are never silently lost. This is NOT applied to truly unknown
// model names which should still be rejected by checkUnknownModelRejection.
if (model === 'unknown') return BUILTIN_FALLBACK_PRICING;
if (model === 'unknown') {
return { pricing: BUILTIN_FALLBACK_PRICING, source: 'builtin_fallback', tier: 'default' };
}

return null;
}
Expand All @@ -150,10 +184,14 @@ function checkUnknownModelRejection(model, provider = undefined) {
if (!config.max) return null; // guard not active, don't reject
if (!model) return null; // no model in request body, can't check
if (config.defaultPricing) return null; // has fallback, don't reject
if (provider === PROVIDER_COPILOT && model.toLowerCase() === 'auto') return null;

const pricing = resolveModelPricing(model);
if (pricing) return null; // model resolved, don't reject
const defaultPricing = resolveModelPricing(model, aiCreditsState, provider);
const highestTierPricing = resolveModelPricing(
model,
aiCreditsState,
provider,
Number.MAX_SAFE_INTEGER,
);
if (defaultPricing && highestTierPricing) return null; // every selectable tier resolved

return {
rejected: true,
Expand All @@ -169,21 +207,30 @@ function checkUnknownModelRejection(model, provider = undefined) {
}

function calculateAiCredits(normalizedUsage, model, state = aiCreditsState, provider = undefined) {
const pricing = resolveModelPricing(model, state);
if (!pricing) return null;
const reportedInput = normalizedUsage.input_tokens || 0;
const cacheReadTokens = normalizedUsage.cache_read_tokens || 0;
const cacheWriteTokens = normalizedUsage.cache_write_tokens || 0;
const inputIncludesCache = normalizedUsage.input_tokens_include_cache === true;
const additiveInput = provider === PROVIDER_ANTHROPIC ||
(provider === PROVIDER_COPILOT && !inputIncludesCache);
const totalInputForTier = additiveInput
? reportedInput + cacheReadTokens + cacheWriteTokens
: reportedInput;
const pricingResolution = resolveModelPricing(model, state, provider, totalInputForTier);
if (!pricingResolution) return null;
const { pricing } = pricingResolution;

// input_tokens semantics differ by provider:
// - Anthropic and Copilot report input_tokens as the NON-cached input only;
// - Anthropic and Copilot's precise copilot_usage report input_tokens as the
// NON-cached input only;
// cache_read_input_tokens and cache_creation_input_tokens are reported
// separately and are ADDITIVE to input_tokens. Subtracting them here would
// over-subtract and undercount the genuinely-fresh input tokens.
// - OpenAI (and OpenAI-compatible providers) report prompt_tokens/input_tokens
// - OpenAI-style usage (including Copilot responses without copilot_usage)
// reports prompt_tokens/input_tokens
// as the TOTAL input, with cached tokens being a SUBSET. Those must be
// subtracted before applying the full input rate to avoid double-counting.
const reportedInput = normalizedUsage.input_tokens || 0;
const cacheReadTokens = normalizedUsage.cache_read_tokens || 0;
const cacheWriteTokens = normalizedUsage.cache_write_tokens || 0;
const nonCachedInput = provider === PROVIDER_ANTHROPIC || provider === PROVIDER_COPILOT
const nonCachedInput = additiveInput
? reportedInput
: Math.max(0, reportedInput - cacheReadTokens - cacheWriteTokens);

Expand All @@ -201,6 +248,11 @@ function calculateAiCredits(normalizedUsage, model, state = aiCreditsState, prov
cacheWriteCredits,
outputCredits,
totalCredits,
pricingSource: pricingResolution.source,
pricingTier: pricingResolution.tier,
pricingObservedAt: pricingResolution.observedAt,
pricingApiVersion: pricingResolution.apiVersion,
pricingDiscountPercent: pricingResolution.discountPercent,
};
}

Expand All @@ -217,6 +269,8 @@ function applyAiCreditsUsage(normalizedUsage, model, provider = undefined) {
cacheWriteCredits: 0,
outputCredits: 0,
totalCredits: 0,
pricingSource: calc.pricingSource,
pricingTier: calc.pricingTier,
};
}

Expand All @@ -226,6 +280,8 @@ function applyAiCreditsUsage(normalizedUsage, model, provider = undefined) {
modelBucket.cacheWriteCredits += calc.cacheWriteCredits;
modelBucket.outputCredits += calc.outputCredits;
modelBucket.totalCredits += calc.totalCredits;
modelBucket.pricingSource = calc.pricingSource;
modelBucket.pricingTier = calc.pricingTier;
aiCreditsState.totalAiCredits += calc.totalCredits;

process.env.AWF_AI_CREDITS_USED = String(roundCredits(aiCreditsState.totalAiCredits));
Expand All @@ -237,6 +293,13 @@ function applyAiCreditsUsage(normalizedUsage, model, provider = undefined) {
cacheWriteCreditsThisResponse: roundCredits(calc.cacheWriteCredits),
outputCreditsThisResponse: roundCredits(calc.outputCredits),
totalAiCredits: roundCredits(aiCreditsState.totalAiCredits),
pricingSource: calc.pricingSource,
pricingTier: calc.pricingTier,
...(calc.pricingObservedAt ? { pricingObservedAt: calc.pricingObservedAt } : {}),
...(calc.pricingApiVersion ? { pricingApiVersion: calc.pricingApiVersion } : {}),
...(calc.pricingDiscountPercent !== undefined
? { pricingDiscountPercent: calc.pricingDiscountPercent }
: {}),
};
}

Expand All @@ -249,6 +312,8 @@ function getAiCreditsReflectState() {
cache_write_credits: roundCredits(usage.cacheWriteCredits),
output_credits: roundCredits(usage.outputCredits),
total: roundCredits(usage.totalCredits),
pricing_source: usage.pricingSource,
pricing_tier: usage.pricingTier,
};
}
return {
Expand Down
Loading
Loading