diff --git a/src/commands/validators/api-proxy-validator.ts b/src/commands/validators/api-proxy-validator.ts index f1b302cb1..bc3268702 100644 --- a/src/commands/validators/api-proxy-validator.ts +++ b/src/commands/validators/api-proxy-validator.ts @@ -73,6 +73,68 @@ export function validateApiProxyOptions( emitCliProxyStatusLogs(config, logger.info.bind(logger), logger.warn.bind(logger)); } +/** + * Recursively resolves an alias key to its first concrete (non-wildcard) model + * name, following nested alias references with cycle detection. + * + * Resolution rules for each pattern: + * - Contains `*` → runtime wildcard, skip. + * - Contains `/` → provider-scoped (e.g. `copilot/gpt-4.1`), cannot + * be validated without provider context, skip. + * - Matches an alias key → nested alias reference, recurse. + * - Otherwise → plain concrete model name, return it. + * + * @param aliasKey Alias name to start resolution from (case-insensitive). + * @param aliases Full alias map (values are arrays of patterns). + * @param visited Accumulates visited keys for cycle detection; callers should + * not pass this argument — it is used by recursive calls only. + * @returns The first concrete model name found, or `undefined` when all paths + * are wildcards, provider-scoped, or form a cycle. + */ +function resolveAliasToFirstConcrete( + aliasKey: string, + aliases: Record, + visited: Set = new Set(), +): string | undefined { + const normalizedKey = aliasKey.toLowerCase(); + + // Cycle guard + if (visited.has(normalizedKey)) return undefined; + visited.add(normalizedKey); + + // Pre-compute the set of lowercased alias keys once to avoid repeated + // Object.keys() calls inside the pattern loop. + const aliasKeySet = new Set(Object.keys(aliases).map(k => k.toLowerCase())); + + // Find the alias entry (case-insensitive); destructure to get the patterns. + const entry = Object.entries(aliases).find(([k]) => k.toLowerCase() === normalizedKey); + if (!entry) return undefined; + + for (const pattern of entry[1]) { + // Runtime wildcard — cannot validate at preflight + if (pattern.includes('*')) continue; + + // Provider-scoped pattern (e.g. "copilot/gpt-4.1") — unvalidatable without + // provider context, skip. + if (pattern.includes('/')) continue; + + // Nested alias reference — recurse with a snapshot of the visited set so + // that sibling patterns after a failed/cyclic branch remain reachable. + // (Passing `visited` directly would mark siblings visited during a failed + // branch and incorrectly skip them on subsequent iterations.) + if (aliasKeySet.has(pattern.toLowerCase())) { + const resolved = resolveAliasToFirstConcrete(pattern, aliases, new Set(visited)); + if (resolved !== undefined) return resolved; + continue; + } + + // Plain concrete model name + return pattern; + } + + return undefined; +} + /** * Resolves the effective `COPILOT_MODEL` value (from `--env`, env-file, or host * env when `--env-all` is active), warns on classic-PAT usage, and validates @@ -112,20 +174,53 @@ export function validateCopilotModelOption( !hasCustomCopilotProviderBaseUrl && (config.copilotGithubToken || config.copilotProviderApiKey) ) { - const validation = validateCopilotModel(copilotModel); - if (!validation.valid) { - logger.error(validation.message); - process.exit(1); - } + // Check whether COPILOT_MODEL is a runtime alias key. Aliases are resolved + // later by the api-proxy using AWF_MODEL_ALIASES. Recursively resolve the + // alias chain (with cycle protection) to find the first concrete model name + // and validate it at preflight so that misconfigured alias chains are caught + // early. If the chain contains only wildcards or provider-scoped patterns, + // validation is skipped — the actual model is only known at request time. + const isAlias = !!config.modelAliases && + Object.keys(config.modelAliases).some(k => k.toLowerCase() === copilotModel.toLowerCase()); - if (validation.resolvedModel !== copilotModel) { - logger.info( - `Normalized COPILOT_MODEL value '${copilotModel}' -> '${validation.resolvedModel}'`, - ); + if (isAlias) { + // Recursively resolve to the first concrete model name and validate it. + // COPILOT_MODEL is left as the alias name so the api-proxy can perform + // its own availability-aware resolution at request time. + const firstConcrete = resolveAliasToFirstConcrete(copilotModel, config.modelAliases!); + if (firstConcrete !== undefined) { + const aliasValidation = validateCopilotModel(firstConcrete); + if (!aliasValidation.valid) { + logger.error( + `Error: alias '${copilotModel}' resolves to model '${firstConcrete}' which is ${aliasValidation.reason === 'retired' ? 'retired or unsupported' : 'unsupported or unrecognized by this AWF version'}.`, + ); + logger.error(aliasValidation.message); + process.exit(1); + } + } + // Alias is valid (or all paths are wildcards/provider-scoped) — leave + // COPILOT_MODEL as the alias name for the api-proxy. + } else { + // Not an alias: validate and normalise the concrete model name directly. + 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}'`, + ); + } + config.additionalEnv = { + ...(config.additionalEnv ?? {}), + COPILOT_MODEL: validation.resolvedModel, + }; } - config.additionalEnv = { - ...(config.additionalEnv ?? {}), - COPILOT_MODEL: validation.resolvedModel, - }; } } + +/** @internal Exported only for unit tests — not part of the public API. */ +// ts-prune-ignore-next +export const testHelpers = { resolveAliasToFirstConcrete }; diff --git a/src/commands/validators/config-assembly-model-detection.test.ts b/src/commands/validators/config-assembly-model-detection.test.ts index 025ae7076..82db46a9b 100644 --- a/src/commands/validators/config-assembly-model-detection.test.ts +++ b/src/commands/validators/config-assembly-model-detection.test.ts @@ -11,6 +11,9 @@ import { setupConfigAssemblyTestSuite, warnClassicPATWithCopilotModel, } from './config-assembly.test-utils'; +import { testHelpers } from './api-proxy-validator'; + +const { resolveAliasToFirstConcrete } = testHelpers; describe('config-assembly', () => { setupConfigAssemblyTestSuite(); @@ -221,5 +224,268 @@ describe('config-assembly', () => { "Normalized COPILOT_MODEL value 'GPT-4.1' -> 'gpt-4.1'", ); }); + + it('should allow COPILOT_MODEL that matches a runtime alias key and resolves to a valid concrete model', () => { + mockBuildConfigOnce({ + copilotGithubToken: 'github_pat_testtoken', + modelAliases: { small: ['gpt-4o-mini', 'gpt-4.1-mini'] }, + }); + + const logAndLimits = createMinimalLogAndLimits(); + logAndLimits.modelAliases = { small: ['gpt-4o-mini', 'gpt-4.1-mini'] }; + + const agentOptions = createMinimalAgentOptions(); + agentOptions.additionalEnv = { COPILOT_MODEL: 'small' }; + + const result = assembleAndValidateConfig( + {}, + 'echo test', + logAndLimits, + createMinimalNetworkOptions(), + agentOptions, + ); + + expect(logger.error).not.toHaveBeenCalled(); + // COPILOT_MODEL must remain as the alias name (not the resolved concrete model) + // so the api-proxy can perform its own availability-aware resolution. + expect(result.additionalEnv?.COPILOT_MODEL).toBeUndefined(); + }); + + it('should allow COPILOT_MODEL alias regardless of case (Small -> matches alias key small)', () => { + mockBuildConfigOnce({ + copilotGithubToken: 'github_pat_testtoken', + modelAliases: { small: ['gpt-4o-mini'] }, + }); + + const logAndLimits = createMinimalLogAndLimits(); + logAndLimits.modelAliases = { small: ['gpt-4o-mini'] }; + + const agentOptions = createMinimalAgentOptions(); + agentOptions.additionalEnv = { COPILOT_MODEL: 'Small' }; + + expect(() => { + assembleAndValidateConfig( + {}, + 'echo test', + logAndLimits, + createMinimalNetworkOptions(), + agentOptions, + ); + }).not.toThrow(); + + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('should reject alias whose first concrete pattern resolves to an unsupported model', () => { + mockBuildConfigOnce({ + copilotGithubToken: 'github_pat_testtoken', + modelAliases: { bad: ['not-a-real-model-xyz'] }, + }); + + const logAndLimits = createMinimalLogAndLimits(); + logAndLimits.modelAliases = { bad: ['not-a-real-model-xyz'] }; + + const agentOptions = createMinimalAgentOptions(); + agentOptions.additionalEnv = { COPILOT_MODEL: 'bad' }; + + expect(() => { + assembleAndValidateConfig( + {}, + 'echo test', + logAndLimits, + createMinimalNetworkOptions(), + agentOptions, + ); + }).toThrow('process.exit(1)'); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("alias 'bad' resolves to model 'not-a-real-model-xyz'"), + ); + }); + + it('should allow alias with only wildcard patterns (cannot validate at preflight)', () => { + mockBuildConfigOnce({ + copilotGithubToken: 'github_pat_testtoken', + modelAliases: { sonnet: ['copilot/*sonnet*'] }, + }); + + const logAndLimits = createMinimalLogAndLimits(); + logAndLimits.modelAliases = { sonnet: ['copilot/*sonnet*'] }; + + const agentOptions = createMinimalAgentOptions(); + agentOptions.additionalEnv = { COPILOT_MODEL: 'sonnet' }; + + expect(() => { + assembleAndValidateConfig( + {}, + 'echo test', + logAndLimits, + createMinimalNetworkOptions(), + agentOptions, + ); + }).not.toThrow(); + + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('should still reject unsupported COPILOT_MODEL values that are not runtime aliases', () => { + mockBuildConfigOnce({ + copilotGithubToken: 'github_pat_testtoken', + modelAliases: { small: ['gpt-4o-mini'] }, + }); + + const logAndLimits = createMinimalLogAndLimits(); + logAndLimits.modelAliases = { small: ['gpt-4o-mini'] }; + + const agentOptions = createMinimalAgentOptions(); + agentOptions.additionalEnv = { COPILOT_MODEL: 'not-a-real-model-xyz' }; + + expect(() => { + assembleAndValidateConfig( + {}, + 'echo test', + logAndLimits, + createMinimalNetworkOptions(), + agentOptions, + ); + }).toThrow('process.exit(1)'); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("model 'not-a-real-model-xyz' is unsupported or unrecognized"), + ); + }); + + it('should resolve a recursive alias chain (smart -> fast -> gpt-4.1) and validate the concrete model', () => { + const aliases = { fast: ['gpt-4.1'], smart: ['fast'] }; + mockBuildConfigOnce({ + copilotGithubToken: 'github_pat_testtoken', + modelAliases: aliases, + }); + + const logAndLimits = createMinimalLogAndLimits(); + logAndLimits.modelAliases = aliases; + + const agentOptions = createMinimalAgentOptions(); + agentOptions.additionalEnv = { COPILOT_MODEL: 'smart' }; + + expect(() => { + assembleAndValidateConfig( + {}, + 'echo test', + logAndLimits, + createMinimalNetworkOptions(), + agentOptions, + ); + }).not.toThrow(); + + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('should reject a recursive alias chain that resolves to an unsupported model', () => { + const aliases = { inner: ['not-a-real-model-xyz'], outer: ['inner'] }; + mockBuildConfigOnce({ + copilotGithubToken: 'github_pat_testtoken', + modelAliases: aliases, + }); + + const logAndLimits = createMinimalLogAndLimits(); + logAndLimits.modelAliases = aliases; + + const agentOptions = createMinimalAgentOptions(); + agentOptions.additionalEnv = { COPILOT_MODEL: 'outer' }; + + expect(() => { + assembleAndValidateConfig( + {}, + 'echo test', + logAndLimits, + createMinimalNetworkOptions(), + agentOptions, + ); + }).toThrow('process.exit(1)'); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("alias 'outer' resolves to model 'not-a-real-model-xyz'"), + ); + }); + + it('should allow a cyclic alias chain without crashing (cycle protection)', () => { + const aliases = { alpha: ['beta'], beta: ['alpha'] }; + mockBuildConfigOnce({ + copilotGithubToken: 'github_pat_testtoken', + modelAliases: aliases, + }); + + const logAndLimits = createMinimalLogAndLimits(); + logAndLimits.modelAliases = aliases; + + const agentOptions = createMinimalAgentOptions(); + agentOptions.additionalEnv = { COPILOT_MODEL: 'alpha' }; + + // Cyclic alias resolves to undefined (no concrete model) — skips validation + expect(() => { + assembleAndValidateConfig( + {}, + 'echo test', + logAndLimits, + createMinimalNetworkOptions(), + agentOptions, + ); + }).not.toThrow(); + + expect(logger.error).not.toHaveBeenCalled(); + }); + }); + + describe('resolveAliasToFirstConcrete', () => { + it('returns undefined for unknown alias key', () => { + expect(resolveAliasToFirstConcrete('unknown', { fast: ['gpt-4.1'] })).toBeUndefined(); + }); + + it('returns the first concrete pattern for a direct alias', () => { + expect(resolveAliasToFirstConcrete('fast', { fast: ['gpt-4.1', 'gpt-4o'] })).toBe('gpt-4.1'); + }); + + it('skips wildcards and returns the first non-wildcard', () => { + expect(resolveAliasToFirstConcrete('s', { s: ['copilot/*sonnet*', 'gpt-4.1'] })).toBe('gpt-4.1'); + }); + + it('skips provider-scoped patterns', () => { + expect(resolveAliasToFirstConcrete('s', { s: ['copilot/gpt-4.1', 'gpt-4o-mini'] })).toBe('gpt-4o-mini'); + }); + + it('returns undefined when all patterns are wildcards', () => { + expect(resolveAliasToFirstConcrete('s', { s: ['copilot/*sonnet*'] })).toBeUndefined(); + }); + + it('resolves a one-level nested alias', () => { + expect(resolveAliasToFirstConcrete('smart', { fast: ['gpt-4.1'], smart: ['fast'] })).toBe('gpt-4.1'); + }); + + it('resolves a multi-level nested alias chain', () => { + const aliases = { a: ['b'], b: ['c'], c: ['gpt-4o-mini'] }; + expect(resolveAliasToFirstConcrete('a', aliases)).toBe('gpt-4o-mini'); + }); + + it('returns undefined for a direct cycle', () => { + expect(resolveAliasToFirstConcrete('x', { x: ['x'] })).toBeUndefined(); + }); + + it('returns undefined for a mutual cycle', () => { + expect(resolveAliasToFirstConcrete('a', { a: ['b'], b: ['a'] })).toBeUndefined(); + }); + + it('skips a cyclic branch and falls through to a valid sibling pattern', () => { + const aliases = { a: ['b', 'gpt-4.1'], b: ['a'] }; + expect(resolveAliasToFirstConcrete('a', aliases)).toBe('gpt-4.1'); + }); + + it('is case-insensitive for alias key lookup', () => { + expect(resolveAliasToFirstConcrete('FAST', { fast: ['gpt-4.1'] })).toBe('gpt-4.1'); + }); + + it('is case-insensitive when following nested alias references', () => { + expect(resolveAliasToFirstConcrete('smart', { FAST: ['gpt-4.1'], smart: ['FAST'] })).toBe('gpt-4.1'); + }); }); });