diff --git a/containers/api-proxy/Dockerfile b/containers/api-proxy/Dockerfile index 4d16505bf..32e1993b4 100644 --- a/containers/api-proxy/Dockerfile +++ b/containers/api-proxy/Dockerfile @@ -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 \ diff --git a/containers/api-proxy/ai-credits-pricing.js b/containers/api-proxy/ai-credits-pricing.js index 589c8dfee..8b1c91a55 100644 --- a/containers/api-proxy/ai-credits-pricing.js +++ b/containers/api-proxy/ai-credits-pricing.js @@ -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 }, @@ -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 }, diff --git a/containers/api-proxy/copilot-byok.test.js b/containers/api-proxy/copilot-byok.test.js index c9a139835..fc0ef6255 100644 --- a/containers/api-proxy/copilot-byok.test.js +++ b/containers/api-proxy/copilot-byok.test.js @@ -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, diff --git a/containers/api-proxy/guards/ai-credits-guard.js b/containers/api-proxy/guards/ai-credits-guard.js index d8719d342..ec30adf37 100644 --- a/containers/api-proxy/guards/ai-credits-guard.js +++ b/containers/api-proxy/guards/ai-credits-guard.js @@ -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'); @@ -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 @@ -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', { @@ -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; } @@ -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, @@ -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); @@ -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, }; } @@ -217,6 +269,8 @@ function applyAiCreditsUsage(normalizedUsage, model, provider = undefined) { cacheWriteCredits: 0, outputCredits: 0, totalCredits: 0, + pricingSource: calc.pricingSource, + pricingTier: calc.pricingTier, }; } @@ -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)); @@ -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 } + : {}), }; } @@ -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 { diff --git a/containers/api-proxy/guards/ai-credits-guard.test.js b/containers/api-proxy/guards/ai-credits-guard.test.js index 5261aeb32..3d65208d1 100644 --- a/containers/api-proxy/guards/ai-credits-guard.test.js +++ b/containers/api-proxy/guards/ai-credits-guard.test.js @@ -10,6 +10,12 @@ const { } = require('./ai-credits-guard'); const { PROVIDER_COPILOT, PROVIDER_OPENAI } = require('../provider-names'); const { collectLogOutput } = require('../test-helpers/log-test-helpers'); +const { + parseProviderModelMetadata, + replaceRuntimeModels, + clearRuntimeModels, +} = require('../runtime-model-catalog'); +const { resetProviderPricingOverlaysForTests } = require('../provider-pricing-overlays'); describe('ai-credits-guard', () => { let originalMaxAiCredits; @@ -21,10 +27,15 @@ describe('ai-credits-guard', () => { delete process.env.AWF_MAX_AI_CREDITS; delete process.env.AWF_DEFAULT_AI_CREDITS_PRICING; resetAiCreditsGuardForTests(); + clearRuntimeModels(); + resetProviderPricingOverlaysForTests(); }); afterEach(() => { resetAiCreditsGuardForTests(); + clearRuntimeModels(); + resetProviderPricingOverlaysForTests(); + delete process.env.AWF_API_PROXY_PROVIDERS; if (originalMaxAiCredits === undefined) { delete process.env.AWF_MAX_AI_CREDITS; } else { @@ -63,6 +74,8 @@ describe('ai-credits-guard', () => { cache_write_credits: 0, output_credits: 0.1, total: 0.12275, + pricing_source: 'curated', + pricing_tier: 'default', }, }, }); @@ -188,6 +201,187 @@ describe('ai-credits-guard', () => { expect(usage.aiCreditsThisResponse).toBeCloseTo(0.275, 10); }); + it('prefers runtime Copilot pricing over the curated table', () => { + replaceRuntimeModels('copilot', parseProviderModelMetadata('copilot', { + data: [{ + id: 'gpt-5.4', + billing: { + token_prices: { + batch_size: 1_000_000, + default: { + input_price: 100, + cache_read_price: 10, + cache_write_price: 0, + output_price: 600, + max_prompt_tokens: 1000, + }, + long_context: { + input_price: 200, + cache_read_price: 20, + cache_write_price: 0, + output_price: 900, + }, + }, + }, + }], + }, { format: 'copilot', apiVersion: '2026-07-01', observedAt: '2026-07-28T00:00:00Z' })); + + const usage = applyAiCreditsUsage({ + input_tokens: 100, + cache_read_tokens: 1000, + output_tokens: 100, + }, 'gpt-5.4', PROVIDER_COPILOT); + + expect(usage).toMatchObject({ + inputCreditsThisResponse: 0.02, + cachedInputCreditsThisResponse: 0.02, + outputCreditsThisResponse: 0.09, + pricingSource: 'provider', + pricingTier: 'long_context', + pricingApiVersion: '2026-07-01', + }); + }); + + it('fills missing runtime fields from lower-priority curated pricing', () => { + replaceRuntimeModels('copilot', parseProviderModelMetadata('copilot', { + data: [{ + id: 'claude-sonnet-4-6', + billing: { + token_prices: { + batch_size: 1_000_000, + default: { input_price: 100, output_price: 600 }, + }, + }, + }], + }, { format: 'copilot' })); + + const usage = applyAiCreditsUsage({ + input_tokens: 1000, + cache_read_tokens: 1000, + cache_write_tokens: 1000, + output_tokens: 1000, + }, 'claude-sonnet-4-6', PROVIDER_COPILOT); + + expect(usage).toMatchObject({ + inputCreditsThisResponse: 0.1, + cachedInputCreditsThisResponse: 0.03, + cacheWriteCreditsThisResponse: 0.375, + outputCreditsThisResponse: 0.6, + pricingSource: 'provider', + }); + }); + + it('fails closed when incomplete runtime pricing has no lower-priority fallback', () => { + process.env.AWF_MAX_AI_CREDITS = '10'; + resetAiCreditsGuardForTests(); + replaceRuntimeModels('copilot', parseProviderModelMetadata('copilot', { + data: [{ + id: 'new-runtime-model', + billing: { + token_prices: { + batch_size: 1_000_000, + default: { + input_price: 100, + output_price: 600, + cache_read_price: 10, + cache_write_price: 20, + max_prompt_tokens: 1000, + }, + long_context: { input_price: 200, output_price: 900 }, + }, + }, + }], + }, { format: 'copilot' })); + + expect(checkUnknownModelRejection('new-runtime-model', PROVIDER_COPILOT)) + .toMatchObject({ rejected: true, model: 'new-runtime-model' }); + }); + + it('does not double-count cached Copilot input or select long context for inclusive usage', () => { + replaceRuntimeModels('copilot', parseProviderModelMetadata('copilot', { + data: [{ + id: 'gpt-5.4', + billing: { + token_prices: { + batch_size: 1_000_000, + default: { + input_price: 100, + cache_read_price: 10, + cache_write_price: 0, + output_price: 600, + max_prompt_tokens: 1000, + }, + long_context: { + input_price: 200, + cache_read_price: 20, + cache_write_price: 0, + output_price: 900, + }, + }, + }, + }], + }, { format: 'copilot' })); + + const usage = applyAiCreditsUsage({ + input_tokens: 800, + cache_read_tokens: 300, + cache_write_tokens: 0, + output_tokens: 100, + input_tokens_include_cache: true, + }, 'gpt-5.4', PROVIDER_COPILOT); + + expect(usage).toMatchObject({ + inputCreditsThisResponse: 0.05, + cachedInputCreditsThisResponse: 0.003, + outputCreditsThisResponse: 0.06, + pricingTier: 'default', + }); + }); + + it('prefers an operator provider overlay over runtime and curated pricing', () => { + process.env.AWF_API_PROXY_PROVIDERS = JSON.stringify({ + 'github-copilot': { + models: { + 'gpt-5.4': { + cost: { + input: '1e-06', + output: '4e-06', + cache_read: '1e-07', + cache_write: '1.25e-06', + }, + }, + }, + }, + }); + replaceRuntimeModels('copilot', parseProviderModelMetadata('copilot', { + data: [{ + id: 'gpt-5.4', + billing: { + token_prices: { + batch_size: 1_000_000, + default: { input_price: 900, output_price: 1800 }, + }, + }, + }], + }, { format: 'copilot' })); + + const usage = applyAiCreditsUsage({ + input_tokens: 1000, + cache_read_tokens: 1000, + cache_write_tokens: 1000, + output_tokens: 1000, + }, 'gpt-5.4', PROVIDER_COPILOT); + + expect(usage).toMatchObject({ + inputCreditsThisResponse: 0.1, + cachedInputCreditsThisResponse: 0.01, + cacheWriteCreditsThisResponse: 0.125, + outputCreditsThisResponse: 0.4, + pricingSource: 'operator', + pricingTier: 'default', + }); + }); + it('warns and skips usage for unknown models', () => { const { lines } = collectLogOutput(); const usage = applyAiCreditsUsage({ input_tokens: 100 }, 'unknown-model'); @@ -470,11 +664,11 @@ describe('ai-credits-guard', () => { expect(sonnet5).toBeNull(); }); - it('allows the Copilot auto selector without default pricing', () => { + it('rejects the Copilot auto selector when runtime pricing cannot be proven', () => { process.env.AWF_MAX_AI_CREDITS = '10'; resetAiCreditsGuardForTests(); - expect(checkUnknownModelRejection('auto', PROVIDER_COPILOT)).toBeNull(); + expect(checkUnknownModelRejection('auto', PROVIDER_COPILOT)).not.toBeNull(); expect(checkUnknownModelRejection('auto', PROVIDER_OPENAI)).not.toBeNull(); }); }); diff --git a/containers/api-proxy/guards/max-model-multiplier-guard.js b/containers/api-proxy/guards/max-model-multiplier-guard.js index a4d5ea82a..49c9364d2 100644 --- a/containers/api-proxy/guards/max-model-multiplier-guard.js +++ b/containers/api-proxy/guards/max-model-multiplier-guard.js @@ -69,6 +69,15 @@ function getModelMultiplierCapBlockState(model) { const config = getMaxModelMultiplierConfig(); if (!config.cap || !model) return null; + if (model.toLowerCase() === 'auto' && !Object.hasOwn(config.multipliers, model)) { + return { + model: sanitizeForLog(model), + multiplier: null, + maxModelMultiplier: config.cap, + reason: 'dynamic_model_unverifiable', + }; + } + const multiplier = resolveMultiplierForModel(model, config); if (multiplier <= config.cap) return null; @@ -86,6 +95,17 @@ function getModelMultiplierCapBlockState(model) { * @returns {{ error: object }} */ function buildModelMultiplierCapError(state) { + if (state.reason === 'dynamic_model_unverifiable') { + return { + error: { + type: 'model_multiplier_cap_unverifiable', + message: 'Model "auto" selects a concrete model at runtime, so its multiplier cannot be proven to be within the configured cap. Configure an explicit multiplier for "auto" to opt in.', + model: state.model, + model_multiplier: null, + max_model_multiplier: state.maxModelMultiplier, + }, + }; + } return { error: { type: 'model_multiplier_cap_exceeded', diff --git a/containers/api-proxy/guards/max-model-multiplier-guard.test.js b/containers/api-proxy/guards/max-model-multiplier-guard.test.js index be9807529..b3a375a1f 100644 --- a/containers/api-proxy/guards/max-model-multiplier-guard.test.js +++ b/containers/api-proxy/guards/max-model-multiplier-guard.test.js @@ -85,6 +85,26 @@ describe('max-model-multiplier-guard', () => { expect(getModelMultiplierCapBlockState('unknown-model')).toBeNull(); }); + it('fails closed for auto unless an explicit multiplier is configured', () => { + process.env.AWF_MAX_MODEL_MULTIPLIER = '5'; + + const state = getModelMultiplierCapBlockState('auto'); + expect(state).toMatchObject({ + model: 'auto', + multiplier: null, + maxModelMultiplier: 5, + reason: 'dynamic_model_unverifiable', + }); + expect(buildModelMultiplierCapError(state).error.type).toBe('model_multiplier_cap_unverifiable'); + }); + + it('allows auto when its explicit multiplier is within the cap', () => { + process.env.AWF_MAX_MODEL_MULTIPLIER = '5'; + process.env.AWF_EFFECTIVE_TOKEN_MODEL_MULTIPLIERS = JSON.stringify({ auto: 5 }); + + expect(getModelMultiplierCapBlockState('auto')).toBeNull(); + }); + it('blocks when configured default multiplier for unknown model exceeds cap', () => { process.env.AWF_MAX_MODEL_MULTIPLIER = '5'; process.env.AWF_EFFECTIVE_TOKEN_MODEL_MULTIPLIERS = JSON.stringify({ 'gpt-4o': 2 }); diff --git a/containers/api-proxy/guards/model-policy-guard.js b/containers/api-proxy/guards/model-policy-guard.js index 99dea83cf..3f3189c8b 100644 --- a/containers/api-proxy/guards/model-policy-guard.js +++ b/containers/api-proxy/guards/model-policy-guard.js @@ -92,6 +92,14 @@ function getModelPolicyBlockState(model) { if (!model) return null; if (!ALLOWED_MODELS && !DISALLOWED_MODELS) return null; + if ( + model.toLowerCase() === 'auto' && + DISALLOWED_MODELS && + !(ALLOWED_MODELS && ALLOWED_MODELS.some(pattern => globMatch(pattern, model))) + ) { + return { model, reason: 'dynamic_model_unverifiable' }; + } + if (DISALLOWED_MODELS && DISALLOWED_MODELS.some(pattern => globMatch(pattern, model))) { return { model, reason: 'disallowed' }; } @@ -110,7 +118,9 @@ function getModelPolicyBlockState(model) { * @returns {{ error: object }} */ function buildModelPolicyError(state) { - const message = state.reason === 'disallowed' + const message = state.reason === 'dynamic_model_unverifiable' + ? `Model '${state.model}' selects a concrete model at runtime, so the configured denylist cannot be proven. Explicitly allow 'auto' to opt in.` + : state.reason === 'disallowed' ? `Model '${state.model}' is not permitted: it is explicitly disallowed by the model policy.` : `Model '${state.model}' is not permitted: it does not match the allowed models policy.`; return { diff --git a/containers/api-proxy/guards/model-policy-guard.test.js b/containers/api-proxy/guards/model-policy-guard.test.js index 617ebf0c0..7838cc2b3 100644 --- a/containers/api-proxy/guards/model-policy-guard.test.js +++ b/containers/api-proxy/guards/model-policy-guard.test.js @@ -124,6 +124,14 @@ describe('getModelPolicyBlockState', () => { expect(guard.getModelPolicyBlockState('claude-sonnet-4.6')).toBeNull(); }); + it('should fail closed for auto because its selected model cannot be checked', () => { + const guard = loadGuard(); + expect(guard.getModelPolicyBlockState('auto')).toEqual({ + model: 'auto', + reason: 'dynamic_model_unverifiable', + }); + }); + it('should handle multiple disallowed patterns', () => { process.env.AWF_DISALLOWED_MODELS = JSON.stringify(['*opus*', 'gpt-5*']); const guard = loadGuard(); @@ -164,6 +172,15 @@ describe('getModelPolicyBlockState', () => { process.env.AWF_DISALLOWED_MODELS = JSON.stringify(['*opus*']); }); + describe('explicit auto opt-in', () => { + it('should allow auto when it is explicitly allowed alongside a denylist', () => { + process.env.AWF_ALLOWED_MODELS = JSON.stringify(['auto', '*sonnet*']); + process.env.AWF_DISALLOWED_MODELS = JSON.stringify(['*opus*']); + const guard = loadGuard(); + expect(guard.getModelPolicyBlockState('auto')).toBeNull(); + }); + }); + it('should block a model in the disallowed list even if it matches allowed', () => { const guard = loadGuard(); const result = guard.getModelPolicyBlockState('claude-opus-4.5'); diff --git a/containers/api-proxy/key-validation.js b/containers/api-proxy/key-validation.js index fea0edead..d1c6c991f 100644 --- a/containers/api-proxy/key-validation.js +++ b/containers/api-proxy/key-validation.js @@ -1,6 +1,11 @@ 'use strict'; -const { fetchJson, httpProbe, extractModelIds } = require('./model-discovery'); +const { fetchJson, httpProbe, extractModelIds, extractModelMetadata } = require('./model-discovery'); +const { + replaceRuntimeModels, + clearRuntimeModels, + getRuntimeCatalogSnapshot, +} = require('./runtime-model-catalog'); const { logRequest } = require('./logging'); const { resolveModel } = require('./model-resolver'); @@ -24,6 +29,7 @@ function resetModelCacheState() { for (const key of Object.keys(cachedModels)) { delete cachedModels[key]; } + clearRuntimeModels(); modelFetchComplete = false; } @@ -58,8 +64,13 @@ async function refreshProviderModelsForResolution(provider) { try { const json = await fetchJson(config.url, config.opts, 10_000); const extracted = extractModelIds(json); + const metadata = extractModelMetadata(provider, json, { + format: config.modelMetadataFormat, + apiVersion: config.apiVersion, + }); if (Array.isArray(extracted) && extracted.length > 0) { cachedModels[config.cacheKey] = extracted; + replaceRuntimeModels(provider, metadata); logRequest('debug', 'model_cache_refresh', { provider, cache_key: config.cacheKey, @@ -167,7 +178,17 @@ async function fetchStartupModels(adapters = []) { fetches.push( fetchJson(config.url, config.opts, TIMEOUT_MS).then((json) => { - cachedModels[config.cacheKey] = extractModelIds(json); + const extracted = extractModelIds(json); + const metadata = extractModelMetadata(adapter.name, json, { + format: config.modelMetadataFormat, + apiVersion: config.apiVersion, + }); + if (Array.isArray(extracted) && extracted.length > 0) { + cachedModels[config.cacheKey] = extracted; + replaceRuntimeModels(adapter.name, metadata); + } else if (cachedModels[config.cacheKey] === undefined) { + cachedModels[config.cacheKey] = null; + } }) ); } @@ -255,6 +276,7 @@ const testHelpers = { _resolveModelForValidation }; module.exports = { keyValidationResults, cachedModels, + getRuntimeCatalogSnapshot, configureKeyValidation, resetKeyValidationState, resetModelCacheState, diff --git a/containers/api-proxy/management.js b/containers/api-proxy/management.js index 2c367a414..638d5e287 100644 --- a/containers/api-proxy/management.js +++ b/containers/api-proxy/management.js @@ -21,6 +21,7 @@ const { getModelApiMappingReflect } = require('./model-api-mapping'); * @typedef {object} ManagementDeps * @property {() => Array} getAdapters - Returns registered adapters array * @property {() => Record} getCachedModels - Returns model cache object + * @property {() => Record} getRuntimeModelMetadata - Returns sanitized runtime metadata * @property {() => boolean} isModelFetchComplete - Whether startup model fetch has run * @property {() => { complete: boolean, results: Record }} getKeyValidationState * @property {() => import('./rate-limiter').RateLimiter} getLimiter @@ -46,6 +47,7 @@ function createManagementHandlers(deps) { const { getAdapters, getCachedModels, + getRuntimeModelMetadata = () => ({}), isModelFetchComplete, getKeyValidationState, getLimiter, @@ -89,6 +91,7 @@ function createManagementHandlers(deps) { */ function reflectEndpoints() { const cachedModels = getCachedModels(); + const runtimeModelMetadata = getRuntimeModelMetadata(); const modelAliases = getModelAliases(); return { endpoints: getAdapters().map(adapter => { @@ -99,6 +102,7 @@ function createManagementHandlers(deps) { base_url: info.base_url, configured: info.configured, models: info.models_cache_key !== null ? (cachedModels[info.models_cache_key] || null) : null, + model_metadata: runtimeModelMetadata[adapter.name] || null, models_url: info.models_url, }; }), diff --git a/containers/api-proxy/model-discovery.js b/containers/api-proxy/model-discovery.js index 6ed121b84..9c1d3eb22 100644 --- a/containers/api-proxy/model-discovery.js +++ b/containers/api-proxy/model-discovery.js @@ -20,6 +20,7 @@ const http = require('http'); const https = require('https'); const { URL } = require('url'); const { sanitizeForLog, logRequest } = require('./logging'); +const { parseProviderModelMetadata } = require('./runtime-model-catalog'); // ── Shared proxy agent ──────────────────────────────────────────────────────── const { proxyAgent } = require('./http-client'); @@ -209,6 +210,10 @@ function extractModelIds(json) { return null; } +function extractModelMetadata(provider, json, options = {}) { + return parseProviderModelMetadata(provider, json, options); +} + // ── buildModelsJson ─────────────────────────────────────────────────────────── /** * Build the models.json payload from current cached state. @@ -218,7 +223,7 @@ function extractModelIds(json) { * @param {object|null} modelAliases - Parsed MODEL_ALIASES (or null) * @returns {object} */ -function buildModelsJson(adapters, cachedModels, modelAliases) { +function buildModelsJson(adapters, cachedModels, modelAliases, runtimeModelMetadata = {}) { const providers = {}; for (const adapter of adapters) { const info = adapter.getReflectionInfo(); @@ -227,6 +232,7 @@ function buildModelsJson(adapters, cachedModels, modelAliases) { models: info.models_cache_key !== null ? (cachedModels[info.models_cache_key] !== undefined ? cachedModels[info.models_cache_key] : null) : null, + model_metadata: runtimeModelMetadata[adapter.name] || null, target: adapter.isEnabled() ? adapter.getTargetHost() : null, }; } @@ -246,11 +252,11 @@ function buildModelsJson(adapters, cachedModels, modelAliases) { * @param {object|null} modelAliases - Parsed MODEL_ALIASES (or null) * @param {string} [logDir] - Directory to write models.json to (default: MODELS_LOG_DIR) */ -function writeModelsJson(adapters, cachedModels, modelAliases, logDir = MODELS_LOG_DIR) { +function writeModelsJson(adapters, cachedModels, modelAliases, logDir = MODELS_LOG_DIR, payload = null) { const filePath = path.join(logDir, 'models.json'); try { fs.mkdirSync(logDir, { recursive: true }); - fs.writeFileSync(filePath, JSON.stringify(buildModelsJson(adapters, cachedModels, modelAliases), null, 2) + '\n', 'utf8'); + fs.writeFileSync(filePath, JSON.stringify(payload || buildModelsJson(adapters, cachedModels, modelAliases), null, 2) + '\n', 'utf8'); logRequest('info', 'models_json_written', { path: filePath }); } catch (err) { logRequest('warn', 'models_json_write_failed', { @@ -265,6 +271,7 @@ module.exports = { fetchJson, httpProbe, extractModelIds, + extractModelMetadata, getModelCapabilityTier, getTierSortedModels, buildModelsJson, diff --git a/containers/api-proxy/provider-pricing-overlays.js b/containers/api-proxy/provider-pricing-overlays.js new file mode 100644 index 000000000..dbb76dc81 --- /dev/null +++ b/containers/api-proxy/provider-pricing-overlays.js @@ -0,0 +1,94 @@ +'use strict'; + +const DOLLARS_PER_TOKEN_TO_DOLLARS_PER_MILLION = 1_000_000; + +let cachedRaw; +let cachedProviders = null; + +function canonicalizeModel(model) { + if (!model || typeof model !== 'string') return ''; + const bare = model.includes('/') ? model.slice(model.indexOf('/') + 1) : model; + return bare.replace(/[._]/g, '-').toLowerCase(); +} + +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 normalizeCost(cost) { + if (!cost || typeof cost !== 'object') return null; + const input = parseDollarsPerToken(cost.input); + const output = parseDollarsPerToken(cost.output); + if (input === null || output === null) return null; + const cachedInput = cost.cache_read === undefined + ? input * 0.1 + : parseDollarsPerToken(cost.cache_read); + const cacheWrite = cost.cache_write === undefined + ? null + : parseDollarsPerToken(cost.cache_write); + if (cachedInput === null || (cost.cache_write !== undefined && cacheWrite === null)) return null; + return { input, cachedInput, cacheWrite, output }; +} + +function getProviderAliases(provider) { + if (provider === 'copilot') return ['copilot', 'github-copilot', 'github']; + if (provider === 'gemini') return ['gemini', 'google']; + return [provider]; +} + +function getProviders() { + const raw = process.env.AWF_API_PROXY_PROVIDERS; + if (raw === cachedRaw) return cachedProviders; + cachedRaw = raw; + cachedProviders = null; + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + cachedProviders = parsed; + } + } catch { + cachedProviders = null; + } + return cachedProviders; +} + +function resolveProviderPricingOverlay(provider, model) { + const providers = getProviders(); + if (!providers || !provider || !model) return null; + let models = null; + for (const alias of getProviderAliases(provider)) { + const candidate = providers[alias]?.models; + if (candidate && typeof candidate === 'object' && !Array.isArray(candidate)) { + models = candidate; + break; + } + } + if (!models) return null; + + const canonical = canonicalizeModel(model); + let match = null; + for (const [configuredModel, entry] of Object.entries(models)) { + const configuredCanonical = canonicalizeModel(configuredModel); + if (configuredCanonical === canonical || + (canonical.startsWith(`${configuredCanonical}-`) && + (!match || configuredCanonical.length > match.canonical.length))) { + match = { canonical: configuredCanonical, entry }; + if (configuredCanonical === canonical) break; + } + } + const pricing = normalizeCost(match?.entry?.cost); + return pricing ? { pricing, source: 'operator', tier: 'default' } : null; +} + +function resetProviderPricingOverlaysForTests() { + cachedRaw = undefined; + cachedProviders = null; +} + +module.exports = { + resolveProviderPricingOverlay, + resetProviderPricingOverlaysForTests, +}; diff --git a/containers/api-proxy/providers/copilot.js b/containers/api-proxy/providers/copilot.js index ca387352c..f5a555b24 100644 --- a/containers/api-proxy/providers/copilot.js +++ b/containers/api-proxy/providers/copilot.js @@ -39,6 +39,8 @@ const { bearerAuthHeaders, withCopilotIntegration } = require('./auth-headers'); const { URL } = require('url'); const { COPILOT_ENV } = require('../provider-env-constants'); +const COPILOT_MODELS_API_VERSION = '2026-07-01'; + /** * Create the GitHub Copilot provider adapter. * @@ -120,8 +122,13 @@ function createCopilotAdapter(env, deps = {}) { url: `https://${rawTarget}/models`, opts: { method: 'GET', - headers: withCopilotIntegration({ 'Authorization': prefix + ' ' + githubToken }, integrationId), + headers: withCopilotIntegration({ + 'Authorization': prefix + ' ' + githubToken, + 'X-GitHub-Api-Version': COPILOT_MODELS_API_VERSION, + }, integrationId), }, + modelMetadataFormat: 'copilot', + apiVersion: COPILOT_MODELS_API_VERSION, ...extra, }; } @@ -193,6 +200,7 @@ function createCopilotAdapter(env, deps = {}) { headers: bearerAuthHeaders(apiKey), }, cacheKey: 'copilot', + modelMetadataFormat: 'openai', }; }, }), diff --git a/containers/api-proxy/providers/index.js b/containers/api-proxy/providers/index.js index 4a6326b93..926be2b29 100644 --- a/containers/api-proxy/providers/index.js +++ b/containers/api-proxy/providers/index.js @@ -32,6 +32,8 @@ const { createVertexAdapter } = require('./vertex'); * @property {string} url - URL to fetch * @property {{ method: string, headers: Record }} opts - Request options * @property {string} cacheKey - Key in cachedModels to store the result + * @property {string} [modelMetadataFormat] - Provider-specific response format + * @property {string} [apiVersion] - API version used to obtain model metadata */ /** diff --git a/containers/api-proxy/runtime-model-catalog.js b/containers/api-proxy/runtime-model-catalog.js new file mode 100644 index 000000000..cd5d8c139 --- /dev/null +++ b/containers/api-proxy/runtime-model-catalog.js @@ -0,0 +1,257 @@ +'use strict'; + +const TOKENS_PER_MILLION = 1_000_000; +const DOLLARS_PER_AIU = 0.01; +const NANO_AIU_PER_AIU = 1_000_000_000; +const GEMINI_MODEL_NAME_PREFIX = 'models/'; + +const runtimeCatalog = Object.create(null); + +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, '-').toLowerCase(); +} + +function normalizeModelId(entry, format) { + if (!entry || typeof entry !== 'object') return null; + const raw = entry.id || entry.name; + if (typeof raw !== 'string' || raw.length === 0) return null; + if (format === 'gemini' && raw.startsWith(GEMINI_MODEL_NAME_PREFIX)) { + return raw.slice(GEMINI_MODEL_NAME_PREFIX.length); + } + return raw; +} + +function normalizePrice(value, batchSize, unit) { + if (value === undefined || value === null) return null; + const numeric = Number(value); + if (!Number.isFinite(numeric) || numeric < 0 || !Number.isFinite(batchSize) || batchSize <= 0) return null; + const aiu = unit === 'nano_aiu' ? numeric / NANO_AIU_PER_AIU : numeric; + return aiu * DOLLARS_PER_AIU * (TOKENS_PER_MILLION / batchSize); +} + +function normalizeTier(rawTier, batchSize, unit) { + if (!rawTier || typeof rawTier !== 'object') return null; + const input = normalizePrice(rawTier.input_price, batchSize, unit); + const output = normalizePrice(rawTier.output_price, batchSize, unit); + if (input === null || output === null) return null; + const cachedInput = normalizePrice( + rawTier.cache_read_price ?? rawTier.cache_price, + batchSize, + unit, + ); + const cacheWrite = normalizePrice(rawTier.cache_write_price, batchSize, unit); + const threshold = Number(rawTier.max_prompt_tokens ?? rawTier.context_max); + return { + input, + output, + ...(cachedInput !== null ? { cachedInput } : {}), + ...(cacheWrite !== null ? { cacheWrite } : {}), + ...(Number.isFinite(threshold) && threshold > 0 ? { threshold } : {}), + }; +} + +function normalizeCopilotPricing(entry) { + const tokenPrices = entry?.billing?.token_prices; + if (!tokenPrices || typeof tokenPrices !== 'object') return null; + const batchSize = Number(tokenPrices.batch_size); + if (!Number.isFinite(batchSize) || batchSize <= 0) return null; + + let defaultTier; + let longContext; + if (tokenPrices.default && typeof tokenPrices.default === 'object') { + defaultTier = normalizeTier(tokenPrices.default, batchSize, 'aiu'); + longContext = normalizeTier(tokenPrices.long_context, batchSize, 'aiu'); + } else { + defaultTier = normalizeTier(tokenPrices, batchSize, 'nano_aiu'); + } + if (!defaultTier) return null; + + return { + default: defaultTier, + ...(longContext ? { longContext } : {}), + }; +} + +function normalizePromotion(entry) { + const promo = entry?.billing?.promo; + const discountPercent = Number(promo?.discount_percent); + if (!promo || !Number.isFinite(discountPercent) || discountPercent < 0 || discountPercent > 100) { + return null; + } + return { + discountPercent, + ...(typeof promo.id === 'string' ? { id: promo.id } : {}), + ...(typeof promo.ends_at === 'string' ? { endsAt: promo.ends_at } : {}), + }; +} + +/** + * Normalize a provider model-list response without retaining the raw payload. + */ +function parseProviderModelMetadata(provider, json, options = {}) { + if (!json || typeof json !== 'object') return null; + const format = options.format || provider; + const entries = Array.isArray(json.data) + ? json.data + : (Array.isArray(json.models) ? json.models : null); + if (!entries) return null; + + const observedAt = options.observedAt || new Date().toISOString(); + const records = entries.map(entry => { + const id = normalizeModelId(entry, format); + if (!id) return null; + const pricing = format === 'copilot' ? normalizeCopilotPricing(entry) : null; + const promotion = format === 'copilot' ? normalizePromotion(entry) : null; + return { + provider, + id, + source: 'provider', + observedAt, + ...(options.apiVersion ? { apiVersion: options.apiVersion } : {}), + ...(entry.capabilities && typeof entry.capabilities === 'object' + ? { capabilities: entry.capabilities } + : {}), + ...(pricing ? { pricing } : {}), + ...(promotion ? { promotion } : {}), + }; + }).filter(Boolean); + + records.sort((a, b) => a.id.localeCompare(b.id)); + return records.length > 0 ? records : null; +} + +function replaceRuntimeModels(provider, records) { + if (!Array.isArray(records) || records.length === 0) return false; + const previousById = new Map( + (runtimeCatalog[provider] || []).map(record => [canonicalizeModel(record.id), record]), + ); + runtimeCatalog[provider] = records.map(record => { + const previous = previousById.get(canonicalizeModel(record.id)); + if (!previous?.pricing) return record; + const defaultTier = chooseTier(record.pricing?.default, previous.pricing.default); + const longContext = chooseTier(record.pricing?.longContext, previous.pricing.longContext); + const pricingProvenance = { + default: getTierProvenance( + defaultTier === previous.pricing.default ? previous : record, + 'default', + ), + ...(longContext ? { + longContext: getTierProvenance( + longContext === previous.pricing.longContext ? previous : record, + 'longContext', + ), + } : {}), + }; + return { + ...record, + pricing: { + default: defaultTier, + ...(longContext ? { longContext } : {}), + }, + pricingProvenance, + }; + }); + return true; +} + +function getTierProvenance(record, tierName) { + return record.pricingProvenance?.[tierName] || { + observedAt: record.pricingObservedAt || record.observedAt, + ...(record.pricingApiVersion || record.apiVersion + ? { apiVersion: record.pricingApiVersion || record.apiVersion } + : {}), + }; +} + +function chooseTier(current, previous) { + if (isCompleteTier(current)) return current; + if (isCompleteTier(previous)) return previous; + return current || previous; +} + +function isCompleteTier(tier) { + return !!tier && + Object.hasOwn(tier, 'input') && + Object.hasOwn(tier, 'output') && + Object.hasOwn(tier, 'cachedInput') && + Object.hasOwn(tier, 'cacheWrite'); +} + +function clearRuntimeModels() { + for (const provider of Object.keys(runtimeCatalog)) delete runtimeCatalog[provider]; +} + +function getRuntimeModels(provider) { + return runtimeCatalog[provider] || null; +} + +function findRuntimeModel(provider, model) { + const records = getRuntimeModels(provider); + if (!records || !model) return null; + const lower = model.toLowerCase(); + const exact = records.find(record => record.id.toLowerCase() === lower); + if (exact) return exact; + const canonical = canonicalizeModel(model); + return records.find(record => canonicalizeModel(record.id) === canonical) || null; +} + +function resolveRuntimePricing(provider, model, inputTokens = 0) { + const record = findRuntimeModel(provider, model); + if (!record?.pricing?.default) return null; + const longContext = record.pricing.longContext; + const threshold = record.pricing.default.threshold; + const useLongContext = !!longContext && !!threshold && inputTokens > threshold; + const tier = useLongContext ? longContext : record.pricing.default; + const tierName = useLongContext ? 'longContext' : 'default'; + const provenance = getTierProvenance(record, tierName); + return { + pricing: tier, + source: 'provider', + tier: useLongContext ? 'long_context' : 'default', + observedAt: provenance.observedAt, + apiVersion: provenance.apiVersion, + }; +} + +function getRuntimeCatalogSnapshot() { + const snapshot = {}; + for (const [provider, records] of Object.entries(runtimeCatalog)) { + snapshot[provider] = records.map(record => ({ + id: record.id, + source: record.source, + observed_at: record.observedAt, + ...(record.apiVersion ? { api_version: record.apiVersion } : {}), + ...(record.pricing ? { + pricing: { + default: record.pricing.default, + ...(record.pricing.longContext ? { long_context: record.pricing.longContext } : {}), + }, + } : {}), + ...(record.promotion ? { + promotion: { + discount_percent: record.promotion.discountPercent, + ...(record.promotion.id ? { id: record.promotion.id } : {}), + ...(record.promotion.endsAt ? { ends_at: record.promotion.endsAt } : {}), + }, + } : {}), + })); + } + return snapshot; +} + +module.exports = { + canonicalizeModel, + parseProviderModelMetadata, + replaceRuntimeModels, + clearRuntimeModels, + getRuntimeModels, + findRuntimeModel, + resolveRuntimePricing, + getRuntimeCatalogSnapshot, + testHelpers: { + normalizeCopilotPricing, + }, +}; diff --git a/containers/api-proxy/runtime-model-catalog.test.js b/containers/api-proxy/runtime-model-catalog.test.js new file mode 100644 index 000000000..f36878cc6 --- /dev/null +++ b/containers/api-proxy/runtime-model-catalog.test.js @@ -0,0 +1,230 @@ +'use strict'; + +const { + parseProviderModelMetadata, + replaceRuntimeModels, + clearRuntimeModels, + resolveRuntimePricing, + getRuntimeCatalogSnapshot, +} = require('./runtime-model-catalog'); + +describe('runtime model catalog', () => { + afterEach(() => clearRuntimeModels()); + + it('normalizes current Copilot tiered pricing into dollars per million tokens', () => { + const records = parseProviderModelMetadata('copilot', { + data: [{ + id: 'gpt-5.6-terra', + billing: { + token_prices: { + batch_size: 1_000_000, + default: { + input_price: 250, + output_price: 1500, + cache_read_price: 25, + cache_write_price: 0, + max_prompt_tokens: 272_000, + }, + long_context: { + input_price: 500, + output_price: 2250, + cache_read_price: 50, + cache_write_price: 0, + max_prompt_tokens: 1_000_000, + }, + }, + }, + }], + }, { format: 'copilot', apiVersion: '2026-07-01', observedAt: '2026-07-28T00:00:00Z' }); + + replaceRuntimeModels('copilot', records); + expect(resolveRuntimePricing('copilot', 'gpt-5.6-terra', 1000)).toMatchObject({ + pricing: { input: 2.5, cachedInput: 0.25, cacheWrite: 0, output: 15 }, + source: 'provider', + tier: 'default', + apiVersion: '2026-07-01', + }); + expect(resolveRuntimePricing('copilot', 'gpt-5.6-terra', 300_000)).toMatchObject({ + pricing: { input: 5, cachedInput: 0.5, cacheWrite: 0, output: 22.5 }, + tier: 'long_context', + }); + }); + + it('normalizes legacy Copilot nano-AIU pricing', () => { + const records = parseProviderModelMetadata('copilot', { + data: [{ + id: 'claude-sonnet-4.6', + billing: { + token_prices: { + batch_size: 1_000_000, + input_price: 300_000_000_000, + output_price: 1_500_000_000_000, + cache_price: 30_000_000_000, + }, + }, + }], + }, { format: 'copilot' }); + replaceRuntimeModels('copilot', records); + + expect(resolveRuntimePricing('copilot', 'claude-sonnet-4-6', 1000).pricing).toEqual({ + input: 3, + cachedInput: 0.3, + output: 15, + }); + }); + + it('exposes an advertised promotion without applying it to pricing', () => { + const records = parseProviderModelMetadata('copilot', { + data: [{ + id: 'gpt-test', + billing: { + token_prices: { + batch_size: 500_000, + default: { + input_price: 100, + output_price: 400, + cache_read_price: 10, + cache_write_price: 0, + }, + }, + promo: { discount_percent: 25, message: 'not retained' }, + }, + }], + }, { format: 'copilot', observedAt: '2026-07-28T00:00:00Z' }); + replaceRuntimeModels('copilot', records); + + expect(resolveRuntimePricing('copilot', 'gpt-test').pricing).toEqual({ + input: 2, + cachedInput: 0.2, + cacheWrite: 0, + output: 8, + }); + expect(getRuntimeCatalogSnapshot().copilot[0].promotion).toEqual({ + discount_percent: 25, + }); + expect(getRuntimeCatalogSnapshot().copilot[0]).not.toHaveProperty('billing'); + expect(JSON.stringify(getRuntimeCatalogSnapshot())).not.toContain('not retained'); + }); + + it('retains complete pricing when a later refresh omits billing fields', () => { + const initial = parseProviderModelMetadata('copilot', { + data: [{ + id: 'gpt-test', + billing: { + token_prices: { + batch_size: 1_000_000, + default: { + input_price: 100, + output_price: 400, + cache_read_price: 10, + cache_write_price: 20, + }, + }, + }, + }], + }, { format: 'copilot', observedAt: '2026-07-28T00:00:00Z' }); + replaceRuntimeModels('copilot', initial); + + const incomplete = parseProviderModelMetadata('copilot', { + data: [{ + id: 'gpt-test', + billing: { + token_prices: { + batch_size: 1_000_000, + default: { input_price: 200, output_price: 800 }, + }, + }, + }], + }, { format: 'copilot', observedAt: '2026-07-28T01:00:00Z' }); + replaceRuntimeModels('copilot', incomplete); + + expect(resolveRuntimePricing('copilot', 'gpt-test').pricing).toEqual({ + input: 1, + cachedInput: 0.1, + cacheWrite: 0.2, + output: 4, + }); + expect(resolveRuntimePricing('copilot', 'gpt-test').observedAt) + .toBe('2026-07-28T00:00:00Z'); + }); + + it('tracks provenance separately when only one refreshed tier is complete', () => { + const initial = parseProviderModelMetadata('copilot', { + data: [{ + id: 'gpt-test', + billing: { + token_prices: { + batch_size: 1_000_000, + default: { + input_price: 100, + output_price: 400, + cache_read_price: 10, + cache_write_price: 20, + max_prompt_tokens: 1000, + }, + long_context: { + input_price: 200, + output_price: 800, + cache_read_price: 20, + cache_write_price: 40, + }, + }, + }, + }], + }, { + format: 'copilot', + apiVersion: 'old', + observedAt: '2026-07-28T00:00:00Z', + }); + replaceRuntimeModels('copilot', initial); + + const mixed = parseProviderModelMetadata('copilot', { + data: [{ + id: 'gpt-test', + billing: { + token_prices: { + batch_size: 1_000_000, + default: { + input_price: 300, + output_price: 1200, + cache_read_price: 30, + cache_write_price: 60, + max_prompt_tokens: 1000, + }, + long_context: { input_price: 400, output_price: 1600 }, + }, + }, + }], + }, { + format: 'copilot', + apiVersion: 'new', + observedAt: '2026-07-28T01:00:00Z', + }); + replaceRuntimeModels('copilot', mixed); + + expect(resolveRuntimePricing('copilot', 'gpt-test', 500)).toMatchObject({ + observedAt: '2026-07-28T01:00:00Z', + apiVersion: 'new', + pricing: { input: 3, cachedInput: 0.3, cacheWrite: 0.6, output: 12 }, + }); + expect(resolveRuntimePricing('copilot', 'gpt-test', 1500)).toMatchObject({ + observedAt: '2026-07-28T00:00:00Z', + apiVersion: 'old', + pricing: { input: 2, cachedInput: 0.2, cacheWrite: 0.4, output: 8 }, + }); + }); + + it('preserves generic provider availability without inventing pricing', () => { + const records = parseProviderModelMetadata('anthropic', { + data: [{ id: 'claude-new', capabilities: { batch: { supported: true } } }], + }); + replaceRuntimeModels('anthropic', records); + + expect(records[0]).toMatchObject({ + provider: 'anthropic', + id: 'claude-new', + source: 'provider', + }); + expect(resolveRuntimePricing('anthropic', 'claude-new')).toBeNull(); + }); +}); diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index 90c9694c3..023362f13 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -24,6 +24,7 @@ const { const { keyValidationResults, cachedModels, + getRuntimeCatalogSnapshot, configureKeyValidation, resetKeyValidationState, resetModelCacheState, @@ -116,6 +117,7 @@ configureKeyValidation({ const { healthResponse, reflectEndpoints, handleManagementEndpoint } = createManagementHandlers({ getAdapters: () => registeredAdapters, getCachedModels: () => cachedModels, + getRuntimeModelMetadata: () => getRuntimeCatalogSnapshot(), isModelFetchComplete: () => isModelFetchComplete(), getKeyValidationState: () => ({ complete: isKeyValidationComplete(), results: keyValidationResults }), getLimiter: () => limiter, @@ -137,14 +139,20 @@ function buildModelsJson() { const filteredAliases = MODEL_ALIASES ? { models: filterResolvableAliases(MODEL_ALIASES.models, cachedModels) } : null; - return _buildModelsJson(registeredAdapters, cachedModels, filteredAliases); + return _buildModelsJson(registeredAdapters, cachedModels, filteredAliases, getRuntimeCatalogSnapshot()); } function writeModelsJson(logDir) { const filteredAliases = MODEL_ALIASES ? { models: filterResolvableAliases(MODEL_ALIASES.models, cachedModels) } : null; - return _writeModelsJson(registeredAdapters, cachedModels, filteredAliases, logDir); + const modelsJson = _buildModelsJson( + registeredAdapters, + cachedModels, + filteredAliases, + getRuntimeCatalogSnapshot(), + ); + return _writeModelsJson(registeredAdapters, cachedModels, filteredAliases, logDir, modelsJson); } function createProviderServer(adapter) { diff --git a/containers/api-proxy/server.network.test.js b/containers/api-proxy/server.network.test.js index 5d5e4eaaf..bb29df50f 100644 --- a/containers/api-proxy/server.network.test.js +++ b/containers/api-proxy/server.network.test.js @@ -317,6 +317,69 @@ describe('fetchStartupModels', () => { expect(cachedModels.copilot).toEqual(['gpt-4o', 'o3-mini']); }); + it('should preserve Copilot runtime pricing metadata', async () => { + mockHttpsRequestWithBody(200, JSON.stringify({ + data: [{ + id: 'gpt-runtime', + billing: { + token_prices: { + batch_size: 1_000_000, + default: { input_price: 100, output_price: 600 }, + }, + }, + }], + })); + await fetchStartupModels([createModelsAdapter('copilot', { + cacheKey: 'copilot', + url: 'https://api.githubcopilot.com/models', + opts: { method: 'GET', headers: { Authorization: '******' } }, + modelMetadataFormat: 'copilot', + apiVersion: '2026-07-01', + })]); + + const { getRuntimeCatalogSnapshot } = require('./key-validation'); + expect(getRuntimeCatalogSnapshot().copilot[0]).toMatchObject({ + id: 'gpt-runtime', + api_version: '2026-07-01', + pricing: { + default: { + input: 1, + output: 6, + }, + }, + }); + }); + + it('should retain the last successful snapshot when a later fetch fails', async () => { + const adapter = createModelsAdapter('copilot', { + cacheKey: 'copilot', + url: 'https://api.githubcopilot.com/models', + opts: { method: 'GET', headers: { Authorization: '******' } }, + modelMetadataFormat: 'copilot', + apiVersion: '2026-07-01', + }); + const successMock = mockHttpsRequestWithBody(200, JSON.stringify({ + data: [{ + id: 'gpt-runtime', + billing: { + token_prices: { + batch_size: 1_000_000, + default: { input_price: 100, output_price: 600 }, + }, + }, + }], + })); + await fetchStartupModels([adapter]); + successMock.mockRestore(); + + mockHttpsRequestWithBody(503, '{"error":"unavailable"}'); + await fetchStartupModels([adapter]); + + const { getRuntimeCatalogSnapshot } = require('./key-validation'); + expect(cachedModels.copilot).toEqual(['gpt-runtime']); + expect(getRuntimeCatalogSnapshot().copilot[0].id).toBe('gpt-runtime'); + }); + it('should populate cachedModels.gemini when Gemini key is configured', async () => { mockHttpsRequestWithBody(200, '{"models":[{"name":"models/gemini-1.5-pro"},{"name":"models/gemini-1.5-flash"}]}'); await fetchStartupModels([createModelsAdapter('gemini', { diff --git a/containers/api-proxy/server.token-guards.test.js b/containers/api-proxy/server.token-guards.test.js index 86a9dc97e..a6890aab2 100644 --- a/containers/api-proxy/server.token-guards.test.js +++ b/containers/api-proxy/server.token-guards.test.js @@ -341,7 +341,7 @@ describe('proxyRequest max-ai-credits guard', () => { expect(payload.error.total_ai_credits).toBeGreaterThanOrEqual(0.1); }); - it('allows Copilot auto requests through the ai credits request guard', async () => { + it('rejects Copilot auto when its concrete runtime price cannot be proven', async () => { const upstreamRequest = makeProxyReq(); const httpsRequestSpy = jest.spyOn(https, 'request').mockImplementation(() => upstreamRequest); @@ -351,8 +351,11 @@ describe('proxyRequest max-ai-credits guard', () => { req.emit('end'); await flushPromises(); - expect(httpsRequestSpy).toHaveBeenCalledTimes(1); - expect(res.writeHead).not.toHaveBeenCalledWith(400, expect.anything()); + expect(httpsRequestSpy).not.toHaveBeenCalled(); + expect(res.writeHead).toHaveBeenCalledWith(400, expect.objectContaining({ + 'Content-Type': 'application/json', + })); + expect(JSON.parse(res.end.mock.calls[0][0]).type).toBe('unknown_model_ai_credits'); }); }); diff --git a/containers/api-proxy/token-budget-log.js b/containers/api-proxy/token-budget-log.js index 74d9a1849..640c2f9bd 100644 --- a/containers/api-proxy/token-budget-log.js +++ b/containers/api-proxy/token-budget-log.js @@ -28,6 +28,8 @@ function computeTokenBudgetUsage({ logRequest, requestId, provider }, normalized model: model || 'unknown', ai_credits_this_response: aiCreditsUsage.aiCreditsThisResponse, ai_credits_total: aiCreditsUsage.totalAiCredits, + pricing_source: aiCreditsUsage.pricingSource, + pricing_tier: aiCreditsUsage.pricingTier, }); } const budgetFields = {}; @@ -39,6 +41,17 @@ function computeTokenBudgetUsage({ logRequest, requestId, provider }, normalized if (aiCreditsUsage) { budgetFields.ai_credits_this_response = aiCreditsUsage.aiCreditsThisResponse; budgetFields.ai_credits_total = aiCreditsUsage.totalAiCredits; + budgetFields.ai_credits_pricing_source = aiCreditsUsage.pricingSource; + budgetFields.ai_credits_pricing_tier = aiCreditsUsage.pricingTier; + if (aiCreditsUsage.pricingObservedAt) { + budgetFields.ai_credits_pricing_observed_at = aiCreditsUsage.pricingObservedAt; + } + if (aiCreditsUsage.pricingApiVersion) { + budgetFields.ai_credits_pricing_api_version = aiCreditsUsage.pricingApiVersion; + } + if (aiCreditsUsage.pricingDiscountPercent !== undefined) { + budgetFields.ai_credits_pricing_discount_percent = aiCreditsUsage.pricingDiscountPercent; + } } return Object.keys(budgetFields).length > 0 ? budgetFields : undefined; } diff --git a/containers/api-proxy/token-budget-log.test.js b/containers/api-proxy/token-budget-log.test.js index 12e5976c1..2b5bbcaa6 100644 --- a/containers/api-proxy/token-budget-log.test.js +++ b/containers/api-proxy/token-budget-log.test.js @@ -55,6 +55,8 @@ describe('computeTokenBudgetUsage', () => { expect(result).toMatchObject({ ai_credits_this_response: expect.any(Number), ai_credits_total: expect.any(Number), + ai_credits_pricing_source: 'curated', + ai_credits_pricing_tier: 'default', }); expect(logRequest).toHaveBeenCalledWith('info', 'token_budget_usage', expect.objectContaining({ request_id: 'req-2', diff --git a/containers/api-proxy/token-parsers.js b/containers/api-proxy/token-parsers.js index 6ccd6d377..6f84b97c2 100644 --- a/containers/api-proxy/token-parsers.js +++ b/containers/api-proxy/token-parsers.js @@ -165,7 +165,16 @@ function buildUsageFromSource(usageSource) { if (typeof reasoningTokens === 'number') usage.reasoning_tokens = reasoningTokens; const cacheReadTokens = extractCacheReadTokens(usageSource); - if (typeof cacheReadTokens === 'number') usage.cache_read_input_tokens = cacheReadTokens; + if (typeof cacheReadTokens === 'number') { + usage.cache_read_input_tokens = cacheReadTokens; + if ( + typeof usageSource.prompt_tokens === 'number' || + (typeof usageSource.input_tokens === 'number' && + (usageSource.input_tokens_details || usageSource.prompt_tokens_details)) + ) { + usage.input_tokens_include_cache = true; + } + } return Object.keys(usage).length > 0 ? usage : null; } @@ -178,12 +187,14 @@ function mergeCopilotBreakdown(usage, json) { if (copilotBreakdown.input_tokens !== undefined) { // Copilot gave us a precise input split: drop the lumped prompt_tokens. delete merged.prompt_tokens; + delete merged.input_tokens_include_cache; } else if (copilotBreakdown.cache_creation_input_tokens !== undefined && typeof merged.prompt_tokens === 'number') { // cache_write present but input absent: infer input = prompt_tokens - cache_write // to avoid double-counting cache_write in normalizeUsage. merged.input_tokens = Math.max(0, merged.prompt_tokens - copilotBreakdown.cache_creation_input_tokens); delete merged.prompt_tokens; + delete merged.input_tokens_include_cache; } return merged; } @@ -213,7 +224,10 @@ function buildResponseCompletionUsage(usage) { const reasoningTokens = extractReasoningTokens(usage); if (typeof reasoningTokens === 'number') out.reasoning_tokens = reasoningTokens; const cacheReadTokens = extractCacheReadTokens(usage); - if (typeof cacheReadTokens === 'number') out.cache_read_input_tokens = cacheReadTokens; + if (typeof cacheReadTokens === 'number') { + out.cache_read_input_tokens = cacheReadTokens; + out.input_tokens_include_cache = true; + } return out; } @@ -225,7 +239,10 @@ function buildStreamingFinalChunkUsage(usage) { const reasoningTokens = extractReasoningTokens(usage); if (typeof reasoningTokens === 'number') out.reasoning_tokens = reasoningTokens; const cacheReadTokens = extractCacheReadTokens(usage); - if (typeof cacheReadTokens === 'number') out.cache_read_input_tokens = cacheReadTokens; + if (typeof cacheReadTokens === 'number') { + out.cache_read_input_tokens = cacheReadTokens; + out.input_tokens_include_cache = true; + } return out; } @@ -451,12 +468,20 @@ function parseSseDataLines(text) { function normalizeUsage(usage) { if (!usage) return null; + const cacheReadTokens = extractCacheReadTokens(usage) ?? 0; + const inputTokensIncludeCache = usage.input_tokens_include_cache === true || + (cacheReadTokens > 0 && ( + typeof usage.prompt_tokens === 'number' || + (typeof usage.input_tokens === 'number' && + (usage.input_tokens_details || usage.prompt_tokens_details)) + )); return { input_tokens: usage.input_tokens ?? usage.prompt_tokens ?? 0, output_tokens: usage.output_tokens ?? usage.completion_tokens ?? 0, - cache_read_tokens: extractCacheReadTokens(usage) ?? 0, + cache_read_tokens: cacheReadTokens, cache_write_tokens: usage.cache_creation_input_tokens ?? 0, reasoning_tokens: usage.reasoning_tokens ?? 0, + ...(inputTokensIncludeCache ? { input_tokens_include_cache: true } : {}), }; } diff --git a/containers/api-proxy/token-parsers.json.test.js b/containers/api-proxy/token-parsers.json.test.js index 7c2863d3d..51fa779cb 100644 --- a/containers/api-proxy/token-parsers.json.test.js +++ b/containers/api-proxy/token-parsers.json.test.js @@ -108,6 +108,7 @@ describe('extractUsageFromJson', () => { completion_tokens: 256, total_tokens: 41600, cache_read_input_tokens: 36500, + input_tokens_include_cache: true, }); }); @@ -178,6 +179,7 @@ describe('extractUsageFromJson', () => { output_tokens: 64, total_tokens: 40064, cache_read_input_tokens: 32128, + input_tokens_include_cache: true, }); }); @@ -206,6 +208,7 @@ describe('extractUsageFromJson', () => { output_tokens: 30, total_tokens: 150, cache_read_input_tokens: 77, + input_tokens_include_cache: true, }); }); @@ -240,6 +243,7 @@ describe('extractUsageFromJson', () => { output_tokens: 30, total_tokens: 150, cache_read_input_tokens: 77, + input_tokens_include_cache: true, }); }); @@ -273,6 +277,7 @@ describe('extractUsageFromJson', () => { total_tokens: 719397, reasoning_tokens: 7715, cache_read_input_tokens: 672256, + input_tokens_include_cache: true, }); }); }); @@ -342,6 +347,7 @@ describe('extractUsageFromJson with copilot_usage', () => { cache_read_tokens: 30, cache_write_tokens: 0, reasoning_tokens: 0, + input_tokens_include_cache: true, }); }); diff --git a/containers/api-proxy/token-parsers.normalize.test.js b/containers/api-proxy/token-parsers.normalize.test.js index 605b6f260..24267f1d3 100644 --- a/containers/api-proxy/token-parsers.normalize.test.js +++ b/containers/api-proxy/token-parsers.normalize.test.js @@ -84,6 +84,7 @@ describe('normalizeUsage', () => { cache_read_tokens: 43894, cache_write_tokens: 0, reasoning_tokens: 0, + input_tokens_include_cache: true, }); }); @@ -102,6 +103,7 @@ describe('normalizeUsage', () => { cache_read_tokens: 43894, cache_write_tokens: 0, reasoning_tokens: 0, + input_tokens_include_cache: true, }); }); @@ -121,6 +123,7 @@ describe('normalizeUsage', () => { cache_read_tokens: 672256, cache_write_tokens: 0, reasoning_tokens: 7715, + input_tokens_include_cache: true, }); }); }); diff --git a/containers/api-proxy/token-parsers.sse.test.js b/containers/api-proxy/token-parsers.sse.test.js index dfd035574..eb76e4c32 100644 --- a/containers/api-proxy/token-parsers.sse.test.js +++ b/containers/api-proxy/token-parsers.sse.test.js @@ -108,6 +108,7 @@ describe('extractUsageFromSseLine', () => { total_tokens: 125, reasoning_tokens: 7, cache_read_input_tokens: 33, + input_tokens_include_cache: true, }); }); @@ -135,6 +136,7 @@ describe('extractUsageFromSseLine', () => { output_tokens: 25, total_tokens: 125, cache_read_input_tokens: 55, + input_tokens_include_cache: true, }); }); @@ -166,6 +168,7 @@ describe('extractUsageFromSseLine', () => { total_tokens: 38103, reasoning_tokens: 128, cache_read_input_tokens: 34816, + input_tokens_include_cache: true, }); }); @@ -214,6 +217,7 @@ describe('extractUsageFromSseLine', () => { completion_tokens: 24, total_tokens: 44001, cache_read_input_tokens: 43894, + input_tokens_include_cache: true, }); }); diff --git a/containers/api-proxy/token-tracker-shared.js b/containers/api-proxy/token-tracker-shared.js index a023d3603..4d56f6c37 100644 --- a/containers/api-proxy/token-tracker-shared.js +++ b/containers/api-proxy/token-tracker-shared.js @@ -42,6 +42,15 @@ function mergeBudgetFields(record, budgetResult) { if (budgetResult.ai_credits_total != null) { record.ai_credits_total = budgetResult.ai_credits_total; } + for (const field of [ + 'ai_credits_pricing_source', + 'ai_credits_pricing_tier', + 'ai_credits_pricing_observed_at', + 'ai_credits_pricing_api_version', + 'ai_credits_pricing_discount_percent', + ]) { + if (budgetResult[field] != null) record[field] = budgetResult[field]; + } } module.exports = { diff --git a/docs/api-proxy-sidecar.md b/docs/api-proxy-sidecar.md index 46051a234..6daa59cb7 100644 --- a/docs/api-proxy-sidecar.md +++ b/docs/api-proxy-sidecar.md @@ -549,6 +549,23 @@ curl http://172.30.0.30:10000/reflect "base_url": "http://api-proxy:10002", "configured": true, "models": ["gpt-4o", "claude-3.5-sonnet"], + "model_metadata": [ + { + "id": "gpt-4o", + "source": "provider", + "observed_at": "2026-07-28T00:00:00.000Z", + "api_version": "2026-07-01", + "pricing": { + "default": { + "input": 2.5, + "cachedInput": 0.25, + "cacheWrite": null, + "output": 10, + "threshold": 272000 + } + } + } + ], "models_url": "http://api-proxy:10002/models" }, { @@ -567,9 +584,20 @@ curl http://172.30.0.30:10000/reflect Fields: - `configured` — `true` if an API key for this provider was found at startup - `models` — list of model IDs fetched from the provider at startup; `null` if the provider is not configured or model fetch failed +- `model_metadata` — sanitized provider metadata, including pricing and provenance when the provider supplies it; currently Copilot supplies runtime pricing - `models_fetch_complete` — `true` once the startup model-fetch pass has finished - `models_url` — URL to query for the live model list +Copilot discovery requests use API version `2026-07-01`. Runtime Copilot prices +override bundled prices, including default and long-context tiers. Other +providers continue to use bundled pricing because their model-list APIs do not +currently advertise token prices. Failed or empty refreshes retain the last +successful snapshot. + +Explicit `apiProxy.providers` model-cost overlays take precedence over runtime +and bundled pricing. Overlay costs use the models.dev format (dollars per token) +and are normalized to dollars per million tokens inside the proxy. + ## Troubleshooting ### Gemini proxy returns 503 diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index 1d9f6fed5..019e429fe 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -112,6 +112,7 @@ AWF settings MAY be supplied via config files, including stdin (`--config -`). - `apiProxy.maxEffectiveTokens` → *(config-only; no CLI equivalent)* - `apiProxy.maxAiCredits` → *(config-only; maps to `AWF_MAX_AI_CREDITS`)* - `apiProxy.defaultAiCreditsPricing` → *(config-only; maps to `AWF_DEFAULT_AI_CREDITS_PRICING`)* +- `apiProxy.providers` → *(config-only; maps to `AWF_API_PROXY_PROVIDERS`)* - `apiProxy.modelMultipliers` → `--max-model-multiplier ` - `apiProxy.defaultModelMultiplier` → *(config-only; maps to `AWF_EFFECTIVE_TOKEN_DEFAULT_MODEL_MULTIPLIER`)* - `apiProxy.maxTurns` → *(config-only; no CLI equivalent)* @@ -761,10 +762,14 @@ Setting `maxAiCredits` above 10,000 MUST NOT raise the effective limit. ### 10.7.1 Model Name Resolution for Pricing -The AI credits guard resolves model names using a two-step lookup: +The AI credits guard resolves model names using this lookup order: -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. +1. **Operator provider overlay** — model prices configured under + `apiProxy.providers`. +2. **Runtime provider metadata** — authoritative token prices discovered from + the configured provider. Copilot supports this today. +3. **Curated pricing table** — a built-in table of known models with exact pricing. +4. **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 @@ -772,11 +777,40 @@ 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 none of these sources 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". +Runtime tiered pricing uses the provider's default-tier prompt threshold. When +the total input exceeds that threshold, all token categories use the +long-context tier. Pricing source, API version, observation time, selected +tier, and any provider-advertised promotion are retained in provenance. +Promotions are informational only because provider discovery does not prove +that a discount applies to a specific request; they never reduce accounting. +Failed or empty discovery responses do not replace the last successful runtime +snapshot. + +Provider overlays use the models.dev provider structure and per-token dollar +rates: + +```yaml +apiProxy: + providers: + github-copilot: + models: + custom-model: + cost: + input: "3e-06" + output: "1.5e-05" + cache_read: "3e-07" + cache_write: "3.75e-06" +``` + +The overlay is passed to both normal and threat-detection API proxy instances +through `AWF_API_PROXY_PROVIDERS`. Provider aliases `github-copilot` and +`copilot` resolve to the Copilot proxy. + ### 10.7.2 Default AI Credits Pricing (Fallback) `defaultAiCreditsPricing` is an optional object with `input` and `output` diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 92eecd9a0..618bf6e56 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -142,6 +142,11 @@ }, "additionalProperties": false }, + "providers": { + "type": "object", + "description": "Per-provider model pricing catalog overlays merged into the API proxy model pricing table for AI-credits accounting.", + "additionalProperties": true + }, "modelMultipliers": { "type": "object", "description": "Per-model multipliers for effective token accounting. Each model's weighted tokens are multiplied by this value before accumulation. Unlisted models use defaultModelMultiplier when set, otherwise the highest configured multiplier. See spec §10.2.", diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 92eecd9a0..618bf6e56 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -142,6 +142,11 @@ }, "additionalProperties": false }, + "providers": { + "type": "object", + "description": "Per-provider model pricing catalog overlays merged into the API proxy model pricing table for AI-credits accounting.", + "additionalProperties": true + }, "modelMultipliers": { "type": "object", "description": "Per-model multipliers for effective token accounting. Each model's weighted tokens are multiplied by this value before accumulation. Unlisted models use defaultModelMultiplier when set, otherwise the highest configured multiplier. See spec §10.2.", diff --git a/src/commands/build-config.test.ts b/src/commands/build-config.test.ts index 648e62c2a..4e3451cde 100644 --- a/src/commands/build-config.test.ts +++ b/src/commands/build-config.test.ts @@ -1,4 +1,6 @@ import { buildConfig } from './build-config'; +import { mapAwfFileConfigToCliOptions } from '../config-mapper'; +import { testHelpers as apiProxyEnvTestHelpers } from '../services/api-proxy-env-config'; /** Minimal valid inputs for buildConfig */ function makeInputs(overrides: Partial[0]> = {}): Parameters[0] { @@ -113,6 +115,28 @@ describe('buildConfig', () => { expect(config.allowedModels).toEqual(['gpt-5.6-sol']); expect(config.disallowedModels).toEqual(['gpt-5.6-luna']); }); + + it('carries pricing configuration from config-file mapping into proxy environment', () => { + const providers = { + anthropic: { + models: { + 'custom-model': { cost: { input: '3e-06', output: '1.5e-05' } }, + }, + }, + }; + const defaultPricing = { input: 3, output: 15, cachedInput: 0.3 }; + const options = mapAwfFileConfigToCliOptions({ + apiProxy: { + providers, + defaultAiCreditsPricing: defaultPricing, + }, + }); + const config = buildConfig(makeInputs({ options: { ...makeInputs().options, ...options } })); + const env = apiProxyEnvTestHelpers.buildRateLimitEnv(config); + + expect(JSON.parse(env.AWF_API_PROXY_PROVIDERS)).toEqual(providers); + expect(JSON.parse(env.AWF_DEFAULT_AI_CREDITS_PRICING)).toEqual(defaultPricing); + }); }); it('should set agentCommand from inputs', () => { diff --git a/src/commands/build-config.ts b/src/commands/build-config.ts index 78ed6d75f..283ae8a5f 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -171,6 +171,8 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { disallowedModels, maxEffectiveTokens, maxAiCredits, + defaultAiCreditsPricing: options.defaultAiCreditsPricing as WrapperConfig['defaultAiCreditsPricing'], + apiProxyProviders: options.apiProxyProviders as WrapperConfig['apiProxyProviders'], effectiveTokenModelMultipliers, effectiveTokenDefaultModelMultiplier, maxModelMultiplierCap, diff --git a/src/commands/validators/api-proxy-validator.ts b/src/commands/validators/api-proxy-validator.ts index bc3268702..1fcbcb77a 100644 --- a/src/commands/validators/api-proxy-validator.ts +++ b/src/commands/validators/api-proxy-validator.ts @@ -138,7 +138,8 @@ function resolveAliasToFirstConcrete( /** * Resolves the effective `COPILOT_MODEL` value (from `--env`, env-file, or host * env when `--env-all` is active), warns on classic-PAT usage, and validates - * the model identifier against the known-models list. + * against the offline catalog. Unknown active models are deferred to runtime + * provider discovery when the API proxy is enabled. * Calls `process.exit(1)` on any failure. */ export function validateCopilotModelOption( @@ -191,11 +192,16 @@ export function validateCopilotModelOption( if (firstConcrete !== undefined) { const aliasValidation = validateCopilotModel(firstConcrete); if (!aliasValidation.valid) { - logger.error( - `Error: alias '${copilotModel}' resolves to model '${firstConcrete}' which is ${aliasValidation.reason === 'retired' ? 'retired or unsupported' : 'unsupported or unrecognized by this AWF version'}.`, + if (aliasValidation.reason === 'retired' || !config.enableApiProxy) { + logger.error( + `Error: alias '${copilotModel}' resolves to model '${firstConcrete}' which is ${aliasValidation.reason === 'retired' ? 'retired or unsupported' : 'unsupported or unrecognized by this AWF version'}.`, + ); + logger.error(aliasValidation.message); + process.exit(1); + } + logger.info( + `Alias '${copilotModel}' targets model '${firstConcrete}', which is not in this AWF version's offline catalog; deferring validation to runtime provider discovery`, ); - logger.error(aliasValidation.message); - process.exit(1); } } // Alias is valid (or all paths are wildcards/provider-scoped) — leave @@ -204,8 +210,18 @@ export function validateCopilotModelOption( // Not an alias: validate and normalise the concrete model name directly. const validation = validateCopilotModel(copilotModel); if (!validation.valid) { - logger.error(validation.message); - process.exit(1); + if (validation.reason === 'retired' || !config.enableApiProxy) { + logger.error(validation.message); + process.exit(1); + } + logger.info( + `COPILOT_MODEL '${copilotModel}' is not in this AWF version's offline catalog; deferring validation to runtime provider discovery`, + ); + config.additionalEnv = { + ...(config.additionalEnv ?? {}), + COPILOT_MODEL: copilotModel, + }; + return; } if (validation.resolvedModel !== copilotModel) { diff --git a/src/commands/validators/config-assembly-model-detection.test.ts b/src/commands/validators/config-assembly-model-detection.test.ts index e132d0520..946ca7607 100644 --- a/src/commands/validators/config-assembly-model-detection.test.ts +++ b/src/commands/validators/config-assembly-model-detection.test.ts @@ -297,7 +297,7 @@ describe('config-assembly', () => { expect(logger.error).not.toHaveBeenCalled(); }); - it('should reject alias whose first concrete pattern resolves to an unsupported model', () => { + it('should defer an alias with an unknown concrete target to runtime discovery', () => { mockBuildConfigOnce({ copilotGithubToken: 'github_pat_testtoken', modelAliases: { bad: ['not-a-real-model-xyz'] }, @@ -309,6 +309,33 @@ describe('config-assembly', () => { const agentOptions = createMinimalAgentOptions(); agentOptions.additionalEnv = { COPILOT_MODEL: 'bad' }; + expect(() => { + assembleAndValidateConfig( + {}, + 'echo test', + logAndLimits, + createMinimalNetworkOptions(), + agentOptions, + ); + }).not.toThrow(); + + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining("Alias 'bad' targets model 'not-a-real-model-xyz'"), + ); + }); + + it('should reject an alias whose concrete target is retired', () => { + mockBuildConfigOnce({ + copilotGithubToken: 'github_pat_testtoken', + modelAliases: { old: ['gpt-5-codex'] }, + }); + + const logAndLimits = createMinimalLogAndLimits(); + logAndLimits.modelAliases = { old: ['gpt-5-codex'] }; + + const agentOptions = createMinimalAgentOptions(); + agentOptions.additionalEnv = { COPILOT_MODEL: 'old' }; + expect(() => { assembleAndValidateConfig( {}, @@ -320,7 +347,7 @@ describe('config-assembly', () => { }).toThrow('process.exit(1)'); expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining("alias 'bad' resolves to model 'not-a-real-model-xyz'"), + expect.stringContaining("alias 'old' resolves to model 'gpt-5-codex' which is retired"), ); }); @@ -349,7 +376,7 @@ describe('config-assembly', () => { expect(logger.error).not.toHaveBeenCalled(); }); - it('should still reject unsupported COPILOT_MODEL values that are not runtime aliases', () => { + it('should defer unknown COPILOT_MODEL values to runtime provider discovery', () => { mockBuildConfigOnce({ copilotGithubToken: 'github_pat_testtoken', modelAliases: { small: ['gpt-4o-mini'] }, @@ -369,10 +396,10 @@ describe('config-assembly', () => { createMinimalNetworkOptions(), agentOptions, ); - }).toThrow('process.exit(1)'); + }).not.toThrow(); - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining("model 'not-a-real-model-xyz' is unsupported or unrecognized"), + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining("deferring validation to runtime provider discovery"), ); }); @@ -402,7 +429,7 @@ describe('config-assembly', () => { expect(logger.error).not.toHaveBeenCalled(); }); - it('should reject a recursive alias chain that resolves to an unsupported model', () => { + it('should defer a recursive alias chain with an unknown target to runtime discovery', () => { const aliases = { inner: ['not-a-real-model-xyz'], outer: ['inner'] }; mockBuildConfigOnce({ copilotGithubToken: 'github_pat_testtoken', @@ -423,10 +450,10 @@ describe('config-assembly', () => { createMinimalNetworkOptions(), agentOptions, ); - }).toThrow('process.exit(1)'); + }).not.toThrow(); - expect(logger.error).toHaveBeenCalledWith( - expect.stringContaining("alias 'outer' resolves to model 'not-a-real-model-xyz'"), + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining("Alias 'outer' targets model 'not-a-real-model-xyz'"), ); }); diff --git a/src/config-file-mapping.test.ts b/src/config-file-mapping.test.ts index 9bc260f89..a1c7635e6 100644 --- a/src/config-file-mapping.test.ts +++ b/src/config-file-mapping.test.ts @@ -158,6 +158,13 @@ describe('mapAwfFileConfigToCliOptions', () => { apiProxy: { maxEffectiveTokens: 6000, maxAiCredits: 1.2, + providers: { + anthropic: { + models: { + 'custom-model': { cost: { input: '3e-06', output: '1.5e-05' } }, + }, + }, + }, modelMultipliers: { 'gpt-4o': 2, 'claude-sonnet-4': 1.5, @@ -168,6 +175,13 @@ describe('mapAwfFileConfigToCliOptions', () => { }); expect(result.maxEffectiveTokens).toBe(6000); expect(result.maxAiCredits).toBe(1.2); + expect(result.apiProxyProviders).toEqual({ + anthropic: { + models: { + 'custom-model': { cost: { input: '3e-06', output: '1.5e-05' } }, + }, + }, + }); expect(result.effectiveTokenModelMultipliers).toEqual({ 'gpt-4o': 2, 'claude-sonnet-4': 1.5, diff --git a/src/config-file.ts b/src/config-file.ts index 24e43e166..228fb172d 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -23,6 +23,7 @@ export interface AwfFileConfig { maxEffectiveTokens?: number; maxAiCredits?: number; defaultAiCreditsPricing?: { input: number; output: number; cachedInput?: number; cacheWrite?: number | null }; + providers?: Record; modelMultipliers?: Record; defaultModelMultiplier?: number; maxModelMultiplierCap?: number; diff --git a/src/config-mapper.ts b/src/config-mapper.ts index 1d609560e..e8e21bc83 100644 --- a/src/config-mapper.ts +++ b/src/config-mapper.ts @@ -36,6 +36,7 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record { const pricingPath = path.resolve( __dirname, @@ -101,4 +133,34 @@ describe('SUPPORTED_COPILOT_MODELS ↔ ai-credits-pricing catalog sync', () => { ); } }); + + it('every supported model has curated pricing or an explicit static-catalog fallback', () => { + const normalizedPricing = new Set(pricingModels.map(normalizeSeparators)); + const missing = [...testHelpers.supportedCopilotModels] + .map(normalizeSeparators) + .filter(model => model !== 'auto') + .filter(model => !SUPPORTED_WITHOUT_CURATED_PRICING.has(model)) + .filter(model => !normalizedPricing.has(model)); + expect(missing).toEqual([]); + }); + + it('every mapped completion family is represented or explicitly excluded', () => { + const mappingPath = path.resolve(__dirname, '..', 'docs', 'model-api-mapping.json'); + const mapping = JSON.parse(fs.readFileSync(mappingPath, 'utf8')) as { + providers: Record }>; + }; + const normalizedSupported = [...testHelpers.supportedCopilotModels].map(normalizeSeparators); + const missing: string[] = []; + for (const provider of Object.values(mapping.providers)) { + for (const entry of provider.models || []) { + if (!entry.family) continue; + const family = normalizeSeparators(entry.family); + if (MAPPING_FAMILIES_NOT_EXPOSED_BY_COPILOT_CLI.has(family)) continue; + if (!normalizedSupported.some(model => model === family || model.startsWith(`${family}-`))) { + missing.push(entry.family); + } + } + } + expect(missing).toEqual([]); + }); }); diff --git a/src/copilot-model.ts b/src/copilot-model.ts index 13509ed1b..5512ce83c 100644 --- a/src/copilot-model.ts +++ b/src/copilot-model.ts @@ -55,6 +55,9 @@ const SUPPORTED_COPILOT_MODELS = new Set([ 'gpt-5.4', 'gpt-5.4-mini', 'gpt-5.5', + 'gpt-5.6-luna', + 'gpt-5.6-sol', + 'gpt-5.6-terra', 'gpt-5-mini', 'o3', 'o3-mini', @@ -62,6 +65,7 @@ const SUPPORTED_COPILOT_MODELS = new Set([ 'claude-haiku-4.5', 'claude-mythos-5', 'claude-opus-4.8', + 'claude-opus-5', 'claude-sonnet-5', 'claude-sonnet-4.5', 'claude-sonnet-4.6', diff --git a/src/services/api-proxy-env-config.test.ts b/src/services/api-proxy-env-config.test.ts index 113db5fa2..bf61f0093 100644 --- a/src/services/api-proxy-env-config.test.ts +++ b/src/services/api-proxy-env-config.test.ts @@ -291,6 +291,18 @@ describe('buildRateLimitEnv', () => { expect(env.AWF_MAX_AI_CREDITS).toBe('1.25'); }); + it('sets AWF_API_PROXY_PROVIDERS when provider pricing overlays are configured', () => { + const providers = { + anthropic: { + models: { + 'custom-model': { cost: { input: '3e-06', output: '1.5e-05' } }, + }, + }, + }; + const env = buildRateLimitEnv({ ...baseConfig, workDir: '/tmp/awf-test', apiProxyProviders: providers }); + expect(JSON.parse(env.AWF_API_PROXY_PROVIDERS)).toEqual(providers); + }); + it('sets AWF_MAX_RUNS when configured', () => { const env = buildRateLimitEnv({ ...baseConfig, workDir: '/tmp/awf-test', maxRuns: 25 }); expect(env.AWF_MAX_RUNS).toBe('25'); diff --git a/src/services/api-proxy-env-config.ts b/src/services/api-proxy-env-config.ts index ba26bd781..b1ab54613 100644 --- a/src/services/api-proxy-env-config.ts +++ b/src/services/api-proxy-env-config.ts @@ -200,6 +200,9 @@ function buildRateLimitEnv(config: WrapperConfig): Record { ...(config.defaultAiCreditsPricing && { AWF_DEFAULT_AI_CREDITS_PRICING: JSON.stringify(config.defaultAiCreditsPricing), }), + ...(config.apiProxyProviders && { + AWF_API_PROXY_PROVIDERS: JSON.stringify(config.apiProxyProviders), + }), ...(config.effectiveTokenModelMultipliers && { AWF_EFFECTIVE_TOKEN_MODEL_MULTIPLIERS: JSON.stringify(config.effectiveTokenModelMultipliers), }), diff --git a/src/types/rate-limit-options.ts b/src/types/rate-limit-options.ts index fcaff7376..fc634d45b 100644 --- a/src/types/rate-limit-options.ts +++ b/src/types/rate-limit-options.ts @@ -46,6 +46,9 @@ export interface RateLimitOptions { */ defaultAiCreditsPricing?: { input: number; output: number; cachedInput?: number; cacheWrite?: number | null }; + /** Provider/model pricing overlays in models.dev provider format. */ + apiProxyProviders?: Record; + /** * Model-specific multipliers used by effective token accounting. *