Files
oh-my-opencode/src/cli/doctor/checks/model-resolution.ts
T
YeonGyu-Kim 6a66bfccec fix(doctor): respect user-configured agent variant (#1464)
* fix(doctor): respect user-configured agent variant

* fix(doctor): align variant resolution with agent-variant.ts

- Add case-insensitive agent key lookup (matches canonical logic)
- Support category-based variant inheritance (agent.category -> categories[cat].variant)
- Separate getCategoryEffectiveVariant for category-specific resolution
- Addresses Oracle review feedback
2026-02-04 11:25:37 +09:00

318 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFileSync, existsSync } from "node:fs"
import type { CheckResult, CheckDefinition } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import { parseJsonc, detectConfigFile } from "../../../shared"
import {
AGENT_MODEL_REQUIREMENTS,
CATEGORY_MODEL_REQUIREMENTS,
type ModelRequirement,
} from "../../../shared/model-requirements"
import { homedir } from "node:os"
import { join } from "node:path"
function getOpenCodeCacheDir(): string {
const xdgCache = process.env.XDG_CACHE_HOME
if (xdgCache) return join(xdgCache, "opencode")
return join(homedir(), ".cache", "opencode")
}
function loadAvailableModels(): { providers: string[]; modelCount: number; cacheExists: boolean } {
const cacheFile = join(getOpenCodeCacheDir(), "models.json")
if (!existsSync(cacheFile)) {
return { providers: [], modelCount: 0, cacheExists: false }
}
try {
const content = readFileSync(cacheFile, "utf-8")
const data = JSON.parse(content) as Record<string, { models?: Record<string, unknown> }>
const providers = Object.keys(data)
let modelCount = 0
for (const providerId of providers) {
const models = data[providerId]?.models
if (models && typeof models === "object") {
modelCount += Object.keys(models).length
}
}
return { providers, modelCount, cacheExists: true }
} catch {
return { providers: [], modelCount: 0, cacheExists: false }
}
}
const PACKAGE_NAME = "oh-my-opencode"
const USER_CONFIG_DIR = join(homedir(), ".config", "opencode")
const USER_CONFIG_BASE = join(USER_CONFIG_DIR, PACKAGE_NAME)
const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME)
export interface AgentResolutionInfo {
name: string
requirement: ModelRequirement
userOverride?: string
effectiveModel: string
effectiveResolution: string
}
export interface CategoryResolutionInfo {
name: string
requirement: ModelRequirement
userOverride?: string
effectiveModel: string
effectiveResolution: string
}
export interface ModelResolutionInfo {
agents: AgentResolutionInfo[]
categories: CategoryResolutionInfo[]
}
interface OmoConfig {
agents?: Record<string, { model?: string; variant?: string; category?: string }>
categories?: Record<string, { model?: string; variant?: string }>
}
function loadConfig(): OmoConfig | null {
const projectDetected = detectConfigFile(PROJECT_CONFIG_BASE)
if (projectDetected.format !== "none") {
try {
const content = readFileSync(projectDetected.path, "utf-8")
return parseJsonc<OmoConfig>(content)
} catch {
return null
}
}
const userDetected = detectConfigFile(USER_CONFIG_BASE)
if (userDetected.format !== "none") {
try {
const content = readFileSync(userDetected.path, "utf-8")
return parseJsonc<OmoConfig>(content)
} catch {
return null
}
}
return null
}
function formatProviderChain(providers: string[]): string {
return providers.join(" → ")
}
function getEffectiveModel(requirement: ModelRequirement, userOverride?: string): string {
if (userOverride) {
return userOverride
}
const firstEntry = requirement.fallbackChain[0]
if (!firstEntry) {
return "unknown"
}
return `${firstEntry.providers[0]}/${firstEntry.model}`
}
function buildEffectiveResolution(
requirement: ModelRequirement,
userOverride?: string,
): string {
if (userOverride) {
return `User override: ${userOverride}`
}
const firstEntry = requirement.fallbackChain[0]
if (!firstEntry) {
return "No fallback chain defined"
}
return `Provider fallback: ${formatProviderChain(firstEntry.providers)}${firstEntry.model}`
}
export function getModelResolutionInfo(): ModelResolutionInfo {
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(
([name, requirement]) => ({
name,
requirement,
effectiveModel: getEffectiveModel(requirement),
effectiveResolution: buildEffectiveResolution(requirement),
}),
)
const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map(
([name, requirement]) => ({
name,
requirement,
effectiveModel: getEffectiveModel(requirement),
effectiveResolution: buildEffectiveResolution(requirement),
}),
)
return { agents, categories }
}
export function getModelResolutionInfoWithOverrides(config: OmoConfig): ModelResolutionInfo {
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(
([name, requirement]) => {
const userOverride = config.agents?.[name]?.model
return {
name,
requirement,
userOverride,
effectiveModel: getEffectiveModel(requirement, userOverride),
effectiveResolution: buildEffectiveResolution(requirement, userOverride),
}
},
)
const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map(
([name, requirement]) => {
const userOverride = config.categories?.[name]?.model
return {
name,
requirement,
userOverride,
effectiveModel: getEffectiveModel(requirement, userOverride),
effectiveResolution: buildEffectiveResolution(requirement, userOverride),
}
},
)
return { agents, categories }
}
function formatModelWithVariant(model: string, variant?: string): string {
return variant ? `${model} (${variant})` : model
}
function getAgentOverride(
agentName: string,
config: OmoConfig,
): { variant?: string; category?: string } | undefined {
const agentOverrides = config.agents
if (!agentOverrides) return undefined
// Direct lookup first, then case-insensitive lookup (matches agent-variant.ts)
return (
agentOverrides[agentName] ??
Object.entries(agentOverrides).find(
([key]) => key.toLowerCase() === agentName.toLowerCase()
)?.[1]
)
}
function getEffectiveVariant(
name: string,
requirement: ModelRequirement,
config: OmoConfig,
): string | undefined {
const agentOverride = getAgentOverride(name, config)
// Priority 1: Agent's direct variant override
if (agentOverride?.variant) {
return agentOverride.variant
}
// Priority 2: Agent's category -> category's variant (matches agent-variant.ts)
const categoryName = agentOverride?.category
if (categoryName) {
const categoryVariant = config.categories?.[categoryName]?.variant
if (categoryVariant) {
return categoryVariant
}
}
// Priority 3: Fall back to requirement's fallback chain
const firstEntry = requirement.fallbackChain[0]
return firstEntry?.variant ?? requirement.variant
}
interface AvailableModelsInfo {
providers: string[]
modelCount: number
cacheExists: boolean
}
function getCategoryEffectiveVariant(
categoryName: string,
requirement: ModelRequirement,
config: OmoConfig,
): string | undefined {
const categoryVariant = config.categories?.[categoryName]?.variant
if (categoryVariant) {
return categoryVariant
}
const firstEntry = requirement.fallbackChain[0]
return firstEntry?.variant ?? requirement.variant
}
function buildDetailsArray(info: ModelResolutionInfo, available: AvailableModelsInfo, config: OmoConfig): string[] {
const details: string[] = []
details.push("═══ Available Models (from cache) ═══")
details.push("")
if (available.cacheExists) {
details.push(` Providers in cache: ${available.providers.length}`)
details.push(` Sample: ${available.providers.slice(0, 6).join(", ")}${available.providers.length > 6 ? "..." : ""}`)
details.push(` Total models: ${available.modelCount}`)
details.push(` Cache: ~/.cache/opencode/models.json`)
details.push(` Runtime: only connected providers used`)
details.push(` Refresh: opencode models --refresh`)
} else {
details.push(" ⚠ Cache not found. Run 'opencode' to populate.")
}
details.push("")
details.push("═══ Configured Models ═══")
details.push("")
details.push("Agents:")
for (const agent of info.agents) {
const marker = agent.userOverride ? "●" : "○"
const display = formatModelWithVariant(agent.effectiveModel, getEffectiveVariant(agent.name, agent.requirement, config))
details.push(` ${marker} ${agent.name}: ${display}`)
}
details.push("")
details.push("Categories:")
for (const category of info.categories) {
const marker = category.userOverride ? "●" : "○"
const display = formatModelWithVariant(
category.effectiveModel,
getCategoryEffectiveVariant(category.name, category.requirement, config)
)
details.push(` ${marker} ${category.name}: ${display}`)
}
details.push("")
details.push("● = user override, ○ = provider fallback")
return details
}
export async function checkModelResolution(): Promise<CheckResult> {
const config = loadConfig() ?? {}
const info = getModelResolutionInfoWithOverrides(config)
const available = loadAvailableModels()
const agentCount = info.agents.length
const categoryCount = info.categories.length
const agentOverrides = info.agents.filter((a) => a.userOverride).length
const categoryOverrides = info.categories.filter((c) => c.userOverride).length
const totalOverrides = agentOverrides + categoryOverrides
const overrideNote = totalOverrides > 0 ? ` (${totalOverrides} override${totalOverrides > 1 ? "s" : ""})` : ""
const cacheNote = available.cacheExists ? `, ${available.modelCount} available` : ", cache not found"
return {
name: CHECK_NAMES[CHECK_IDS.MODEL_RESOLUTION],
status: available.cacheExists ? "pass" : "warn",
message: `${agentCount} agents, ${categoryCount} categories${overrideNote}${cacheNote}`,
details: buildDetailsArray(info, available, config),
}
}
export function getModelResolutionCheckDefinition(): CheckDefinition {
return {
id: CHECK_IDS.MODEL_RESOLUTION,
name: CHECK_NAMES[CHECK_IDS.MODEL_RESOLUTION],
category: "configuration",
check: checkModelResolution,
critical: false,
}
}