diff --git a/containers/api-proxy/guards/ai-credits-guard.js b/containers/api-proxy/guards/ai-credits-guard.js index 10c28a293..d8719d342 100644 --- a/containers/api-proxy/guards/ai-credits-guard.js +++ b/containers/api-proxy/guards/ai-credits-guard.js @@ -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 diff --git a/containers/api-proxy/guards/ai-credits-guard.test.js b/containers/api-proxy/guards/ai-credits-guard.test.js index 93ed99b7f..5261aeb32 100644 --- a/containers/api-proxy/guards/ai-credits-guard.test.js +++ b/containers/api-proxy/guards/ai-credits-guard.test.js @@ -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', () => { @@ -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(); + }); }); }); diff --git a/containers/api-proxy/guards/common-guard-checks.js b/containers/api-proxy/guards/common-guard-checks.js index 1a2a774a6..331357eaf 100644 --- a/containers/api-proxy/guards/common-guard-checks.js +++ b/containers/api-proxy/guards/common-guard-checks.js @@ -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} Array of guard descriptor objects. */ -function buildCommonGuardChecks(deps, model) { +function buildCommonGuardChecks(deps, model, provider = null) { const { getEffectiveTokenBlockState, buildEffectiveTokenLimitError, @@ -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', diff --git a/containers/api-proxy/model-resolver.js b/containers/api-proxy/model-resolver.js index 366156816..51c96a6c3 100644 --- a/containers/api-proxy/model-resolver.js +++ b/containers/api-proxy/model-resolver.js @@ -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(' → ')}]`); diff --git a/containers/api-proxy/model-resolver.test.js b/containers/api-proxy/model-resolver.test.js index 6e22db21d..192a4e700 100644 --- a/containers/api-proxy/model-resolver.test.js +++ b/containers/api-proxy/model-resolver.test.js @@ -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(); @@ -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); diff --git a/containers/api-proxy/proxy-guards.js b/containers/api-proxy/proxy-guards.js index 570b2d240..7891f36e9 100644 --- a/containers/api-proxy/proxy-guards.js +++ b/containers/api-proxy/proxy-guards.js @@ -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; diff --git a/containers/api-proxy/server.startup-model-validation.test.js b/containers/api-proxy/server.startup-model-validation.test.js index 1aa3ff333..8cf7502b2 100644 --- a/containers/api-proxy/server.startup-model-validation.test.js +++ b/containers/api-proxy/server.startup-model-validation.test.js @@ -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']; diff --git a/containers/api-proxy/server.token-guards.test.js b/containers/api-proxy/server.token-guards.test.js index 6f8142713..86a9dc97e 100644 --- a/containers/api-proxy/server.token-guards.test.js +++ b/containers/api-proxy/server.token-guards.test.js @@ -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; @@ -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', () => { diff --git a/containers/api-proxy/websocket-guards.js b/containers/api-proxy/websocket-guards.js index b4b046049..37607d951 100644 --- a/containers/api-proxy/websocket-guards.js +++ b/containers/api-proxy/websocket-guards.js @@ -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; diff --git a/src/commands/validators/config-assembly-model-detection.test.ts b/src/commands/validators/config-assembly-model-detection.test.ts index 82db46a9b..e132d0520 100644 --- a/src/commands/validators/config-assembly-model-detection.test.ts +++ b/src/commands/validators/config-assembly-model-detection.test.ts @@ -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', diff --git a/src/copilot-model.test.ts b/src/copilot-model.test.ts index 94afd69ad..c2cc2de21 100644 --- a/src/copilot-model.test.ts +++ b/src/copilot-model.test.ts @@ -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', diff --git a/src/copilot-model.ts b/src/copilot-model.ts index fca9578e5..13509ed1b 100644 --- a/src/copilot-model.ts +++ b/src/copilot-model.ts @@ -42,6 +42,7 @@ const RETIRED_COPILOT_MODEL_ALIASES: Record = { * 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',