From 76239972c210c63b7f62023dbdf91f40d21f8ed5 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 7 Apr 2026 20:24:03 +0900 Subject: [PATCH] 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 --- .../checks/model-resolution-cache.test.ts | 150 ++++++++++++++++++ .../doctor/checks/model-resolution-cache.ts | 50 +++++- 2 files changed, 197 insertions(+), 3 deletions(-) create mode 100644 src/cli/doctor/checks/model-resolution-cache.test.ts diff --git a/src/cli/doctor/checks/model-resolution-cache.test.ts b/src/cli/doctor/checks/model-resolution-cache.test.ts new file mode 100644 index 000000000..df6d68f16 --- /dev/null +++ b/src/cli/doctor/checks/model-resolution-cache.test.ts @@ -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 + }) +}) diff --git a/src/cli/doctor/checks/model-resolution-cache.ts b/src/cli/doctor/checks/model-resolution-cache.ts index 7c1b75233..d1e82cc3e 100644 --- a/src/cli/doctor/checks/model-resolution-cache.ts +++ b/src/cli/doctor/checks/model-resolution-cache.ts @@ -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 }>(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 }>>(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 } }