Merge pull request #3200 from code-yeongyu/fix/doctor-custom-providers

fix(doctor): false-positive 'Provider not found' for custom OpenAI-compatible providers
This commit is contained in:
YeonGyu-Kim
2026-04-09 11:13:40 +09:00
committed by GitHub
2 changed files with 197 additions and 3 deletions
@@ -0,0 +1,150 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { mkdirSync, writeFileSync, rmSync } from "node:fs"
import { join } from "node:path"
import { loadAvailableModelsFromCache } from "./model-resolution-cache"
describe("loadAvailableModelsFromCache", () => {
const originalXDGCache = process.env.XDG_CACHE_HOME
const originalXDGConfig = process.env.XDG_CONFIG_HOME
let tempDir: string
beforeEach(() => {
tempDir = join("/tmp", `doctor-cache-test-${Date.now()}`)
mkdirSync(join(tempDir, "cache", "opencode"), { recursive: true })
mkdirSync(join(tempDir, "config", "opencode"), { recursive: true })
process.env.XDG_CACHE_HOME = join(tempDir, "cache")
process.env.XDG_CONFIG_HOME = join(tempDir, "config")
})
afterEach(() => {
process.env.XDG_CACHE_HOME = originalXDGCache
process.env.XDG_CONFIG_HOME = originalXDGConfig
rmSync(tempDir, { recursive: true, force: true })
})
test("returns cacheExists: false when no models.json and no custom providers", () => {
const result = loadAvailableModelsFromCache()
expect(result.cacheExists).toBe(false)
expect(result.providers).toEqual([])
expect(result.modelCount).toBe(0)
})
test("reads providers from models.json cache", () => {
writeFileSync(
join(tempDir, "cache", "opencode", "models.json"),
JSON.stringify({
openai: { models: { "gpt-5.4": {} } },
anthropic: { models: { "claude-opus-4-6": {}, "claude-sonnet-4-6": {} } },
})
)
const result = loadAvailableModelsFromCache()
expect(result.cacheExists).toBe(true)
expect(result.providers).toContain("openai")
expect(result.providers).toContain("anthropic")
expect(result.modelCount).toBe(3)
})
test("includes custom providers from opencode.json even if not in cache", () => {
writeFileSync(
join(tempDir, "cache", "opencode", "models.json"),
JSON.stringify({
openai: { models: { "gpt-5.4": {} } },
})
)
writeFileSync(
join(tempDir, "config", "opencode", "opencode.json"),
JSON.stringify({
provider: {
"openai-custom": {
npm: "@ai-sdk/openai-compatible",
models: { "gpt-5.4": {} },
},
"my-local-llm": {
npm: "@ai-sdk/openai-compatible",
models: { "local-model": {} },
},
},
})
)
const result = loadAvailableModelsFromCache()
expect(result.cacheExists).toBe(true)
expect(result.providers).toContain("openai")
expect(result.providers).toContain("openai-custom")
expect(result.providers).toContain("my-local-llm")
})
test("deduplicates providers that appear in both cache and opencode.json", () => {
writeFileSync(
join(tempDir, "cache", "opencode", "models.json"),
JSON.stringify({
openai: { models: { "gpt-5.4": {} } },
})
)
writeFileSync(
join(tempDir, "config", "opencode", "opencode.json"),
JSON.stringify({
provider: {
openai: { models: { "custom-model": {} } },
},
})
)
const result = loadAvailableModelsFromCache()
const openaiCount = result.providers.filter((p) => p === "openai").length
expect(openaiCount).toBe(1)
})
test("returns custom providers even without models.json cache", () => {
// No models.json exists
writeFileSync(
join(tempDir, "config", "opencode", "opencode.json"),
JSON.stringify({
provider: {
"openai-custom": {
npm: "@ai-sdk/openai-compatible",
models: { "gpt-5.4": {} },
},
},
})
)
const result = loadAvailableModelsFromCache()
expect(result.cacheExists).toBe(true) // custom providers make it effectively "exists"
expect(result.providers).toContain("openai-custom")
})
test("reads from opencode.jsonc (JSONC variant)", () => {
writeFileSync(
join(tempDir, "config", "opencode", "opencode.jsonc"),
`{
// This is a comment
"provider": {
"my-provider": {
"models": { "test-model": {} }
}
}
}`
)
const result = loadAvailableModelsFromCache()
expect(result.providers).toContain("my-provider")
})
test("ignores malformed opencode.json gracefully", () => {
writeFileSync(
join(tempDir, "cache", "opencode", "models.json"),
JSON.stringify({ openai: { models: { "gpt-5.4": {} } } })
)
writeFileSync(
join(tempDir, "config", "opencode", "opencode.json"),
"this is not valid json {{{",
)
const result = loadAvailableModelsFromCache()
expect(result.cacheExists).toBe(true)
expect(result.providers).toContain("openai")
// Should not crash, just skip the config
})
})
@@ -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 }
}