Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 73 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,45 @@ const BUILTIN_FALLBACK_PRICING = Object.freeze({
output: 15.00,
});

// Static ceiling for recognized dynamic selectors whose concrete runtime model
// is unknown at accounting time. These rates bound every priced Copilot model
// in the curated catalog; retain the ceiling if the catalog is unavailable.
const DYNAMIC_SELECTOR_PRICING_CEILING = Object.freeze({
input: 10.00,
cachedInput: 1.00,
cacheWrite: 12.50,
output: 50.00,
});

function buildDynamicSelectorFallbackPricing() {
const pricing = { ...DYNAMIC_SELECTOR_PRICING_CEILING };
for (const catalogPricing of Object.values(pricingByModel)) {
for (const field of Object.keys(pricing)) {
if (typeof catalogPricing[field] === 'number') {
pricing[field] = Math.max(pricing[field], catalogPricing[field]);
}
}
}
return Object.freeze(pricing);
}

const DYNAMIC_SELECTOR_FALLBACK_PRICING = buildDynamicSelectorFallbackPricing();

// Kept separate from the generic unknown-model fallback because a dynamic
// selector is an explicit provider-supported request, not an unknown model.
const DYNAMIC_SELECTOR_FALLBACK_PRICING_SOURCE = 'dynamic_selector_fallback';

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 +141,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 +186,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_PRICING_SOURCE,
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 +300,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 +335,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 +360,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 +374,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 +390,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 +412,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 +475,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: 3.5,
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: 3.5,
by_model: {
auto: {
input_credits: 1,
cached_input_credits: 0,
cache_write_credits: 0,
output_credits: 2.5,
total: 3.5,
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
33 changes: 29 additions & 4 deletions containers/api-proxy/server.websocket.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -362,11 +362,9 @@ describe('proxyWebSocket', () => {

// ── Security guard tests ──────────────────────────────────────────────────────
//
// These tests verify that common (non-model-specific) security guards are
// These tests verify that common and query-model-specific security guards are
// enforced on the WebSocket upgrade path using the shared buildCommonGuardChecks
// factory. Model-specific guards (model_multiplier_cap, retired_model,
// unknown_model_ai_credits) are intentionally skipped because WebSocket
// upgrades pass model=null (no JSON body to extract a model from).
// factory.
// Guards are triggered by directly calling their apply functions (same
// technique used in guards/*.test.js unit tests).

Expand All @@ -377,6 +375,7 @@ describe('proxyWebSocket security guards', () => {
let applyEffectiveTokenUsage, resetEffectiveTokenGuardForTests;
let applyPermissionDenied, resetPermissionDeniedGuardForTests;
let applyAiCreditsUsage, resetAiCreditsGuardForTests;
let resetMaxModelMultiplierGuardForTests;

beforeAll(() => {
jest.resetModules();
Expand All @@ -386,6 +385,7 @@ describe('proxyWebSocket security guards', () => {
({ applyEffectiveTokenUsage, resetEffectiveTokenGuardForTests } = require('./guards/effective-token-guard'));
({ applyPermissionDenied, resetPermissionDeniedGuardForTests } = require('./guards/max-permission-denied-guard'));
({ applyAiCreditsUsage, resetAiCreditsGuardForTests } = require('./guards/ai-credits-guard'));
({ resetMaxModelMultiplierGuardForTests } = require('./guards/max-model-multiplier-guard'));
});

afterAll(() => {
Expand All @@ -398,11 +398,14 @@ describe('proxyWebSocket security guards', () => {
delete process.env.AWF_MAX_EFFECTIVE_TOKENS;
delete process.env.AWF_MAX_PERMISSION_DENIED;
delete process.env.AWF_MAX_AI_CREDITS;
delete process.env.AWF_MAX_MODEL_MULTIPLIER;
delete process.env.AWF_EFFECTIVE_TOKEN_MODEL_MULTIPLIERS;
resetMaxRunsGuardForTests();
resetMaxCacheMissesGuardForTests();
resetEffectiveTokenGuardForTests();
resetPermissionDeniedGuardForTests();
resetAiCreditsGuardForTests();
resetMaxModelMultiplierGuardForTests();
jest.restoreAllMocks();
});

Expand Down Expand Up @@ -466,6 +469,28 @@ describe('proxyWebSocket security guards', () => {
expect(socket.destroy).toHaveBeenCalled();
});

it('rejects an unknown WebSocket query model when AI-credit accounting is enabled', () => {
process.env.AWF_MAX_AI_CREDITS = '10';

const socket = makeMockSocket();
wsProxy(makeUpgradeReq({ url: '/v1/responses?model=bogus' }), socket, Buffer.alloc(0), 'api.openai.com', {}, 'copilot');

expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('HTTP/1.1 400 Bad Request'));
expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('"unknown_model_ai_credits"'));
expect(socket.destroy).toHaveBeenCalled();
});

it('rejects an unverifiable Copilot auto WebSocket model multiplier', () => {
process.env.AWF_MAX_MODEL_MULTIPLIER = '1';

const socket = makeMockSocket();
wsProxy(makeUpgradeReq({ url: '/v1/responses?model=auto' }), socket, Buffer.alloc(0), 'api.openai.com', {}, 'copilot');

expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('HTTP/1.1 400 Bad Request'));
expect(socket.write).toHaveBeenCalledWith(expect.stringContaining('"model_multiplier_cap_unverifiable"'));
expect(socket.destroy).toHaveBeenCalled();
});

it('allows the upgrade when no guards are triggered', () => {
// No guard env vars set and no usage applied — all guards pass.
// Without HTTPS_PROXY the upgrade will fail with 502, but the key point is
Expand Down
Loading
Loading