Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
55 changes: 54 additions & 1 deletion containers/api-proxy/guards/ai-credits-guard.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,26 @@ const BUILTIN_FALLBACK_PRICING = Object.freeze({
output: 15.00,
});

// Conservative fallback pricing for recognized dynamic selectors whose concrete
// runtime model is unknown at accounting time.
const DYNAMIC_SELECTOR_FALLBACK_PRICING = Object.freeze({
input: 3.00,
cachedInput: 0.30,
cacheWrite: 3.75,
output: 15.00,
});

function getDynamicSelectorDescriptor(model, provider = undefined) {
if (typeof model !== 'string') return null;
if (provider !== PROVIDER_COPILOT) return null;
if (model.toLowerCase() !== 'auto') return null;
return { name: 'copilot:auto' };
}

function isRecognizedDynamicSelector(model, provider = undefined) {
return !!getDynamicSelectorDescriptor(model, provider);
}

function roundCredits(value) {
return Math.round((value + Number.EPSILON) * 1_000_000) / 1_000_000;
}
Expand Down Expand Up @@ -102,7 +122,7 @@ function resolveModelPricing(model, state = aiCreditsState, provider = undefined
.every(field => Object.hasOwn(runtime.pricing, field))) {
return runtime;
}
const fallback = resolveLowerPriorityPricing(model, state, options);
const fallback = resolveLowerPriorityPricing(model, state, { ...options, provider });
if (!runtime) return fallback;
const mergedPricing = {};
for (const field of ['input', 'cachedInput', 'cacheWrite', 'output']) {
Expand Down Expand Up @@ -147,6 +167,18 @@ function resolveLowerPriorityPricing(model, state, options = {}) {
return { pricing: catalogModel.pricing, source: 'models.dev', tier: 'default' };
}

const dynamicSelector = getDynamicSelectorDescriptor(model, options.provider);
if (dynamicSelector) {
return {
pricing: DYNAMIC_SELECTOR_FALLBACK_PRICING,
source: 'dynamic_selector_fallback',
tier: 'conservative',
accountingPolicy: 'dynamic_selector_fallback',
dynamicSelector: dynamicSelector.name,
usedFallbackPricing: true,
};
}

// Speculative callers (e.g. filtering a fallback candidate pool) pass quiet:true
// so that probing a model neither emits an operator-facing warning nor marks the
// model as already-warned — which would suppress the warning if it is genuinely
Expand Down Expand Up @@ -249,6 +281,7 @@ function calculateAiCredits(normalizedUsage, model, state = aiCreditsState, prov
const pricingResolution = resolveModelPricing(model, state, provider, totalInputForTier);
if (!pricingResolution) return null;
const { pricing } = pricingResolution;
const dynamicSelector = getDynamicSelectorDescriptor(model, provider);

// input_tokens semantics differ by provider:
// - Anthropic and Copilot's precise copilot_usage report input_tokens as the
Expand Down Expand Up @@ -283,6 +316,13 @@ function calculateAiCredits(normalizedUsage, model, state = aiCreditsState, prov
pricingObservedAt: pricingResolution.observedAt,
pricingApiVersion: pricingResolution.apiVersion,
pricingDiscountPercent: pricingResolution.discountPercent,
accountingPolicy: pricingResolution.accountingPolicy ||
(dynamicSelector ? 'dynamic_selector_runtime' : model === 'unknown' ? 'unknown_model_fallback' : 'concrete_model'),
usedFallbackPricing: pricingResolution.usedFallbackPricing === true ||
pricingResolution.source === 'configured_default' ||
pricingResolution.source === 'builtin_fallback' ||
pricingResolution.source === 'dynamic_selector_fallback',
dynamicSelector: pricingResolution.dynamicSelector || dynamicSelector?.name || null,
};
}

Expand All @@ -301,6 +341,9 @@ function applyAiCreditsUsage(normalizedUsage, model, provider = undefined) {
totalCredits: 0,
pricingSource: calc.pricingSource,
pricingTier: calc.pricingTier,
accountingPolicy: calc.accountingPolicy,
fallbackPricingUsed: calc.usedFallbackPricing,
dynamicSelector: calc.dynamicSelector,
};
}

