diff --git a/src/agents/model-catalog.test.ts b/src/agents/model-catalog.test.ts index 791947ad8fa..5d69ae1c4ea 100644 --- a/src/agents/model-catalog.test.ts +++ b/src/agents/model-catalog.test.ts @@ -103,4 +103,124 @@ describe("loadModelCatalog", () => { expect(spark?.name).toBe("gpt-5.3-codex-spark"); expect(spark?.reasoning).toBe(true); }); + + it("merges configured models for opted-in non-pi-native providers", async () => { + __setModelCatalogImportForTest( + async () => + ({ + AuthStorage: class {}, + ModelRegistry: class { + getAll() { + return [{ id: "gpt-4.1", provider: "openai", name: "GPT-4.1" }]; + } + }, + }) as unknown as PiSdkModule, + ); + + const result = await loadModelCatalog({ + config: { + models: { + providers: { + kilocode: { + models: [ + { + id: "google/gemini-3-pro-preview", + name: "Gemini 3 Pro Preview", + input: ["text", "image"], + reasoning: true, + contextWindow: 1048576, + }, + ], + }, + }, + }, + } as OpenClawConfig, + }); + + expect(result).toContainEqual( + expect.objectContaining({ + provider: "kilocode", + id: "google/gemini-3-pro-preview", + name: "Gemini 3 Pro Preview", + }), + ); + }); + + it("does not merge configured models for providers that are not opted in", async () => { + __setModelCatalogImportForTest( + async () => + ({ + AuthStorage: class {}, + ModelRegistry: class { + getAll() { + return [{ id: "gpt-4.1", provider: "openai", name: "GPT-4.1" }]; + } + }, + }) as unknown as PiSdkModule, + ); + + const result = await loadModelCatalog({ + config: { + models: { + providers: { + qianfan: { + models: [ + { + id: "deepseek-v3.2", + name: "DEEPSEEK V3.2", + }, + ], + }, + }, + }, + } as OpenClawConfig, + }); + + expect( + result.some((entry) => entry.provider === "qianfan" && entry.id === "deepseek-v3.2"), + ).toBe(false); + }); + + it("does not duplicate opted-in configured models already present in ModelRegistry", async () => { + __setModelCatalogImportForTest( + async () => + ({ + AuthStorage: class {}, + ModelRegistry: class { + getAll() { + return [ + { + id: "anthropic/claude-opus-4.6", + provider: "kilocode", + name: "Claude Opus 4.6", + }, + ]; + } + }, + }) as unknown as PiSdkModule, + ); + + const result = await loadModelCatalog({ + config: { + models: { + providers: { + kilocode: { + models: [ + { + id: "anthropic/claude-opus-4.6", + name: "Configured Claude Opus 4.6", + }, + ], + }, + }, + }, + } as OpenClawConfig, + }); + + const matches = result.filter( + (entry) => entry.provider === "kilocode" && entry.id === "anthropic/claude-opus-4.6", + ); + expect(matches).toHaveLength(1); + expect(matches[0]?.name).toBe("Claude Opus 4.6"); + }); }); diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts index beda4dc5848..82ca5686493 100644 --- a/src/agents/model-catalog.ts +++ b/src/agents/model-catalog.ts @@ -33,6 +33,7 @@ let importPiSdk = defaultImportPiSdk; const CODEX_PROVIDER = "openai-codex"; const OPENAI_CODEX_GPT53_MODEL_ID = "gpt-5.3-codex"; const OPENAI_CODEX_GPT53_SPARK_MODEL_ID = "gpt-5.3-codex-spark"; +const NON_PI_NATIVE_MODEL_PROVIDERS = new Set(["kilocode"]); function applyOpenAICodexSparkFallback(models: ModelCatalogEntry[]): void { const hasSpark = models.some( @@ -59,6 +60,89 @@ function applyOpenAICodexSparkFallback(models: ModelCatalogEntry[]): void { }); } +function normalizeConfiguredModelInput(input: unknown): Array<"text" | "image"> | undefined { + if (!Array.isArray(input)) { + return undefined; + } + const normalized = input.filter( + (item): item is "text" | "image" => item === "text" || item === "image", + ); + return normalized.length > 0 ? normalized : undefined; +} + +function readConfiguredOptInProviderModels(config: OpenClawConfig): ModelCatalogEntry[] { + const providers = config.models?.providers; + if (!providers || typeof providers !== "object") { + return []; + } + + const out: ModelCatalogEntry[] = []; + for (const [providerRaw, providerValue] of Object.entries(providers)) { + const provider = providerRaw.toLowerCase().trim(); + if (!NON_PI_NATIVE_MODEL_PROVIDERS.has(provider)) { + continue; + } + if (!providerValue || typeof providerValue !== "object") { + continue; + } + + const configuredModels = (providerValue as { models?: unknown }).models; + if (!Array.isArray(configuredModels)) { + continue; + } + + for (const configuredModel of configuredModels) { + if (!configuredModel || typeof configuredModel !== "object") { + continue; + } + const idRaw = (configuredModel as { id?: unknown }).id; + if (typeof idRaw !== "string") { + continue; + } + const id = idRaw.trim(); + if (!id) { + continue; + } + const rawName = (configuredModel as { name?: unknown }).name; + const name = (typeof rawName === "string" ? rawName : id).trim() || id; + const contextWindowRaw = (configuredModel as { contextWindow?: unknown }).contextWindow; + const contextWindow = + typeof contextWindowRaw === "number" && contextWindowRaw > 0 ? contextWindowRaw : undefined; + const reasoningRaw = (configuredModel as { reasoning?: unknown }).reasoning; + const reasoning = typeof reasoningRaw === "boolean" ? reasoningRaw : undefined; + const input = normalizeConfiguredModelInput((configuredModel as { input?: unknown }).input); + out.push({ id, name, provider, contextWindow, reasoning, input }); + } + } + + return out; +} + +function mergeConfiguredOptInProviderModels(params: { + config: OpenClawConfig; + models: ModelCatalogEntry[]; +}): void { + const configured = readConfiguredOptInProviderModels(params.config); + if (configured.length === 0) { + return; + } + + const seen = new Set( + params.models.map( + (entry) => `${entry.provider.toLowerCase().trim()}::${entry.id.toLowerCase().trim()}`, + ), + ); + + for (const entry of configured) { + const key = `${entry.provider.toLowerCase().trim()}::${entry.id.toLowerCase().trim()}`; + if (seen.has(key)) { + continue; + } + params.models.push(entry); + seen.add(key); + } +} + export function resetModelCatalogCacheForTest() { modelCatalogPromise = null; hasLoggedModelCatalogError = false; @@ -142,6 +226,7 @@ export async function loadModelCatalog(params?: { const input = Array.isArray(entry?.input) ? entry.input : undefined; models.push({ id, name, provider, contextWindow, reasoning, input }); } + mergeConfiguredOptInProviderModels({ config: cfg, models }); applyOpenAICodexSparkFallback(models); if (models.length === 0) {