@@ -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 ( / ^ ( g p t - 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 ( / ^ ( g p t - 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 ( / ^ ( g p t - 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 ( / ^ ( g p t - 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.
0 commit comments