mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-02 03:28:27 +00:00
refactor(providers): unify lifecycle-safe manifest registration (#114381)
* refactor(providers): canonicalize manifest-driven registration * fix(zai): eliminate type-only SDK import side effects
This commit is contained in:
parent
65cce37480
commit
eec2c0cc26
20 changed files with 892 additions and 825 deletions
|
|
@ -2,13 +2,12 @@
|
|||
* Arcee AI provider plugin entry. It supports direct Arcee auth and OpenRouter
|
||||
* routing while normalizing OpenRouter model ids and base URLs.
|
||||
*/
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import { buildOpenAICompatibleLiveModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
|
||||
import {
|
||||
readConfiguredProviderCatalogEntries,
|
||||
type ProviderCatalogContext,
|
||||
} 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 {
|
||||
applyArceeConfig,
|
||||
|
|
@ -30,50 +29,6 @@ const ARCEE_WIZARD_GROUP = {
|
|||
groupHint: "Direct API or OpenRouter",
|
||||
} as const;
|
||||
|
||||
function buildArceeAuthMethods() {
|
||||
return [
|
||||
createProviderApiKeyAuthMethod({
|
||||
providerId: PROVIDER_ID,
|
||||
methodId: "arcee-platform",
|
||||
label: "Arcee AI API key",
|
||||
hint: "Direct access to Arcee platform",
|
||||
optionKey: "arceeaiApiKey",
|
||||
flagName: "--arceeai-api-key",
|
||||
envVar: "ARCEEAI_API_KEY",
|
||||
promptMessage: "Enter Arcee AI API key",
|
||||
defaultModel: ARCEE_DEFAULT_MODEL_REF,
|
||||
expectedProviders: [PROVIDER_ID],
|
||||
applyConfig: (cfg) => applyArceeConfig(cfg),
|
||||
wizard: {
|
||||
choiceId: "arceeai-api-key",
|
||||
choiceLabel: "Arcee AI API key",
|
||||
choiceHint: "Direct (chat.arcee.ai)",
|
||||
...ARCEE_WIZARD_GROUP,
|
||||
},
|
||||
}),
|
||||
createProviderApiKeyAuthMethod({
|
||||
providerId: PROVIDER_ID,
|
||||
methodId: "openrouter",
|
||||
label: "OpenRouter API key",
|
||||
hint: "Access Arcee models via OpenRouter",
|
||||
optionKey: "openrouterApiKey",
|
||||
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),
|
||||
wizard: {
|
||||
choiceId: "arceeai-openrouter",
|
||||
choiceLabel: "OpenRouter API key",
|
||||
choiceHint: "Via OpenRouter (openrouter.ai)",
|
||||
...ARCEE_WIZARD_GROUP,
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
async function resolveArceeCatalog(ctx: ProviderCatalogContext) {
|
||||
const directAuth = ctx.resolveProviderApiKey(PROVIDER_ID);
|
||||
if (directAuth.apiKey) {
|
||||
|
|
@ -114,45 +69,77 @@ function normalizeArceeResolvedModel<T extends { baseUrl?: string; id: string }>
|
|||
}
|
||||
|
||||
/** Provider entry for Arcee direct and OpenRouter-backed models. */
|
||||
export default definePluginEntry({
|
||||
export default defineSingleProviderPluginEntry({
|
||||
id: PROVIDER_ID,
|
||||
name: "Arcee AI Provider",
|
||||
description: "Bundled Arcee AI provider plugin",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: "Arcee AI",
|
||||
docsPath: "/providers/arcee",
|
||||
envVars: ["ARCEEAI_API_KEY", "OPENROUTER_API_KEY"],
|
||||
auth: buildArceeAuthMethods(),
|
||||
catalog: {
|
||||
run: resolveArceeCatalog,
|
||||
provider: {
|
||||
label: "Arcee AI",
|
||||
docsPath: "/providers/arcee",
|
||||
envVars: ["ARCEEAI_API_KEY", "OPENROUTER_API_KEY"],
|
||||
auth: [
|
||||
{
|
||||
methodId: "arcee-platform",
|
||||
label: "Arcee AI API key",
|
||||
hint: "Direct access to Arcee platform",
|
||||
optionKey: "arceeaiApiKey",
|
||||
flagName: "--arceeai-api-key",
|
||||
envVar: "ARCEEAI_API_KEY",
|
||||
promptMessage: "Enter Arcee AI API key",
|
||||
defaultModel: ARCEE_DEFAULT_MODEL_REF,
|
||||
applyConfig: applyArceeConfig,
|
||||
wizard: {
|
||||
choiceId: "arceeai-api-key",
|
||||
choiceLabel: "Arcee AI API key",
|
||||
choiceHint: "Direct (chat.arcee.ai)",
|
||||
...ARCEE_WIZARD_GROUP,
|
||||
},
|
||||
},
|
||||
staticCatalog: {
|
||||
run: async () => ({ provider: buildArceeProvider() }),
|
||||
{
|
||||
methodId: "openrouter",
|
||||
label: "OpenRouter API key",
|
||||
hint: "Access Arcee models via OpenRouter",
|
||||
optionKey: "openrouterApiKey",
|
||||
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: applyArceeOpenRouterConfig,
|
||||
wizard: {
|
||||
choiceId: "arceeai-openrouter",
|
||||
choiceLabel: "OpenRouter API key",
|
||||
choiceHint: "Via OpenRouter (openrouter.ai)",
|
||||
...ARCEE_WIZARD_GROUP,
|
||||
},
|
||||
},
|
||||
augmentModelCatalog: ({ config }) =>
|
||||
readConfiguredProviderCatalogEntries({
|
||||
config,
|
||||
providerId: PROVIDER_ID,
|
||||
}),
|
||||
normalizeConfig: ({ providerConfig }) => {
|
||||
const normalizedBaseUrl = normalizeArceeOpenRouterBaseUrl(providerConfig.baseUrl);
|
||||
return normalizedBaseUrl && normalizedBaseUrl !== providerConfig.baseUrl
|
||||
? { ...providerConfig, baseUrl: normalizedBaseUrl }
|
||||
: undefined;
|
||||
},
|
||||
normalizeResolvedModel: ({ model }) => normalizeArceeResolvedModel(model),
|
||||
normalizeTransport: ({ api: apiLocal, baseUrl }) => {
|
||||
const normalizedBaseUrl = normalizeArceeOpenRouterBaseUrl(baseUrl);
|
||||
return normalizedBaseUrl && normalizedBaseUrl !== baseUrl
|
||||
? {
|
||||
api: apiLocal,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
...buildProviderReplayFamilyHooks({ family: "openai-compatible" }),
|
||||
});
|
||||
],
|
||||
catalog: {
|
||||
run: resolveArceeCatalog,
|
||||
staticRun: async () => ({ provider: buildArceeProvider() }),
|
||||
},
|
||||
augmentModelCatalog: ({ config }) =>
|
||||
readConfiguredProviderCatalogEntries({
|
||||
config,
|
||||
providerId: PROVIDER_ID,
|
||||
}),
|
||||
normalizeConfig: ({ providerConfig }) => {
|
||||
const normalizedBaseUrl = normalizeArceeOpenRouterBaseUrl(providerConfig.baseUrl);
|
||||
return normalizedBaseUrl && normalizedBaseUrl !== providerConfig.baseUrl
|
||||
? { ...providerConfig, baseUrl: normalizedBaseUrl }
|
||||
: undefined;
|
||||
},
|
||||
normalizeResolvedModel: ({ model }) => normalizeArceeResolvedModel(model),
|
||||
normalizeTransport: ({ api: apiLocal, baseUrl }) => {
|
||||
const normalizedBaseUrl = normalizeArceeOpenRouterBaseUrl(baseUrl);
|
||||
return normalizedBaseUrl && normalizedBaseUrl !== baseUrl
|
||||
? {
|
||||
api: apiLocal,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
...buildProviderReplayFamilyHooks({ family: "openai-compatible" }),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,8 +6,21 @@ import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-ru
|
|||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
import { BYTEPLUS_CODING_MODEL_CATALOG, BYTEPLUS_MODEL_CATALOG } from "./models.js";
|
||||
import { BYTEPLUS_PROVIDER_CATALOG_ENTRIES } from "./provider-catalog.js";
|
||||
|
||||
describe("byteplus plugin", () => {
|
||||
it("preserves both provider-owned static catalogs and paired ordering", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
expect(provider.catalog?.order).toBe("paired");
|
||||
expect(provider.staticCatalog?.order).toBe("paired");
|
||||
expect(await provider.staticCatalog?.run({} as never)).toEqual({
|
||||
providers: Object.fromEntries(
|
||||
BYTEPLUS_PROVIDER_CATALOG_ENTRIES.map(({ id, buildProvider }) => [id, buildProvider()]),
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it("augments the catalog with bundled standard and plan models", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
expect(provider.auth?.[0]?.starterModel).toBe("byteplus-plan/ark-code-latest");
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
/**
|
||||
* BytePlus provider plugin entrypoint for model and video generation providers.
|
||||
*/
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import { buildOpenAICompatibleLiveModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
|
||||
import { readManifestProviderDefaultModelRef } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
|
||||
import { ensureModelAllowlistEntry } from "openclaw/plugin-sdk/provider-onboard";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
import { BYTEPLUS_PROVIDER_CATALOG_ENTRIES } from "./provider-catalog.js";
|
||||
|
|
@ -13,90 +12,65 @@ import { buildBytePlusVideoGenerationProvider } from "./video-generation-provide
|
|||
const PROVIDER_ID = "byteplus";
|
||||
const BYTEPLUS_DEFAULT_MODEL_REF = readManifestProviderDefaultModelRef(manifest, "byteplus-plan")!;
|
||||
|
||||
export default definePluginEntry({
|
||||
export default defineSingleProviderPluginEntry({
|
||||
id: PROVIDER_ID,
|
||||
name: "BytePlus Provider",
|
||||
description: "Bundled BytePlus provider plugin",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: "BytePlus",
|
||||
docsPath: "/concepts/model-providers#byteplus-international",
|
||||
envVars: ["BYTEPLUS_API_KEY"],
|
||||
auth: [
|
||||
createProviderApiKeyAuthMethod({
|
||||
providerId: PROVIDER_ID,
|
||||
methodId: "api-key",
|
||||
label: "BytePlus API key",
|
||||
hint: "API key",
|
||||
optionKey: "byteplusApiKey",
|
||||
flagName: "--byteplus-api-key",
|
||||
envVar: "BYTEPLUS_API_KEY",
|
||||
promptMessage: "Enter BytePlus API key",
|
||||
defaultModel: BYTEPLUS_DEFAULT_MODEL_REF,
|
||||
expectedProviders: ["byteplus"],
|
||||
applyConfig: (cfg) =>
|
||||
ensureModelAllowlistEntry({
|
||||
cfg,
|
||||
modelRef: BYTEPLUS_DEFAULT_MODEL_REF,
|
||||
}),
|
||||
wizard: {
|
||||
choiceId: "byteplus-api-key",
|
||||
choiceLabel: "BytePlus API key",
|
||||
groupId: "byteplus",
|
||||
groupLabel: "BytePlus",
|
||||
groupHint: "API key",
|
||||
},
|
||||
}),
|
||||
],
|
||||
catalog: {
|
||||
order: "paired",
|
||||
run: async (ctx) => {
|
||||
const auth = ctx.resolveProviderApiKey(PROVIDER_ID);
|
||||
const apiKey = auth.apiKey;
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
providers: Object.fromEntries(
|
||||
await Promise.all(
|
||||
BYTEPLUS_PROVIDER_CATALOG_ENTRIES.map(
|
||||
async ({ id, buildProvider }) =>
|
||||
[
|
||||
id,
|
||||
await buildOpenAICompatibleLiveModelProviderConfig({
|
||||
providerId: id,
|
||||
providerConfig: buildProvider(),
|
||||
apiKey,
|
||||
discoveryApiKey: auth.discoveryApiKey,
|
||||
}),
|
||||
] as const,
|
||||
),
|
||||
manifest,
|
||||
provider: {
|
||||
label: "BytePlus",
|
||||
docsPath: "/concepts/model-providers#byteplus-international",
|
||||
manifestAuth: {
|
||||
defaultModel: BYTEPLUS_DEFAULT_MODEL_REF,
|
||||
applyConfig: (cfg) =>
|
||||
ensureModelAllowlistEntry({ cfg, modelRef: BYTEPLUS_DEFAULT_MODEL_REF }),
|
||||
},
|
||||
catalog: {
|
||||
order: "paired",
|
||||
run: async (ctx) => {
|
||||
const auth = ctx.resolveProviderApiKey(PROVIDER_ID);
|
||||
const apiKey = auth.apiKey;
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
providers: Object.fromEntries(
|
||||
await Promise.all(
|
||||
BYTEPLUS_PROVIDER_CATALOG_ENTRIES.map(
|
||||
async ({ id, buildProvider }) =>
|
||||
[
|
||||
id,
|
||||
await buildOpenAICompatibleLiveModelProviderConfig({
|
||||
providerId: id,
|
||||
providerConfig: buildProvider(),
|
||||
apiKey,
|
||||
discoveryApiKey: auth.discoveryApiKey,
|
||||
}),
|
||||
] as const,
|
||||
),
|
||||
),
|
||||
};
|
||||
},
|
||||
},
|
||||
staticCatalog: {
|
||||
order: "paired",
|
||||
run: async () => ({
|
||||
providers: Object.fromEntries(
|
||||
BYTEPLUS_PROVIDER_CATALOG_ENTRIES.map(({ id, buildProvider }) => [id, buildProvider()]),
|
||||
),
|
||||
}),
|
||||
};
|
||||
},
|
||||
augmentModelCatalog: () =>
|
||||
BYTEPLUS_PROVIDER_CATALOG_ENTRIES.flatMap(({ id: provider, models }) =>
|
||||
models.map((entry) => ({
|
||||
provider,
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
reasoning: entry.reasoning,
|
||||
input: [...entry.input],
|
||||
contextWindow: entry.contextWindow,
|
||||
})),
|
||||
staticRun: async () => ({
|
||||
providers: Object.fromEntries(
|
||||
BYTEPLUS_PROVIDER_CATALOG_ENTRIES.map(({ id, buildProvider }) => [id, buildProvider()]),
|
||||
),
|
||||
});
|
||||
}),
|
||||
},
|
||||
augmentModelCatalog: () =>
|
||||
BYTEPLUS_PROVIDER_CATALOG_ENTRIES.flatMap(({ id: provider, models }) =>
|
||||
models.map((entry) => ({
|
||||
provider,
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
reasoning: entry.reasoning,
|
||||
input: [...entry.input],
|
||||
contextWindow: entry.contextWindow,
|
||||
})),
|
||||
),
|
||||
},
|
||||
register(api) {
|
||||
api.registerVideoGenerationProvider(buildBytePlusVideoGenerationProvider());
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -454,6 +454,38 @@ describe("ClawRouter plugin", () => {
|
|||
expect(provider?.resolveDynamicModel?.(context as never)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps discovered models isolated to their plugin registration", async () => {
|
||||
providerAuthRuntimeMocks.resolveApiKeyForProvider.mockResolvedValue({
|
||||
apiKey: "resolved-proxy-key",
|
||||
mode: "api-key",
|
||||
source: "auth profile",
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => Response.json(LIVE_CATALOG)),
|
||||
);
|
||||
const first = await registerSingleProviderPlugin(plugin);
|
||||
const second = await registerSingleProviderPlugin(plugin);
|
||||
const context = {
|
||||
config: { models: {} },
|
||||
agentDir: "/agent",
|
||||
workspaceDir: "/workspace",
|
||||
provider: "clawrouter",
|
||||
modelId: "openai/gpt-5.5",
|
||||
modelRegistry: { find: vi.fn(() => null) },
|
||||
authProfileId: "clawrouter-profile",
|
||||
authProfileMode: "api_key",
|
||||
};
|
||||
|
||||
await first.prepareDynamicModel?.(context as never);
|
||||
|
||||
expect(first.resolveDynamicModel?.(context as never)).toMatchObject({
|
||||
id: "openai/gpt-5.5",
|
||||
});
|
||||
expect(second.resolveDynamicModel?.(context as never)).toBeUndefined();
|
||||
expect(second.preferRuntimeResolvedModel?.(context as never)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the previous dynamic model snapshot while rebuilding", async () => {
|
||||
providerAuthRuntimeMocks.resolveApiKeyForProvider
|
||||
.mockResolvedValueOnce({ apiKey: "decoy-token" })
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
// ClawRouter plugin entrypoint registers credential-scoped model routing and quota reporting.
|
||||
import {
|
||||
definePluginEntry,
|
||||
type ProviderAuthMethod,
|
||||
type ProviderResolveDynamicModelContext,
|
||||
type ProviderRuntimeModel,
|
||||
import type {
|
||||
ProviderResolveDynamicModelContext,
|
||||
ProviderRuntimeModel,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
|
||||
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
import {
|
||||
buildClawRouterProviderConfig,
|
||||
normalizeClawRouterApiBaseUrl,
|
||||
|
|
@ -37,32 +36,6 @@ const perplexityTools = {
|
|||
inspectToolSchemas: inspectPerplexityToolSchemas,
|
||||
};
|
||||
|
||||
function buildApiKeyAuth(): ProviderAuthMethod {
|
||||
return createProviderApiKeyAuthMethod({
|
||||
providerId: PROVIDER_ID,
|
||||
methodId: "api-key",
|
||||
label: "ClawRouter proxy key",
|
||||
hint: "Credential-scoped access to approved models and budgets",
|
||||
optionKey: "clawrouterApiKey",
|
||||
flagName: "--clawrouter-api-key",
|
||||
envVar: ENV_VAR,
|
||||
promptMessage: "Enter ClawRouter proxy key",
|
||||
noteTitle: "ClawRouter",
|
||||
noteMessage: [
|
||||
"Use the proxy key issued by your ClawRouter administrator.",
|
||||
"OpenClaw discovers only the models granted to that key.",
|
||||
].join("\n"),
|
||||
wizard: {
|
||||
choiceId: "clawrouter-api-key",
|
||||
choiceLabel: "ClawRouter proxy key",
|
||||
choiceHint: "Approved models through one managed key",
|
||||
groupId: PROVIDER_ID,
|
||||
groupLabel: "ClawRouter",
|
||||
groupHint: "Managed model access and quotas",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function configuredBaseUrl(
|
||||
config: { models?: { providers?: Record<string, { baseUrl?: unknown }> } } | null | undefined,
|
||||
): string | undefined {
|
||||
|
|
@ -116,19 +89,25 @@ function resolveToolFamily(modelId: string) {
|
|||
return openAiTools;
|
||||
}
|
||||
|
||||
export default definePluginEntry({
|
||||
export default defineSingleProviderPluginEntry({
|
||||
id: PROVIDER_ID,
|
||||
name: "ClawRouter",
|
||||
description: "Managed multi-provider model routing and quotas",
|
||||
register(api) {
|
||||
manifest,
|
||||
provider() {
|
||||
const dynamicModels = new Map<string, Map<string, ProviderRuntimeModel>>();
|
||||
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
return {
|
||||
label: "ClawRouter",
|
||||
docsPath: "/providers/clawrouter",
|
||||
envVars: [ENV_VAR],
|
||||
auth: [buildApiKeyAuth()],
|
||||
manifestAuth: {
|
||||
hint: "Credential-scoped access to approved models and budgets",
|
||||
noteTitle: "ClawRouter",
|
||||
noteMessage: [
|
||||
"Use the proxy key issued by your ClawRouter administrator.",
|
||||
"OpenClaw discovers only the models granted to that key.",
|
||||
].join("\n"),
|
||||
},
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) => {
|
||||
|
|
@ -249,6 +228,6 @@ export default definePluginEntry({
|
|||
baseUrl: configuredBaseUrl(ctx.config),
|
||||
timeoutMs: ctx.timeoutMs,
|
||||
}),
|
||||
});
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
} from "openclaw/plugin-sdk/llm";
|
||||
// Groq tests cover index plugin behavior.
|
||||
import { capturePluginRegistration } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveGroqReasoningCompatPatch } from "./api.js";
|
||||
import plugin from "./index.js";
|
||||
|
|
@ -330,7 +331,7 @@ describe("groq provider compat", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("registers Groq model and media providers", () => {
|
||||
it("registers Groq model and media providers", async () => {
|
||||
const captured = capturePluginRegistration(plugin);
|
||||
const [provider] = captured.providers;
|
||||
if (!provider) {
|
||||
|
|
@ -353,6 +354,13 @@ describe("groq provider compat", () => {
|
|||
groupId: "groq",
|
||||
},
|
||||
});
|
||||
expect(await provider.staticCatalog?.run({} as never)).toEqual({
|
||||
provider: buildManifestModelProviderConfig({
|
||||
providerId: "groq",
|
||||
catalog: manifest.modelCatalog.providers.groq,
|
||||
}),
|
||||
});
|
||||
expect(captured.modelCatalogProviders.map((entry) => entry.kinds)).toEqual([["text"]]);
|
||||
expect(captured.mediaUnderstandingProviders).toHaveLength(1);
|
||||
const [mediaProvider] = captured.mediaUnderstandingProviders;
|
||||
if (!mediaProvider) {
|
||||
|
|
|
|||
|
|
@ -4,27 +4,13 @@ import {
|
|||
streamSimple,
|
||||
type AssistantMessageEvent,
|
||||
} from "openclaw/plugin-sdk/llm";
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import { buildOpenAICompatibleProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
|
||||
import {
|
||||
buildManifestModelProviderConfig,
|
||||
readManifestProviderDefaultModelRef,
|
||||
} from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
|
||||
import { groqMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
|
||||
const GROQ_DEFAULT_MODEL_REF = readManifestProviderDefaultModelRef(manifest, "groq")!;
|
||||
const GROQ_OVERSIZED_RECOVERY_MODEL_ID = "llama-3.3-70b-versatile";
|
||||
const GROQ_FALLBACK_MAX_TOKENS = 1_024;
|
||||
|
||||
function buildGroqCatalogProvider() {
|
||||
return buildManifestModelProviderConfig({
|
||||
providerId: "groq",
|
||||
catalog: manifest.modelCatalog.providers.groq,
|
||||
});
|
||||
}
|
||||
|
||||
function hasWireMaxTokens(value: unknown): boolean {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return false;
|
||||
|
|
@ -147,61 +133,27 @@ function wrapGroqOversizedRequestRecovery(
|
|||
};
|
||||
}
|
||||
|
||||
export default definePluginEntry({
|
||||
export default defineSingleProviderPluginEntry({
|
||||
id: "groq",
|
||||
name: "Groq Provider",
|
||||
description: "Bundled Groq provider plugin",
|
||||
manifest,
|
||||
provider: {
|
||||
label: "Groq",
|
||||
docsPath: "/providers/groq",
|
||||
catalog: { liveModelDiscovery: true },
|
||||
wrapStreamFn: (ctx) =>
|
||||
wrapGroqOversizedRequestRecovery(
|
||||
ctx.streamFn,
|
||||
// Older compatible hosts omit this provenance. Only a known discovered default
|
||||
// is safe to replace; an unknown value could be a user-configured cap.
|
||||
ctx.modelId === GROQ_OVERSIZED_RECOVERY_MODEL_ID &&
|
||||
!hasExplicitMaxTokens(ctx.extraParams) &&
|
||||
!hasExplicitMaxTokens(ctx.model?.params) &&
|
||||
ctx.model?.maxTokensSource === "discovered",
|
||||
),
|
||||
},
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: "groq",
|
||||
label: "Groq",
|
||||
docsPath: "/providers/groq",
|
||||
envVars: ["GROQ_API_KEY"],
|
||||
auth: [
|
||||
createProviderApiKeyAuthMethod({
|
||||
providerId: "groq",
|
||||
methodId: "api-key",
|
||||
label: "Groq API key",
|
||||
hint: "Fast OpenAI-compatible inference",
|
||||
optionKey: "groqApiKey",
|
||||
flagName: "--groq-api-key",
|
||||
envVar: "GROQ_API_KEY",
|
||||
promptMessage: "Enter Groq API key",
|
||||
defaultModel: GROQ_DEFAULT_MODEL_REF,
|
||||
wizard: {
|
||||
choiceId: "groq-api-key",
|
||||
choiceLabel: "Groq API key",
|
||||
choiceHint: "Fast OpenAI-compatible inference",
|
||||
groupId: "groq",
|
||||
groupLabel: "Groq",
|
||||
groupHint: "Fast OpenAI-compatible inference",
|
||||
},
|
||||
}),
|
||||
],
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: (ctx) =>
|
||||
buildOpenAICompatibleProviderCatalog({
|
||||
ctx,
|
||||
providerId: "groq",
|
||||
buildProvider: buildGroqCatalogProvider,
|
||||
}),
|
||||
},
|
||||
staticCatalog: {
|
||||
order: "simple",
|
||||
run: async () => ({ provider: buildGroqCatalogProvider() }),
|
||||
},
|
||||
wrapStreamFn: (ctx) =>
|
||||
wrapGroqOversizedRequestRecovery(
|
||||
ctx.streamFn,
|
||||
// Older compatible hosts omit this provenance. Only a known discovered default
|
||||
// is safe to replace; an unknown value could be a user-configured cap.
|
||||
ctx.modelId === GROQ_OVERSIZED_RECOVERY_MODEL_ID &&
|
||||
!hasExplicitMaxTokens(ctx.extraParams) &&
|
||||
!hasExplicitMaxTokens(ctx.model?.params) &&
|
||||
ctx.model?.maxTokensSource === "discovered",
|
||||
),
|
||||
});
|
||||
api.registerMediaUnderstandingProvider(groqMediaUnderstandingProvider);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
// Kimi Coding plugin entrypoint registers its OpenClaw integration.
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
|
||||
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import type { SecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
import { isRecord, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { applyKimiCodeConfig, KIMI_CODING_MODEL_REF } from "./onboard.js";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
import { buildKimiCodingProvider, normalizeKimiCodingModelId } from "./provider-catalog.js";
|
||||
import { isKimiK3ModelId, resolveThinkingProfile } from "./provider-policy-api.js";
|
||||
import { KIMI_REPLAY_POLICY } from "./replay-policy.js";
|
||||
|
|
@ -26,87 +26,70 @@ function findExplicitProviderConfig(
|
|||
);
|
||||
return isRecord(match?.[1]) ? match[1] : undefined;
|
||||
}
|
||||
export default definePluginEntry({
|
||||
export default defineSingleProviderPluginEntry({
|
||||
id: PLUGIN_ID,
|
||||
name: "Kimi Provider",
|
||||
description: "Bundled Kimi provider plugin",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: "Kimi",
|
||||
aliases: ["kimi-code", "kimi-coding"],
|
||||
docsPath: "/providers/moonshot",
|
||||
envVars: ["KIMI_API_KEY", "KIMICODE_API_KEY"],
|
||||
auth: [
|
||||
createProviderApiKeyAuthMethod({
|
||||
providerId: PROVIDER_ID,
|
||||
methodId: "api-key",
|
||||
label: "Kimi Code API key (subscription)",
|
||||
hint: "Kimi Code membership · https://www.kimi.com/membership/pricing",
|
||||
optionKey: "kimiCodeApiKey",
|
||||
flagName: "--kimi-code-api-key",
|
||||
envVar: "KIMI_API_KEY",
|
||||
promptMessage: "Enter Kimi API key",
|
||||
defaultModel: KIMI_CODING_MODEL_REF,
|
||||
expectedProviders: ["kimi", "kimi-code", "kimi-coding"],
|
||||
applyConfig: (cfg) => applyKimiCodeConfig(cfg),
|
||||
noteMessage: [
|
||||
"Kimi uses a dedicated coding endpoint and API key.",
|
||||
"Get your API key at: https://www.kimi.com/code/console",
|
||||
].join("\n"),
|
||||
noteTitle: "Kimi",
|
||||
wizard: {
|
||||
choiceId: "kimi-code-api-key",
|
||||
choiceLabel: "Kimi Code API key (subscription)",
|
||||
groupId: "moonshot",
|
||||
groupLabel: "Moonshot AI (Kimi)",
|
||||
groupHint: "Kimi Code membership · https://www.kimi.com/membership/pricing",
|
||||
manifest,
|
||||
provider: {
|
||||
id: PROVIDER_ID,
|
||||
label: "Kimi",
|
||||
aliases: ["kimi-code", "kimi-coding"],
|
||||
docsPath: "/providers/moonshot",
|
||||
envVars: ["KIMI_API_KEY", "KIMICODE_API_KEY"],
|
||||
manifestAuth: {
|
||||
promptMessage: "Enter Kimi API key",
|
||||
defaultModel: KIMI_CODING_MODEL_REF,
|
||||
expectedProviders: ["kimi", "kimi-code", "kimi-coding"],
|
||||
applyConfig: applyKimiCodeConfig,
|
||||
noteMessage: [
|
||||
"Kimi uses a dedicated coding endpoint and API key.",
|
||||
"Get your API key at: https://www.kimi.com/code/console",
|
||||
].join("\n"),
|
||||
noteTitle: "Kimi",
|
||||
},
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) => {
|
||||
const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
const explicitProvider = findExplicitProviderConfig(
|
||||
ctx.config.models?.providers as Record<string, unknown> | undefined,
|
||||
PROVIDER_ID,
|
||||
);
|
||||
const builtInProvider = buildKimiCodingProvider();
|
||||
const explicitBaseUrl = normalizeOptionalString(explicitProvider?.baseUrl) ?? "";
|
||||
const explicitHeaders = isRecord(explicitProvider?.headers)
|
||||
? (explicitProvider.headers as Record<string, SecretInput>)
|
||||
: undefined;
|
||||
return {
|
||||
provider: {
|
||||
...builtInProvider,
|
||||
...(explicitBaseUrl ? { baseUrl: explicitBaseUrl } : {}),
|
||||
...(explicitHeaders
|
||||
? {
|
||||
headers: {
|
||||
...builtInProvider.headers,
|
||||
...explicitHeaders,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
apiKey,
|
||||
},
|
||||
}),
|
||||
],
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) => {
|
||||
const apiKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
const explicitProvider = findExplicitProviderConfig(
|
||||
ctx.config.models?.providers as Record<string, unknown> | undefined,
|
||||
PROVIDER_ID,
|
||||
);
|
||||
const builtInProvider = buildKimiCodingProvider();
|
||||
const explicitBaseUrl = normalizeOptionalString(explicitProvider?.baseUrl) ?? "";
|
||||
const explicitHeaders = isRecord(explicitProvider?.headers)
|
||||
? (explicitProvider.headers as Record<string, SecretInput>)
|
||||
: undefined;
|
||||
return {
|
||||
provider: {
|
||||
...builtInProvider,
|
||||
...(explicitBaseUrl ? { baseUrl: explicitBaseUrl } : {}),
|
||||
...(explicitHeaders
|
||||
? {
|
||||
headers: {
|
||||
...builtInProvider.headers,
|
||||
...explicitHeaders,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
apiKey,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
buildReplayPolicy: () => KIMI_REPLAY_POLICY,
|
||||
normalizeResolvedModel: ({ model }) => {
|
||||
const normalizedId = normalizeKimiCodingModelId(model.id);
|
||||
return normalizedId === model.id ? undefined : { ...model, id: normalizedId };
|
||||
},
|
||||
normalizeModelId: ({ modelId }) => normalizeKimiCodingModelId(modelId),
|
||||
resolveThinkingProfile,
|
||||
wrapSimpleCompletionStreamFn: (ctx) =>
|
||||
isKimiK3ModelId(ctx.modelId) ? wrapKimiProviderStream(ctx) : ctx.streamFn,
|
||||
wrapStreamFn: wrapKimiProviderStream,
|
||||
});
|
||||
},
|
||||
buildReplayPolicy: () => KIMI_REPLAY_POLICY,
|
||||
normalizeResolvedModel: ({ model }) => {
|
||||
const normalizedId = normalizeKimiCodingModelId(model.id);
|
||||
return normalizedId === model.id ? undefined : { ...model, id: normalizedId };
|
||||
},
|
||||
normalizeModelId: ({ modelId }) => normalizeKimiCodingModelId(modelId),
|
||||
resolveThinkingProfile,
|
||||
wrapSimpleCompletionStreamFn: (ctx) =>
|
||||
isKimiK3ModelId(ctx.modelId) ? wrapKimiProviderStream(ctx) : ctx.streamFn,
|
||||
wrapStreamFn: wrapKimiProviderStream,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -78,6 +78,20 @@ describe("opencode-go provider plugin", () => {
|
|||
clearLiveCatalogCacheForTests();
|
||||
});
|
||||
|
||||
it("registers only the Go auth choice from its own provider manifest", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
expect(provider.id).toBe("opencode-go");
|
||||
expect(provider.envVars).toEqual(["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"]);
|
||||
expect(provider.auth.map((method) => method.id)).toEqual(["api-key"]);
|
||||
expect(provider.auth.map((method) => method.wizard?.choiceId)).toEqual(["opencode-go"]);
|
||||
expect(provider.auth[0]?.wizard).toMatchObject({
|
||||
choiceLabel: "OpenCode Go catalog",
|
||||
groupId: "opencode",
|
||||
groupHint: "Shared API key for Zen + Go catalogs",
|
||||
});
|
||||
});
|
||||
|
||||
it("registers image media understanding through the OpenCode Go plugin", async () => {
|
||||
const { mediaProviders } = await registerProviderPlugin({
|
||||
plugin,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
// Opencode Go plugin entrypoint registers its OpenClaw integration.
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
|
||||
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { applyOpencodeGoConfig, OPENCODE_GO_DEFAULT_MODEL_REF } from "./api.js";
|
||||
import { opencodeGoMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
import {
|
||||
buildOpencodeGoLiveProviderConfig,
|
||||
buildStaticOpencodeGoProviderConfig,
|
||||
|
|
@ -18,12 +18,6 @@ import { createOpencodeGoWrapper } from "./stream.js";
|
|||
const PROVIDER_ID = "opencode-go";
|
||||
const OPENCODE_SHARED_PROFILE_IDS = ["opencode:default", "opencode-go:default"] as const;
|
||||
const OPENCODE_SHARED_HINT = "Shared API key for Zen + Go catalogs";
|
||||
const OPENCODE_SHARED_WIZARD_GROUP = {
|
||||
groupId: "opencode",
|
||||
groupLabel: "OpenCode",
|
||||
groupHint: OPENCODE_SHARED_HINT,
|
||||
} as const;
|
||||
|
||||
type OpencodeGoCatalogAuth = {
|
||||
apiKey?: string;
|
||||
discoveryApiKey?: string;
|
||||
|
|
@ -44,103 +38,90 @@ function resolveOpencodeGoCatalogAuth(
|
|||
return hasCatalogAuth(sharedOpencodeAuth) ? sharedOpencodeAuth : undefined;
|
||||
}
|
||||
|
||||
export default definePluginEntry({
|
||||
export default defineSingleProviderPluginEntry({
|
||||
id: PROVIDER_ID,
|
||||
name: "OpenCode Go Provider",
|
||||
description: "Bundled OpenCode Go provider plugin",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: "OpenCode Go",
|
||||
docsPath: "/providers/models",
|
||||
envVars: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
|
||||
auth: [
|
||||
createProviderApiKeyAuthMethod({
|
||||
providerId: PROVIDER_ID,
|
||||
methodId: "api-key",
|
||||
label: "OpenCode Go catalog",
|
||||
hint: OPENCODE_SHARED_HINT,
|
||||
optionKey: "opencodeGoApiKey",
|
||||
flagName: "--opencode-go-api-key",
|
||||
envVar: "OPENCODE_API_KEY",
|
||||
promptMessage: "Enter OpenCode API key",
|
||||
profileIds: [...OPENCODE_SHARED_PROFILE_IDS],
|
||||
defaultModel: OPENCODE_GO_DEFAULT_MODEL_REF,
|
||||
applyConfig: (cfg) => applyOpencodeGoConfig(cfg),
|
||||
expectedProviders: ["opencode", "opencode-go"],
|
||||
noteMessage: [
|
||||
"OpenCode uses one API key across the Zen and Go catalogs.",
|
||||
"Go focuses on Kimi, GLM, and MiniMax coding models.",
|
||||
"Get your API key at: https://opencode.ai/auth",
|
||||
].join("\n"),
|
||||
noteTitle: "OpenCode",
|
||||
wizard: {
|
||||
choiceId: "opencode-go",
|
||||
choiceLabel: "OpenCode Go catalog",
|
||||
...OPENCODE_SHARED_WIZARD_GROUP,
|
||||
},
|
||||
}),
|
||||
],
|
||||
normalizeConfig: ({ providerConfig }) => {
|
||||
const normalizedBaseUrl = normalizeOpencodeGoBaseUrl({
|
||||
api: providerConfig.api,
|
||||
baseUrl: providerConfig.baseUrl,
|
||||
});
|
||||
return normalizedBaseUrl && normalizedBaseUrl !== providerConfig.baseUrl
|
||||
? { ...providerConfig, baseUrl: normalizedBaseUrl }
|
||||
: undefined;
|
||||
},
|
||||
normalizeResolvedModel: ({ model }) => {
|
||||
const normalizedBaseUrl = normalizeOpencodeGoBaseUrl({
|
||||
api: model.api,
|
||||
baseUrl: model.baseUrl,
|
||||
});
|
||||
const baseUrlNormalized =
|
||||
normalizedBaseUrl && normalizedBaseUrl !== model.baseUrl
|
||||
? { ...model, baseUrl: normalizedBaseUrl }
|
||||
: model;
|
||||
const modelNormalized = normalizeOpencodeGoResolvedModel(baseUrlNormalized);
|
||||
if (modelNormalized) {
|
||||
return modelNormalized;
|
||||
manifest,
|
||||
provider: {
|
||||
label: "OpenCode Go",
|
||||
docsPath: "/providers/models",
|
||||
envVars: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
|
||||
manifestAuth: {
|
||||
hint: OPENCODE_SHARED_HINT,
|
||||
promptMessage: "Enter OpenCode API key",
|
||||
profileIds: [...OPENCODE_SHARED_PROFILE_IDS],
|
||||
defaultModel: OPENCODE_GO_DEFAULT_MODEL_REF,
|
||||
applyConfig: applyOpencodeGoConfig,
|
||||
expectedProviders: ["opencode", "opencode-go"],
|
||||
noteMessage: [
|
||||
"OpenCode uses one API key across the Zen and Go catalogs.",
|
||||
"Go focuses on Kimi, GLM, and MiniMax coding models.",
|
||||
"Get your API key at: https://opencode.ai/auth",
|
||||
].join("\n"),
|
||||
noteTitle: "OpenCode",
|
||||
},
|
||||
normalizeConfig: ({ providerConfig }) => {
|
||||
const normalizedBaseUrl = normalizeOpencodeGoBaseUrl({
|
||||
api: providerConfig.api,
|
||||
baseUrl: providerConfig.baseUrl,
|
||||
});
|
||||
return normalizedBaseUrl && normalizedBaseUrl !== providerConfig.baseUrl
|
||||
? { ...providerConfig, baseUrl: normalizedBaseUrl }
|
||||
: undefined;
|
||||
},
|
||||
normalizeResolvedModel: ({ model }) => {
|
||||
const normalizedBaseUrl = normalizeOpencodeGoBaseUrl({
|
||||
api: model.api,
|
||||
baseUrl: model.baseUrl,
|
||||
});
|
||||
const baseUrlNormalized =
|
||||
normalizedBaseUrl && normalizedBaseUrl !== model.baseUrl
|
||||
? { ...model, baseUrl: normalizedBaseUrl }
|
||||
: model;
|
||||
const modelNormalized = normalizeOpencodeGoResolvedModel(baseUrlNormalized);
|
||||
if (modelNormalized) {
|
||||
return modelNormalized;
|
||||
}
|
||||
return baseUrlNormalized !== model ? baseUrlNormalized : undefined;
|
||||
},
|
||||
normalizeTransport: ({ api: apiLocal, baseUrl }) => {
|
||||
const normalizedBaseUrl = normalizeOpencodeGoBaseUrl({ api: apiLocal, baseUrl });
|
||||
return normalizedBaseUrl && normalizedBaseUrl !== baseUrl
|
||||
? {
|
||||
api: apiLocal,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
resolveDynamicModel: ({ modelId }) => resolveOpencodeGoModel(modelId),
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) => {
|
||||
const auth = resolveOpencodeGoCatalogAuth(ctx.resolveProviderApiKey);
|
||||
if (!auth) {
|
||||
return null;
|
||||
}
|
||||
return baseUrlNormalized !== model ? baseUrlNormalized : undefined;
|
||||
},
|
||||
normalizeTransport: ({ api: apiLocal, baseUrl }) => {
|
||||
const normalizedBaseUrl = normalizeOpencodeGoBaseUrl({ api: apiLocal, baseUrl });
|
||||
return normalizedBaseUrl && normalizedBaseUrl !== baseUrl
|
||||
? {
|
||||
api: apiLocal,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
resolveDynamicModel: ({ modelId }) => resolveOpencodeGoModel(modelId),
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) => {
|
||||
const auth = resolveOpencodeGoCatalogAuth(ctx.resolveProviderApiKey);
|
||||
if (!auth) {
|
||||
return null;
|
||||
}
|
||||
if (!auth.discoveryApiKey) {
|
||||
return {
|
||||
provider: buildStaticOpencodeGoProviderConfig(auth.apiKey),
|
||||
};
|
||||
}
|
||||
if (!auth.discoveryApiKey) {
|
||||
return {
|
||||
provider: await buildOpencodeGoLiveProviderConfig({
|
||||
apiKey: auth.apiKey ?? auth.discoveryApiKey,
|
||||
discoveryApiKey: auth.discoveryApiKey,
|
||||
}),
|
||||
provider: buildStaticOpencodeGoProviderConfig(auth.apiKey),
|
||||
};
|
||||
},
|
||||
}
|
||||
return {
|
||||
provider: await buildOpencodeGoLiveProviderConfig({
|
||||
apiKey: auth.apiKey ?? auth.discoveryApiKey,
|
||||
discoveryApiKey: auth.discoveryApiKey,
|
||||
}),
|
||||
};
|
||||
},
|
||||
augmentModelCatalog: () => listOpencodeGoModelCatalogEntries(),
|
||||
...buildProviderReplayFamilyHooks({ family: "passthrough-gemini" }),
|
||||
resolveThinkingProfile,
|
||||
wrapStreamFn: (ctx) => createOpencodeGoWrapper(ctx.streamFn, ctx.thinkingLevel),
|
||||
isModernModelRef: () => true,
|
||||
});
|
||||
},
|
||||
augmentModelCatalog: () => listOpencodeGoModelCatalogEntries(),
|
||||
...buildProviderReplayFamilyHooks({ family: "passthrough-gemini" }),
|
||||
resolveThinkingProfile,
|
||||
wrapStreamFn: (ctx) => createOpencodeGoWrapper(ctx.streamFn, ctx.thinkingLevel),
|
||||
isModernModelRef: () => true,
|
||||
},
|
||||
register(api) {
|
||||
api.registerMediaUnderstandingProvider(opencodeGoMediaUnderstandingProvider);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -44,6 +44,21 @@ describe("opencode provider plugin", () => {
|
|||
beforeEach(() => {
|
||||
clearLiveCatalogCacheForTests();
|
||||
});
|
||||
|
||||
it("registers only the Zen auth choice from its own provider manifest", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
expect(provider.id).toBe("opencode");
|
||||
expect(provider.envVars).toEqual(["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"]);
|
||||
expect(provider.auth.map((method) => method.id)).toEqual(["api-key"]);
|
||||
expect(provider.auth.map((method) => method.wizard?.choiceId)).toEqual(["opencode-zen"]);
|
||||
expect(provider.auth[0]?.wizard).toMatchObject({
|
||||
choiceLabel: "OpenCode Zen catalog",
|
||||
groupId: "opencode",
|
||||
groupHint: "Shared API key for Zen + Go catalogs",
|
||||
});
|
||||
});
|
||||
|
||||
it("registers image media understanding through the OpenCode plugin", async () => {
|
||||
const { mediaProviders } = await registerProviderPlugin({
|
||||
plugin,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
// Opencode plugin entrypoint registers its OpenClaw integration.
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
|
||||
import {
|
||||
buildProviderReplayFamilyHooks,
|
||||
matchesExactOrPrefix,
|
||||
|
|
@ -8,6 +7,7 @@ import {
|
|||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { applyOpencodeZenConfig, OPENCODE_ZEN_DEFAULT_MODEL } from "./api.js";
|
||||
import { opencodeMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
import {
|
||||
buildOpencodeZenLiveProviderConfig,
|
||||
buildStaticOpencodeZenProviderConfig,
|
||||
|
|
@ -22,12 +22,6 @@ const PROVIDER_ID = "opencode";
|
|||
const MINIMAX_MODERN_MODEL_MATCHERS = ["minimax-m2.7"] as const;
|
||||
const OPENCODE_SHARED_PROFILE_IDS = ["opencode:default", "opencode-go:default"] as const;
|
||||
const OPENCODE_SHARED_HINT = "Shared API key for Zen + Go catalogs";
|
||||
const OPENCODE_SHARED_WIZARD_GROUP = {
|
||||
groupId: "opencode",
|
||||
groupLabel: "OpenCode",
|
||||
groupHint: OPENCODE_SHARED_HINT,
|
||||
} as const;
|
||||
|
||||
type OpencodeZenCatalogAuth = {
|
||||
apiKey?: string;
|
||||
discoveryApiKey?: string;
|
||||
|
|
@ -56,103 +50,85 @@ function isModernOpencodeModel(modelId: string): boolean {
|
|||
return !matchesExactOrPrefix(lower, MINIMAX_MODERN_MODEL_MATCHERS);
|
||||
}
|
||||
|
||||
export default definePluginEntry({
|
||||
export default defineSingleProviderPluginEntry({
|
||||
id: PROVIDER_ID,
|
||||
name: "OpenCode Zen Provider",
|
||||
description: "Bundled OpenCode Zen provider plugin",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: "OpenCode Zen",
|
||||
docsPath: "/providers/models",
|
||||
envVars: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
|
||||
auth: [
|
||||
createProviderApiKeyAuthMethod({
|
||||
providerId: PROVIDER_ID,
|
||||
methodId: "api-key",
|
||||
label: "OpenCode Zen catalog",
|
||||
hint: OPENCODE_SHARED_HINT,
|
||||
optionKey: "opencodeZenApiKey",
|
||||
flagName: "--opencode-zen-api-key",
|
||||
envVar: "OPENCODE_API_KEY",
|
||||
promptMessage: "Enter OpenCode API key",
|
||||
profileIds: [...OPENCODE_SHARED_PROFILE_IDS],
|
||||
defaultModel: OPENCODE_ZEN_DEFAULT_MODEL,
|
||||
applyConfig: (cfg) => applyOpencodeZenConfig(cfg),
|
||||
expectedProviders: ["opencode", "opencode-go"],
|
||||
noteMessage: [
|
||||
"OpenCode uses one API key across the Zen and Go catalogs.",
|
||||
"Zen provides access to Claude, GPT, Gemini, and more models.",
|
||||
"Get your API key at: https://opencode.ai/auth",
|
||||
"Choose the Zen catalog when you want the curated multi-model proxy.",
|
||||
].join("\n"),
|
||||
noteTitle: "OpenCode",
|
||||
wizard: {
|
||||
choiceId: "opencode-zen",
|
||||
choiceLabel: "OpenCode Zen catalog",
|
||||
...OPENCODE_SHARED_WIZARD_GROUP,
|
||||
},
|
||||
}),
|
||||
],
|
||||
normalizeConfig: ({ providerConfig }) => {
|
||||
const normalizedBaseUrl = normalizeOpencodeZenBaseUrl({
|
||||
api: providerConfig.api,
|
||||
baseUrl: providerConfig.baseUrl,
|
||||
});
|
||||
return normalizedBaseUrl && normalizedBaseUrl !== providerConfig.baseUrl
|
||||
? { ...providerConfig, baseUrl: normalizedBaseUrl }
|
||||
: undefined;
|
||||
},
|
||||
normalizeResolvedModel: ({ model }) => {
|
||||
const normalizedBaseUrl = normalizeOpencodeZenBaseUrl({
|
||||
api: model.api,
|
||||
baseUrl: model.baseUrl,
|
||||
});
|
||||
return normalizedBaseUrl && normalizedBaseUrl !== model.baseUrl
|
||||
? { ...model, baseUrl: normalizedBaseUrl }
|
||||
: undefined;
|
||||
},
|
||||
normalizeTransport: ({ api: apiLocal, baseUrl }) => {
|
||||
const normalizedBaseUrl = normalizeOpencodeZenBaseUrl({ api: apiLocal, baseUrl });
|
||||
return normalizedBaseUrl && normalizedBaseUrl !== baseUrl
|
||||
? {
|
||||
api: apiLocal,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
resolveDynamicModel: ({ modelId }) => resolveOpencodeZenModel(modelId),
|
||||
staticCatalog: {
|
||||
order: "simple",
|
||||
run: async () => ({
|
||||
provider: buildStaticOpencodeZenProviderConfig(),
|
||||
}),
|
||||
},
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) => {
|
||||
const auth = resolveOpencodeZenCatalogAuth(ctx.resolveProviderApiKey);
|
||||
if (!auth) {
|
||||
return null;
|
||||
}
|
||||
if (!auth.discoveryApiKey) {
|
||||
return {
|
||||
provider: buildStaticOpencodeZenProviderConfig(auth.apiKey),
|
||||
};
|
||||
manifest,
|
||||
provider: {
|
||||
label: "OpenCode Zen",
|
||||
docsPath: "/providers/models",
|
||||
envVars: ["OPENCODE_API_KEY", "OPENCODE_ZEN_API_KEY"],
|
||||
manifestAuth: {
|
||||
hint: OPENCODE_SHARED_HINT,
|
||||
promptMessage: "Enter OpenCode API key",
|
||||
profileIds: [...OPENCODE_SHARED_PROFILE_IDS],
|
||||
defaultModel: OPENCODE_ZEN_DEFAULT_MODEL,
|
||||
applyConfig: applyOpencodeZenConfig,
|
||||
expectedProviders: ["opencode", "opencode-go"],
|
||||
noteMessage: [
|
||||
"OpenCode uses one API key across the Zen and Go catalogs.",
|
||||
"Zen provides access to Claude, GPT, Gemini, and more models.",
|
||||
"Get your API key at: https://opencode.ai/auth",
|
||||
"Choose the Zen catalog when you want the curated multi-model proxy.",
|
||||
].join("\n"),
|
||||
noteTitle: "OpenCode",
|
||||
},
|
||||
normalizeConfig: ({ providerConfig }) => {
|
||||
const normalizedBaseUrl = normalizeOpencodeZenBaseUrl({
|
||||
api: providerConfig.api,
|
||||
baseUrl: providerConfig.baseUrl,
|
||||
});
|
||||
return normalizedBaseUrl && normalizedBaseUrl !== providerConfig.baseUrl
|
||||
? { ...providerConfig, baseUrl: normalizedBaseUrl }
|
||||
: undefined;
|
||||
},
|
||||
normalizeResolvedModel: ({ model }) => {
|
||||
const normalizedBaseUrl = normalizeOpencodeZenBaseUrl({
|
||||
api: model.api,
|
||||
baseUrl: model.baseUrl,
|
||||
});
|
||||
return normalizedBaseUrl && normalizedBaseUrl !== model.baseUrl
|
||||
? { ...model, baseUrl: normalizedBaseUrl }
|
||||
: undefined;
|
||||
},
|
||||
normalizeTransport: ({ api: apiLocal, baseUrl }) => {
|
||||
const normalizedBaseUrl = normalizeOpencodeZenBaseUrl({ api: apiLocal, baseUrl });
|
||||
return normalizedBaseUrl && normalizedBaseUrl !== baseUrl
|
||||
? {
|
||||
api: apiLocal,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
}
|
||||
: undefined;
|
||||
},
|
||||
resolveDynamicModel: ({ modelId }) => resolveOpencodeZenModel(modelId),
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) => {
|
||||
const auth = resolveOpencodeZenCatalogAuth(ctx.resolveProviderApiKey);
|
||||
if (!auth) {
|
||||
return null;
|
||||
}
|
||||
if (!auth.discoveryApiKey) {
|
||||
return {
|
||||
provider: await buildOpencodeZenLiveProviderConfig({
|
||||
apiKey: auth.apiKey ?? auth.discoveryApiKey,
|
||||
discoveryApiKey: auth.discoveryApiKey,
|
||||
}),
|
||||
provider: buildStaticOpencodeZenProviderConfig(auth.apiKey),
|
||||
};
|
||||
},
|
||||
}
|
||||
return {
|
||||
provider: await buildOpencodeZenLiveProviderConfig({
|
||||
apiKey: auth.apiKey ?? auth.discoveryApiKey,
|
||||
discoveryApiKey: auth.discoveryApiKey,
|
||||
}),
|
||||
};
|
||||
},
|
||||
augmentModelCatalog: () => listOpencodeZenModelCatalogEntries(),
|
||||
...buildProviderReplayFamilyHooks({ family: "passthrough-gemini" }),
|
||||
isModernModelRef: ({ modelId }) => isModernOpencodeModel(modelId),
|
||||
resolveThinkingProfile: resolveOpencodeThinkingProfile,
|
||||
});
|
||||
staticRun: async () => ({ provider: buildStaticOpencodeZenProviderConfig() }),
|
||||
},
|
||||
augmentModelCatalog: () => listOpencodeZenModelCatalogEntries(),
|
||||
...buildProviderReplayFamilyHooks({ family: "passthrough-gemini" }),
|
||||
isModernModelRef: ({ modelId }) => isModernOpencodeModel(modelId),
|
||||
resolveThinkingProfile: resolveOpencodeThinkingProfile,
|
||||
},
|
||||
register(api) {
|
||||
api.registerMediaUnderstandingProvider(opencodeMediaUnderstandingProvider);
|
||||
registerOpenCodeSessionCatalog(api);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -144,7 +144,14 @@ describe("openrouter provider hooks", () => {
|
|||
id: "openrouter",
|
||||
name: "OpenRouter Provider",
|
||||
});
|
||||
const modelCatalogProvider = expectUnifiedModelCatalogProviderRegistration({
|
||||
const textModelCatalogProvider = expectUnifiedModelCatalogProviderRegistration({
|
||||
plugin: openrouterPlugin,
|
||||
pluginId: "openrouter",
|
||||
pluginName: "OpenRouter Provider",
|
||||
provider: "openrouter",
|
||||
kind: "text",
|
||||
});
|
||||
const videoModelCatalogProvider = expectUnifiedModelCatalogProviderRegistration({
|
||||
plugin: openrouterPlugin,
|
||||
pluginId: "openrouter",
|
||||
pluginName: "OpenRouter Provider",
|
||||
|
|
@ -162,7 +169,9 @@ describe("openrouter provider hooks", () => {
|
|||
expect(imageProviders.map((provider) => provider.id)).toEqual(["openrouter"]);
|
||||
expect(musicProviders.map((provider) => provider.id)).toEqual(["openrouter"]);
|
||||
expect(videoProviders.map((provider) => provider.id)).toEqual(["openrouter"]);
|
||||
expect(modelCatalogProvider.liveCatalog).toBeTypeOf("function");
|
||||
expect(textModelCatalogProvider.staticCatalog).toBeTypeOf("function");
|
||||
expect(textModelCatalogProvider.liveCatalog).toBeTypeOf("function");
|
||||
expect(videoModelCatalogProvider.liveCatalog).toBeTypeOf("function");
|
||||
});
|
||||
|
||||
it("registers OAuth and API-key auth methods", async () => {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
// Openrouter plugin entrypoint registers its OpenClaw integration.
|
||||
import {
|
||||
definePluginEntry,
|
||||
type ProviderReplayPolicy,
|
||||
type ProviderReplayPolicyContext,
|
||||
type ProviderResolveDynamicModelContext,
|
||||
type ProviderRuntimeModel,
|
||||
import type {
|
||||
ProviderReplayPolicy,
|
||||
ProviderReplayPolicyContext,
|
||||
ProviderResolveDynamicModelContext,
|
||||
ProviderRuntimeModel,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
|
||||
import {
|
||||
buildProviderReplayFamilyHooks,
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
|
|
@ -22,6 +21,7 @@ import { isOpenRouterMistralModelId, normalizeOpenRouterApiModelId } from "./mod
|
|||
import { buildOpenRouterMusicGenerationProvider } from "./music-generation-provider.js";
|
||||
import { createOpenRouterOAuthAuthMethod } from "./oauth.js";
|
||||
import { applyOpenrouterConfig, OPENROUTER_DEFAULT_MODEL_REF } from "./onboard.js";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
import {
|
||||
buildOpenrouterLiveProvider,
|
||||
buildOpenrouterProvider,
|
||||
|
|
@ -228,11 +228,12 @@ function resolveOpenRouterFusionPromptContribution(
|
|||
return lines.length > 2 ? { dynamicSuffix: lines.join("\n") } : undefined;
|
||||
}
|
||||
|
||||
export default definePluginEntry({
|
||||
export default defineSingleProviderPluginEntry({
|
||||
id: "openrouter",
|
||||
name: "OpenRouter Provider",
|
||||
description: "Bundled OpenRouter provider plugin",
|
||||
register(api) {
|
||||
manifest,
|
||||
provider() {
|
||||
function buildDynamicOpenRouterModel(
|
||||
ctx: ProviderResolveDynamicModelContext,
|
||||
): ProviderRuntimeModel {
|
||||
|
|
@ -281,35 +282,15 @@ export default definePluginEntry({
|
|||
return base;
|
||||
}
|
||||
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
return {
|
||||
label: "OpenRouter",
|
||||
docsPath: "/providers/models",
|
||||
envVars: ["OPENROUTER_API_KEY"],
|
||||
auth: [
|
||||
createProviderApiKeyAuthMethod({
|
||||
providerId: PROVIDER_ID,
|
||||
methodId: "api-key",
|
||||
label: "OpenRouter API key",
|
||||
hint: "API key",
|
||||
optionKey: "openrouterApiKey",
|
||||
flagName: "--openrouter-api-key",
|
||||
envVar: "OPENROUTER_API_KEY",
|
||||
promptMessage: "Enter OpenRouter API key",
|
||||
defaultModel: OPENROUTER_DEFAULT_MODEL_REF,
|
||||
expectedProviders: ["openrouter"],
|
||||
applyConfig: (cfg) => applyOpenrouterConfig(cfg),
|
||||
wizard: {
|
||||
choiceId: "openrouter-api-key",
|
||||
choiceLabel: "OpenRouter API key",
|
||||
groupId: "openrouter",
|
||||
groupLabel: "OpenRouter",
|
||||
groupHint: "OAuth or API key",
|
||||
onboardingScopes: ["text-inference", "music-generation"],
|
||||
},
|
||||
}),
|
||||
createOpenRouterOAuthAuthMethod(),
|
||||
],
|
||||
manifestAuth: {
|
||||
hint: "API key",
|
||||
defaultModel: OPENROUTER_DEFAULT_MODEL_REF,
|
||||
applyConfig: applyOpenrouterConfig,
|
||||
},
|
||||
extraAuth: [createOpenRouterOAuthAuthMethod()],
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: async (ctx) => {
|
||||
|
|
@ -325,10 +306,7 @@ export default definePluginEntry({
|
|||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
staticCatalog: {
|
||||
order: "simple",
|
||||
run: async () => ({
|
||||
staticRun: async () => ({
|
||||
provider: buildOpenrouterProvider(),
|
||||
}),
|
||||
},
|
||||
|
|
@ -375,7 +353,9 @@ export default definePluginEntry({
|
|||
timeoutMs: ctx.timeoutMs,
|
||||
fetchFn: ctx.fetchFn,
|
||||
}),
|
||||
});
|
||||
};
|
||||
},
|
||||
register(api) {
|
||||
api.registerMediaUnderstandingProvider(openrouterMediaUnderstandingProvider);
|
||||
api.registerImageGenerationProvider(buildOpenRouterImageGenerationProvider());
|
||||
api.registerMusicGenerationProvider(buildOpenRouterMusicGenerationProvider());
|
||||
|
|
|
|||
|
|
@ -6,8 +6,21 @@ import { describe, expect, it } from "vitest";
|
|||
import { VOLCENGINE_UNSUPPORTED_TOOL_SCHEMA_KEYWORDS } from "./api.js";
|
||||
import plugin from "./index.js";
|
||||
import { DOUBAO_CODING_MODEL_CATALOG, DOUBAO_MODEL_CATALOG } from "./models.js";
|
||||
import { VOLCENGINE_PROVIDER_CATALOG_ENTRIES } from "./provider-catalog.js";
|
||||
|
||||
describe("volcengine plugin", () => {
|
||||
it("preserves both provider-owned static catalogs and paired ordering", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
expect(provider.catalog?.order).toBe("paired");
|
||||
expect(provider.staticCatalog?.order).toBe("paired");
|
||||
expect(await provider.staticCatalog?.run({} as never)).toEqual({
|
||||
providers: Object.fromEntries(
|
||||
VOLCENGINE_PROVIDER_CATALOG_ENTRIES.map(({ id, buildProvider }) => [id, buildProvider()]),
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
it("augments the catalog with bundled standard and plan models", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
expect(provider.auth?.[0]?.starterModel).toBe("volcengine-plan/ark-code-latest");
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
// Volcengine plugin entrypoint registers its OpenClaw integration.
|
||||
import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import { buildOpenAICompatibleLiveModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
|
||||
import { readManifestProviderDefaultModelRef } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
|
||||
import { ensureModelAllowlistEntry } from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { applyVolcengineToolSchemaCompat } from "./api.js";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
|
|
@ -15,95 +14,67 @@ const VOLCENGINE_DEFAULT_MODEL_REF = readManifestProviderDefaultModelRef(
|
|||
"volcengine-plan",
|
||||
)!;
|
||||
|
||||
export default definePluginEntry({
|
||||
export default defineSingleProviderPluginEntry({
|
||||
id: PROVIDER_ID,
|
||||
name: "Volcengine Provider",
|
||||
description: "Bundled Volcengine provider plugin",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: "Volcengine",
|
||||
docsPath: "/concepts/model-providers#volcano-engine-doubao",
|
||||
envVars: ["VOLCANO_ENGINE_API_KEY"],
|
||||
hookAliases: ["volcengine-plan"],
|
||||
auth: [
|
||||
createProviderApiKeyAuthMethod({
|
||||
providerId: PROVIDER_ID,
|
||||
methodId: "api-key",
|
||||
label: "Volcano Engine API key",
|
||||
hint: "API key",
|
||||
optionKey: "volcengineApiKey",
|
||||
flagName: "--volcengine-api-key",
|
||||
envVar: "VOLCANO_ENGINE_API_KEY",
|
||||
promptMessage: "Enter Volcano Engine API key",
|
||||
defaultModel: VOLCENGINE_DEFAULT_MODEL_REF,
|
||||
expectedProviders: ["volcengine"],
|
||||
applyConfig: (cfg) =>
|
||||
ensureModelAllowlistEntry({
|
||||
cfg,
|
||||
modelRef: VOLCENGINE_DEFAULT_MODEL_REF,
|
||||
}),
|
||||
wizard: {
|
||||
choiceId: "volcengine-api-key",
|
||||
choiceLabel: "Volcano Engine API key",
|
||||
groupId: "volcengine",
|
||||
groupLabel: "Volcano Engine",
|
||||
groupHint: "API key",
|
||||
},
|
||||
}),
|
||||
],
|
||||
catalog: {
|
||||
order: "paired",
|
||||
run: async (ctx) => {
|
||||
const auth = ctx.resolveProviderApiKey(PROVIDER_ID);
|
||||
const apiKey = auth.apiKey;
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
providers: Object.fromEntries(
|
||||
await Promise.all(
|
||||
VOLCENGINE_PROVIDER_CATALOG_ENTRIES.map(
|
||||
async ({ id, buildProvider }) =>
|
||||
[
|
||||
id,
|
||||
await buildOpenAICompatibleLiveModelProviderConfig({
|
||||
providerId: id,
|
||||
providerConfig: buildProvider(),
|
||||
apiKey,
|
||||
discoveryApiKey: auth.discoveryApiKey,
|
||||
}),
|
||||
] as const,
|
||||
),
|
||||
manifest,
|
||||
provider: {
|
||||
label: "Volcengine",
|
||||
docsPath: "/concepts/model-providers#volcano-engine-doubao",
|
||||
hookAliases: ["volcengine-plan"],
|
||||
manifestAuth: {
|
||||
defaultModel: VOLCENGINE_DEFAULT_MODEL_REF,
|
||||
applyConfig: (cfg) =>
|
||||
ensureModelAllowlistEntry({ cfg, modelRef: VOLCENGINE_DEFAULT_MODEL_REF }),
|
||||
},
|
||||
catalog: {
|
||||
order: "paired",
|
||||
run: async (ctx) => {
|
||||
const auth = ctx.resolveProviderApiKey(PROVIDER_ID);
|
||||
const apiKey = auth.apiKey;
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
providers: Object.fromEntries(
|
||||
await Promise.all(
|
||||
VOLCENGINE_PROVIDER_CATALOG_ENTRIES.map(
|
||||
async ({ id, buildProvider }) =>
|
||||
[
|
||||
id,
|
||||
await buildOpenAICompatibleLiveModelProviderConfig({
|
||||
providerId: id,
|
||||
providerConfig: buildProvider(),
|
||||
apiKey,
|
||||
discoveryApiKey: auth.discoveryApiKey,
|
||||
}),
|
||||
] as const,
|
||||
),
|
||||
),
|
||||
};
|
||||
},
|
||||
},
|
||||
staticCatalog: {
|
||||
order: "paired",
|
||||
run: async () => ({
|
||||
providers: Object.fromEntries(
|
||||
VOLCENGINE_PROVIDER_CATALOG_ENTRIES.map(({ id, buildProvider }) => [
|
||||
id,
|
||||
buildProvider(),
|
||||
]),
|
||||
),
|
||||
}),
|
||||
};
|
||||
},
|
||||
augmentModelCatalog: () =>
|
||||
VOLCENGINE_PROVIDER_CATALOG_ENTRIES.flatMap(({ id: provider, models }) =>
|
||||
models.map((entry) => ({
|
||||
provider,
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
reasoning: entry.reasoning,
|
||||
input: [...entry.input],
|
||||
contextWindow: entry.contextWindow,
|
||||
})),
|
||||
staticRun: async () => ({
|
||||
providers: Object.fromEntries(
|
||||
VOLCENGINE_PROVIDER_CATALOG_ENTRIES.map(({ id, buildProvider }) => [id, buildProvider()]),
|
||||
),
|
||||
normalizeResolvedModel: ({ model }) => applyVolcengineToolSchemaCompat(model),
|
||||
});
|
||||
}),
|
||||
},
|
||||
augmentModelCatalog: () =>
|
||||
VOLCENGINE_PROVIDER_CATALOG_ENTRIES.flatMap(({ id: provider, models }) =>
|
||||
models.map((entry) => ({
|
||||
provider,
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
reasoning: entry.reasoning,
|
||||
input: [...entry.input],
|
||||
contextWindow: entry.contextWindow,
|
||||
})),
|
||||
),
|
||||
normalizeResolvedModel: ({ model }) => applyVolcengineToolSchemaCompat(model),
|
||||
},
|
||||
register(api) {
|
||||
api.registerSpeechProvider(buildVolcengineSpeechProvider());
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ import path from "node:path";
|
|||
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
|
||||
import type { Context, Model } from "openclaw/plugin-sdk/llm";
|
||||
import { registerSingleProviderPlugin } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import { buildOpenAICompletionsParams } from "openclaw/plugin-sdk/provider-transport-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import plugin from "./index.js";
|
||||
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
||||
|
||||
function createGlm47Template() {
|
||||
return {
|
||||
|
|
@ -49,6 +51,26 @@ function expectModelFields(
|
|||
}
|
||||
|
||||
describe("zai provider plugin", () => {
|
||||
it("preserves all regional auth choices and the exact manifest-owned static catalog", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
expect(provider.aliases).toEqual(["z-ai", "z.ai"]);
|
||||
expect(provider.envVars).toEqual(["ZAI_API_KEY", "Z_AI_API_KEY"]);
|
||||
expect(provider.auth.map((method) => method.id)).toEqual([
|
||||
"api-key",
|
||||
"coding-global",
|
||||
"coding-cn",
|
||||
"global",
|
||||
"cn",
|
||||
]);
|
||||
expect(await provider.staticCatalog?.run({} as never)).toEqual({
|
||||
provider: buildManifestModelProviderConfig({
|
||||
providerId: "zai",
|
||||
catalog: manifest.modelCatalog.providers.zai,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("owns replay policy for OpenAI-compatible Z.ai transports", async () => {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
|
||||
|
|
|
|||
|
|
@ -2,13 +2,12 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
definePluginEntry,
|
||||
type ProviderAuthContext,
|
||||
type ProviderAuthMethod,
|
||||
type ProviderAuthMethodNonInteractiveContext,
|
||||
type ProviderResolveDynamicModelContext,
|
||||
type ProviderWrapStreamFnContext,
|
||||
import type {
|
||||
ProviderAuthContext,
|
||||
ProviderAuthMethod,
|
||||
ProviderAuthMethodNonInteractiveContext,
|
||||
ProviderResolveDynamicModelContext,
|
||||
ProviderWrapStreamFnContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
applyAuthProfileConfig,
|
||||
|
|
@ -20,8 +19,7 @@ import {
|
|||
upsertAuthProfileWithLock,
|
||||
validateApiKeyInput,
|
||||
} from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import { buildOpenAICompatibleProviderCatalog } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
|
||||
import { buildManifestModelProviderConfig } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import { defineSingleProviderPluginEntry } from "openclaw/plugin-sdk/provider-entry";
|
||||
import {
|
||||
buildProviderReplayFamilyHooks,
|
||||
resolveFamilyForwardCompatModel,
|
||||
|
|
@ -45,13 +43,6 @@ const GLM5_TEMPLATE_MODEL_ID = "glm-4.7";
|
|||
const PROFILE_ID = "zai:default";
|
||||
type UpsertAuthProfileParams = Parameters<typeof upsertAuthProfileWithLock>[0];
|
||||
|
||||
function buildZaiCatalogProvider() {
|
||||
return buildManifestModelProviderConfig({
|
||||
providerId: PROVIDER_ID,
|
||||
catalog: manifest.modelCatalog.providers.zai,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveDeprecatedPiAgentAuthPath(env: NodeJS.ProcessEnv): string {
|
||||
const home = env.HOME?.trim() || env.USERPROFILE?.trim() || os.homedir();
|
||||
return path.join(home, ".pi", "agent", "auth.json");
|
||||
|
|
@ -340,101 +331,89 @@ function buildZaiApiKeyMethod(params: {
|
|||
};
|
||||
}
|
||||
|
||||
export default definePluginEntry({
|
||||
export default defineSingleProviderPluginEntry({
|
||||
id: PROVIDER_ID,
|
||||
name: "Z.AI Provider",
|
||||
description: "Bundled Z.AI provider plugin",
|
||||
register(api) {
|
||||
api.registerProvider({
|
||||
id: PROVIDER_ID,
|
||||
label: "Z.AI",
|
||||
aliases: ["z-ai", "z.ai"],
|
||||
docsPath: "/providers/models",
|
||||
envVars: ["ZAI_API_KEY", "Z_AI_API_KEY"],
|
||||
auth: [
|
||||
buildZaiApiKeyMethod({
|
||||
id: "api-key",
|
||||
choiceId: "zai-api-key",
|
||||
choiceLabel: "Z.AI API key",
|
||||
}),
|
||||
buildZaiApiKeyMethod({
|
||||
id: "coding-global",
|
||||
choiceId: "zai-coding-global",
|
||||
choiceLabel: "Coding-Plan-Global",
|
||||
choiceHint: "GLM Coding Plan Global (api.z.ai)",
|
||||
endpoint: "coding-global",
|
||||
}),
|
||||
buildZaiApiKeyMethod({
|
||||
id: "coding-cn",
|
||||
choiceId: "zai-coding-cn",
|
||||
choiceLabel: "Coding-Plan-CN",
|
||||
choiceHint: "GLM Coding Plan CN (open.bigmodel.cn)",
|
||||
endpoint: "coding-cn",
|
||||
}),
|
||||
buildZaiApiKeyMethod({
|
||||
id: "global",
|
||||
choiceId: "zai-global",
|
||||
choiceLabel: "Global",
|
||||
choiceHint: "Z.AI Global (api.z.ai)",
|
||||
endpoint: "global",
|
||||
}),
|
||||
buildZaiApiKeyMethod({
|
||||
id: "cn",
|
||||
choiceId: "zai-cn",
|
||||
choiceLabel: "CN",
|
||||
choiceHint: "Z.AI CN (open.bigmodel.cn)",
|
||||
endpoint: "cn",
|
||||
}),
|
||||
],
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: (ctx) =>
|
||||
buildOpenAICompatibleProviderCatalog({
|
||||
ctx,
|
||||
providerId: PROVIDER_ID,
|
||||
buildProvider: buildZaiCatalogProvider,
|
||||
allowExplicitBaseUrl: true,
|
||||
}),
|
||||
},
|
||||
staticCatalog: {
|
||||
order: "simple",
|
||||
run: async () => ({ provider: buildZaiCatalogProvider() }),
|
||||
},
|
||||
resolveDynamicModel: (ctx) => resolveGlm5ForwardCompatModel(ctx),
|
||||
matchesContextOverflowError: ({ errorMessage }) =>
|
||||
/\b(?:tokens? in request more than max tokens? allowed|prompt exceeds max(?:imum)? length)\b/i.test(
|
||||
errorMessage,
|
||||
),
|
||||
...buildProviderReplayFamilyHooks({
|
||||
family: "openai-compatible",
|
||||
dropReasoningFromHistory: false,
|
||||
manifest,
|
||||
provider: {
|
||||
label: "Z.AI",
|
||||
aliases: ["z-ai", "z.ai"],
|
||||
docsPath: "/providers/models",
|
||||
envVars: ["ZAI_API_KEY", "Z_AI_API_KEY"],
|
||||
auth: [],
|
||||
extraAuth: [
|
||||
buildZaiApiKeyMethod({
|
||||
id: "api-key",
|
||||
choiceId: "zai-api-key",
|
||||
choiceLabel: "Z.AI API key",
|
||||
}),
|
||||
prepareExtraParams: (ctx) => defaultToolStreamExtraParams(ctx.extraParams),
|
||||
wrapStreamFn: (ctx) => wrapZaiStreamFn(ctx),
|
||||
resolveThinkingProfile,
|
||||
isModernModelRef: ({ modelId }) => {
|
||||
const lower = normalizeLowercaseStringOrEmpty(modelId);
|
||||
return (
|
||||
lower.startsWith("glm-5") ||
|
||||
lower.startsWith("glm-4.7") ||
|
||||
lower.startsWith("glm-4.7-flash") ||
|
||||
lower.startsWith("glm-4.7-flashx")
|
||||
);
|
||||
},
|
||||
resolveUsageAuth: async (ctx) => {
|
||||
const apiKey = ctx.resolveApiKeyFromConfigAndStore({
|
||||
providerIds: [PROVIDER_ID, "z-ai"],
|
||||
envDirect: [ctx.env.ZAI_API_KEY, ctx.env.Z_AI_API_KEY],
|
||||
});
|
||||
if (apiKey) {
|
||||
return { token: apiKey };
|
||||
}
|
||||
const legacyToken = resolveDeprecatedPiAgentAccessToken(ctx.env, ["z-ai", PROVIDER_ID]);
|
||||
return legacyToken ? { token: legacyToken } : null;
|
||||
},
|
||||
fetchUsageSnapshot: async (ctx) => await fetchZaiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn),
|
||||
isCacheTtlEligible: () => true,
|
||||
});
|
||||
buildZaiApiKeyMethod({
|
||||
id: "coding-global",
|
||||
choiceId: "zai-coding-global",
|
||||
choiceLabel: "Coding-Plan-Global",
|
||||
choiceHint: "GLM Coding Plan Global (api.z.ai)",
|
||||
endpoint: "coding-global",
|
||||
}),
|
||||
buildZaiApiKeyMethod({
|
||||
id: "coding-cn",
|
||||
choiceId: "zai-coding-cn",
|
||||
choiceLabel: "Coding-Plan-CN",
|
||||
choiceHint: "GLM Coding Plan CN (open.bigmodel.cn)",
|
||||
endpoint: "coding-cn",
|
||||
}),
|
||||
buildZaiApiKeyMethod({
|
||||
id: "global",
|
||||
choiceId: "zai-global",
|
||||
choiceLabel: "Global",
|
||||
choiceHint: "Z.AI Global (api.z.ai)",
|
||||
endpoint: "global",
|
||||
}),
|
||||
buildZaiApiKeyMethod({
|
||||
id: "cn",
|
||||
choiceId: "zai-cn",
|
||||
choiceLabel: "CN",
|
||||
choiceHint: "Z.AI CN (open.bigmodel.cn)",
|
||||
endpoint: "cn",
|
||||
}),
|
||||
],
|
||||
catalog: { allowExplicitBaseUrl: true, liveModelDiscovery: true },
|
||||
resolveDynamicModel: (ctx) => resolveGlm5ForwardCompatModel(ctx),
|
||||
matchesContextOverflowError: ({ errorMessage }) =>
|
||||
/\b(?:tokens? in request more than max tokens? allowed|prompt exceeds max(?:imum)? length)\b/i.test(
|
||||
errorMessage,
|
||||
),
|
||||
...buildProviderReplayFamilyHooks({
|
||||
family: "openai-compatible",
|
||||
dropReasoningFromHistory: false,
|
||||
}),
|
||||
prepareExtraParams: (ctx) => defaultToolStreamExtraParams(ctx.extraParams),
|
||||
wrapStreamFn: (ctx) => wrapZaiStreamFn(ctx),
|
||||
resolveThinkingProfile,
|
||||
isModernModelRef: ({ modelId }) => {
|
||||
const lower = normalizeLowercaseStringOrEmpty(modelId);
|
||||
return (
|
||||
lower.startsWith("glm-5") ||
|
||||
lower.startsWith("glm-4.7") ||
|
||||
lower.startsWith("glm-4.7-flash") ||
|
||||
lower.startsWith("glm-4.7-flashx")
|
||||
);
|
||||
},
|
||||
resolveUsageAuth: async (ctx) => {
|
||||
const apiKey = ctx.resolveApiKeyFromConfigAndStore({
|
||||
providerIds: [PROVIDER_ID, "z-ai"],
|
||||
envDirect: [ctx.env.ZAI_API_KEY, ctx.env.Z_AI_API_KEY],
|
||||
});
|
||||
if (apiKey) {
|
||||
return { token: apiKey };
|
||||
}
|
||||
const legacyToken = resolveDeprecatedPiAgentAccessToken(ctx.env, ["z-ai", PROVIDER_ID]);
|
||||
return legacyToken ? { token: legacyToken } : null;
|
||||
},
|
||||
fetchUsageSnapshot: async (ctx) => await fetchZaiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn),
|
||||
isCacheTtlEligible: () => true,
|
||||
},
|
||||
register(api) {
|
||||
api.registerMediaUnderstandingProvider(zaiMediaUnderstandingProvider);
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -137,6 +137,132 @@ describe("defineSingleProviderPluginEntry", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it("preserves manifest-owned onboarding scope and assistant metadata", () => {
|
||||
const manifest = createProviderManifest();
|
||||
const entry = defineSingleProviderPluginEntry({
|
||||
id: "demo",
|
||||
name: "Demo Provider",
|
||||
description: "Demo provider plugin",
|
||||
manifest: {
|
||||
...manifest,
|
||||
providerAuthChoices: [
|
||||
{
|
||||
...manifest.providerAuthChoices[0]!,
|
||||
assistantPriority: 4,
|
||||
assistantVisibility: "manual-only",
|
||||
onboardingFeatured: true,
|
||||
onboardingScopes: ["text-inference", "music-generation"],
|
||||
},
|
||||
],
|
||||
},
|
||||
provider: { label: "Demo", docsPath: "/providers/demo", catalog: {} },
|
||||
});
|
||||
|
||||
expect(capturePluginRegistration(entry).providers[0]?.auth[0]?.wizard).toMatchObject({
|
||||
assistantPriority: 4,
|
||||
assistantVisibility: "manual-only",
|
||||
onboardingFeatured: true,
|
||||
onboardingScopes: ["text-inference", "music-generation"],
|
||||
});
|
||||
});
|
||||
|
||||
it("creates registration-scoped provider state for provider factories", () => {
|
||||
let registrations = 0;
|
||||
const registrationApis: unknown[] = [];
|
||||
const entry = defineSingleProviderPluginEntry({
|
||||
id: "demo",
|
||||
name: "Demo Provider",
|
||||
description: "Demo provider plugin",
|
||||
manifest: createProviderManifest(),
|
||||
provider(api) {
|
||||
registrationApis.push(api);
|
||||
const registration = ++registrations;
|
||||
return {
|
||||
label: "Demo",
|
||||
docsPath: "/providers/demo",
|
||||
catalog: {},
|
||||
normalizeModelId: ({ modelId }) => `${modelId}:${registration}`,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const firstRegistration = capturePluginRegistration(entry);
|
||||
const secondRegistration = capturePluginRegistration(entry);
|
||||
const first = firstRegistration.providers[0];
|
||||
const second = secondRegistration.providers[0];
|
||||
|
||||
expect(first?.normalizeModelId?.({ provider: "demo", modelId: "example" })).toBe("example:1");
|
||||
expect(second?.normalizeModelId?.({ provider: "demo", modelId: "example" })).toBe("example:2");
|
||||
expect(registrationApis).toEqual([firstRegistration.api, secondRegistration.api]);
|
||||
expect(registrationApis[0]).not.toBe(registrationApis[1]);
|
||||
expect(registrations).toBe(2);
|
||||
});
|
||||
|
||||
it("merges manifest onboarding metadata with provider-owned wizard model policies", () => {
|
||||
const manifest = createProviderManifest();
|
||||
const entry = defineSingleProviderPluginEntry({
|
||||
id: "demo",
|
||||
name: "Demo Provider",
|
||||
description: "Demo provider plugin",
|
||||
manifest: {
|
||||
...manifest,
|
||||
providerAuthChoices: [
|
||||
{
|
||||
...manifest.providerAuthChoices[0]!,
|
||||
assistantPriority: 4,
|
||||
assistantVisibility: "manual-only",
|
||||
onboardingFeatured: true,
|
||||
onboardingScopes: ["text-inference", "music-generation"],
|
||||
},
|
||||
],
|
||||
},
|
||||
provider: {
|
||||
label: "Demo",
|
||||
docsPath: "/providers/demo",
|
||||
manifestAuth: {
|
||||
wizard: {
|
||||
modelAllowlist: { allowedKeys: ["demo/default"], loadCatalog: true },
|
||||
modelSelection: { promptWhenAuthChoiceProvided: true, allowKeepCurrent: false },
|
||||
},
|
||||
},
|
||||
catalog: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturePluginRegistration(entry).providers[0]?.auth[0]?.wizard).toMatchObject({
|
||||
choiceId: "demo-api-key",
|
||||
choiceLabel: "Demo API key",
|
||||
choiceHint: "Manifest-owned key",
|
||||
groupId: "demo-group",
|
||||
groupLabel: "Demo providers",
|
||||
groupHint: "Manifest-owned setup",
|
||||
assistantPriority: 4,
|
||||
assistantVisibility: "manual-only",
|
||||
onboardingFeatured: true,
|
||||
onboardingScopes: ["text-inference", "music-generation"],
|
||||
methodId: "api-key",
|
||||
modelAllowlist: { allowedKeys: ["demo/default"], loadCatalog: true },
|
||||
modelSelection: { promptWhenAuthChoiceProvided: true, allowKeepCurrent: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("allows provider-owned manifest auth to disable the onboarding wizard", () => {
|
||||
const entry = defineSingleProviderPluginEntry({
|
||||
id: "demo",
|
||||
name: "Demo Provider",
|
||||
description: "Demo provider plugin",
|
||||
manifest: createProviderManifest(),
|
||||
provider: {
|
||||
label: "Demo",
|
||||
docsPath: "/providers/demo",
|
||||
manifestAuth: { wizard: false },
|
||||
catalog: {},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturePluginRegistration(entry).providers[0]?.auth[0]?.wizard).toBeUndefined();
|
||||
});
|
||||
|
||||
it("honors explicit base URLs and provider-owned manifest auth overrides", async () => {
|
||||
const entry = defineSingleProviderPluginEntry({
|
||||
id: "demo",
|
||||
|
|
|
|||
|
|
@ -42,11 +42,30 @@ import {
|
|||
|
||||
type ApiKeyAuthMethodOptions = Parameters<typeof createProviderApiKeyAuthMethod>[0];
|
||||
|
||||
type SingleProviderPluginManifestAuthChoice = Pick<
|
||||
PluginManifestProviderAuthChoice,
|
||||
| "provider"
|
||||
| "method"
|
||||
| "choiceId"
|
||||
| "choiceLabel"
|
||||
| "choiceHint"
|
||||
| "groupId"
|
||||
| "groupLabel"
|
||||
| "groupHint"
|
||||
| "optionKey"
|
||||
| "cliFlag"
|
||||
| "assistantPriority"
|
||||
| "onboardingFeatured"
|
||||
> & {
|
||||
assistantVisibility?: string;
|
||||
onboardingScopes?: readonly string[];
|
||||
};
|
||||
|
||||
type SingleProviderPluginManifest = {
|
||||
setup?: {
|
||||
providers?: readonly Pick<PluginManifestSetupProvider, "id" | "envVars">[];
|
||||
};
|
||||
providerAuthChoices?: readonly PluginManifestProviderAuthChoice[];
|
||||
providerAuthChoices?: readonly SingleProviderPluginManifestAuthChoice[];
|
||||
modelCatalog?: {
|
||||
providers?: Readonly<Record<string, unknown>>;
|
||||
discovery?: Readonly<Record<string, unknown>>;
|
||||
|
|
@ -126,6 +145,48 @@ export type SingleProviderPluginCatalogOptions =
|
|||
/**
|
||||
* Defines one provider plugin plus optional extra registration hooks.
|
||||
*/
|
||||
type SingleProviderPluginDefinition = {
|
||||
/**
|
||||
* Provider id override when the runtime provider id differs from the plugin id.
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* Human-readable provider label.
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* Documentation route used by provider setup and diagnostics.
|
||||
*/
|
||||
docsPath: string;
|
||||
/**
|
||||
* Alternate provider ids accepted by routing and configuration lookups.
|
||||
*/
|
||||
aliases?: string[];
|
||||
/**
|
||||
* Explicit environment variables advertised for credentials.
|
||||
*/
|
||||
envVars?: string[];
|
||||
/**
|
||||
* API-key auth methods converted through the shared provider auth helper.
|
||||
*/
|
||||
auth?: SingleProviderPluginApiKeyAuthOptions[];
|
||||
/**
|
||||
* Provider-owned behavior layered over manifest-derived API-key auth.
|
||||
*/
|
||||
manifestAuth?: ManifestProviderAuthOptions;
|
||||
/**
|
||||
* Non-API-key or provider-owned auth methods appended after generated methods.
|
||||
*/
|
||||
extraAuth?: ProviderAuthMethod[];
|
||||
/**
|
||||
* Live/static catalog implementation for this provider.
|
||||
*/
|
||||
catalog: SingleProviderPluginCatalogOptions;
|
||||
} & Omit<
|
||||
ProviderPlugin,
|
||||
"id" | "label" | "docsPath" | "aliases" | "envVars" | "auth" | "catalog" | "staticCatalog"
|
||||
>;
|
||||
|
||||
export type SingleProviderPluginOptions = {
|
||||
/**
|
||||
* Plugin id and default provider id when `provider.id` is omitted.
|
||||
|
|
@ -158,47 +219,9 @@ export type SingleProviderPluginOptions = {
|
|||
* Primary provider registration. Extra provider fields are forwarded after
|
||||
* the helper-owned id/auth/catalog fields are normalized.
|
||||
*/
|
||||
provider?: {
|
||||
/**
|
||||
* Provider id override when the runtime provider id differs from the plugin id.
|
||||
*/
|
||||
id?: string;
|
||||
/**
|
||||
* Human-readable provider label.
|
||||
*/
|
||||
label: string;
|
||||
/**
|
||||
* Documentation route used by provider setup and diagnostics.
|
||||
*/
|
||||
docsPath: string;
|
||||
/**
|
||||
* Alternate provider ids accepted by routing and configuration lookups.
|
||||
*/
|
||||
aliases?: string[];
|
||||
/**
|
||||
* Explicit environment variables advertised for credentials.
|
||||
*/
|
||||
envVars?: string[];
|
||||
/**
|
||||
* API-key auth methods converted through the shared provider auth helper.
|
||||
*/
|
||||
auth?: SingleProviderPluginApiKeyAuthOptions[];
|
||||
/**
|
||||
* Provider-owned behavior layered over manifest-derived API-key auth.
|
||||
*/
|
||||
manifestAuth?: ManifestProviderAuthOptions;
|
||||
/**
|
||||
* Non-API-key auth methods appended after generated API-key methods.
|
||||
*/
|
||||
extraAuth?: ProviderAuthMethod[];
|
||||
/**
|
||||
* Live/static catalog implementation for this provider.
|
||||
*/
|
||||
catalog: SingleProviderPluginCatalogOptions;
|
||||
} & Omit<
|
||||
ProviderPlugin,
|
||||
"id" | "label" | "docsPath" | "aliases" | "envVars" | "auth" | "catalog" | "staticCatalog"
|
||||
>;
|
||||
provider?:
|
||||
| SingleProviderPluginDefinition
|
||||
| ((api: OpenClawPluginApi) => SingleProviderPluginDefinition);
|
||||
/**
|
||||
* Optional hook for registering companion capabilities with the same plugin entry.
|
||||
*/
|
||||
|
|
@ -223,6 +246,14 @@ function resolveManifestProviderAuth(params: {
|
|||
throw new Error(`Incomplete manifest API-key auth for provider "${params.providerId}"`);
|
||||
}
|
||||
const defaultModel = readManifestProviderDefaultModelRef(params.manifest, params.providerId);
|
||||
const assistantVisibility =
|
||||
choice.assistantVisibility === "visible" || choice.assistantVisibility === "manual-only"
|
||||
? choice.assistantVisibility
|
||||
: undefined;
|
||||
const onboardingScopes = choice.onboardingScopes?.filter(
|
||||
(scope): scope is NonNullable<ProviderPluginWizardSetup["onboardingScopes"]>[number] =>
|
||||
scope === "text-inference" || scope === "image-generation" || scope === "music-generation",
|
||||
);
|
||||
return [
|
||||
{
|
||||
methodId: choice.method,
|
||||
|
|
@ -235,16 +266,28 @@ function resolveManifestProviderAuth(params: {
|
|||
envVar,
|
||||
promptMessage: `Enter ${choice.choiceLabel}`,
|
||||
...(defaultModel ? { defaultModel } : {}),
|
||||
wizard: {
|
||||
choiceId: choice.choiceId,
|
||||
choiceLabel: choice.choiceLabel,
|
||||
groupId: choice.groupId ?? params.providerId,
|
||||
groupLabel: choice.groupLabel ?? params.providerLabel,
|
||||
...(choice.choiceHint ? { choiceHint: choice.choiceHint } : {}),
|
||||
...(choice.groupHint ? { groupHint: choice.groupHint } : {}),
|
||||
methodId: choice.method,
|
||||
},
|
||||
...params.overrides,
|
||||
wizard:
|
||||
params.overrides?.wizard === false
|
||||
? false
|
||||
: {
|
||||
choiceId: choice.choiceId,
|
||||
choiceLabel: choice.choiceLabel,
|
||||
groupId: choice.groupId ?? params.providerId,
|
||||
groupLabel: choice.groupLabel ?? params.providerLabel,
|
||||
...(choice.choiceHint ? { choiceHint: choice.choiceHint } : {}),
|
||||
...(choice.groupHint ? { groupHint: choice.groupHint } : {}),
|
||||
...(choice.assistantPriority !== undefined
|
||||
? { assistantPriority: choice.assistantPriority }
|
||||
: {}),
|
||||
...(assistantVisibility ? { assistantVisibility } : {}),
|
||||
...(choice.onboardingFeatured !== undefined
|
||||
? { onboardingFeatured: choice.onboardingFeatured }
|
||||
: {}),
|
||||
...(onboardingScopes?.length ? { onboardingScopes } : {}),
|
||||
methodId: choice.method,
|
||||
...params.overrides?.wizard,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -263,6 +306,13 @@ function resolveWizardSetup(params: {
|
|||
choiceId: wizard.choiceId ?? `${params.providerId}-${methodId}`,
|
||||
choiceLabel: wizard.choiceLabel ?? params.auth.label,
|
||||
...(wizard.choiceHint ? { choiceHint: wizard.choiceHint } : {}),
|
||||
...(wizard.assistantPriority !== undefined
|
||||
? { assistantPriority: wizard.assistantPriority }
|
||||
: {}),
|
||||
...(wizard.assistantVisibility ? { assistantVisibility: wizard.assistantVisibility } : {}),
|
||||
...(wizard.onboardingFeatured !== undefined
|
||||
? { onboardingFeatured: wizard.onboardingFeatured }
|
||||
: {}),
|
||||
groupId: wizard.groupId ?? params.providerId,
|
||||
groupLabel: wizard.groupLabel ?? params.providerLabel,
|
||||
...((wizard.groupHint ?? params.auth.hint)
|
||||
|
|
@ -271,6 +321,7 @@ function resolveWizardSetup(params: {
|
|||
methodId,
|
||||
...(wizard.onboardingScopes ? { onboardingScopes: wizard.onboardingScopes } : {}),
|
||||
...(wizard.modelAllowlist ? { modelAllowlist: wizard.modelAllowlist } : {}),
|
||||
...(wizard.modelSelection ? { modelSelection: wizard.modelSelection } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -320,7 +371,9 @@ export function defineSingleProviderPluginEntry(options: SingleProviderPluginOpt
|
|||
...(options.kind ? { kind: options.kind } : {}),
|
||||
...(options.configSchema ? { configSchema: options.configSchema } : {}),
|
||||
register(api) {
|
||||
const provider = options.provider;
|
||||
// Factories keep caches and other provider state owned by each plugin registration.
|
||||
const provider =
|
||||
typeof options.provider === "function" ? options.provider(api) : options.provider;
|
||||
if (provider) {
|
||||
const providerId = provider.id ?? options.id;
|
||||
if (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue