From 19a4324b3e70fc36f51644714dd3c68b421fce92 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 10 Feb 2026 13:25:49 +0900 Subject: [PATCH] fix(provider-cache): extract models from provider.list().all response OpenCode SDK does not expose client.model.list API. This caused the provider-models cache to always be empty (models: {}), which in turn caused delegate-task categories with requiresModel (e.g., 'deep', 'artistry') to fail with misleading 'Unknown category' errors. Changes: - connected-providers-cache.ts: Extract models from provider.list() response's .all array instead of calling non-existent client.model.list - category-resolver.ts: Distinguish between 'unknown category' and 'model not available' errors with clearer error messages - Add comprehensive tests for both fixes Bug chain: client.model?.list is undefined -> empty cache -> isModelAvailable returns false for requiresModel categories -> null returned from resolveCategoryConfig -> 'Unknown category' error (wrong message) --- src/shared/connected-providers-cache.test.ts | 133 ++++++++++++++++++ src/shared/connected-providers-cache.ts | 42 +++--- .../delegate-task/category-resolver.test.ts | 62 ++++++++ src/tools/delegate-task/category-resolver.ts | 28 +++- 4 files changed, 239 insertions(+), 26 deletions(-) create mode 100644 src/shared/connected-providers-cache.test.ts create mode 100644 src/tools/delegate-task/category-resolver.test.ts diff --git a/src/shared/connected-providers-cache.test.ts b/src/shared/connected-providers-cache.test.ts new file mode 100644 index 000000000..2200a9b19 --- /dev/null +++ b/src/shared/connected-providers-cache.test.ts @@ -0,0 +1,133 @@ +import { describe, test, expect, beforeEach, afterEach, spyOn } from "bun:test" +import { existsSync, mkdirSync, rmSync } from "fs" +import { join } from "path" +import * as dataPath from "./data-path" +import { updateConnectedProvidersCache, readProviderModelsCache } from "./connected-providers-cache" + +const TEST_CACHE_DIR = join(import.meta.dir, "__test-cache__") + +describe("updateConnectedProvidersCache", () => { + let cacheDirSpy: ReturnType + + beforeEach(() => { + cacheDirSpy = spyOn(dataPath, "getOmoOpenCodeCacheDir").mockReturnValue(TEST_CACHE_DIR) + if (existsSync(TEST_CACHE_DIR)) { + rmSync(TEST_CACHE_DIR, { recursive: true }) + } + mkdirSync(TEST_CACHE_DIR, { recursive: true }) + }) + + afterEach(() => { + cacheDirSpy.mockRestore() + if (existsSync(TEST_CACHE_DIR)) { + rmSync(TEST_CACHE_DIR, { recursive: true }) + } + }) + + test("extracts models from provider.list().all response", async () => { + //#given + const mockClient = { + provider: { + list: async () => ({ + data: { + connected: ["openai", "anthropic"], + all: [ + { + id: "openai", + name: "OpenAI", + env: [], + models: { + "gpt-5.3-codex": { id: "gpt-5.3-codex", name: "GPT-5.3 Codex" }, + "gpt-5.2": { id: "gpt-5.2", name: "GPT-5.2" }, + }, + }, + { + id: "anthropic", + name: "Anthropic", + env: [], + models: { + "claude-opus-4-6": { id: "claude-opus-4-6", name: "Claude Opus 4.6" }, + "claude-sonnet-4-5": { id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }, + }, + }, + ], + }, + }), + }, + } + + //#when + await updateConnectedProvidersCache(mockClient) + + //#then + const cache = readProviderModelsCache() + expect(cache).not.toBeNull() + expect(cache!.connected).toEqual(["openai", "anthropic"]) + expect(cache!.models).toEqual({ + openai: ["gpt-5.3-codex", "gpt-5.2"], + anthropic: ["claude-opus-4-6", "claude-sonnet-4-5"], + }) + }) + + test("writes empty models when provider has no models", async () => { + //#given + const mockClient = { + provider: { + list: async () => ({ + data: { + connected: ["empty-provider"], + all: [ + { + id: "empty-provider", + name: "Empty", + env: [], + models: {}, + }, + ], + }, + }), + }, + } + + //#when + await updateConnectedProvidersCache(mockClient) + + //#then + const cache = readProviderModelsCache() + expect(cache).not.toBeNull() + expect(cache!.models).toEqual({}) + }) + + test("writes empty models when all field is missing", async () => { + //#given + const mockClient = { + provider: { + list: async () => ({ + data: { + connected: ["openai"], + }, + }), + }, + } + + //#when + await updateConnectedProvidersCache(mockClient) + + //#then + const cache = readProviderModelsCache() + expect(cache).not.toBeNull() + expect(cache!.models).toEqual({}) + }) + + test("does nothing when client.provider.list is not available", async () => { + //#given + const mockClient = {} + + //#when + await updateConnectedProvidersCache(mockClient) + + //#then + const cache = readProviderModelsCache() + expect(cache).toBeNull() + }) +}) diff --git a/src/shared/connected-providers-cache.ts b/src/shared/connected-providers-cache.ts index 9c1ff5e18..e9a1ec368 100644 --- a/src/shared/connected-providers-cache.ts +++ b/src/shared/connected-providers-cache.ts @@ -149,10 +149,12 @@ export function writeProviderModelsCache(data: { models: Record Promise<{ data?: { connected?: string[] } }> - } - model?: { - list?: () => Promise<{ data?: Array<{ id: string; provider: string }> }> + list?: () => Promise<{ + data?: { + connected?: string[] + all?: Array<{ id: string; models?: Record }> + } + }> } }): Promise { if (!client?.provider?.list) { @@ -167,31 +169,23 @@ export async function updateConnectedProvidersCache(client: { writeConnectedProvidersCache(connected) - // Always update provider-models cache (overwrite with fresh data) - let modelsByProvider: Record = {} - if (client.model?.list) { - try { - const modelsResult = await client.model.list() - const models = modelsResult.data ?? [] + const modelsByProvider: Record = {} + const allProviders = result.data?.all ?? [] - for (const model of models) { - if (!modelsByProvider[model.provider]) { - modelsByProvider[model.provider] = [] - } - modelsByProvider[model.provider].push(model.id) + for (const provider of allProviders) { + if (provider.models) { + const modelIds = Object.keys(provider.models) + if (modelIds.length > 0) { + modelsByProvider[provider.id] = modelIds } - - log("[connected-providers-cache] Fetched models from API", { - providerCount: Object.keys(modelsByProvider).length, - totalModels: models.length, - }) - } catch (modelErr) { - log("[connected-providers-cache] Error fetching models, writing empty cache", { error: String(modelErr) }) } - } else { - log("[connected-providers-cache] client.model.list not available, writing empty cache") } + log("[connected-providers-cache] Extracted models from provider list", { + providerCount: Object.keys(modelsByProvider).length, + totalModels: Object.values(modelsByProvider).reduce((sum, ids) => sum + ids.length, 0), + }) + writeProviderModelsCache({ models: modelsByProvider, connected, diff --git a/src/tools/delegate-task/category-resolver.test.ts b/src/tools/delegate-task/category-resolver.test.ts new file mode 100644 index 000000000..febfe3f16 --- /dev/null +++ b/src/tools/delegate-task/category-resolver.test.ts @@ -0,0 +1,62 @@ +import { describe, test, expect } from "bun:test" +import { resolveCategoryExecution } from "./category-resolver" +import type { ExecutorContext } from "./executor-types" + +describe("resolveCategoryExecution", () => { + const createMockExecutorContext = (): ExecutorContext => ({ + client: {} as any, + manager: {} as any, + directory: "/tmp/test", + userCategories: {}, + sisyphusJuniorModel: undefined, + }) + + test("returns clear error when category exists but required model is not available", async () => { + //#given + const args = { + category: "deep", + prompt: "test prompt", + description: "Test task", + run_in_background: false, + load_skills: [], + blockedBy: undefined, + enableSkillTools: false, + } + const executorCtx = createMockExecutorContext() + const inheritedModel = undefined + const systemDefaultModel = "anthropic/claude-sonnet-4-5" + + //#when + const result = await resolveCategoryExecution(args, executorCtx, inheritedModel, systemDefaultModel) + + //#then + expect(result.error).toBeDefined() + expect(result.error).toContain("deep") + expect(result.error).toMatch(/model.*not.*available|requires.*model/i) + expect(result.error).not.toContain("Unknown category") + }) + + test("returns 'unknown category' error for truly unknown categories", async () => { + //#given + const args = { + category: "definitely-not-a-real-category-xyz123", + prompt: "test prompt", + description: "Test task", + run_in_background: false, + load_skills: [], + blockedBy: undefined, + enableSkillTools: false, + } + const executorCtx = createMockExecutorContext() + const inheritedModel = undefined + const systemDefaultModel = "anthropic/claude-sonnet-4-5" + + //#when + const result = await resolveCategoryExecution(args, executorCtx, inheritedModel, systemDefaultModel) + + //#then + expect(result.error).toBeDefined() + expect(result.error).toContain("Unknown category") + expect(result.error).toContain("definitely-not-a-real-category-xyz123") + }) +}) diff --git a/src/tools/delegate-task/category-resolver.ts b/src/tools/delegate-task/category-resolver.ts index 84df498aa..57d427e4f 100644 --- a/src/tools/delegate-task/category-resolver.ts +++ b/src/tools/delegate-task/category-resolver.ts @@ -29,7 +29,10 @@ export async function resolveCategoryExecution( const availableModels = await getAvailableModelsForDelegateTask(client) - const resolved = resolveCategoryConfig(args.category!, { + const categoryName = args.category! + const categoryExists = DEFAULT_CATEGORIES[categoryName] !== undefined || userCategories?.[categoryName] !== undefined + + const resolved = resolveCategoryConfig(categoryName, { userCategories, inheritedModel, systemDefaultModel, @@ -37,6 +40,27 @@ export async function resolveCategoryExecution( }) if (!resolved) { + const requirement = CATEGORY_MODEL_REQUIREMENTS[categoryName] + const allCategoryNames = Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories }).join(", ") + + if (categoryExists && requirement?.requiresModel) { + return { + agentToUse: "", + categoryModel: undefined, + categoryPromptAppend: undefined, + modelInfo: undefined, + actualModel: undefined, + isUnstableAgent: false, + error: `Category "${categoryName}" requires model "${requirement.requiresModel}" which is not available. + +To use this category: +1. Connect a provider with this model: ${requirement.requiresModel} +2. Or configure an alternative model in your oh-my-opencode.json for this category + +Available categories: ${allCategoryNames}`, + } + } + return { agentToUse: "", categoryModel: undefined, @@ -44,7 +68,7 @@ export async function resolveCategoryExecution( modelInfo: undefined, actualModel: undefined, isUnstableAgent: false, - error: `Unknown category: "${args.category}". Available: ${Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories }).join(", ")}`, + error: `Unknown category: "${categoryName}". Available: ${allCategoryNames}`, } }