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
56 changes: 41 additions & 15 deletions containers/api-proxy/key-validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,30 @@ async function fetchStartupModels(adapters = []) {
modelFetchComplete = true;
}

/**
* Resolve a model ID for startup validation across all providers.
*
* Uses resolveModel for proper alias resolution — recursive, cycle-protected,
* case-insensitive. Middle-power fallback is intentionally disabled so only
* genuine alias→model and direct matches are counted.
*
* @param {string} requestedModel
* @param {{ models: Record<string, string[]|{patterns: string[], fallback?: boolean}> }|null} modelAliases
* @param {Record<string, string[]|null>} models
* @returns {{ provider: string, resolvedModel: string, log: string[] } | null}
*/
function _resolveModelForValidation(requestedModel, modelAliases, models) {
const aliases = modelAliases ? modelAliases.models : {};
const noFallback = { enabled: false };
for (const provider of Object.keys(models)) {
const result = resolveModel(requestedModel, aliases, models, provider, [], noFallback);
if (result) {
return { provider, resolvedModel: result.resolvedModel, log: result.log };
}
}
return null;
}

function validateRequestedModel() {
const requestedModel = (process.env.AWF_REQUESTED_MODEL || '').trim();
if (!requestedModel) return;
Expand All @@ -193,22 +217,10 @@ function validateRequestedModel() {
return;
}

const normalizedRequested = requestedModel.toLowerCase();
const found = allModels.some(m => m.toLowerCase() === normalizedRequested);

let aliasResolved = false;
const modelAliases = getModelAliases();
if (!found && modelAliases) {
for (const provider of Object.keys(cachedModels)) {
const result = resolveModel(requestedModel, modelAliases.models, cachedModels, provider, [], { enabled: false });
if (result) {
aliasResolved = true;
break;
}
}
}
const resolution = _resolveModelForValidation(requestedModel, modelAliases, cachedModels);

if (!found && !aliasResolved) {
if (!resolution) {
const availableModels = allModels.slice(0, 20).join(', ');
const truncated = allModels.length > 20 ? ` (and ${allModels.length - 20} more)` : '';
logRequest('error', 'model_unavailable_at_startup', {
Expand All @@ -219,14 +231,27 @@ function validateRequestedModel() {
`Available models: ${availableModels}${truncated}`,
});
} else {
for (const line of resolution.log) {
logRequest('debug', 'model_validation_step', { message: line, provider: resolution.provider });
}
// resolved_via is 'alias' when: the model name matches an alias key (alias lookup
// takes precedence in resolveModel) OR the resolved name differs from the request.
const requestedKey = requestedModel.toLowerCase();
const isAlias = modelAliases
? Object.keys(modelAliases.models).some(k => k.toLowerCase() === requestedKey)
: false;
const resolvedVia = isAlias || resolution.resolvedModel.toLowerCase() !== requestedKey ? 'alias' : 'direct';
logRequest('info', 'model_validation', {
requested_model: requestedModel,
resolved_via: aliasResolved ? 'alias' : 'direct',
resolved_via: resolvedVia,
message: `Requested model '${requestedModel}' is available`,
});
}
}

/** @internal Exposed for unit tests only. */
const testHelpers = { _resolveModelForValidation };

module.exports = {
keyValidationResults,
cachedModels,
Expand All @@ -242,4 +267,5 @@ module.exports = {
validateApiKeys,
fetchStartupModels,
validateRequestedModel,
testHelpers,
};
252 changes: 252 additions & 0 deletions containers/api-proxy/model-resolver.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -534,3 +534,255 @@ describe('resolveModel with modelPolicyConfig', () => {
expect(result).toBeNull();
});
});

// ── Complex alias tree resolution ────────────────────────────────────────────

describe('resolveModel — complex alias trees', () => {
// Disable middle-power fallback throughout so failed branches clearly return null
const noFallback = { enabled: false };

const baseModels = {
copilot: [
'claude-haiku-3.5',
'claude-sonnet-4.5',
'claude-sonnet-4.6',
'claude-opus-4.5',
'gpt-4o',
'gpt-4o-mini',
'gpt-5.2',
],
anthropic: [
'claude-3-haiku-20240307',
'claude-3-5-sonnet-20241022',
'claude-3-opus-20240229',
],
openai: ['gpt-4o', 'gpt-4-turbo', 'gpt-5.2'],
};

it('resolves a 4-level deep chain to the highest-version concrete model', () => {
// deep → level1 → level2 → level3 → copilot/*sonnet*
const aliases = {
deep: ['level1'],
level1: ['level2'],
level2: ['level3'],
level3: ['copilot/*sonnet*'],
};
const result = resolveModel('deep', aliases, baseModels, 'copilot', [], noFallback);
expect(result).not.toBeNull();
expect(result.resolvedModel).toBe('claude-sonnet-4.6');
});

it('resolves a 5-level deep chain picking the highest-version match', () => {
// a → b → c → d → e → copilot/*gpt-5*
const aliases = {
a: ['b'],
b: ['c'],
c: ['d'],
d: ['e'],
e: ['copilot/*gpt-5*'],
};
const result = resolveModel('a', aliases, baseModels, 'copilot', [], noFallback);
expect(result).not.toBeNull();
expect(result.resolvedModel).toBe('gpt-5.2');
});

it('fan-out: alias with two sub-alias branches merges candidates and picks highest version', () => {
// best → [sonnet-branch, haiku-branch]
// Each sub-alias resolves independently to its own highest-version model:
// sonnet-branch → copilot/*sonnet* → sub-alias resolution yields claude-sonnet-4.6
// haiku-branch → copilot/*haiku* → sub-alias resolution yields claude-haiku-3.5
// The parent fan-out collects [4.6, 3.5] and version-sorts → 4.6 wins
const aliases = {
best: ['sonnet-branch', 'haiku-branch'],
'sonnet-branch': ['copilot/*sonnet*'],
'haiku-branch': ['copilot/*haiku*'],
};
const result = resolveModel('best', aliases, baseModels, 'copilot', [], noFallback);
expect(result).not.toBeNull();
expect(result.resolvedModel).toBe('claude-sonnet-4.6');
});

it('fan-out: resolves via the only working branch when the other branch has no matches', () => {
// prefer-codex → [codex-branch, sonnet-branch]
// codex-branch → copilot/gpt-5*codex* → no match in baseModels
// sonnet-branch → copilot/*sonnet* → matches
// Use object-syntax for codex-branch to suppress its middle-power fallback
const aliases = {
'prefer-codex': ['codex-branch', 'sonnet-branch'],
'codex-branch': { patterns: ['copilot/gpt-5*codex*'], fallback: false },
'sonnet-branch': ['copilot/*sonnet*'],
};
const result = resolveModel('prefer-codex', aliases, baseModels, 'copilot', [], noFallback);
expect(result).not.toBeNull();
expect(result.resolvedModel).toBe('claude-sonnet-4.6');
});

it('detects a 3-node cycle (A → B → C → A) and returns null', () => {
const aliases = {
a: ['b'],
b: ['c'],
c: ['a'],
};
const result = resolveModel('a', aliases, baseModels, 'copilot', [], noFallback);
expect(result).toBeNull();
});

it('detects a cycle entered mid-chain (A → B → C → B) and returns null', () => {
const aliases = {
a: ['b'],
b: ['c'],
c: ['b'],
};
const result = resolveModel('a', aliases, baseModels, 'copilot', [], noFallback);
expect(result).toBeNull();
});

it('detects a diamond cycle where all branches eventually cycle back', () => {
// top → [left, right]; left → bottom; right → bottom; bottom → left
const aliases = {
top: ['left', 'right'],
left: ['bottom'],
right: ['bottom'],
bottom: ['left'],
};
const result = resolveModel('top', aliases, baseModels, 'copilot', [], noFallback);
expect(result).toBeNull();
});

it('succeeds when one branch is a direct provider pattern alongside a cyclic sub-alias', () => {
// combo → ['copilot/*sonnet*', 'cycle-start']
// copilot/*sonnet* is a provider pattern (matched directly, no recursion needed)
// cycle-start → cycle-end → cycle-start (cycle, contributes nothing)
const aliases = {
combo: ['copilot/*sonnet*', 'cycle-start'],
'cycle-start': ['cycle-end'],
'cycle-end': ['cycle-start'],
};
const result = resolveModel('combo', aliases, baseModels, 'copilot', [], noFallback);
expect(result).not.toBeNull();
expect(result.resolvedModel).toBe('claude-sonnet-4.6');
});

it('skips provider patterns for a different provider when resolving a specific provider', () => {
// openai-only alias has patterns only for openai; resolving for copilot finds nothing
const aliases = {
'openai-only': ['openai/*gpt*'],
};
const result = resolveModel('openai-only', aliases, baseModels, 'copilot', [], noFallback);
expect(result).toBeNull();
});

it('deduplicates candidates when two branches resolve to the same model', () => {
// dup → [path-a, path-b]; both paths → copilot/*sonnet*
// After dedup, exactly one copy of each model; still picks highest version
const aliases = {
dup: ['path-a', 'path-b'],
'path-a': ['copilot/*sonnet*'],
'path-b': ['copilot/*sonnet*'],
};
const result = resolveModel('dup', aliases, baseModels, 'copilot', [], noFallback);
expect(result).not.toBeNull();
expect(result.resolvedModel).toBe('claude-sonnet-4.6');
});

it('resolves a sibling tree where branches have different depths and picks the overall highest version', () => {
// root → [mid-a, mid-b]
// mid-a → leaf-a → copilot/*opus* (2 levels deep, yields claude-opus-4.5)
// mid-b → copilot/*haiku* (1 level deep, yields claude-haiku-3.5)
// candidates at root level: [opus-4.5, haiku-3.5]
// compareByVersion extracts leading numeric segments: opus-4.5 → [4,5], haiku-3.5 → [3,5]
// First segment comparison: 4 > 3 → claude-opus-4.5 sorts first (highest version)
const aliases = {
root: ['mid-a', 'mid-b'],
'mid-a': ['leaf-a'],
'leaf-a': ['copilot/*opus*'],
'mid-b': ['copilot/*haiku*'],
};
const result = resolveModel('root', aliases, baseModels, 'copilot', [], noFallback);
expect(result).not.toBeNull();
expect(result.resolvedModel).toBe('claude-opus-4.5');
});

it('resolves a tree that includes the default ("") alias as an intermediate node', () => {
// "" → top-alias → mid → copilot/gpt-4o
const aliases = {
'': ['top-alias'],
'top-alias': ['mid'],
mid: ['copilot/gpt-4o'],
};
const result = resolveModel('', aliases, baseModels, 'copilot', [], noFallback);
expect(result).not.toBeNull();
expect(result.resolvedModel).toBe('gpt-4o');
});

it('returns null when every branch of the tree targets unavailable models', () => {
const aliases = {
root: ['branch-a', 'branch-b'],
'branch-a': { patterns: ['copilot/nonexistent-xyz'], fallback: false },
'branch-b': { patterns: ['openai/nonexistent-abc'], fallback: false },
};
const result = resolveModel('root', aliases, baseModels, 'copilot', [], noFallback);
expect(result).toBeNull();
});

it('accumulates log entries from all levels of a multi-hop chain', () => {
// 3-level chain: deep → level1 → level2 → copilot/*sonnet*
const aliases = {
deep: ['level1'],
level1: ['level2'],
level2: ['copilot/*sonnet*'],
};
const result = resolveModel('deep', aliases, baseModels, 'copilot', [], noFallback);
expect(result).not.toBeNull();
// Expect at least one log entry per alias hop (3 hops minimum)
expect(result.log.length).toBeGreaterThanOrEqual(3);
const logText = result.log.join('\n');
expect(logText).toContain('deep');
expect(logText).toContain('level1');
expect(logText).toContain('level2');
});

it('resolves case-insensitive keys at every level of the tree', () => {
// Alias keys use mixed case; requested model is lowercase
const aliases = {
'DEEP-ALIAS': ['MID-ALIAS'],
'MID-ALIAS': ['LEAF-ALIAS'],
'LEAF-ALIAS': ['copilot/*sonnet*'],
};
const result = resolveModel('deep-alias', aliases, baseModels, 'copilot', [], noFallback);
expect(result).not.toBeNull();
expect(result.resolvedModel).toBe('claude-sonnet-4.6');
});

it('resolves a wide flat tree: alias referencing many sub-aliases, picks global highest', () => {
// wide → [opus-ref, sonnet-ref, haiku-ref, gpt-ref]
const aliases = {
wide: ['opus-ref', 'sonnet-ref', 'haiku-ref', 'gpt-ref'],
'opus-ref': ['copilot/*opus*'],
'sonnet-ref': ['copilot/*sonnet*'],
'haiku-ref': ['copilot/*haiku*'],
'gpt-ref': ['copilot/gpt-4o'],
};
const result = resolveModel('wide', aliases, baseModels, 'copilot', [], noFallback);
expect(result).not.toBeNull();
// All four sub-aliases resolve to one model each; version sort picks the highest overall
// claude-sonnet-4.6 (v4.6) > claude-opus-4.5 (v4.5) > ...
expect(result.resolvedModel).toBe('claude-sonnet-4.6');
});

it('resolves an alias tree that mixes provider-pattern leaves with sub-alias leaves', () => {
// mixed → ['copilot/*gpt-5*', 'sonnet-ref']
// 'copilot/*gpt-5*' is a provider pattern → all matching models added directly: [gpt-5.2]
// 'sonnet-ref' is a sub-alias ref → sub-alias resolution yields one model: claude-sonnet-4.6
// candidates: [gpt-5.2, claude-sonnet-4.6]
// compareByVersion extracts numeric segments: gpt-5.2 → [5,2], claude-sonnet-4.6 → [4,6]
// First segment comparison: 5 > 4 → gpt-5.2 sorts first (highest version)
const aliases = {
mixed: ['copilot/*gpt-5*', 'sonnet-ref'],
'sonnet-ref': ['copilot/*sonnet*'],
};
const result = resolveModel('mixed', aliases, baseModels, 'copilot', [], noFallback);
expect(result).not.toBeNull();
expect(result.resolvedModel).toBe('gpt-5.2');
});
});
Loading
Loading