fix(doctor): include custom providers from opencode.json in provider check

The doctor's 'Model override uses unavailable provider' check only
looked at providers from ~/.cache/opencode/models.json (built-in
providers from models.dev). Custom OpenAI-compatible providers defined
in the user's opencode.json (under the 'provider' key) were not
included, causing false-positive warnings.

Now loadAvailableModelsFromCache() also reads provider names from
~/.config/opencode/opencode.json and ~/.config/opencode/opencode.jsonc,
merging them with the cache providers. This eliminates the false
positive while preserving real warnings for truly unknown providers.

7 new tests cover: cache-only, custom-only, merged, deduplicated,
JSONC variant, and malformed config resilience.

Fixes #3199
This commit is contained in:
YeonGyu-Kim
2026-04-07 20:24:03 +09:00
parent 83a80e21fb
commit 76239972c2
2 changed files with 197 additions and 3 deletions
@@ -10,10 +10,51 @@ function getOpenCodeCacheDir(): string {
return join(homedir(), ".cache", "opencode")
}
function getOpenCodeConfigDir(): string {
const xdgConfig = process.env.XDG_CONFIG_HOME
if (xdgConfig) return join(xdgConfig, "opencode")
return join(homedir(), ".config", "opencode")
}
/**
* Read custom provider names from opencode.json configs.
* Custom providers defined in the user's opencode.json (under the "provider" key)
* are valid at runtime but don't appear in the model cache (models.json), which
* only contains built-in providers from models.dev. This causes false-positive
* warnings in doctor.
*/
function loadCustomProviderNames(): string[] {
const configDir = getOpenCodeConfigDir()
const candidatePaths = [
join(configDir, "opencode.json"),
join(configDir, "opencode.jsonc"),
]
for (const configPath of candidatePaths) {
if (!existsSync(configPath)) continue
try {
const content = readFileSync(configPath, "utf-8")
const data = parseJsonc<{ provider?: Record<string, unknown> }>(content)
if (data?.provider && typeof data.provider === "object") {
return Object.keys(data.provider)
}
} catch {
// ignore parse errors
}
}
return []
}
export function loadAvailableModelsFromCache(): AvailableModelsInfo {
const cacheFile = join(getOpenCodeCacheDir(), "models.json")
const customProviders = loadCustomProviderNames()
if (!existsSync(cacheFile)) {
// Even without the cache, custom providers are valid
if (customProviders.length > 0) {
return { providers: customProviders, modelCount: 0, cacheExists: true }
}
return { providers: [], modelCount: 0, cacheExists: false }
}
@@ -21,16 +62,19 @@ export function loadAvailableModelsFromCache(): AvailableModelsInfo {
const content = readFileSync(cacheFile, "utf-8")
const data = parseJsonc<Record<string, { models?: Record<string, unknown> }>>(content)
const providers = Object.keys(data)
const cacheProviders = Object.keys(data)
let modelCount = 0
for (const providerId of providers) {
for (const providerId of cacheProviders) {
const models = data[providerId]?.models
if (models && typeof models === "object") {
modelCount += Object.keys(models).length
}
}
return { providers, modelCount, cacheExists: true }
// Merge cache providers with custom providers from opencode.json
const allProviders = [...new Set([...cacheProviders, ...customProviders])]
return { providers: allProviders, modelCount, cacheExists: true }
} catch {
return { providers: [], modelCount: 0, cacheExists: false }
}