fix: remove provider hardcoding and fix arcee openrouter

This commit is contained in:
Peter Steinberger
2026-04-06 18:19:09 +01:00
parent 95106be59b
commit 177be0f237
15 changed files with 375 additions and 293 deletions

View File

@@ -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();
});
});

View File

@@ -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,
});
},

View File

@@ -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)" }],
}),
});

View File

@@ -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),
})),
};
}

View File

@@ -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,
},
]);
});
});

View File

@@ -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),
},

View File

@@ -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,
},
]);
});
});

View File

@@ -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/"),

2
pnpm-lock.yaml generated
View File

@@ -288,6 +288,8 @@ importers:
extensions/anthropic-vertex: {}
extensions/arcee: {}
extensions/bluebubbles:
devDependencies:
openclaw:

View File

@@ -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",

View File

@@ -44,8 +44,6 @@ const defaultImportPiSdk = () => import("./pi-model-discovery-runtime.js");
let importPiSdk = defaultImportPiSdk;
let modelSuppressionPromise: Promise<typeof import("./model-suppression.runtime.js")> | 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,

View File

@@ -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";

View File

@@ -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",
]),
);
});
});

View File

@@ -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 =
/^(?<policyPath>[a-z0-9_.-]+)\s*=\s*"open"\s+requires\s+(?<allowPath>[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),
});

View File

@@ -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;