Expand All @@ -312,6 +355,9 @@ function applyAiCreditsUsage(normalizedUsage, model, provider = undefined) {
modelBucket.totalCredits += calc.totalCredits;
modelBucket.pricingSource = calc.pricingSource;
modelBucket.pricingTier = calc.pricingTier;
modelBucket.accountingPolicy = calc.accountingPolicy;
modelBucket.fallbackPricingUsed = calc.usedFallbackPricing;
modelBucket.dynamicSelector = calc.dynamicSelector;
aiCreditsState.totalAiCredits += calc.totalCredits;

process.env.AWF_AI_CREDITS_USED = String(roundCredits(aiCreditsState.totalAiCredits));
Expand All @@ -325,6 +371,9 @@ function applyAiCreditsUsage(normalizedUsage, model, provider = undefined) {
totalAiCredits: roundCredits(aiCreditsState.totalAiCredits),
pricingSource: calc.pricingSource,
pricingTier: calc.pricingTier,
accountingPolicy: calc.accountingPolicy,
fallbackPricingUsed: calc.usedFallbackPricing,
dynamicSelector: calc.dynamicSelector,
...(calc.pricingObservedAt ? { pricingObservedAt: calc.pricingObservedAt } : {}),
...(calc.pricingApiVersion ? { pricingApiVersion: calc.pricingApiVersion } : {}),
...(calc.pricingDiscountPercent !== undefined
Expand All @@ -344,6 +393,9 @@ function getAiCreditsReflectState() {
total: roundCredits(usage.totalCredits),
pricing_source: usage.pricingSource,
pricing_tier: usage.pricingTier,
accounting_policy: usage.accountingPolicy || null,
fallback_pricing_used: usage.fallbackPricingUsed === true,
dynamic_selector: usage.dynamicSelector || null,
};
}
return {
Expand Down Expand Up @@ -404,6 +456,7 @@ module.exports = {
getAiCreditsBlockState,
buildAiCreditsLimitError,
checkUnknownModelRejection,
isRecognizedDynamicSelector,
isModelPriceable,
canonicalizeModel,
resetAiCreditsGuardForTests,
Expand Down
46 changes: 44 additions & 2 deletions containers/api-proxy/guards/ai-credits-guard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const {
getAiCreditsBlockState,
buildAiCreditsLimitError,
checkUnknownModelRejection,
isRecognizedDynamicSelector,
isModelPriceable,
canonicalizeModel,
resetAiCreditsGuardForTests,
Expand Down Expand Up @@ -77,6 +78,9 @@ describe('ai-credits-guard', () => {
total: 0.12275,
pricing_source: 'curated',
pricing_tier: 'default',
accounting_policy: 'concrete_model',
fallback_pricing_used: false,
dynamic_selector: null,
},
},
});
Expand Down Expand Up @@ -665,15 +669,53 @@ describe('ai-credits-guard', () => {
expect(sonnet5).toBeNull();
});

it('rejects the auto selector when runtime pricing cannot be proven', () => {
it('allows the Copilot auto selector and rejects unknown-provider auto selectors', () => {
process.env.AWF_MAX_AI_CREDITS = '10';
resetAiCreditsGuardForTests();

expect(checkUnknownModelRejection('auto', PROVIDER_COPILOT)).not.toBeNull();
expect(checkUnknownModelRejection('auto', PROVIDER_COPILOT)).toBeNull();
expect(checkUnknownModelRejection('auto', PROVIDER_OPENAI)).not.toBeNull();
});
});

it('uses conservative dynamic-selector fallback pricing for Copilot auto', () => {
process.env.AWF_MAX_AI_CREDITS = '10';
resetAiCreditsGuardForTests();

const usage = applyAiCreditsUsage({
input_tokens: 1000,
output_tokens: 500,
}, 'auto', PROVIDER_COPILOT);

expect(usage).toMatchObject({
aiCreditsThisResponse: 1.05,
pricingSource: 'dynamic_selector_fallback',
pricingTier: 'conservative',
accountingPolicy: 'dynamic_selector_fallback',
fallbackPricingUsed: true,
dynamicSelector: 'copilot:auto',
});
expect(isRecognizedDynamicSelector('auto', PROVIDER_COPILOT)).toBe(true);
expect(isRecognizedDynamicSelector('auto', PROVIDER_OPENAI)).toBe(false);
expect(getAiCreditsReflectState()).toEqual({
total: 1.05,
by_model: {
auto: {
input_credits: 0.3,
cached_input_credits: 0,
cache_write_credits: 0,
output_credits: 0.75,
total: 1.05,
pricing_source: 'dynamic_selector_fallback',
pricing_tier: 'conservative',
accounting_policy: 'dynamic_selector_fallback',
fallback_pricing_used: true,
dynamic_selector: 'copilot:auto',
},
},
});
});

describe('isModelPriceable (side-effect-free)', () => {
it('reports priced and unpriced models correctly', () => {
expect(isModelPriceable('gpt-4-turbo', PROVIDER_OPENAI)).toBe(true);
Expand Down
2 changes: 1 addition & 1 deletion containers/api-proxy/guards/common-guard-checks.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ function buildCommonGuardChecks(deps, model, provider = null) {
// Model-specific guards — only active when a model was identified in the request.
...(model ? [
{
block: getModelMultiplierCapBlockState(model),
block: getModelMultiplierCapBlockState(model, provider),
isBlocked: block => !!block,
statusCode: 400,
eventName: 'model_multiplier_cap_exceeded',
Expand Down
7 changes: 4 additions & 3 deletions containers/api-proxy/guards/max-model-multiplier-guard.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const { sanitizeForLog } = require('../logging');
const { parseModelMultipliers, parsePositiveNumber } = require('./guard-utils');
const { isRecognizedDynamicSelector } = require('./ai-credits-guard');

const maxModelMultiplierConfigCache = {
rawCap: undefined,
Expand Down Expand Up @@ -65,11 +66,11 @@ function resolveMultiplierForModel(model, config) {
* @param {string|null} model - The model name from the request body (may be null)
* @returns {{ model: string, multiplier: number, maxModelMultiplier: number } | null}
*/
function getModelMultiplierCapBlockState(model) {
function getModelMultiplierCapBlockState(model, provider = undefined) {
const config = getMaxModelMultiplierConfig();
if (!config.cap || !model) return null;

if (model.toLowerCase() === 'auto' && !Object.hasOwn(config.multipliers, model)) {
if (isRecognizedDynamicSelector(model, provider) && !Object.hasOwn(config.multipliers, model)) {
return {
model: sanitizeForLog(model),
multiplier: null,
Expand Down Expand Up @@ -99,7 +100,7 @@ function buildModelMultiplierCapError(state) {
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.',
message: `Model "${state.model}" selects a concrete model at runtime, so its multiplier cannot be proven to be within the configured cap. Configure an explicit multiplier for "${state.model}" to opt in.`,
model: state.model,
model_multiplier: null,
max_model_multiplier: state.maxModelMultiplier,
Expand Down
13 changes: 10 additions & 3 deletions containers/api-proxy/guards/max-model-multiplier-guard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const {
buildModelMultiplierCapError,
resetMaxModelMultiplierGuardForTests,
} = require('./max-model-multiplier-guard');
const { PROVIDER_COPILOT, PROVIDER_OPENAI } = require('../provider-names');

describe('max-model-multiplier-guard', () => {
beforeEach(() => {
Expand Down Expand Up @@ -85,10 +86,10 @@ describe('max-model-multiplier-guard', () => {
expect(getModelMultiplierCapBlockState('unknown-model')).toBeNull();
});

it('fails closed for auto unless an explicit multiplier is configured', () => {
it('fails closed for Copilot auto unless an explicit multiplier is configured', () => {
process.env.AWF_MAX_MODEL_MULTIPLIER = '5';

const state = getModelMultiplierCapBlockState('auto');
const state = getModelMultiplierCapBlockState('auto', PROVIDER_COPILOT);
expect(state).toMatchObject({
model: 'auto',
multiplier: null,
Expand All @@ -102,7 +103,13 @@ describe('max-model-multiplier-guard', () => {
process.env.AWF_MAX_MODEL_MULTIPLIER = '5';
process.env.AWF_EFFECTIVE_TOKEN_MODEL_MULTIPLIERS = JSON.stringify({ auto: 5 });

expect(getModelMultiplierCapBlockState('auto')).toBeNull();
expect(getModelMultiplierCapBlockState('auto', PROVIDER_COPILOT)).toBeNull();
});

it('treats non-Copilot auto as a normal model name', () => {
process.env.AWF_MAX_MODEL_MULTIPLIER = '5';

expect(getModelMultiplierCapBlockState('auto', PROVIDER_OPENAI)).toBeNull();
});

it('blocks when configured default multiplier for unknown model exceeds cap', () => {
Expand Down
9 changes: 3 additions & 6 deletions containers/api-proxy/server.token-guards.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,7 @@ describe('proxyRequest max-ai-credits guard', () => {
expect(payload.error.total_ai_credits).toBeGreaterThanOrEqual(0.1);
});

it('rejects Copilot auto when its concrete runtime price cannot be proven', async () => {
it('allows Copilot auto and defers accounting to runtime usage tracking', async () => {
const upstreamRequest = makeProxyReq();
const httpsRequestSpy = jest.spyOn(https, 'request').mockImplementation(() => upstreamRequest);

Expand All @@ -351,11 +351,8 @@ describe('proxyRequest max-ai-credits guard', () => {
req.emit('end');
await flushPromises();

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');
expect(httpsRequestSpy).toHaveBeenCalledTimes(1);
expect(res.writeHead).not.toHaveBeenCalledWith(400, expect.anything());
});
});

Expand Down
6 changes: 6 additions & 0 deletions containers/api-proxy/token-budget-log.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ function computeTokenBudgetUsage({ logRequest, requestId, provider }, normalized
ai_credits_total: aiCreditsUsage.totalAiCredits,
pricing_source: aiCreditsUsage.pricingSource,
pricing_tier: aiCreditsUsage.pricingTier,
accounting_policy: aiCreditsUsage.accountingPolicy,
fallback_pricing_used: aiCreditsUsage.fallbackPricingUsed,
dynamic_selector: aiCreditsUsage.dynamicSelector,
});
}
const budgetFields = {};
Expand All @@ -43,6 +46,9 @@ function computeTokenBudgetUsage({ logRequest, requestId, provider }, normalized
budgetFields.ai_credits_total = aiCreditsUsage.totalAiCredits;
budgetFields.ai_credits_pricing_source = aiCreditsUsage.pricingSource;
budgetFields.ai_credits_pricing_tier = aiCreditsUsage.pricingTier;
budgetFields.ai_credits_accounting_policy = aiCreditsUsage.accountingPolicy;
budgetFields.ai_credits_fallback_pricing_used = aiCreditsUsage.fallbackPricingUsed;
budgetFields.ai_credits_dynamic_selector = aiCreditsUsage.dynamicSelector;
if (aiCreditsUsage.pricingObservedAt) {
budgetFields.ai_credits_pricing_observed_at = aiCreditsUsage.pricingObservedAt;
}
Expand Down
26 changes: 26 additions & 0 deletions containers/api-proxy/token-budget-log.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,30 @@ describe('computeTokenBudgetUsage', () => {
});
expect(logRequest).toHaveBeenCalledTimes(1);
});

it('reports dynamic selector accounting policy in token diagnostics', () => {
process.env.AWF_MAX_AI_CREDITS = '100';
const result = computeTokenBudgetUsage(
{ logRequest, requestId: 'req-dynamic', provider: 'copilot' },
{ input_tokens: 1000, output_tokens: 500 },
'auto',
);

expect(result).toMatchObject({
ai_credits_pricing_source: 'dynamic_selector_fallback',
ai_credits_pricing_tier: 'conservative',
ai_credits_accounting_policy: 'dynamic_selector_fallback',
ai_credits_fallback_pricing_used: true,
ai_credits_dynamic_selector: 'copilot:auto',
});
expect(logRequest).toHaveBeenCalledWith('info', 'token_budget_usage', expect.objectContaining({
request_id: 'req-dynamic',
provider: 'copilot',
model: 'auto',
pricing_source: 'dynamic_selector_fallback',
accounting_policy: 'dynamic_selector_fallback',
fallback_pricing_used: true,
dynamic_selector: 'copilot:auto',
}));
});
});
3 changes: 3 additions & 0 deletions containers/api-proxy/token-tracker-shared.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ function mergeBudgetFields(record, budgetResult) {
for (const field of [
'ai_credits_pricing_source',
'ai_credits_pricing_tier',
'ai_credits_accounting_policy',
'ai_credits_fallback_pricing_used',
'ai_credits_dynamic_selector',
'ai_credits_pricing_observed_at',
'ai_credits_pricing_api_version',
'ai_credits_pricing_discount_percent',
Expand Down
Loading