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: 1 addition & 3 deletions scripts/ci/smoke-claude-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,7 @@ describe('smoke claude workflow optimization config', () => {
expect(lock).not.toContain("<< 'ENVEOF'");
expect(lock).toContain('Report turn usage');
expect(lock).toContain('target: 1, hard cap: 2');
expect(lock).toContain(
'github/gh-aw-actions/setup@3ea13c02d765410340d533515cb31a7eef2baaf0 # v0.77.5'
);
expect(lock).toMatch(/github\/gh-aw-actions\/setup@[a-f0-9]{40} # v\d+\.\d+\.\d+/);
expect(lock).not.toContain('mcp__playwright__browser_navigate');
expect(lock).not.toContain('playwright_prompt.md');
expect(lock).not.toContain('mcr.microsoft.com/playwright/mcp');
Expand Down
82 changes: 82 additions & 0 deletions src/commands/validators/config-assembly.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -638,6 +638,41 @@ describe('config-assembly', () => {
}
});

it('should not fall back to host env when --env sets empty COPILOT_MODEL', () => {
const originalCopilotModel = process.env.COPILOT_MODEL;
try {
process.env.COPILOT_MODEL = 'gpt-4';

mockBuildConfigOnce({
envAll: true,
copilotGithubToken: 'ghp_testtoken',
});

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

assembleAndValidateConfig(
{},
'echo test',
createMinimalLogAndLimits(),
createMinimalNetworkOptions(),
agentOptions,
);

expect(warnClassicPATWithCopilotModel).toHaveBeenCalledWith(
true,
false,
expect.any(Function),
);
} finally {
if (originalCopilotModel) {
process.env.COPILOT_MODEL = originalCopilotModel;
} else {
delete process.env.COPILOT_MODEL;
}
}
});

it('should handle array of env files', () => {
const envFilePath1 = path.join(testDir, 'test1.env');
const envFilePath2 = path.join(testDir, 'test2.env');
Expand All @@ -657,6 +692,53 @@ describe('config-assembly', () => {
expect.any(Function),
);
});

it('should reject retired COPILOT_MODEL aliases before launch', () => {
mockBuildConfigOnce({
copilotGithubToken: 'github_pat_testtoken',
});

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',
});

const agentOptions = createMinimalAgentOptions();
agentOptions.additionalEnv = { COPILOT_MODEL: ' GPT-4.1 ' };

assembleAndValidateConfig(
{},
'echo test',
createMinimalLogAndLimits(),
createMinimalNetworkOptions(),
agentOptions,
);

expect(logger.info).toHaveBeenCalledWith(
"Normalized COPILOT_MODEL value 'GPT-4.1' -> 'gpt-4.1'",
);
});
});

