OmoConfig interface was missing variant property, causing doctor to show variants from ModelRequirement fallback chain instead of user's config. - Add variant to OmoConfig agent/category entries - Add userVariant to resolution info interfaces - Update getEffectiveVariant to prioritize user variant - Add tests verifying variant capture
324 lines
10 KiB
TypeScript
324 lines
10 KiB
TypeScript
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
|
||
userVariant?: string
|
||
effectiveModel: string
|
||
effectiveResolution: string
|
||
}
|
||
|
||
export interface CategoryResolutionInfo {
|
||
name: string
|
||
requirement: ModelRequirement
|
||
userOverride?: string
|
||
userVariant?: 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
|
||
const userVariant = config.agents?.[name]?.variant
|
||
return {
|
||
name,
|
||
requirement,
|
||
userOverride,
|
||
userVariant,
|
||
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
|
||
const userVariant = config.categories?.[name]?.variant
|
||
return {
|
||
name,
|
||
requirement,
|
||
userOverride,
|
||
userVariant,
|
||
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,
|
||
}
|
||
}
|