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
121 changes: 108 additions & 13 deletions src/commands/validators/api-proxy-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string[]>,
visited: Set<string> = 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
Expand Down Expand Up @@ -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 };
Loading
Loading