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
4 changes: 3 additions & 1 deletion containers/api-proxy/guards/ai-credits-guard.js
Original file line number Diff line number Diff line change
Expand Up @@ -142,13 +142,15 @@ function resolveModelPricing(model, state = aiCreditsState) {
* Only rejects when maxAiCredits is active and no default pricing is configured.
*
* @param {string} model
* @param {string} [provider]
* @returns {{ rejected: boolean, model: string, error: object } | null}
*/
function checkUnknownModelRejection(model) {
function checkUnknownModelRejection(model, provider = undefined) {
const config = getAiCreditsConfig();
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
Expand Down
9 changes: 9 additions & 0 deletions containers/api-proxy/guards/ai-credits-guard.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ const {
canonicalizeModel,
resetAiCreditsGuardForTests,
} = require('./ai-credits-guard');
const { PROVIDER_COPILOT, PROVIDER_OPENAI } = require('../provider-names');
const { collectLogOutput } = require('../test-helpers/log-test-helpers');

describe('ai-credits-guard', () => {
Expand Down Expand Up @@ -468,5 +469,13 @@ describe('ai-credits-guard', () => {
const sonnet5 = checkUnknownModelRejection('claude-sonnet-5');
expect(sonnet5).toBeNull();
});

it('allows the Copilot auto selector without default pricing', () => {
process.env.AWF_MAX_AI_CREDITS = '10';
resetAiCreditsGuardForTests();

expect(checkUnknownModelRejection('auto', PROVIDER_COPILOT)).toBeNull();
expect(checkUnknownModelRejection('auto', PROVIDER_OPENAI)).not.toBeNull();
});
});
});
5 changes: 3 additions & 2 deletions containers/api-proxy/guards/common-guard-checks.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,10 @@
* @param {Function} deps.buildModelPolicyError
* @param {string|null} model - Model name extracted from the request, or null
* to skip model-specific guards.
* @param {string|null} [provider=null] - Provider handling the request.
* @returns {Array<object>} Array of guard descriptor objects.
*/
function buildCommonGuardChecks(deps, model) {
function buildCommonGuardChecks(deps, model, provider = null) {
const {
getEffectiveTokenBlockState,
buildEffectiveTokenLimitError,
Expand Down Expand Up @@ -164,7 +165,7 @@ function buildCommonGuardChecks(deps, model) {
}),
},
{
block: checkUnknownModelRejection(model),
block: checkUnknownModelRejection(model, provider),
isBlocked: block => !!block,
statusCode: 400,
eventName: 'unknown_model_ai_credits',
Expand Down
12 changes: 12 additions & 0 deletions containers/api-proxy/model-resolver.js
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,18 @@ function resolveModel(requestedModel, aliases, availableModels, currentProvider,
const key = requestedModel.toLowerCase();
const fallbackConfig = normalizeFallbackConfig(modelFallbackConfig);

if (currentProvider === 'copilot' && key === 'auto') {
log.push('[model-resolver] special pass-through: "auto"');
return {
resolvedModel: requestedModel,
candidates: [requestedModel],
log,
fallback: fallbackConfig.enabled
? { activated: false, selection_method: 'middle_power_median', reason: 'direct_match' }
: undefined,
};
}

// Loop detection
if (chain.includes(key)) {
log.push(`[model-resolver] loop detected: "${requestedModel}" already in chain [${chain.join(' → ')}]`);
Expand Down
13 changes: 13 additions & 0 deletions containers/api-proxy/model-resolver.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,13 @@ describe('resolveModel', () => {
expect(result.resolvedModel).toBe('gpt-4o');
});

it('should treat the Copilot auto model as a pass-through', () => {
const result = resolveModel('auto', aliases, availableModels, 'copilot');
expect(result).not.toBeNull();
expect(result.resolvedModel).toBe('auto');
expect(result.fallback.activated).toBe(false);
});

it('should be case-insensitive for alias lookup', () => {
const result = resolveModel('SONNET', aliases, availableModels, 'copilot');
expect(result).not.toBeNull();
Expand Down Expand Up @@ -358,6 +365,12 @@ describe('rewriteModelInBody', () => {
expect(result).toBeNull(); // No rewrite needed
});

it('should not rewrite the Copilot auto model', () => {
const body = Buffer.from(JSON.stringify({ model: 'auto', messages: [] }));
const result = rewriteModelInBody(body, 'copilot', aliases, availableModels);
expect(result).toBeNull();
});

it('should return null for non-JSON body', () => {
const body = Buffer.from('not json');
const result = rewriteModelInBody(body, 'copilot', aliases, availableModels);
Expand Down
2 changes: 1 addition & 1 deletion containers/api-proxy/proxy-guards.js
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ function enforceGuards({ body, provider, req, res, requestId, startTime, span, i
checkUnknownModelRejection,
getModelPolicyBlockState,
buildModelPolicyError,
}, model);
}, model, provider);

for (const guard of guardChecks) {
if (!guard.isBlocked(guard.block)) continue;
Expand Down
10 changes: 10 additions & 0 deletions containers/api-proxy/server.startup-model-validation.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,16 @@ describe('validateRequestedModel', () => {
}));
});

it('treats the Copilot auto model as available without requiring it in provider model lists', () => {
process.env.AWF_REQUESTED_MODEL = 'auto';
cachedModels.copilot = ['gpt-4o', 'gpt-4o-mini'];
validateRequestedModel();
expect(logRequest).toHaveBeenCalledWith('info', 'model_validation', expect.objectContaining({
requested_model: 'auto',
resolved_via: 'direct',
}));
});

it('searches across multiple providers', () => {
process.env.AWF_REQUESTED_MODEL = 'claude-sonnet-4-5';
cachedModels.copilot = ['gpt-4o'];
Expand Down
27 changes: 27 additions & 0 deletions containers/api-proxy/server.token-guards.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,19 @@ describe('proxyRequest max-ai-credits guard', () => {
return makeReqFactory('/v1/chat/completions', headers);
}

function makeModelReq(body, headers = {}) {
const req = makeReq(headers);
const bodyBuf = Buffer.from(body);
const originalEmit = req.emit.bind(req);
req.emit = function(event, ...args) {
if (event === 'end') {
originalEmit('data', bodyBuf);
}
return originalEmit(event, ...args);
};
return req;
}

beforeEach(() => {
process.env.AWF_MAX_AI_CREDITS = '0.1';
delete process.env.AWF_MAX_EFFECTIVE_TOKENS;
Expand Down Expand Up @@ -327,6 +340,20 @@ describe('proxyRequest max-ai-credits guard', () => {
expect(payload.error.max_ai_credits).toBe(0.1);
expect(payload.error.total_ai_credits).toBeGreaterThanOrEqual(0.1);
});

it('allows Copilot auto requests through the ai credits request guard', async () => {
const upstreamRequest = makeProxyReq();
const httpsRequestSpy = jest.spyOn(https, 'request').mockImplementation(() => upstreamRequest);

const req = makeModelReq(JSON.stringify({ model: 'auto', messages: [] }));
const res = makeRes();
proxyRequest(req, res, 'api.githubcopilot.com', { Authorization: '******' }, 'copilot');
req.emit('end');
await flushPromises();

expect(httpsRequestSpy).toHaveBeenCalledTimes(1);
expect(res.writeHead).not.toHaveBeenCalledWith(400, expect.anything());
});
});

describe('proxyRequest permission-denied guard', () => {
Expand Down
2 changes: 1 addition & 1 deletion containers/api-proxy/websocket-guards.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ const HTTP_STATUS_LINES = {
function enforceWebSocketGuards({ socket, logRequest, requestId, provider }, guardDeps) {
// WebSocket upgrade requests have no JSON body, so model-specific guards
// receive null and are skipped (their getters return null for null models).
const guardChecks = buildCommonGuardChecks(guardDeps, null);
const guardChecks = buildCommonGuardChecks(guardDeps, null, provider);

for (const guard of guardChecks) {
if (!guard.isBlocked(guard.block)) continue;
Expand Down
21 changes: 21 additions & 0 deletions src/commands/validators/config-assembly-model-detection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,27 @@ describe('config-assembly', () => {
);
});

it('should allow COPILOT_MODEL=auto', () => {
mockBuildConfigOnce({
copilotGithubToken: 'github_pat_testtoken',
});

const agentOptions = createMinimalAgentOptions();
agentOptions.additionalEnv = { COPILOT_MODEL: 'auto' };

expect(() => {
assembleAndValidateConfig(
{},
'echo test',
createMinimalLogAndLimits(),
createMinimalNetworkOptions(),
agentOptions,
);
}).not.toThrow();

expect(logger.error).not.toHaveBeenCalled();
});

it('should allow COPILOT_MODEL that matches a runtime alias key and resolves to a valid concrete model', () => {
mockBuildConfigOnce({
copilotGithubToken: 'github_pat_testtoken',
Expand Down
5 changes: 5 additions & 0 deletions src/copilot-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ describe('validateCopilotModel', () => {
expect(result).toEqual({ valid: true, resolvedModel: 'gpt-5.3-codex' });
});

it('accepts the Copilot auto model', () => {
const result = validateCopilotModel(' auto ');
expect(result).toEqual({ valid: true, resolvedModel: 'auto' });
});

it.each([
'gpt-4.5',
'gpt-5.1',
Expand Down
1 change: 1 addition & 0 deletions src/copilot-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const RETIRED_COPILOT_MODEL_ALIASES: Record<string, string> = {
* explicitly listed as a non-CLI model in the test's exclusion set).
*/
const SUPPORTED_COPILOT_MODELS = new Set([
'auto',
'gpt-4',
'gpt-4.1',
'gpt-4.5',
Expand Down
Loading