fix(model-resolver): return variant from fallback chain, handle model name normalization

- Add variant to ModelResolutionResult return type
- Return variant from matched fallback entry
- Add normalizeModelName() for Claude model hyphen/period differences
- Add transformModelForProvider() for github-copilot model names
- Update delegate-task to use resolved variant (user config takes priority)
- Fix test expectations for new fallback behavior
This commit is contained in:
justsisyphus
2026-01-23 02:20:32 +09:00
parent 7de376e24f
commit 6e84a14f20
7 changed files with 71 additions and 46 deletions
+13 -6
View File
@@ -25,6 +25,13 @@ import { log } from "./logger"
* fuzzyMatchModel("gpt-5.2", available) // → "openai/gpt-5.2"
* fuzzyMatchModel("claude", available, ["openai"]) // → null (provider filter excludes anthropic)
*/
function normalizeModelName(name: string): string {
return name
.toLowerCase()
.replace(/claude-(opus|sonnet|haiku)-4-5/g, "claude-$1-4.5")
.replace(/claude-(opus|sonnet|haiku)-4\.5/g, "claude-$1-4.5")
}
export function fuzzyMatchModel(
target: string,
available: Set<string>,
@@ -37,7 +44,7 @@ export function fuzzyMatchModel(
return null
}
const targetLower = target.toLowerCase()
const targetNormalized = normalizeModelName(target)
// Filter by providers if specified
let candidates = Array.from(available)
@@ -55,19 +62,19 @@ export function fuzzyMatchModel(
return null
}
// Find all matches (case-insensitive substring match)
// Find all matches (case-insensitive substring match with normalization)
const matches = candidates.filter((model) =>
model.toLowerCase().includes(targetLower),
normalizeModelName(model).includes(targetNormalized),
)
log("[fuzzyMatchModel] substring matches", { targetLower, matchCount: matches.length, matches })
log("[fuzzyMatchModel] substring matches", { targetNormalized, matchCount: matches.length, matches })
if (matches.length === 0) {
return null
}
// Priority 1: Exact match
const exactMatch = matches.find((model) => model.toLowerCase() === targetLower)
// Priority 1: Exact match (normalized)
const exactMatch = matches.find((model) => normalizeModelName(model) === targetNormalized)
if (exactMatch) {
log("[fuzzyMatchModel] exact match found", { exactMatch })
return exactMatch
+13 -12
View File
@@ -206,10 +206,11 @@ describe("resolveModelWithFallback", () => {
// #then
expect(result.model).toBe("github-copilot/claude-opus-4-5-preview")
expect(result.source).toBe("provider-fallback")
expect(logSpy).toHaveBeenCalledWith("Model resolved via fallback chain", {
expect(logSpy).toHaveBeenCalledWith("Model resolved via fallback chain (availability confirmed)", {
provider: "github-copilot",
model: "claude-opus-4-5",
match: "github-copilot/claude-opus-4-5-preview",
variant: undefined,
})
})
@@ -315,8 +316,8 @@ describe("resolveModelWithFallback", () => {
})
})
describe("Step 3: System default", () => {
test("returns systemDefaultModel with system-default source when nothing matches", () => {
describe("Step 3: First fallback entry (no availability match)", () => {
test("returns first fallbackChain entry when no availability match found", () => {
// #given
const input: ExtendedModelResolutionInput = {
fallbackChain: [
@@ -330,12 +331,12 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("google/gemini-3-pro")
expect(result.source).toBe("system-default")
expect(logSpy).toHaveBeenCalledWith("Model resolved via system default", { model: "google/gemini-3-pro" })
expect(result.model).toBe("anthropic/nonexistent-model")
expect(result.source).toBe("provider-fallback")
expect(logSpy).toHaveBeenCalledWith("Model resolved via fallback chain first entry (no availability match)", { model: "anthropic/nonexistent-model", variant: undefined })
})
test("returns system default when availableModels is empty", () => {
test("returns first fallbackChain entry when availableModels is empty", () => {
// #given
const input: ExtendedModelResolutionInput = {
fallbackChain: [
@@ -349,8 +350,8 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("google/gemini-3-pro")
expect(result.source).toBe("system-default")
expect(result.model).toBe("anthropic/claude-opus-4-5")
expect(result.source).toBe("provider-fallback")
})
test("returns system default when fallbackChain is not provided", () => {
@@ -430,7 +431,7 @@ describe("resolveModelWithFallback", () => {
expect(result.source).toBe("provider-fallback")
})
test("falls through all entries to system default when none match", () => {
test("falls through to first fallbackChain entry when none match availability", () => {
// #given
const availableModels = new Set(["other/model"])
@@ -446,8 +447,8 @@ describe("resolveModelWithFallback", () => {
})
// #then
expect(result.model).toBe("system/default")
expect(result.source).toBe("system-default")
expect(result.model).toBe("openai/gpt-5.2")
expect(result.source).toBe("provider-fallback")
})
})
+5 -4
View File
@@ -16,6 +16,7 @@ export type ModelSource =
export type ModelResolutionResult = {
model: string
source: ModelSource
variant?: string
}
export type ExtendedModelResolutionInput = {
@@ -57,8 +58,8 @@ export function resolveModelWithFallback(
const fullModel = `${provider}/${entry.model}`
const match = fuzzyMatchModel(fullModel, availableModels, [provider])
if (match) {
log("Model resolved via fallback chain (availability confirmed)", { provider, model: entry.model, match })
return { model: match, source: "provider-fallback" }
log("Model resolved via fallback chain (availability confirmed)", { provider, model: entry.model, match, variant: entry.variant })
return { model: match, source: "provider-fallback", variant: entry.variant }
}
}
}
@@ -68,8 +69,8 @@ export function resolveModelWithFallback(
const firstEntry = fallbackChain[0]
if (firstEntry.providers.length > 0) {
const fallbackModel = `${firstEntry.providers[0]}/${firstEntry.model}`
log("Model resolved via fallback chain first entry (no availability match)", { model: fallbackModel })
return { model: fallbackModel, source: "provider-fallback" }
log("Model resolved via fallback chain first entry (no availability match)", { model: fallbackModel, variant: firstEntry.variant })
return { model: fallbackModel, source: "provider-fallback", variant: firstEntry.variant }
}
}