describe('successful config assembly', () => {
Expand Down
83 changes: 54 additions & 29 deletions src/commands/validators/config-assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
emitCliProxyStatusLogs,
warnClassicPATWithCopilotModel,
} from '../../api-proxy-config';
import { validateCopilotModel } from '../../copilot-model';
import {
buildRateLimitConfig,
validateRateLimitFlags,
Expand Down Expand Up @@ -43,6 +44,31 @@ export function assembleAndValidateConfig(
networkOptions: NetworkOptionsResult,
agentOptions: AgentOptionsResult,
): WrapperConfig {
const readCopilotModelFromEnvFiles = (envFile: unknown): string | undefined => {
const envFiles = Array.isArray(envFile) ? envFile : envFile ? [envFile] : [];
let lastSeen: string | undefined;
for (const candidate of envFiles) {
if (typeof candidate !== 'string' || candidate.trim() === '') continue;
try {
const envFilePath = path.isAbsolute(candidate)
? candidate
: path.resolve(process.cwd(), candidate);
const envFileContents = fs.readFileSync(envFilePath, 'utf8');
for (const line of envFileContents.split(/\r?\n/)) {
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.startsWith('#')) continue;
const match = trimmedLine.match(/^(?:export\s+)?COPILOT_MODEL\s*=\s*(.*)$/);
if (match) {
lastSeen = match[1]?.trim() || '';
}
}
} catch {
// Ignore unreadable env files here; this check is only for a pre-flight warning.
}
}
return lastSeen;
};

// --- Config assembly -----------------------------------------------------

const config = buildConfig({
Expand Down Expand Up @@ -213,41 +239,40 @@ export function assembleAndValidateConfig(
// Log CLI proxy status
emitCliProxyStatusLogs(config, logger.info.bind(logger), logger.warn.bind(logger));

// Warn if a classic PAT is combined with COPILOT_MODEL (Copilot CLI 1.0.21+ incompatibility)
const hasCopilotModelInEnvFiles = (envFile: unknown): boolean => {
const envFiles = Array.isArray(envFile) ? envFile : envFile ? [envFile] : [];
for (const candidate of envFiles) {
if (typeof candidate !== 'string' || candidate.trim() === '') continue;
try {
const envFilePath = path.isAbsolute(candidate)
? candidate
: path.resolve(process.cwd(), candidate);
const envFileContents = fs.readFileSync(envFilePath, 'utf8');
for (const line of envFileContents.split(/\r?\n/)) {
const trimmedLine = line.trim();
if (!trimmedLine || trimmedLine.startsWith('#')) continue;
if (/^(?:export\s+)?COPILOT_MODEL\s*=/.test(trimmedLine)) {
return true;
}
}
} catch {
// Ignore unreadable env files here; this check is only for a pre-flight warning.
}
}
return false;
};

// Check if COPILOT_MODEL is set via --env/-e flags, host env (when --env-all is active), or --env-file
const copilotModelFromFlags = !!agentOptions.additionalEnv['COPILOT_MODEL'];
const copilotModelInHostEnv = !!(config.envAll && process.env.COPILOT_MODEL);
const copilotModelInEnvFile = hasCopilotModelInEnvFiles(
// Check if COPILOT_MODEL is set via --env/-e flags, --env-file, or host env (when --env-all is active)
const copilotModelFromFlags = agentOptions.additionalEnv.COPILOT_MODEL;
const copilotModelInEnvFile = readCopilotModelFromEnvFiles(
(config as { envFile?: unknown }).envFile,
);
const copilotModelInHostEnv = config.envAll ? process.env.COPILOT_MODEL : undefined;
const copilotModel = (
copilotModelFromFlags ??
copilotModelInEnvFile ??
copilotModelInHostEnv
)?.trim();
warnClassicPATWithCopilotModel(
config.copilotGithubToken?.startsWith('ghp_') ?? false,
copilotModelFromFlags || copilotModelInHostEnv || copilotModelInEnvFile,
!!copilotModel,
logger.warn.bind(logger),
);

if (copilotModel && config.copilotGithubToken) {
const validation = validateCopilotModel(copilotModel);
if (!validation.valid) {
logger.error(validation.message);
process.exit(1);
}

if (validation.resolvedModel !== copilotModel) {
logger.info(
`Normalized COPILOT_MODEL value '${copilotModel}' -> '${validation.resolvedModel}'`,
);
}
Comment on lines +266 to +270
config.additionalEnv = {
...(config.additionalEnv ?? {}),
COPILOT_MODEL: validation.resolvedModel,
};
}

return config;
}
50 changes: 50 additions & 0 deletions src/copilot-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { validateCopilotModel } from './copilot-model';

describe('validateCopilotModel', () => {
it('rejects retired aliases with a clear suggestion', () => {
const result = validateCopilotModel('gpt-5-codex');
expect(result.valid).toBe(false);
if (result.valid) {
return;
}
expect(result.reason).toBe('retired');
expect(result.message).toContain("Did you mean 'gpt-5.3-codex'?");
});

it('accepts supported canonical models', () => {
const result = validateCopilotModel('gpt-5.3-codex');
expect(result).toEqual({ valid: true, resolvedModel: 'gpt-5.3-codex' });
});

it('accepts empty values after trimming', () => {
const result = validateCopilotModel(' ');
expect(result).toEqual({ valid: true, resolvedModel: '' });
});

it('normalizes supported models with whitespace and casing', () => {
const result = validateCopilotModel(' GPT-4.1 ');
expect(result).toEqual({ valid: true, resolvedModel: 'gpt-4.1' });
});

it('rejects unsupported models with suggestion when close to known catalog', () => {
const result = validateCopilotModel('gpt-5.3-codx');
expect(result.valid).toBe(false);
if (result.valid) {
return;
}
expect(result.reason).toBe('unsupported');
expect(result.message).toContain("Did you mean 'gpt-5.3-codex'?");
});

it('rejects unsupported models without suggestion when no close match exists', () => {
const result = validateCopilotModel('this-model-does-not-exist-anywhere-12345');
expect(result.valid).toBe(false);
if (result.valid) {
return;
}
expect(result.reason).toBe('unsupported');
expect(result.message).toBe(
"Error: model 'this-model-does-not-exist-anywhere-12345' is retired or unsupported.",
);
});
});
98 changes: 98 additions & 0 deletions src/copilot-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
interface CopilotModelValidationSuccess {
valid: true;
resolvedModel: string;
}

interface CopilotModelValidationFailure {
valid: false;
reason: 'retired' | 'unsupported';
message: string;
}

export type CopilotModelValidationResult =
| CopilotModelValidationSuccess
| CopilotModelValidationFailure;

const RETIRED_COPILOT_MODEL_ALIASES: Record<string, string> = {
'gpt-5-codex': 'gpt-5.3-codex',
};

const SUPPORTED_COPILOT_MODELS = new Set([
'gpt-4',
'gpt-4.1',
'gpt-4o',
'gpt-4o-mini',
'gpt-5.2',
'gpt-5.2-codex',
'gpt-5.3-codex',
'gpt-5.4',
'gpt-5.4-mini',
'gpt-5.5',
'gpt-5-mini',
'o3',
'o3-mini',
'claude-haiku-4.5',
'claude-opus-4.8',
'claude-sonnet-4.5',
'claude-sonnet-4.6',
'gemini-3.1-pro-preview',
'gemini-3.5-flash',
]);

function suggestionFor(model: string): string | undefined {
let best: { candidate: string; distance: number } | undefined;
for (const candidate of SUPPORTED_COPILOT_MODELS) {
const distance = levenshtein(model, candidate);
if (!best || distance < best.distance) {
best = { candidate, distance };
}
}
return best && best.distance <= 6 ? best.candidate : undefined;
}

function levenshtein(a: string, b: string): number {
const dp = Array.from({ length: a.length + 1 }, () => new Array<number>(b.length + 1).fill(0));
for (let i = 0; i <= a.length; i++) dp[i][0] = i;
for (let j = 0; j <= b.length; j++) dp[0][j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + cost,
);
}
}
return dp[a.length][b.length];
}

export function validateCopilotModel(rawModel: string): CopilotModelValidationResult {
const trimmed = rawModel.trim();
if (!trimmed) {
return { valid: true, resolvedModel: trimmed };
}
const normalized = trimmed.toLowerCase();

const retiredReplacement = RETIRED_COPILOT_MODEL_ALIASES[normalized];
if (retiredReplacement) {
return {
valid: false,
reason: 'retired',
message: `Error: model '${trimmed}' is retired or unsupported. Did you mean '${retiredReplacement}'?`,
};
}

if (SUPPORTED_COPILOT_MODELS.has(normalized)) {
return { valid: true, resolvedModel: normalized };
}

const suggested = suggestionFor(normalized);
return {
valid: false,
reason: 'unsupported',
message: suggested
? `Error: model '${trimmed}' is retired or unsupported. Did you mean '${suggested}'?`
: `Error: model '${trimmed}' is retired or unsupported.`,
};
}
Loading