refactor(model-core): remove src back-imports via core utilities and adapter

- move fuzzyMatchModel and transformModelForProvider into model-core

- replace connected-providers re-export with adapter contract

- route shared wrappers through model-core exports

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-05-21 11:36:01 +09:00
parent 819bf0d11f
commit 2462d7af29
9 changed files with 222 additions and 51 deletions
@@ -1,5 +1,38 @@
export {
findProviderModelMetadata,
readConnectedProvidersCache,
readProviderModelsCache,
} from "../../../src/shared/connected-providers-cache"
import type { ModelMetadata } from "./provider-cache"
export interface ProviderModelsCache {
readonly models: Record<string, readonly string[] | readonly ModelMetadata[]>
readonly connected: readonly string[]
readonly updatedAt: string
}
export interface ConnectedProvidersAdapter {
readConnectedProvidersCache(): string[] | null
findProviderModelMetadata(providerID: string, modelID: string): ModelMetadata | undefined
readProviderModelsCache(): ProviderModelsCache | null
}
export function readConnectedProvidersCache(): string[] | null {
return null
}
export function findProviderModelMetadata(
_providerID: string,
_modelID: string,
): ModelMetadata | undefined {
return undefined
}
export function readProviderModelsCache(): ProviderModelsCache | null {
return null
}
export const connectedProvidersAdapter: ConnectedProvidersAdapter = {
readConnectedProvidersCache,
findProviderModelMetadata,
readProviderModelsCache,
}
export const disconnectedConnectedProvidersAdapter: ConnectedProvidersAdapter = {
...connectedProvidersAdapter,
}
+5
View File
@@ -24,6 +24,11 @@ export * from "./model-format-normalizer"
export * from "./model-normalization"
export * from "./model-string-parser"
export * from "./model-sanitizer"
export {
fuzzyMatchModel,
isModelAvailable,
} from "./model-availability"
export { transformModelForProvider } from "./provider-model-id-transform"
export * from "./fallback-chain-from-models"
export * from "./known-variants"
export {
@@ -0,0 +1,64 @@
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 {
if (available.size === 0) {
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)
})
}
if (candidates.length === 0) {
return null
}
const matches = candidates.filter((model) =>
normalizeModelName(model).includes(targetNormalized),
)
if (matches.length === 0) {
return null
}
const exactMatch = matches.find((model) => normalizeModelName(model) === targetNormalized)
if (exactMatch) {
return exactMatch
}
const exactModelIdMatches = matches.filter((model) => {
const modelId = model.split("/").slice(1).join("/")
return normalizeModelName(modelId) === targetNormalized
})
if (exactModelIdMatches.length > 0) {
return exactModelIdMatches.reduce((shortest, current) =>
current.length < shortest.length ? current : shortest,
)
}
return matches.reduce((shortest, current) =>
current.length < shortest.length ? current : shortest,
)
}
export function isModelAvailable(
targetModel: string,
availableModels: Set<string>,
): boolean {
return fuzzyMatchModel(targetModel, availableModels) !== null
}
@@ -1,6 +1,6 @@
import { fuzzyMatchModel } from "../../../src/shared/model-availability"
import { fuzzyMatchModel } from "./model-availability"
import type { FallbackEntry } from "./model-requirements"
import { transformModelForProvider } from "../../../src/shared/provider-model-id-transform"
import { transformModelForProvider } from "./provider-model-id-transform"
import { normalizeModel } from "./model-normalization"
import type { ProviderCache } from "./provider-cache"
@@ -57,6 +57,20 @@ export type ModelResolutionResult = {
reason?: string
}
export type ModelResolutionDeps = {
fuzzyMatchModel: (
target: string,
available: Set<string>,
providers?: string[],
) => string | null
transformModelForProvider: (provider: string, model: string) => string
}
const DEFAULT_MODEL_RESOLUTION_DEPS: ModelResolutionDeps = {
fuzzyMatchModel,
transformModelForProvider,
}
export function resolveModelPipeline(
request: ModelResolutionRequest,
@@ -64,6 +78,7 @@ export function resolveModelPipeline(
readConnectedProvidersCache: () => null,
findProviderModelMetadata: () => undefined,
},
deps: ModelResolutionDeps = DEFAULT_MODEL_RESOLUTION_DEPS,
): ModelResolutionResult | undefined {
const attempted: string[] = []
const { intent, constraints, policy } = request
@@ -89,7 +104,7 @@ export function resolveModelPipeline(
if (availableModels.size > 0) {
const parts = normalizedCategoryDefault.split("/")
const providerHint = parts.length >= 2 ? [parts[0]] : undefined
const match = fuzzyMatchModel(normalizedCategoryDefault, availableModels, providerHint)
const match = deps.fuzzyMatchModel(normalizedCategoryDefault, availableModels, providerHint)
if (match) {
log("Model resolved via category default (fuzzy matched)", {
original: normalizedCategoryDefault,
@@ -110,7 +125,7 @@ export function resolveModelPipeline(
const provider = parts[0]
if (connectedProviders.includes(provider)) {
const modelName = parts.slice(1).join("/")
const transformedModel = `${provider}/${transformModelForProvider(provider, modelName)}`
const transformedModel = `${provider}/${deps.transformModelForProvider(provider, modelName)}`
log("Model resolved via category default (connected provider)", {
model: transformedModel,
original: normalizedCategoryDefault,
@@ -139,7 +154,7 @@ export function resolveModelPipeline(
const provider = parts[0]
if (connectedSet.has(provider)) {
const modelName = parts.slice(1).join("/")
const transformedModel = `${provider}/${transformModelForProvider(provider, modelName)}`
const transformedModel = `${provider}/${deps.transformModelForProvider(provider, modelName)}`
log("Model resolved via user fallback_models (connected provider)", { model: transformedModel, original: model })
return { model: transformedModel, provenance: "provider-fallback", attempted }
}
@@ -152,7 +167,7 @@ export function resolveModelPipeline(
attempted.push(model)
const parts = model.split("/")
const providerHint = parts.length >= 2 ? [parts[0]] : undefined
const match = fuzzyMatchModel(model, availableModels, providerHint)
const match = deps.fuzzyMatchModel(model, availableModels, providerHint)
if (match) {
log("Model resolved via user fallback_models (availability confirmed)", { model: model, match })
return { model: match, provenance: "provider-fallback", attempted }
@@ -173,7 +188,7 @@ export function resolveModelPipeline(
for (const entry of fallbackChain) {
for (const provider of entry.providers) {
if (connectedSet.has(provider)) {
const transformedModelId = transformModelForProvider(provider, entry.model)
const transformedModelId = deps.transformModelForProvider(provider, entry.model)
const model = `${provider}/${transformedModelId}`
log("Model resolved via fallback chain (connected provider)", {
provider,
@@ -195,7 +210,7 @@ export function resolveModelPipeline(
for (const entry of fallbackChain) {
for (const provider of entry.providers) {
const fullModel = `${provider}/${entry.model}`
const match = fuzzyMatchModel(fullModel, availableModels, [provider])
const match = deps.fuzzyMatchModel(fullModel, availableModels, [provider])
if (match) {
log("Model resolved via fallback chain (availability confirmed)", {
provider,
@@ -212,7 +227,7 @@ export function resolveModelPipeline(
}
}
const crossProviderMatch = fuzzyMatchModel(entry.model, availableModels)
const crossProviderMatch = deps.fuzzyMatchModel(entry.model, availableModels)
if (crossProviderMatch) {
log("Model resolved via fallback chain (cross-provider fuzzy match)", {
model: entry.model,
+3 -1
View File
@@ -3,6 +3,7 @@ import type { FallbackModelObject } from "./fallback-model-object"
import { normalizeModel } from "./model-normalization"
import { resolveModelPipeline } from "./model-resolution-pipeline"
import { KNOWN_VARIANTS } from "./known-variants"
import type { ConnectedProvidersAdapter } from "./connected-providers-cache"
import * as connectedProvidersCache from "./connected-providers-cache"
export type ModelResolutionInput = {
@@ -44,13 +45,14 @@ export function resolveModel(input: ModelResolutionInput): string | undefined {
export function resolveModelWithFallback(
input: ExtendedModelResolutionInput,
connectedProvidersAdapter: ConnectedProvidersAdapter = connectedProvidersCache,
): ModelResolutionResult | undefined {
const { uiSelectedModel, userModel, userFallbackModels, categoryDefaultModel, fallbackChain, availableModels, systemDefaultModel } = input
const resolved = resolveModelPipeline({
intent: { uiSelectedModel, userModel, userFallbackModels, categoryDefaultModel },
constraints: { availableModels },
policy: { fallbackChain, systemDefaultModel },
}, connectedProvidersCache)
}, connectedProvidersAdapter)
if (!resolved) {
return undefined
@@ -0,0 +1,55 @@
function inferSubProvider(model: string): string | undefined {
if (model.startsWith("claude-")) return "anthropic"
if (model.startsWith("gpt-")) return "openai"
if (model.startsWith("gemini-")) return "google"
if (model.startsWith("grok-")) return "xai"
if (model.startsWith("minimax-")) return "minimax"
if (model.startsWith("kimi-")) return "moonshotai"
if (model.startsWith("glm-")) return "zai"
return undefined
}
const CLAUDE_VERSION_DOT = /claude-(\w+)-(\d+)-(\d+)/g
const GEMINI_31_PRO_PREVIEW = /gemini-3\.1-pro(?!-)/g
const GEMINI_3_FLASH_PREVIEW = /gemini-3-flash(?!-)/g
function claudeVersionDot(model: string): string {
return model.replace(CLAUDE_VERSION_DOT, "claude-$1-$2.$3")
}
function applyGatewayTransforms(model: string): string {
return claudeVersionDot(model).replace(
GEMINI_31_PRO_PREVIEW,
"gemini-3.1-pro-preview",
)
}
export function transformModelForProvider(provider: string, model: string): string {
if (provider === "vercel") {
const slashIndex = model.indexOf("/")
if (slashIndex !== -1) {
const subProvider = model.substring(0, slashIndex)
const subModel = model.substring(slashIndex + 1)
return `${subProvider}/${applyGatewayTransforms(subModel)}`
}
const subProvider = inferSubProvider(model)
if (subProvider) {
return `${subProvider}/${applyGatewayTransforms(model)}`
}
return model
}
if (provider === "github-copilot") {
return claudeVersionDot(model)
.replace(GEMINI_31_PRO_PREVIEW, "gemini-3.1-pro-preview")
.replace(GEMINI_3_FLASH_PREVIEW, "gemini-3-flash-preview")
}
if (provider === "google") {
return model
.replace(GEMINI_31_PRO_PREVIEW, "gemini-3.1-pro-preview")
.replace(GEMINI_3_FLASH_PREVIEW, "gemini-3-flash-preview")
}
if (provider === "anthropic") {
return claudeVersionDot(model)
}
return model
}
+6 -20
View File
@@ -5,6 +5,12 @@ import { getOpenCodeCacheDir } from "./data-path"
import * as connectedProvidersCache from "./connected-providers-cache"
import { normalizeSDKResponse } from "./normalize-sdk-response"
function normalizeModelName(name: string): string {
return name
.toLowerCase()
.replace(/claude-(opus|sonnet|haiku)-(\d+)[.-](\d+)/g, "claude-$1-$2.$3")
}
/**
* Fuzzy match a target model name against available models
*
@@ -25,12 +31,6 @@ import { normalizeSDKResponse } from "./normalize-sdk-response"
* fuzzyMatchModel("gpt-5.4", available) // → "openai/gpt-5.4"
* fuzzyMatchModel("claude", available, ["openai"]) // → null (provider filter excludes anthropic)
*/
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>,
@@ -45,7 +45,6 @@ export function fuzzyMatchModel(
const targetNormalized = normalizeModelName(target)
// Filter by providers if specified
let candidates = Array.from(available)
if (providers && providers.length > 0) {
const providerSet = new Set(providers)
@@ -61,7 +60,6 @@ export function fuzzyMatchModel(
return null
}
// Find all matches (case-insensitive substring match with normalization)
const matches = candidates.filter((model) =>
normalizeModelName(model).includes(targetNormalized),
)
@@ -73,16 +71,12 @@ export function fuzzyMatchModel(
return null
}
// Priority 1: Exact match (normalized full model string)
const exactMatch = matches.find((model) => normalizeModelName(model) === targetNormalized)
if (exactMatch) {
log("[fuzzyMatchModel] exact match found", { exactMatch })
return exactMatch
}
// Priority 2: Exact model ID match (part after provider/)
// This ensures "big-pickle" matches "zai-coding-plan/big-pickle" over "zai-coding-plan/glm-5"
// Use filter + shortest to handle multi-provider cases (e.g., openai/gpt-5.4 + opencode/gpt-5.4)
const exactModelIdMatches = matches.filter((model) => {
const modelId = model.split("/").slice(1).join("/")
return normalizeModelName(modelId) === targetNormalized
@@ -95,7 +89,6 @@ export function fuzzyMatchModel(
return result
}
// Priority 3: Shorter model name (more specific, fallback for partial matches)
const result = matches.reduce((shortest, current) =>
current.length < shortest.length ? current : shortest,
)
@@ -103,13 +96,6 @@ export function fuzzyMatchModel(
return result
}
/**
* Check if a target model is available (fuzzy match by model name, no provider filtering)
*
* @param targetModel - Model name to check (e.g., "gpt-5.3-codex")
* @param availableModels - Set of available models in "provider/model" format
* @returns true if model is available, false otherwise
*/
export function isModelAvailable(
targetModel: string,
availableModels: Set<string>,
+27 -11
View File
@@ -1,12 +1,28 @@
import {
resolveModel,
resolveModelWithFallback as resolveModelWithFallbackFromCore,
normalizeFallbackModels,
flattenToFallbackModelStrings,
} from "@oh-my-opencode/model-core"
import type {
ModelResolutionInput,
ExtendedModelResolutionInput,
} from "@oh-my-opencode/model-core"
import * as connectedProvidersCache from "./connected-providers-cache"
export { resolveModel, normalizeFallbackModels, flattenToFallbackModelStrings }
type CoreModelResolutionResult = ReturnType<typeof resolveModelWithFallbackFromCore>
export type ModelResolutionResult = Exclude<CoreModelResolutionResult, undefined>
export type ModelSource = ModelResolutionResult["source"]
export function resolveModelWithFallback(
input: ExtendedModelResolutionInput,
): CoreModelResolutionResult {
return resolveModelWithFallbackFromCore(input, connectedProvidersCache)
}
export type {
ModelResolutionInput,
ModelSource,
ModelResolutionResult,
ExtendedModelResolutionInput,
} from "@oh-my-opencode/model-core"
export {
resolveModel,
resolveModelWithFallback,
normalizeFallbackModels,
flattenToFallbackModelStrings,
} from "@oh-my-opencode/model-core"
ModelResolutionInput,
ExtendedModelResolutionInput,
}
@@ -23,18 +23,13 @@ function applyGatewayTransforms(model: string): string {
}
export function transformModelForProvider(provider: string, model: string): string {
// Vercel AI Gateway expects <sub-provider>/<model> (e.g. anthropic/claude-opus-4.7).
// Canonical names in model-requirements.ts may be bare (claude-opus-4-7) or
// already prefixed (anthropic/claude-opus-4-7). Both need gateway-specific transforms.
if (provider === "vercel") {
// Already prefixed — transform only the model part
const slashIndex = model.indexOf("/")
if (slashIndex !== -1) {
const subProvider = model.substring(0, slashIndex)
const subModel = model.substring(slashIndex + 1)
return `${subProvider}/${applyGatewayTransforms(subModel)}`
}
// Bare name — infer sub-provider from model prefix (claude- → anthropic, etc.)
const subProvider = inferSubProvider(model)
if (subProvider) {
return `${subProvider}/${applyGatewayTransforms(model)}`