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:
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user