Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
264 changes: 264 additions & 0 deletions src/commands/validators/config-assembly-model-detection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
setupConfigAssemblyTestSuite,
warnClassicPATWithCopilotModel,
} from './config-assembly.test-utils';
import { resolveAliasToFirstConcrete } from './config-assembly';

describe('config-assembly', () => {
setupConfigAssemblyTestSuite();
Expand Down Expand Up @@ -221,5 +222,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'"),
);
});
Comment on lines +279 to +304

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