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
57 changes: 57 additions & 0 deletions containers/api-proxy/guards/retired-model-guard.js
Original file line number Diff line number Diff line change
@@ -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,
};
54 changes: 54 additions & 0 deletions containers/api-proxy/guards/retired-model-guard.test.js
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
17 changes: 17 additions & 0 deletions containers/api-proxy/proxy-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)),
Expand Down
26 changes: 26 additions & 0 deletions src/commands/validators/config-assembly.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 1 addition & 1 deletion src/commands/validators/config-assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading