Skip to content

Commit 55a764d

Browse files
authored
refactor(model-resolver): decompose resolveModel into focused sub-functions; move version utils tests (#4938)
* Initial plan * Decompose resolveModel and add model-utils.test.js --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent 1a4fde6 commit 55a764d

3 files changed

Lines changed: 229 additions & 189 deletions

File tree

containers/api-proxy/model-resolver.js

Lines changed: 118 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -144,102 +144,85 @@ function tryMiddlePowerFallback(requestedModel, availableModels, currentProvider
144144
}
145145

146146
/**
147-
* Resolve a model name through the alias chain for a given provider.
147+
* Attempt to resolve a model that has no alias entry.
148148
*
149-
* Resolution algorithm:
150-
* 1. Look up requestedModel in aliases (case-insensitive key match)
151-
* 2. For each entry in the alias list:
152-
* a. If entry is "provider/pattern" — match against available models for that provider
153-
* (only entries matching currentProvider are considered)
154-
* b. If entry has no "/" — recursively resolve as another alias
155-
* 3. Collect all candidates, sort by version (highest first), return the best match
149+
* Tries in order:
150+
* 1. Direct match — model name already in provider's available list.
151+
* 2. GPT-5 family version fallback — gpt-5.<minor> unavailable → highest gpt-5.x.
152+
* 3. Middle-power fallback.
156153
*
157-
* @param {string} requestedModel - Model name from the request body (or "" for default)
158-
* @param {Record<string, string[]|{patterns: string[], fallback?: boolean}>} aliases - Alias map from parseModelAliases()
159-
* @param {Record<string, string[]|null>} availableModels - Cached provider models
160-
* @param {string} currentProvider - Provider handling this request (e.g. "copilot")
161-
* @param {string[]} [chain=[]] - Accumulates visited alias names for loop detection
162-
* @param {{ enabled?: boolean, strategy?: string }} [modelFallbackConfig]
154+
* @param {string} key - Lowercased requested model name
155+
* @param {string} requestedModel - Original requested model name (for log messages)
156+
* @param {string} currentProvider
157+
* @param {Record<string, string[]|null>} availableModels
158+
* @param {{ enabled: boolean, strategy: string }} fallbackConfig
159+
* @param {string[]} log - Accumulator for resolution log messages (mutated in place)
163160
* @returns {{ resolvedModel: string, log: string[], fallback?: object } | null}
164161
*/
165-
function resolveModel(requestedModel, aliases, availableModels, currentProvider, chain = [], modelFallbackConfig = DEFAULT_MODEL_FALLBACK) {
166-
const log = [];
167-
const key = requestedModel.toLowerCase();
168-
const fallbackConfig = normalizeFallbackConfig(modelFallbackConfig);
169-
170-
// ── Loop detection ────────────────────────────────────────────────────────
171-
if (chain.includes(key)) {
172-
log.push(`[model-resolver] loop detected: "${requestedModel}" already in chain [${chain.join(' → ')}]`);
173-
return null;
162+
function _resolveDirectMatch(key, requestedModel, currentProvider, availableModels, fallbackConfig, log) {
163+
const providerModels = (availableModels[currentProvider] || []);
164+
165+
// 1. Direct match: model name already in the provider's available list
166+
const direct = providerModels.find(m => m.toLowerCase() === key);
167+
if (direct) {
168+
log.push(`[model-resolver] direct match: "${requestedModel}" → "${direct}"`);
169+
return {
170+
resolvedModel: direct,
171+
log,
172+
fallback: fallbackConfig.enabled
173+
? { activated: false, selection_method: 'middle_power_median', reason: 'direct_match' }
174+
: undefined,
175+
};
174176
}
175-
const newChain = [...chain, key];
176-
177-
// ── Find alias entry (case-insensitive) ───────────────────────────────────
178-
let aliasEntry = Object.entries(aliases).find(([k]) => k.toLowerCase() === key);
179177

180-
if (!aliasEntry) {
181-
// Family fallback: treat gpt-5.<minor> as gpt-5 when only the family alias
182-
// exists. This keeps versioned IDs like gpt-5.4 compatible with configs that
183-
// define "gpt-5" alias patterns.
184-
const familyAlias = key.match(/^(gpt-5)\.\d+(?:[._-].*)?$/)?.[1];
185-
if (familyAlias) {
186-
aliasEntry = Object.entries(aliases).find(([k]) => k.toLowerCase() === familyAlias);
187-
if (aliasEntry) {
188-
log.push(`[model-resolver] fallback alias: "${requestedModel}" → "${aliasEntry[0]}"`);
189-
}
190-
}
191-
}
192-
193-
if (!aliasEntry) {
194-
// No alias defined — check if the model directly matches an available model for
195-
// this provider. If yes, pass it through as-is (no rewrite needed).
196-
const providerModels = (availableModels[currentProvider] || []);
197-
const direct = providerModels.find(m => m.toLowerCase() === key);
198-
if (direct) {
199-
log.push(`[model-resolver] direct match: "${requestedModel}" → "${direct}"`);
178+
// 2. GPT-5 family version fallback: gpt-5.<minor> not available → highest gpt-5.x
179+
const family = key.match(/^(gpt-5)\.\d+$/)?.[1];
180+
if (family) {
181+
const familyPrefix = `${family}.`;
182+
const familyCandidates = providerModels.filter(m => m.toLowerCase().startsWith(familyPrefix));
183+
if (familyCandidates.length > 0) {
184+
const sorted = [...new Set(familyCandidates)].sort(compareByVersion);
185+
const fallback = sorted[0];
186+
log.push(`[model-resolver] requested model "${requestedModel}" not available, falling back to "${fallback}"`);
200187
return {
201-
resolvedModel: direct,
188+
resolvedModel: fallback,
202189
log,
203190
fallback: fallbackConfig.enabled
204-
? { activated: false, selection_method: 'middle_power_median', reason: 'direct_match' }
191+
? { activated: false, selection_method: 'middle_power_median', reason: 'family_version_fallback' }
205192
: undefined,
206193
};
207194
}
208-
209-
// If a gpt-5.<minor> model is requested but unavailable, fall back to the
210-
// highest available model in the same family for this provider.
211-
const family = key.match(/^(gpt-5)\.\d+$/)?.[1];
212-
if (family) {
213-
const familyPrefix = `${family}.`;
214-
const familyCandidates = providerModels.filter(m => m.toLowerCase().startsWith(familyPrefix));
215-
if (familyCandidates.length > 0) {
216-
const sorted = [...new Set(familyCandidates)].sort(compareByVersion);
217-
const fallback = sorted[0];
218-
log.push(`[model-resolver] requested model "${requestedModel}" not available, falling back to "${fallback}"`);
219-
return {
220-
resolvedModel: fallback,
221-
log,
222-
fallback: fallbackConfig.enabled
223-
? { activated: false, selection_method: 'middle_power_median', reason: 'family_version_fallback' }
224-
: undefined,
225-
};
226-
}
227-
}
228-
const fallbackResult = tryMiddlePowerFallback(
229-
requestedModel, availableModels, currentProvider,
230-
'no_alias_match_and_not_in_available_models', fallbackConfig, log
231-
);
232-
if (fallbackResult) return fallbackResult;
233-
// No match at all — cannot resolve.
234-
return null;
235195
}
236196

237-
const [aliasKey, aliasRaw] = aliasEntry;
238-
const aliasDefinition = resolveAliasDefinition(aliasRaw);
197+
// 3. Middle-power fallback
198+
return tryMiddlePowerFallback(
199+
requestedModel, availableModels, currentProvider,
200+
'no_alias_match_and_not_in_available_models', fallbackConfig, log
201+
);
202+
}
203+
204+
/**
205+
* Expand alias patterns for a resolved alias entry and pick the best candidate.
206+
*
207+
* For each pattern:
208+
* - "provider/modelpattern" — glob-match against the current provider's available models.
209+
* - "aliasname" (no slash) — recursively resolve as another alias reference.
210+
*
211+
* @param {string} aliasKey - The alias key that was matched (used in log messages)
212+
* @param {{ patterns: string[], fallback: boolean }} aliasDefinition
213+
* @param {string} requestedModel - Original requested model name (for log/fallback)
214+
* @param {Record<string, string[]|{patterns: string[], fallback?: boolean}>} aliases
215+
* @param {Record<string, string[]|null>} availableModels
216+
* @param {string} currentProvider
217+
* @param {string[]} newChain - Resolution chain with the current key already appended
218+
* @param {{ enabled: boolean, strategy: string }} fallbackConfig
219+
* @param {string[]} log - Accumulator for resolution log messages (mutated in place)
220+
* @returns {{ resolvedModel: string, log: string[], fallback?: object } | null}
221+
*/
222+
function _resolveAliasPatterns(aliasKey, aliasDefinition, requestedModel, aliases, availableModels, currentProvider, newChain, fallbackConfig, log) {
239223
const patterns = aliasDefinition.patterns;
240224
log.push(`[model-resolver] alias: "${requestedModel}" → [${patterns.join(', ')}]`);
241225

242-
// ── Expand each pattern ───────────────────────────────────────────────────
243226
const candidates = [];
244227

245228
for (const pattern of patterns) {
@@ -272,17 +255,15 @@ function resolveModel(requestedModel, aliases, availableModels, currentProvider,
272255
log.push(`[model-resolver] no candidates found for "${aliasKey}" on provider "${currentProvider}"`);
273256
const hasProviderPattern = patterns.some((pattern) => pattern.includes('/'));
274257
if (aliasDefinition.fallback && hasProviderPattern) {
275-
const fallbackResult = tryMiddlePowerFallback(
258+
return tryMiddlePowerFallback(
276259
requestedModel, availableModels, currentProvider,
277260
'no_alias_match_and_not_in_available_models', fallbackConfig, log
278261
);
279-
if (fallbackResult) return fallbackResult;
280262
}
281263
return null;
282264
}
283265

284-
// ── Sort by version (highest first) and pick the best ────────────────────
285-
// Deduplicate while preserving sort order
266+
// Deduplicate, sort by version (highest first), and pick the best
286267
const unique = [...new Set(candidates)];
287268
unique.sort(compareByVersion);
288269

@@ -303,6 +284,60 @@ function resolveModel(requestedModel, aliases, availableModels, currentProvider,
303284
};
304285
}
305286

287+
/**
288+
* Resolve a model name through the alias chain for a given provider.
289+
*
290+
* Resolution algorithm:
291+
* 1. Loop detection — bail out if key already visited.
292+
* 2. Alias lookup (case-insensitive); family alias fallback for gpt-5.<minor>.
293+
* 3. No alias found → _resolveDirectMatch (direct, family-version, or middle-power).
294+
* 4. Alias found → _resolveAliasPatterns (pattern expansion + best-candidate selection).
295+
*
296+
* @param {string} requestedModel - Model name from the request body (or "" for default)
297+
* @param {Record<string, string[]|{patterns: string[], fallback?: boolean}>} aliases - Alias map from parseModelAliases()
298+
* @param {Record<string, string[]|null>} availableModels - Cached provider models
299+
* @param {string} currentProvider - Provider handling this request (e.g. "copilot")
300+
* @param {string[]} [chain=[]] - Accumulates visited alias names for loop detection
301+
* @param {{ enabled?: boolean, strategy?: string }} [modelFallbackConfig]
302+
* @returns {{ resolvedModel: string, log: string[], fallback?: object } | null}
303+
*/
304+
function resolveModel(requestedModel, aliases, availableModels, currentProvider, chain = [], modelFallbackConfig = DEFAULT_MODEL_FALLBACK) {
305+
const log = [];
306+
const key = requestedModel.toLowerCase();
307+
const fallbackConfig = normalizeFallbackConfig(modelFallbackConfig);
308+
309+
// Loop detection
310+
if (chain.includes(key)) {
311+
log.push(`[model-resolver] loop detected: "${requestedModel}" already in chain [${chain.join(' → ')}]`);
312+
return null;
313+
}
314+
const newChain = [...chain, key];
315+
316+
// Find alias entry (case-insensitive)
317+
let aliasEntry = Object.entries(aliases).find(([k]) => k.toLowerCase() === key);
318+
319+
if (!aliasEntry) {
320+
// Family fallback: treat gpt-5.<minor> as gpt-5 when only the family alias
321+
// exists. This keeps versioned IDs like gpt-5.4 compatible with configs that
322+
// define "gpt-5" alias patterns.
323+
const familyAlias = key.match(/^(gpt-5)\.\d+(?:[._-].*)?$/)?.[1];
324+
if (familyAlias) {
325+
aliasEntry = Object.entries(aliases).find(([k]) => k.toLowerCase() === familyAlias);
326+
if (aliasEntry) {
327+
log.push(`[model-resolver] fallback alias: "${requestedModel}" → "${aliasEntry[0]}"`);
328+
}
329+
}
330+
}
331+
332+
if (!aliasEntry) {
333+
return _resolveDirectMatch(key, requestedModel, currentProvider, availableModels, fallbackConfig, log);
334+
}
335+
336+
const [aliasKey, aliasRaw] = aliasEntry;
337+
const aliasDefinition = resolveAliasDefinition(aliasRaw);
338+
return _resolveAliasPatterns(aliasKey, aliasDefinition, requestedModel, aliases, availableModels, currentProvider, newChain, fallbackConfig, log);
339+
}
340+
306341
/**
307342
* Filter an alias map to only include aliases resolvable to at least one
308343
* available model for at least one provider that has model data.

containers/api-proxy/model-resolver.test.js

Lines changed: 3 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
/**
22
* Tests for model-resolver.js
3+
*
4+
* Tests for the pure version utilities (globMatch, extractVersionNumbers,
5+
* compareByVersion) live in model-utils.test.js.
36
*/
47

58
const {
69
parseModelAliases,
7-
globMatch,
8-
extractVersionNumbers,
9-
compareByVersion,
1010
selectMiddlePowerFallback,
1111
filterResolvableAliases,
1212
resolveModel,
@@ -79,109 +79,6 @@ describe('parseModelAliases', () => {
7979
});
8080
});
8181

82-
// ── globMatch ──────────────────────────────────────────────────────────────
83-
84-
describe('globMatch', () => {
85-
it('should match exact strings', () => {
86-
expect(globMatch('gpt-4o', 'gpt-4o')).toBe(true);
87-
});
88-
89-
it('should not match different strings', () => {
90-
expect(globMatch('gpt-4o', 'gpt-4')).toBe(false);
91-
});
92-
93-
it('should match * wildcard at end', () => {
94-
expect(globMatch('gpt-4*', 'gpt-4o')).toBe(true);
95-
expect(globMatch('gpt-4*', 'gpt-4-turbo')).toBe(true);
96-
});
97-
98-
it('should match * wildcard in middle', () => {
99-
expect(globMatch('claude-*-sonnet*', 'claude-3.5-sonnet-20241022')).toBe(true);
100-
});
101-
102-
it('should match * wildcard at start', () => {
103-
expect(globMatch('*sonnet*', 'claude-3.5-sonnet-20241022')).toBe(true);
104-
});
105-
106-
it('should be case-insensitive', () => {
107-
expect(globMatch('CLAUDE-*', 'claude-3.5-sonnet')).toBe(true);
108-
expect(globMatch('*SONNET*', 'claude-3.5-sonnet')).toBe(true);
109-
});
110-
111-
it('should not match * as a partial segment', () => {
112-
expect(globMatch('gpt-5-codex', 'gpt-5-codex-extra')).toBe(false);
113-
});
114-
115-
it('should match model names with version numbers', () => {
116-
expect(globMatch('*sonnet*', 'claude-sonnet-4.6')).toBe(true);
117-
expect(globMatch('*sonnet*', 'claude-sonnet-4.5')).toBe(true);
118-
});
119-
120-
it('should treat ? as a literal character, not a regex quantifier', () => {
121-
// Pattern with literal '?' should only match a string containing '?'
122-
expect(globMatch('model?version', 'model?version')).toBe(true);
123-
expect(globMatch('model?version', 'modelXversion')).toBe(false);
124-
expect(globMatch('model?version', 'modelversion')).toBe(false);
125-
});
126-
127-
it('should treat other regex metacharacters as literals', () => {
128-
expect(globMatch('model.v1', 'modelXv1')).toBe(false); // '.' is literal, not wildcard
129-
expect(globMatch('model.v1', 'model.v1')).toBe(true);
130-
expect(globMatch('(test)', '(test)')).toBe(true);
131-
expect(globMatch('(test)', 'xtest)')).toBe(false);
132-
});
133-
});
134-
135-
// ── extractVersionNumbers ──────────────────────────────────────────────────
136-
137-
describe('extractVersionNumbers', () => {
138-
it('should extract version from claude-sonnet-4.6', () => {
139-
expect(extractVersionNumbers('claude-sonnet-4.6')).toEqual([4, 6]);
140-
});
141-
142-
it('should extract version from gpt-4o', () => {
143-
expect(extractVersionNumbers('gpt-4o')).toEqual([4]);
144-
});
145-
146-
it('should extract version from gemini-1.5-pro', () => {
147-
expect(extractVersionNumbers('gemini-1.5-pro')).toEqual([1, 5]);
148-
});
149-
150-
it('should return empty array for model with no numbers', () => {
151-
expect(extractVersionNumbers('my-model')).toEqual([]);
152-
});
153-
154-
it('should handle multi-digit version numbers', () => {
155-
expect(extractVersionNumbers('model-20241022')).toEqual([20241022]);
156-
});
157-
});
158-
159-
// ── compareByVersion ───────────────────────────────────────────────────────
160-
161-
describe('compareByVersion', () => {
162-
it('should sort higher version first', () => {
163-
const models = ['claude-sonnet-4.5', 'claude-sonnet-4.6', 'claude-sonnet-4.7'];
164-
models.sort(compareByVersion);
165-
expect(models[0]).toBe('claude-sonnet-4.7');
166-
});
167-
168-
it('should sort claude-sonnet-4.6 before claude-sonnet-4.5', () => {
169-
expect(compareByVersion('claude-sonnet-4.5', 'claude-sonnet-4.6')).toBeGreaterThan(0);
170-
expect(compareByVersion('claude-sonnet-4.6', 'claude-sonnet-4.5')).toBeLessThan(0);
171-
});
172-
173-
it('should use lexicographic fallback for same version', () => {
174-
// Both have no version numbers — falls back to localeCompare
175-
const result = compareByVersion('alpha-model', 'beta-model');
176-
expect(result).toBeLessThan(0); // 'alpha' < 'beta' lexicographically
177-
});
178-
179-
it('should handle models without version numbers gracefully', () => {
180-
const models = ['gpt-4o', 'o1'];
181-
expect(() => models.sort(compareByVersion)).not.toThrow();
182-
});
183-
});
184-
18582
// ── resolveModel ───────────────────────────────────────────────────────────
18683

18784
describe('resolveModel', () => {

0 commit comments

Comments
 (0)