From 2462d7af29e5f287fdbf86ed2e188c9b3c0c54c4 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Thu, 21 May 2026 11:36:01 +0900 Subject: [PATCH] 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 --- .../src/connected-providers-cache.ts | 43 +++++++++++-- packages/model-core/src/index.ts | 5 ++ packages/model-core/src/model-availability.ts | 64 +++++++++++++++++++ .../src/model-resolution-pipeline.ts | 33 +++++++--- packages/model-core/src/model-resolver.ts | 4 +- .../src/provider-model-id-transform.ts | 55 ++++++++++++++++ src/shared/model-availability.ts | 26 ++------ src/shared/model-resolver.ts | 38 +++++++---- src/shared/provider-model-id-transform.ts | 5 -- 9 files changed, 222 insertions(+), 51 deletions(-) create mode 100644 packages/model-core/src/model-availability.ts create mode 100644 packages/model-core/src/provider-model-id-transform.ts diff --git a/packages/model-core/src/connected-providers-cache.ts b/packages/model-core/src/connected-providers-cache.ts index 0f6d32325..cd237cb4a 100644 --- a/packages/model-core/src/connected-providers-cache.ts +++ b/packages/model-core/src/connected-providers-cache.ts @@ -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 + 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, +} diff --git a/packages/model-core/src/index.ts b/packages/model-core/src/index.ts index 8b0352a0b..5b100d925 100644 --- a/packages/model-core/src/index.ts +++ b/packages/model-core/src/index.ts @@ -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 { diff --git a/packages/model-core/src/model-availability.ts b/packages/model-core/src/model-availability.ts new file mode 100644 index 000000000..f040463e4 --- /dev/null +++ b/packages/model-core/src/model-availability.ts @@ -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, + 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, +): boolean { + return fuzzyMatchModel(targetModel, availableModels) !== null +} diff --git a/packages/model-core/src/model-resolution-pipeline.ts b/packages/model-core/src/model-resolution-pipeline.ts index 05c13c61e..0fcccf375 100644 --- a/packages/model-core/src/model-resolution-pipeline.ts +++ b/packages/model-core/src/model-resolution-pipeline.ts @@ -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, + 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, diff --git a/packages/model-core/src/model-resolver.ts b/packages/model-core/src/model-resolver.ts index aa690aa09..c7d20cf16 100644 --- a/packages/model-core/src/model-resolver.ts +++ b/packages/model-core/src/model-resolver.ts @@ -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 diff --git a/packages/model-core/src/provider-model-id-transform.ts b/packages/model-core/src/provider-model-id-transform.ts new file mode 100644 index 000000000..b8d4455e5 --- /dev/null +++ b/packages/model-core/src/provider-model-id-transform.ts @@ -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 +} diff --git a/src/shared/model-availability.ts b/src/shared/model-availability.ts index aaec07338..d54461ada 100644 --- a/src/shared/model-availability.ts +++ b/src/shared/model-availability.ts @@ -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, @@ -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, diff --git a/src/shared/model-resolver.ts b/src/shared/model-resolver.ts index 457f8d18a..f26e44ffb 100644 --- a/src/shared/model-resolver.ts +++ b/src/shared/model-resolver.ts @@ -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 +export type ModelResolutionResult = Exclude +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, +} diff --git a/src/shared/provider-model-id-transform.ts b/src/shared/provider-model-id-transform.ts index 59904199d..a4869c3c9 100644 --- a/src/shared/provider-model-id-transform.ts +++ b/src/shared/provider-model-id-transform.ts @@ -23,18 +23,13 @@ function applyGatewayTransforms(model: string): string { } export function transformModelForProvider(provider: string, model: string): string { - // Vercel AI Gateway expects / (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)}`