mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 09:41:11 +00:00
fix: remove provider hardcoding and fix arcee openrouter
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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)" }],
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
2
pnpm-lock.yaml
generated
@@ -288,6 +288,8 @@ importers:
|
||||
|
||||
extensions/anthropic-vertex: {}
|
||||
|
||||
extensions/arcee: {}
|
||||
|
||||
extensions/bluebubbles:
|
||||
devDependencies:
|
||||
openclaw:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
30
src/config/io.shell-env-expected-keys.test.ts
Normal file
30
src/config/io.shell-env-expected-keys.test.ts
Normal 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",
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user