diff --git a/containers/api-proxy/guards/retired-model-guard.js b/containers/api-proxy/guards/retired-model-guard.js new file mode 100644 index 000000000..b29a53b48 --- /dev/null +++ b/containers/api-proxy/guards/retired-model-guard.js @@ -0,0 +1,57 @@ +'use strict'; + +const { sanitizeForLog } = require('../logging'); + +/** + * Known-retired Copilot model names mapped to their suggested replacements. + * + * When the API proxy receives a request body that references one of these + * model names, it rejects the request immediately with a 400 rather than + * forwarding it to the upstream provider. Forwarding a retired model name + * to the Copilot API tends to surface an authentication-flavoured error (401 + * or 403) rather than a clear "model unavailable" message, which misleads + * operators into thinking their API keys are invalid. + * + * Keep this list in sync with RETIRED_COPILOT_MODEL_ALIASES in + * src/copilot-model.ts (the TypeScript CLI equivalent). + */ +const RETIRED_COPILOT_MODELS = { + 'gpt-5-codex': 'gpt-5.3-codex', +}; + +/** + * Returns a block-state object when the given model name is a known-retired + * Copilot model, or null when the model is not retired / is absent. + * + * @param {string|null} model - The model name extracted from the request body. + * @returns {{ model: string, suggestion: string } | null} + */ +function getRetiredModelBlockState(model) { + if (!model) return null; + const key = model.toLowerCase(); + const suggestion = RETIRED_COPILOT_MODELS[key]; + if (!suggestion) return null; + return { model: sanitizeForLog(model), suggestion }; +} + +/** + * Builds the structured 400 error response body for a retired-model rejection. + * + * @param {{ model: string, suggestion: string }} state + * @returns {{ error: object }} + */ +function buildRetiredModelError(state) { + return { + error: { + type: 'retired_model', + message: `Model '${state.model}' is retired or unsupported. Did you mean '${state.suggestion}'?`, + model: state.model, + suggestion: state.suggestion, + }, + }; +} + +module.exports = { + getRetiredModelBlockState, + buildRetiredModelError, +}; diff --git a/containers/api-proxy/guards/retired-model-guard.test.js b/containers/api-proxy/guards/retired-model-guard.test.js new file mode 100644 index 000000000..fc5e36c2f --- /dev/null +++ b/containers/api-proxy/guards/retired-model-guard.test.js @@ -0,0 +1,54 @@ +'use strict'; + +const { + getRetiredModelBlockState, + buildRetiredModelError, +} = require('./retired-model-guard'); + +describe('retired-model-guard', () => { + describe('getRetiredModelBlockState', () => { + it('returns null for null model', () => { + expect(getRetiredModelBlockState(null)).toBeNull(); + }); + + it('returns null for empty string', () => { + expect(getRetiredModelBlockState('')).toBeNull(); + }); + + it('returns null for an active/supported model', () => { + expect(getRetiredModelBlockState('gpt-4o')).toBeNull(); + expect(getRetiredModelBlockState('gpt-5.3-codex')).toBeNull(); + expect(getRetiredModelBlockState('claude-sonnet-4.6')).toBeNull(); + }); + + it('returns a block state for the retired gpt-5-codex model', () => { + const state = getRetiredModelBlockState('gpt-5-codex'); + expect(state).not.toBeNull(); + expect(state.model).toBe('gpt-5-codex'); + expect(state.suggestion).toBe('gpt-5.3-codex'); + }); + + it('matches retired models case-insensitively', () => { + expect(getRetiredModelBlockState('GPT-5-CODEX')).not.toBeNull(); + expect(getRetiredModelBlockState('Gpt-5-Codex')).not.toBeNull(); + }); + + it('preserves the original casing in the returned model field', () => { + const state = getRetiredModelBlockState('GPT-5-CODEX'); + expect(state.model).toBe('GPT-5-CODEX'); + }); + }); + + describe('buildRetiredModelError', () => { + it('includes the model and suggestion in the error body', () => { + const state = { model: 'gpt-5-codex', suggestion: 'gpt-5.3-codex' }; + const result = buildRetiredModelError(state); + expect(result.error.type).toBe('retired_model'); + expect(result.error.model).toBe('gpt-5-codex'); + expect(result.error.suggestion).toBe('gpt-5.3-codex'); + expect(result.error.message).toContain("gpt-5-codex"); + expect(result.error.message).toContain("gpt-5.3-codex"); + expect(result.error.message).toContain("Did you mean"); + }); + }); +}); diff --git a/containers/api-proxy/proxy-request.js b/containers/api-proxy/proxy-request.js index 36a8c25b4..9d158356c 100644 --- a/containers/api-proxy/proxy-request.js +++ b/containers/api-proxy/proxy-request.js @@ -61,6 +61,10 @@ const { checkUnknownModelRejection, resetAiCreditsGuardForTests, } = require('./guards/ai-credits-guard'); +const { + getRetiredModelBlockState, + buildRetiredModelError, +} = require('./guards/retired-model-guard'); // ── Optional token tracker (graceful degradation when not bundled) ──────────── let trackTokenUsage; @@ -442,6 +446,19 @@ function enforceGuards({ body, provider, req, res, requestId, startTime, span }) }), }] : []), + ...(checkModelMultiplier + ? [{ + block: getRetiredModelBlockState(extractModelFromBody(body)), + isBlocked: block => !!block, + statusCode: 400, + eventName: 'retired_model', + buildError: buildRetiredModelError, + buildLogFields: block => ({ + model: block.model, + suggestion: block.suggestion, + }), + }] + : []), ...(checkModelMultiplier ? [{ block: checkUnknownModelRejection(extractModelFromBody(body)), diff --git a/src/commands/validators/config-assembly.test.ts b/src/commands/validators/config-assembly.test.ts index 6e3ca3b30..902288a5f 100644 --- a/src/commands/validators/config-assembly.test.ts +++ b/src/commands/validators/config-assembly.test.ts @@ -720,6 +720,32 @@ describe('config-assembly', () => { ); }); + it('should reject retired COPILOT_MODEL aliases in BYOK mode (copilotProviderApiKey)', () => { + mockBuildConfigOnce({ + copilotProviderApiKey: 'byok-api-key-for-azure-foundry', + }); + + const agentOptions = createMinimalAgentOptions(); + agentOptions.additionalEnv = { COPILOT_MODEL: 'gpt-5-codex' }; + + expect(() => { + assembleAndValidateConfig( + {}, + 'echo test', + createMinimalLogAndLimits(), + createMinimalNetworkOptions(), + agentOptions, + ); + }).toThrow('process.exit(1)'); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("model 'gpt-5-codex' is retired or unsupported"), + ); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Did you mean 'gpt-5.3-codex'?"), + ); + }); + it('should log normalization when COPILOT_MODEL casing is adjusted', () => { mockBuildConfigOnce({ copilotGithubToken: 'github_pat_testtoken', diff --git a/src/commands/validators/config-assembly.ts b/src/commands/validators/config-assembly.ts index 9c15699b4..815042026 100644 --- a/src/commands/validators/config-assembly.ts +++ b/src/commands/validators/config-assembly.ts @@ -257,7 +257,7 @@ export function assembleAndValidateConfig( logger.warn.bind(logger), ); - if (copilotModel && config.copilotGithubToken) { + if (copilotModel && (config.copilotGithubToken || config.copilotProviderApiKey)) { const validation = validateCopilotModel(copilotModel); if (!validation.valid) { logger.error(validation.message);