Merge pull request #2237 from iyoda/refactor/model-resolution-dedup
refactor(shared): deduplicate model resolution utility functions
This commit is contained in:
commit
8b57ca8c6c
@ -1,11 +1,7 @@
|
|||||||
import { log } from "../../shared"
|
import { log, normalizeModelID } from "../../shared"
|
||||||
|
|
||||||
const OPUS_4_6_PATTERN = /claude-opus-4[-.]6/i
|
const OPUS_4_6_PATTERN = /claude-opus-4[-.]6/i
|
||||||
|
|
||||||
function normalizeModelID(modelID: string): string {
|
|
||||||
return modelID.replace(/\.(\d+)/g, "-$1")
|
|
||||||
}
|
|
||||||
|
|
||||||
function isClaudeProvider(providerID: string, modelID: string): boolean {
|
function isClaudeProvider(providerID: string, modelID: string): boolean {
|
||||||
if (["anthropic", "google-vertex-anthropic", "opencode"].includes(providerID)) return true
|
if (["anthropic", "google-vertex-anthropic", "opencode"].includes(providerID)) return true
|
||||||
if (providerID === "github-copilot" && modelID.toLowerCase().includes("claude")) return true
|
if (providerID === "github-copilot" && modelID.toLowerCase().includes("claude")) return true
|
||||||
|
|||||||
@ -16,6 +16,8 @@
|
|||||||
* inconsistencies defensively while maintaining backwards compatibility.
|
* inconsistencies defensively while maintaining backwards compatibility.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { normalizeModelID } from "../../shared"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts provider-specific prefix from model ID (if present).
|
* Extracts provider-specific prefix from model ID (if present).
|
||||||
* Custom providers may use prefixes for routing (e.g., vertex_ai/, openai/).
|
* Custom providers may use prefixes for routing (e.g., vertex_ai/, openai/).
|
||||||
@ -36,24 +38,6 @@ function extractModelPrefix(modelID: string): { prefix: string; base: string } {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes model IDs to use consistent hyphen formatting.
|
|
||||||
* GitHub Copilot may use dots (claude-opus-4.6) but our maps use hyphens (claude-opus-4-6).
|
|
||||||
* This ensures lookups work regardless of format.
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* normalizeModelID("claude-opus-4.6") // "claude-opus-4-6"
|
|
||||||
* normalizeModelID("gemini-3.5-pro") // "gemini-3-5-pro"
|
|
||||||
* normalizeModelID("gpt-5.2") // "gpt-5-2"
|
|
||||||
* normalizeModelID("vertex_ai/claude-opus-4.6") // "vertex_ai/claude-opus-4-6"
|
|
||||||
*/
|
|
||||||
function normalizeModelID(modelID: string): string {
|
|
||||||
// Replace dots with hyphens when followed by a digit
|
|
||||||
// This handles version numbers like 4.5 → 4-5, 5.2 → 5-2
|
|
||||||
return modelID.replace(/\.(\d+)/g, "-$1")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Maps model IDs to their "high reasoning" variant (internal convention)
|
// Maps model IDs to their "high reasoning" variant (internal convention)
|
||||||
// For OpenAI models, this signals that reasoning_effort should be set to "high"
|
// For OpenAI models, this signals that reasoning_effort should be set to "high"
|
||||||
|
|||||||
@ -33,7 +33,7 @@ resolveModel(input)
|
|||||||
4. System default: Ultimate fallback
|
4. System default: Ultimate fallback
|
||||||
```
|
```
|
||||||
|
|
||||||
Key files: `model-resolver.ts` (entry), `model-resolution-pipeline.ts` (orchestration), `model-requirements.ts` (fallback chains), `model-name-matcher.ts` (fuzzy matching).
|
Key files: `model-resolver.ts` (entry), `model-resolution-pipeline.ts` (orchestration), `model-requirements.ts` (fallback chains), `model-availability.ts` (fuzzy matching).
|
||||||
|
|
||||||
## MIGRATION SYSTEM
|
## MIGRATION SYSTEM
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { readConnectedProvidersCache } from "./connected-providers-cache"
|
import { readConnectedProvidersCache } from "./connected-providers-cache"
|
||||||
import { log } from "./logger"
|
import { log } from "./logger"
|
||||||
import { fuzzyMatchModel } from "./model-name-matcher"
|
import { fuzzyMatchModel } from "./model-availability"
|
||||||
|
|
||||||
type FallbackEntry = { providers: string[]; model: string }
|
type FallbackEntry = { providers: string[]; model: string }
|
||||||
|
|
||||||
|
|||||||
@ -34,6 +34,7 @@ export * from "./system-directive"
|
|||||||
export * from "./agent-tool-restrictions"
|
export * from "./agent-tool-restrictions"
|
||||||
export * from "./model-requirements"
|
export * from "./model-requirements"
|
||||||
export * from "./model-resolver"
|
export * from "./model-resolver"
|
||||||
|
export { normalizeModel, normalizeModelID } from "./model-normalization"
|
||||||
export { normalizeFallbackModels } from "./model-resolver"
|
export { normalizeFallbackModels } from "./model-resolver"
|
||||||
export { resolveModelPipeline } from "./model-resolution-pipeline"
|
export { resolveModelPipeline } from "./model-resolution-pipeline"
|
||||||
export type {
|
export type {
|
||||||
|
|||||||
@ -1,83 +0,0 @@
|
|||||||
import { log } from "./logger"
|
|
||||||
|
|
||||||
function normalizeModelName(name: string): string {
|
|
||||||
return name
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/claude-(opus|sonnet|haiku)-(\d+)[.-](\d+)/g, "claude-$1-$2.$3")
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fuzzyMatchModel(
|
|
||||||
target: string,
|
|
||||||
available: Set<string>,
|
|
||||||
providers?: string[],
|
|
||||||
): string | null {
|
|
||||||
log("[fuzzyMatchModel] called", { target, availableCount: available.size, providers })
|
|
||||||
|
|
||||||
if (available.size === 0) {
|
|
||||||
log("[fuzzyMatchModel] empty available set")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetNormalized = normalizeModelName(target)
|
|
||||||
|
|
||||||
let candidates = Array.from(available)
|
|
||||||
if (providers && providers.length > 0) {
|
|
||||||
const providerSet = new Set(providers)
|
|
||||||
candidates = candidates.filter((model) => {
|
|
||||||
const [provider] = model.split("/")
|
|
||||||
return providerSet.has(provider)
|
|
||||||
})
|
|
||||||
log("[fuzzyMatchModel] filtered by providers", {
|
|
||||||
candidateCount: candidates.length,
|
|
||||||
candidates: candidates.slice(0, 10),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
log("[fuzzyMatchModel] no candidates after filter")
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const matches = candidates.filter((model) =>
|
|
||||||
normalizeModelName(model).includes(targetNormalized),
|
|
||||||
)
|
|
||||||
|
|
||||||
log("[fuzzyMatchModel] substring matches", {
|
|
||||||
targetNormalized,
|
|
||||||
matchCount: matches.length,
|
|
||||||
matches,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (matches.length === 0) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const exactMatch = matches.find(
|
|
||||||
(model) => normalizeModelName(model) === targetNormalized,
|
|
||||||
)
|
|
||||||
if (exactMatch) {
|
|
||||||
log("[fuzzyMatchModel] exact match found", { exactMatch })
|
|
||||||
return exactMatch
|
|
||||||
}
|
|
||||||
|
|
||||||
const exactModelIdMatches = matches.filter((model) => {
|
|
||||||
const modelId = model.split("/").slice(1).join("/")
|
|
||||||
return normalizeModelName(modelId) === targetNormalized
|
|
||||||
})
|
|
||||||
if (exactModelIdMatches.length > 0) {
|
|
||||||
const result = exactModelIdMatches.reduce((shortest, current) =>
|
|
||||||
current.length < shortest.length ? current : shortest,
|
|
||||||
)
|
|
||||||
log("[fuzzyMatchModel] exact model ID match found", {
|
|
||||||
result,
|
|
||||||
candidateCount: exactModelIdMatches.length,
|
|
||||||
})
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = matches.reduce((shortest, current) =>
|
|
||||||
current.length < shortest.length ? current : shortest,
|
|
||||||
)
|
|
||||||
log("[fuzzyMatchModel] shortest match", { result })
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
123
src/shared/model-normalization.test.ts
Normal file
123
src/shared/model-normalization.test.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { normalizeModel, normalizeModelID } from "./model-normalization"
|
||||||
|
|
||||||
|
describe("normalizeModel", () => {
|
||||||
|
describe("#given undefined input", () => {
|
||||||
|
test("#when normalizeModel is called with undefined #then returns undefined", () => {
|
||||||
|
// given
|
||||||
|
const input = undefined
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = normalizeModel(input)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given empty string", () => {
|
||||||
|
test("#when normalizeModel is called with empty string #then returns undefined", () => {
|
||||||
|
// given
|
||||||
|
const input = ""
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = normalizeModel(input)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given whitespace-only string", () => {
|
||||||
|
test("#when normalizeModel is called with whitespace-only string #then returns undefined", () => {
|
||||||
|
// given
|
||||||
|
const input = " "
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = normalizeModel(input)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given valid model string", () => {
|
||||||
|
test("#when normalizeModel is called with valid model string #then returns same string", () => {
|
||||||
|
// given
|
||||||
|
const input = "claude-3-opus"
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = normalizeModel(input)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBe("claude-3-opus")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given string with leading and trailing spaces", () => {
|
||||||
|
test("#when normalizeModel is called with spaces #then returns trimmed string", () => {
|
||||||
|
// given
|
||||||
|
const input = " claude-3-opus "
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = normalizeModel(input)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBe("claude-3-opus")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given string with only spaces", () => {
|
||||||
|
test("#when normalizeModel is called with only spaces #then returns undefined", () => {
|
||||||
|
// given
|
||||||
|
const input = " "
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = normalizeModel(input)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("normalizeModelID", () => {
|
||||||
|
describe("#given model with dots in version numbers", () => {
|
||||||
|
test("#when normalizeModelID is called with claude-3.5-sonnet #then returns claude-3-5-sonnet", () => {
|
||||||
|
// given
|
||||||
|
const input = "claude-3.5-sonnet"
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = normalizeModelID(input)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBe("claude-3-5-sonnet")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given model without dots", () => {
|
||||||
|
test("#when normalizeModelID is called with claude-opus #then returns unchanged", () => {
|
||||||
|
// given
|
||||||
|
const input = "claude-opus"
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = normalizeModelID(input)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBe("claude-opus")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("#given model with multiple dot-numbers", () => {
|
||||||
|
test("#when normalizeModelID is called with model.1.2 #then returns model-1-2", () => {
|
||||||
|
// given
|
||||||
|
const input = "model.1.2"
|
||||||
|
|
||||||
|
// when
|
||||||
|
const result = normalizeModelID(input)
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(result).toBe("model-1-2")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
8
src/shared/model-normalization.ts
Normal file
8
src/shared/model-normalization.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
export function normalizeModel(model?: string): string | undefined {
|
||||||
|
const trimmed = model?.trim()
|
||||||
|
return trimmed || undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeModelID(modelID: string): string {
|
||||||
|
return modelID.replace(/\.(\d+)/g, "-$1")
|
||||||
|
}
|
||||||
@ -3,6 +3,7 @@ import * as connectedProvidersCache from "./connected-providers-cache"
|
|||||||
import { fuzzyMatchModel } from "./model-availability"
|
import { fuzzyMatchModel } from "./model-availability"
|
||||||
import type { FallbackEntry } from "./model-requirements"
|
import type { FallbackEntry } from "./model-requirements"
|
||||||
import { transformModelForProvider } from "./provider-model-id-transform"
|
import { transformModelForProvider } from "./provider-model-id-transform"
|
||||||
|
import { normalizeModel } from "./model-normalization"
|
||||||
|
|
||||||
export type ModelResolutionRequest = {
|
export type ModelResolutionRequest = {
|
||||||
intent?: {
|
intent?: {
|
||||||
@ -35,10 +36,6 @@ export type ModelResolutionResult = {
|
|||||||
reason?: string
|
reason?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeModel(model?: string): string | undefined {
|
|
||||||
const trimmed = model?.trim()
|
|
||||||
return trimmed || undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveModelPipeline(
|
export function resolveModelPipeline(
|
||||||
request: ModelResolutionRequest,
|
request: ModelResolutionRequest,
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import type { FallbackEntry } from "./model-requirements"
|
import type { FallbackEntry } from "./model-requirements"
|
||||||
|
import { normalizeModel } from "./model-normalization"
|
||||||
import { resolveModelPipeline } from "./model-resolution-pipeline"
|
import { resolveModelPipeline } from "./model-resolution-pipeline"
|
||||||
|
|
||||||
export type ModelResolutionInput = {
|
export type ModelResolutionInput = {
|
||||||
@ -29,10 +30,6 @@ export type ExtendedModelResolutionInput = {
|
|||||||
systemDefaultModel?: string
|
systemDefaultModel?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeModel(model?: string): string | undefined {
|
|
||||||
const trimmed = model?.trim()
|
|
||||||
return trimmed || undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveModel(input: ModelResolutionInput): string | undefined {
|
export function resolveModel(input: ModelResolutionInput): string | undefined {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,11 +1,8 @@
|
|||||||
import type { FallbackEntry } from "../../shared/model-requirements"
|
import type { FallbackEntry } from "../../shared/model-requirements"
|
||||||
|
import { normalizeModel } from "../../shared/model-normalization"
|
||||||
import { fuzzyMatchModel } from "../../shared/model-availability"
|
import { fuzzyMatchModel } from "../../shared/model-availability"
|
||||||
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
|
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
|
||||||
|
|
||||||
function normalizeModel(model?: string): string | undefined {
|
|
||||||
const trimmed = model?.trim()
|
|
||||||
return trimmed || undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveModelForDelegateTask(input: {
|
export function resolveModelForDelegateTask(input: {
|
||||||
userModel?: string
|
userModel?: string
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user