diff --git a/extensions/arcee/index.test.ts b/extensions/arcee/index.test.ts index 4f87dcaedef..587ef479e02 100644 --- a/extensions/arcee/index.test.ts +++ b/extensions/arcee/index.test.ts @@ -29,6 +29,44 @@ describe("arcee provider plugin", () => { expect(orChoice?.method.id).toBe("openrouter"); }); + it("stores the OpenRouter onboarding path under the OpenRouter auth profile", async () => { + const provider = await registerSingleProviderPlugin(arceePlugin); + const openRouterMethod = provider.auth?.find((method) => method.id === "openrouter"); + if (!openRouterMethod?.runNonInteractive) { + throw new Error("expected OpenRouter non-interactive auth"); + } + + const config = await openRouterMethod.runNonInteractive({ + config: {}, + opts: {}, + env: {}, + runtime: { + error: () => {}, + exit: () => {}, + log: () => {}, + }, + resolveApiKey: async () => ({ + key: "sk-or-test", + source: "profile", + }), + toApiKeyCredential: () => null, + } as never); + + expect(config?.auth?.profiles?.["openrouter:default"]).toMatchObject({ + provider: "openrouter", + mode: "api_key", + }); + expect(config?.models?.providers?.arcee).toMatchObject({ + baseUrl: "https://openrouter.ai/api/v1", + api: "openai-completions", + }); + expect(config?.models?.providers?.arcee?.models?.map((model) => model.id)).toEqual([ + "arcee/trinity-mini", + "arcee/trinity-large-preview", + "arcee/trinity-large-thinking", + ]); + }); + it("builds the direct Arcee AI model catalog", async () => { const provider = await registerSingleProviderPlugin(arceePlugin); expect(provider.catalog).toBeDefined(); @@ -81,9 +119,41 @@ describe("arcee provider plugin", () => { expect(catalog.provider.baseUrl).toBe("https://openrouter.ai/api/v1"); expect(catalog.provider.models?.map((model) => model.id)).toEqual([ - "trinity-mini", - "trinity-large-preview", - "trinity-large-thinking", + "arcee/trinity-mini", + "arcee/trinity-large-preview", + "arcee/trinity-large-thinking", ]); }); + + it("normalizes Arcee OpenRouter models to vendor-prefixed runtime ids", async () => { + const provider = await registerSingleProviderPlugin(arceePlugin); + + expect( + provider.normalizeResolvedModel?.({ + modelId: "arcee/trinity-large-thinking", + model: { + provider: "arcee", + id: "trinity-large-thinking", + name: "Trinity Large Thinking", + api: "openai-completions", + baseUrl: "https://openrouter.ai/api/v1", + }, + } as never), + ).toMatchObject({ + id: "arcee/trinity-large-thinking", + }); + + expect( + provider.normalizeResolvedModel?.({ + modelId: "arcee/trinity-large-thinking", + model: { + provider: "arcee", + id: "trinity-large-thinking", + name: "Trinity Large Thinking", + api: "openai-completions", + baseUrl: "https://api.arcee.ai/api/v1", + }, + } as never), + ).toBeUndefined(); + }); }); diff --git a/extensions/arcee/index.ts b/extensions/arcee/index.ts index 8d52846f4c4..72880af63ba 100644 --- a/extensions/arcee/index.ts +++ b/extensions/arcee/index.ts @@ -1,5 +1,6 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; +import { readConfiguredProviderCatalogEntries } from "openclaw/plugin-sdk/provider-catalog-shared"; import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared"; import { applyArceeConfig, @@ -7,7 +8,12 @@ import { ARCEE_DEFAULT_MODEL_REF, ARCEE_OPENROUTER_DEFAULT_MODEL_REF, } from "./onboard.js"; -import { buildArceeProvider, buildArceeOpenRouterProvider } from "./provider-catalog.js"; +import { + buildArceeProvider, + buildArceeOpenRouterProvider, + isArceeOpenRouterBaseUrl, + toArceeOpenRouterModelId, +} from "./provider-catalog.js"; const PROVIDER_ID = "arcee"; const OPENAI_COMPATIBLE_REPLAY_HOOKS = buildProviderReplayFamilyHooks({ @@ -55,6 +61,7 @@ export default definePluginEntry({ flagName: "--openrouter-api-key", envVar: "OPENROUTER_API_KEY", promptMessage: "Enter OpenRouter API key", + profileId: "openrouter:default", defaultModel: ARCEE_OPENROUTER_DEFAULT_MODEL_REF, expectedProviders: [PROVIDER_ID, "openrouter"], applyConfig: (cfg) => applyArceeOpenRouterConfig(cfg), @@ -81,6 +88,18 @@ export default definePluginEntry({ return null; }, }, + augmentModelCatalog: ({ config }) => + readConfiguredProviderCatalogEntries({ + config, + providerId: PROVIDER_ID, + }), + normalizeResolvedModel: ({ model }) => + isArceeOpenRouterBaseUrl(model.baseUrl) + ? { + ...model, + id: toArceeOpenRouterModelId(model.id), + } + : undefined, ...OPENAI_COMPATIBLE_REPLAY_HOOKS, }); }, diff --git a/extensions/arcee/onboard.ts b/extensions/arcee/onboard.ts index 5cb5fe97ea6..3afd79e2d5b 100644 --- a/extensions/arcee/onboard.ts +++ b/extensions/arcee/onboard.ts @@ -3,8 +3,7 @@ import { type OpenClawConfig, } from "openclaw/plugin-sdk/provider-onboard"; import { buildArceeModelDefinition, ARCEE_BASE_URL, ARCEE_MODEL_CATALOG } from "./api.js"; - -const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; +import { OPENROUTER_BASE_URL, toArceeOpenRouterModelId } from "./provider-catalog.js"; export const ARCEE_DEFAULT_MODEL_REF = "arcee/trinity-large-thinking"; export const ARCEE_OPENROUTER_DEFAULT_MODEL_REF = "arcee/trinity-large-thinking"; @@ -26,7 +25,10 @@ const arceeOpenRouterPresetAppliers = createModelCatalogPresetAppliers({ providerId: "arcee", api: "openai-completions", baseUrl: OPENROUTER_BASE_URL, - catalogModels: ARCEE_MODEL_CATALOG.map(buildArceeModelDefinition), + catalogModels: ARCEE_MODEL_CATALOG.map((model) => ({ + ...buildArceeModelDefinition(model), + id: toArceeOpenRouterModelId(model.id), + })), aliases: [{ modelRef: ARCEE_OPENROUTER_DEFAULT_MODEL_REF, alias: "Arcee AI (OpenRouter)" }], }), }); diff --git a/extensions/arcee/provider-catalog.ts b/extensions/arcee/provider-catalog.ts index 64b77b1b076..b46e8e910fd 100644 --- a/extensions/arcee/provider-catalog.ts +++ b/extensions/arcee/provider-catalog.ts @@ -1,7 +1,25 @@ import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; import { buildArceeModelDefinition, ARCEE_BASE_URL, ARCEE_MODEL_CATALOG } from "./api.js"; -const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; +export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; + +function normalizeBaseUrl(baseUrl: string | undefined): string { + return String(baseUrl ?? "") + .trim() + .replace(/\/+$/, ""); +} + +export function isArceeOpenRouterBaseUrl(baseUrl: string | undefined): boolean { + return normalizeBaseUrl(baseUrl) === OPENROUTER_BASE_URL; +} + +export function toArceeOpenRouterModelId(modelId: string): string { + const normalized = modelId.trim(); + if (!normalized || normalized.startsWith("arcee/")) { + return normalized; + } + return `arcee/${normalized}`; +} export function buildArceeProvider(): ModelProviderConfig { return { @@ -15,6 +33,9 @@ export function buildArceeOpenRouterProvider(): ModelProviderConfig { return { baseUrl: OPENROUTER_BASE_URL, api: "openai-completions", - models: ARCEE_MODEL_CATALOG.map(buildArceeModelDefinition), + models: ARCEE_MODEL_CATALOG.map((model) => ({ + ...buildArceeModelDefinition(model), + id: toArceeOpenRouterModelId(model.id), + })), }; } diff --git a/extensions/deepseek/index.test.ts b/extensions/deepseek/index.test.ts index 961e1ecf8ad..b9103b9e05a 100644 --- a/extensions/deepseek/index.test.ts +++ b/extensions/deepseek/index.test.ts @@ -50,4 +50,39 @@ describe("deepseek provider plugin", () => { catalog.provider.models?.find((model) => model.id === "deepseek-reasoner")?.reasoning, ).toBe(true); }); + + it("publishes configured DeepSeek models through plugin-owned catalog augmentation", async () => { + const provider = await registerSingleProviderPlugin(deepseekPlugin); + + expect( + provider.augmentModelCatalog?.({ + config: { + models: { + providers: { + deepseek: { + models: [ + { + id: "deepseek-chat", + name: "DeepSeek Chat", + input: ["text"], + reasoning: false, + contextWindow: 65536, + }, + ], + }, + }, + }, + }, + } as never), + ).toEqual([ + { + provider: "deepseek", + id: "deepseek-chat", + name: "DeepSeek Chat", + input: ["text"], + reasoning: false, + contextWindow: 65536, + }, + ]); + }); }); diff --git a/extensions/deepseek/index.ts b/extensions/deepseek/index.ts index f071942df99..08a9dd41010 100644 --- a/extensions/deepseek/index.ts +++ b/extensions/deepseek/index.ts @@ -1,3 +1,4 @@ +import { readConfiguredProviderCatalogEntries } from "openclaw/plugin-sdk/provider-catalog-shared"; import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry"; import { applyDeepSeekConfig, DEEPSEEK_DEFAULT_MODEL_REF } from "./onboard.js"; import { buildDeepSeekProvider } from "./provider-catalog.js"; @@ -34,6 +35,11 @@ export default defineSingleProviderPluginEntry({ catalog: { buildProvider: buildDeepSeekProvider, }, + augmentModelCatalog: ({ config }) => + readConfiguredProviderCatalogEntries({ + config, + providerId: PROVIDER_ID, + }), matchesContextOverflowError: ({ errorMessage }) => /\bdeepseek\b.*(?:input.*too long|context.*exceed)/i.test(errorMessage), }, diff --git a/extensions/kilocode/index.test.ts b/extensions/kilocode/index.test.ts index e30a8f6a3c8..a7afcf45c24 100644 --- a/extensions/kilocode/index.test.ts +++ b/extensions/kilocode/index.test.ts @@ -78,4 +78,39 @@ describe("kilocode provider plugin", () => { expect(capturedPayload).not.toHaveProperty("reasoning"); }); + + it("publishes configured Kilo models through plugin-owned catalog augmentation", async () => { + const provider = await registerSingleProviderPlugin(plugin); + + expect( + provider.augmentModelCatalog?.({ + config: { + models: { + providers: { + kilocode: { + models: [ + { + id: "google/gemini-3-pro-preview", + name: "Gemini 3 Pro Preview", + input: ["text", "image"], + reasoning: true, + contextWindow: 1048576, + }, + ], + }, + }, + }, + }, + } as never), + ).toEqual([ + { + provider: "kilocode", + id: "google/gemini-3-pro-preview", + name: "Gemini 3 Pro Preview", + input: ["text", "image"], + reasoning: true, + contextWindow: 1048576, + }, + ]); + }); }); diff --git a/extensions/kilocode/index.ts b/extensions/kilocode/index.ts index 9c4019570f3..94c62a53525 100644 --- a/extensions/kilocode/index.ts +++ b/extensions/kilocode/index.ts @@ -1,3 +1,4 @@ +import { readConfiguredProviderCatalogEntries } from "openclaw/plugin-sdk/provider-catalog-shared"; import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry"; import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared"; import { buildProviderStreamFamilyHooks } from "openclaw/plugin-sdk/provider-stream-family"; @@ -33,6 +34,11 @@ export default defineSingleProviderPluginEntry({ catalog: { buildProvider: buildKilocodeProviderWithDiscovery, }, + augmentModelCatalog: ({ config }) => + readConfiguredProviderCatalogEntries({ + config, + providerId: PROVIDER_ID, + }), ...PASSTHROUGH_GEMINI_REPLAY_HOOKS, ...KILOCODE_THINKING_STREAM_HOOKS, isCacheTtlEligible: (ctx) => ctx.modelId.startsWith("anthropic/"), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 05b9f1a7bab..cccb38d4bbe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,6 +288,8 @@ importers: extensions/anthropic-vertex: {} + extensions/arcee: {} + extensions/bluebubbles: devDependencies: openclaw: diff --git a/src/agents/model-catalog.test.ts b/src/agents/model-catalog.test.ts index 4251445c35f..afb810a80e9 100644 --- a/src/agents/model-catalog.test.ts +++ b/src/agents/model-catalog.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import { resetLogger, setLoggerOverride } from "../logging/logger.js"; +import { augmentModelCatalogWithProviderPlugins } from "../plugins/provider-runtime.runtime.js"; vi.mock("./models-config.js", () => ({ ensureOpenClawModelsJson: vi.fn().mockResolvedValue({ agentDir: "/tmp", wrote: false }), })); @@ -21,6 +22,8 @@ import { type PiSdkModule, } from "./model-catalog.test-harness.js"; +const augmentCatalogMock = vi.mocked(augmentModelCatalogWithProviderPlugins); + function mockPiDiscoveryModels(models: unknown[]) { __setModelCatalogImportForTest( async () => @@ -241,32 +244,20 @@ describe("loadModelCatalog", () => { ).toBe(false); }); - it("merges configured models for opted-in non-pi-native providers", async () => { + it("merges provider-owned supplemental catalog entries", async () => { mockSingleOpenAiCatalogModel(); + augmentCatalogMock.mockResolvedValueOnce([ + { + provider: "kilocode", + id: "google/gemini-3-pro-preview", + name: "Gemini 3 Pro Preview", + input: ["text", "image"], + reasoning: true, + contextWindow: 1048576, + }, + ]); - const result = await loadModelCatalog({ - config: { - models: { - providers: { - kilocode: { - baseUrl: "https://api.kilo.ai/api/gateway/", - api: "openai-completions", - models: [ - { - id: "google/gemini-3-pro-preview", - name: "Gemini 3 Pro Preview", - input: ["text", "image"], - reasoning: true, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - ], - }, - }, - }, - } as OpenClawConfig, - }); + const result = await loadModelCatalog({ config: {} as OpenClawConfig }); expect(result).toContainEqual( expect.objectContaining({ @@ -277,71 +268,45 @@ describe("loadModelCatalog", () => { ); }); - it("merges configured models for opted-in ollama provider", async () => { + it("dedupes supplemental models against registry entries", async () => { mockSingleOpenAiCatalogModel(); + augmentCatalogMock.mockResolvedValueOnce([ + { + provider: "ollama", + id: "llama3.2", + name: "Llama 3.2", + reasoning: true, + input: ["text"], + contextWindow: 1048576, + }, + { + provider: "openai", + id: "gpt-4.1", + name: "Duplicate GPT-4.1", + }, + ]); - const result = await loadModelCatalog({ - config: { - models: { - providers: { - ollama: { - baseUrl: "http://127.0.0.1:11434", - api: "ollama", - models: [ - { - id: "llama3.2", - name: "Llama 3.2", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1048576, - maxTokens: 65536, - }, - ], - }, - }, - }, - } as OpenClawConfig, - }); + const result = await loadModelCatalog({ config: {} as OpenClawConfig }); expect(result).toContainEqual( expect.objectContaining({ provider: "ollama", id: "llama3.2", name: "Llama 3.2" }), ); + expect( + result.filter((entry) => entry.provider === "openai" && entry.id === "gpt-4.1"), + ).toHaveLength(1); }); - it("does not merge configured models for providers that are not opted in", async () => { + it("does not add unrelated models when provider plugins return nothing", async () => { mockSingleOpenAiCatalogModel(); - const result = await loadModelCatalog({ - config: { - models: { - providers: { - qianfan: { - baseUrl: "https://qianfan.baidubce.com/v2", - api: "openai-completions", - models: [ - { - id: "deepseek-v3.2", - name: "DEEPSEEK V3.2", - reasoning: true, - input: ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 98304, - maxTokens: 32768, - }, - ], - }, - }, - }, - } as OpenClawConfig, - }); + const result = await loadModelCatalog({ config: {} 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 () => { + it("does not duplicate provider-owned supplemental models already present in ModelRegistry", async () => { mockPiDiscoveryModels([ { id: "kilo/auto", @@ -349,30 +314,18 @@ describe("loadModelCatalog", () => { name: "Kilo Auto", }, ]); + augmentCatalogMock.mockResolvedValueOnce([ + { + provider: "kilocode", + id: "kilo/auto", + name: "Configured Kilo Auto", + reasoning: true, + input: ["text", "image"], + contextWindow: 1000000, + }, + ]); - const result = await loadModelCatalog({ - config: { - models: { - providers: { - kilocode: { - baseUrl: "https://api.kilo.ai/api/gateway/", - api: "openai-completions", - models: [ - { - id: "kilo/auto", - name: "Configured Kilo Auto", - reasoning: true, - input: ["text", "image"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - contextWindow: 1000000, - maxTokens: 128000, - }, - ], - }, - }, - }, - } as OpenClawConfig, - }); + const result = await loadModelCatalog({ config: {} as OpenClawConfig }); const matches = result.filter( (entry) => entry.provider === "kilocode" && entry.id === "kilo/auto", diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts index b446635c4e5..92e8f2e7dbf 100644 --- a/src/agents/model-catalog.ts +++ b/src/agents/model-catalog.ts @@ -44,8 +44,6 @@ const defaultImportPiSdk = () => import("./pi-model-discovery-runtime.js"); let importPiSdk = defaultImportPiSdk; let modelSuppressionPromise: Promise | undefined; -const NON_PI_NATIVE_MODEL_PROVIDERS = new Set(["arcee", "deepseek", "kilocode", "ollama"]); - function shouldLogModelCatalogTiming(): boolean { return process.env.OPENCLAW_DEBUG_INGRESS_TIMING === "1"; } @@ -55,89 +53,6 @@ function loadModelSuppression() { return modelSuppressionPromise; } -function normalizeConfiguredModelInput(input: unknown): ModelInputType[] | undefined { - if (!Array.isArray(input)) { - return undefined; - } - const normalized = input.filter( - (item): item is ModelInputType => item === "text" || item === "image" || item === "document", - ); - 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; @@ -236,8 +151,6 @@ 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 }); - logStage("configured-models-merged", `entries=${models.length}`); const supplemental = await augmentModelCatalogWithProviderPlugins({ config: cfg, env: process.env, diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index 7a7eaae8ab4..ee2f0a9843e 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -9,91 +9,10 @@ export type OnboardMode = "local" | "remote"; */ export type BuiltInAuthChoice = // Legacy alias for `setup-token` (kept for backwards CLI compatibility). - | "oauth" - | "setup-token" - | "token" - | "arceeai-api-key" - | "arceeai-openrouter" - | "chutes" - | "deepseek-api-key" - | "openai-codex" - | "openai-api-key" - | "openrouter-api-key" - | "kilocode-api-key" - | "litellm-api-key" - | "ai-gateway-api-key" - | "cloudflare-ai-gateway-api-key" - | "moonshot-api-key" - | "moonshot-api-key-cn" - | "kimi-code-api-key" - | "synthetic-api-key" - | "venice-api-key" - | "together-api-key" - | "huggingface-api-key" - | "apiKey" - | "gemini-api-key" - | "google-gemini-cli" - | "zai-api-key" - | "zai-coding-global" - | "zai-coding-cn" - | "zai-global" - | "zai-cn" - | "xiaomi-api-key" - | "minimax-global-oauth" - | "minimax-global-api" - | "minimax-cn-oauth" - | "minimax-cn-api" - | "opencode-zen" - | "opencode-go" - | "github-copilot" - | "copilot-proxy" - | "xai-api-key" - | "mistral-api-key" - | "volcengine-api-key" - | "byteplus-api-key" - | "qianfan-api-key" - | "qwen-standard-api-key-cn" - | "qwen-standard-api-key" - | "qwen-api-key-cn" - | "qwen-api-key" - | "modelstudio-standard-api-key-cn" - | "modelstudio-standard-api-key" - | "modelstudio-api-key-cn" - | "modelstudio-api-key" - | "custom-api-key" - | "skip"; + "oauth" | "setup-token" | "token" | "apiKey" | "custom-api-key" | "skip"; export type AuthChoice = BuiltInAuthChoice | (string & {}); -export type BuiltInAuthChoiceGroupId = - | "openai" - | "anthropic" - | "arcee" - | "chutes" - | "deepseek" - | "google" - | "copilot" - | "openrouter" - | "kilocode" - | "litellm" - | "ai-gateway" - | "cloudflare-ai-gateway" - | "moonshot" - | "zai" - | "xiaomi" - | "opencode" - | "minimax" - | "synthetic" - | "venice" - | "mistral" - | "together" - | "huggingface" - | "qianfan" - | "qwen" - | "modelstudio" - | "xai" - | "volcengine" - | "byteplus" - | "custom"; +export type BuiltInAuthChoiceGroupId = "custom"; export type AuthChoiceGroupId = BuiltInAuthChoiceGroupId | (string & {}); export type GatewayAuthChoice = "token" | "password"; export type ResetScope = "config" | "config+creds+sessions" | "full"; diff --git a/src/config/io.shell-env-expected-keys.test.ts b/src/config/io.shell-env-expected-keys.test.ts new file mode 100644 index 00000000000..448f553b8ff --- /dev/null +++ b/src/config/io.shell-env-expected-keys.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it, vi } from "vitest"; + +const listKnownProviderAuthEnvVarNames = vi.hoisted(() => vi.fn(() => ["OPENAI_API_KEY"])); + +vi.mock("../secrets/provider-env-vars.js", () => ({ + listKnownProviderAuthEnvVarNames, +})); + +describe("config io shell env expected keys", () => { + it("includes provider auth env vars from manifest-driven provider metadata", async () => { + listKnownProviderAuthEnvVarNames.mockReturnValueOnce([ + "OPENAI_API_KEY", + "ARCEEAI_API_KEY", + "FIREWORKS_ALT_API_KEY", + ]); + + vi.resetModules(); + const { resolveShellEnvExpectedKeys } = await import("./io.js"); + + expect(resolveShellEnvExpectedKeys({} as NodeJS.ProcessEnv)).toEqual( + expect.arrayContaining([ + "OPENAI_API_KEY", + "ARCEEAI_API_KEY", + "FIREWORKS_ALT_API_KEY", + "OPENCLAW_GATEWAY_TOKEN", + "SLACK_BOT_TOKEN", + ]), + ); + }); +}); diff --git a/src/config/io.ts b/src/config/io.ts index 7f6ab29a24a..6d713c95b97 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -14,6 +14,7 @@ import { shouldEnableShellEnvFallback, } from "../infra/shell-env.js"; import { listPluginDoctorLegacyConfigRules } from "../plugins/doctor-contract-registry.js"; +import { listKnownProviderAuthEnvVarNames } from "../secrets/provider-env-vars.js"; import { sanitizeTerminalText } from "../terminal/safe-text.js"; import { VERSION } from "../version.js"; import { DuplicateAgentDirError, findDuplicateAgentDirs } from "./agent-dirs.js"; @@ -70,22 +71,7 @@ export { export { CircularIncludeError, ConfigIncludeError } from "./includes.js"; export { MissingEnvVarError } from "./env-substitution.js"; -const SHELL_ENV_EXPECTED_KEYS = [ - "OPENAI_API_KEY", - "ANTHROPIC_API_KEY", - "ARCEEAI_API_KEY", - "DEEPSEEK_API_KEY", - "ANTHROPIC_OAUTH_TOKEN", - "GEMINI_API_KEY", - "ZAI_API_KEY", - "OPENROUTER_API_KEY", - "AI_GATEWAY_API_KEY", - "MINIMAX_API_KEY", - "QWEN_API_KEY", - "MODELSTUDIO_API_KEY", - "SYNTHETIC_API_KEY", - "KILOCODE_API_KEY", - "ELEVENLABS_API_KEY", +const CORE_SHELL_ENV_EXPECTED_KEYS = [ "TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN", @@ -94,6 +80,12 @@ const SHELL_ENV_EXPECTED_KEYS = [ "OPENCLAW_GATEWAY_PASSWORD", ]; +export function resolveShellEnvExpectedKeys(env: NodeJS.ProcessEnv): string[] { + return [ + ...new Set([...listKnownProviderAuthEnvVarNames({ env }), ...CORE_SHELL_ENV_EXPECTED_KEYS]), + ]; +} + const OPEN_DM_POLICY_ALLOW_FROM_RE = /^(?[a-z0-9_.-]+)\s*=\s*"open"\s+requires\s+(?[a-z0-9_.-]+)(?:\s+\(or\s+[a-z0-9_.-]+\))?\s+to include "\*"$/i; @@ -1711,7 +1703,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { loadShellEnvFallback({ enabled: true, env: deps.env, - expectedKeys: SHELL_ENV_EXPECTED_KEYS, + expectedKeys: resolveShellEnvExpectedKeys(deps.env), logger: deps.logger, timeoutMs: resolveShellEnvFallbackTimeoutMs(deps.env), }); @@ -1841,7 +1833,7 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { loadShellEnvFallback({ enabled: true, env: deps.env, - expectedKeys: SHELL_ENV_EXPECTED_KEYS, + expectedKeys: resolveShellEnvExpectedKeys(deps.env), logger: deps.logger, timeoutMs: cfg.env?.shellEnv?.timeoutMs ?? resolveShellEnvFallbackTimeoutMs(deps.env), }); diff --git a/src/plugin-sdk/provider-catalog-shared.ts b/src/plugin-sdk/provider-catalog-shared.ts index 3b09d570fb8..ca75298e196 100644 --- a/src/plugin-sdk/provider-catalog-shared.ts +++ b/src/plugin-sdk/provider-catalog-shared.ts @@ -3,6 +3,9 @@ // Keep provider-owned exports out of this subpath so plugin loaders can import it // without recursing through provider-specific facades. +import { findNormalizedProviderKey } from "../agents/provider-id.js"; +import type { OpenClawConfig } from "../config/config.js"; +import type { ModelDefinitionConfig } from "../config/types.models.js"; import { resolveProviderRequestCapabilities } from "./provider-http.js"; import type { ModelProviderConfig } from "./provider-model-shared.js"; @@ -14,6 +17,82 @@ export { findCatalogTemplate, } from "../plugins/provider-catalog.js"; +export type ConfiguredProviderCatalogEntry = { + id: string; + name: string; + provider: string; + contextWindow?: number; + reasoning?: boolean; + input?: Array<"text" | "image" | "document">; +}; + +function normalizeConfiguredCatalogModelInput( + input: unknown, +): ConfiguredProviderCatalogEntry["input"] | undefined { + if (!Array.isArray(input)) { + return undefined; + } + const normalized = input.filter( + (item): item is "text" | "image" | "document" => + item === "text" || item === "image" || item === "document", + ); + return normalized.length > 0 ? normalized : undefined; +} + +function resolveConfiguredProviderModels( + config: OpenClawConfig | undefined, + providerId: string, +): ModelDefinitionConfig[] { + const providers = config?.models?.providers; + if (!providers || typeof providers !== "object") { + return []; + } + const providerKey = findNormalizedProviderKey(providers, providerId); + if (!providerKey) { + return []; + } + const providerConfig = providers[providerKey]; + if (!providerConfig || typeof providerConfig !== "object") { + return []; + } + return Array.isArray(providerConfig.models) ? providerConfig.models : []; +} + +export function readConfiguredProviderCatalogEntries(params: { + config?: OpenClawConfig; + providerId: string; + publishedProviderId?: string; +}): ConfiguredProviderCatalogEntry[] { + const provider = params.publishedProviderId ?? params.providerId; + const models = resolveConfiguredProviderModels(params.config, params.providerId); + const entries: ConfiguredProviderCatalogEntry[] = []; + for (const model of models) { + if (!model || typeof model !== "object") { + continue; + } + const id = typeof model.id === "string" ? model.id.trim() : ""; + if (!id) { + continue; + } + const name = (typeof model.name === "string" ? model.name : id).trim() || id; + const contextWindow = + typeof model.contextWindow === "number" && model.contextWindow > 0 + ? model.contextWindow + : undefined; + const reasoning = typeof model.reasoning === "boolean" ? model.reasoning : undefined; + const input = normalizeConfiguredCatalogModelInput(model.input); + entries.push({ + provider, + id, + name, + ...(contextWindow ? { contextWindow } : {}), + ...(reasoning !== undefined ? { reasoning } : {}), + ...(input ? { input } : {}), + }); + } + return entries; +} + function withStreamingUsageCompat(provider: ModelProviderConfig): ModelProviderConfig { if (!Array.isArray(provider.models) || provider.models.length === 0) { return provider;