diff --git a/extensions/anthropic/provider-contract-api.ts b/extensions/anthropic/provider-contract-api.ts new file mode 100644 index 00000000000..34acbcc9d7f --- /dev/null +++ b/extensions/anthropic/provider-contract-api.ts @@ -0,0 +1,59 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +const noopAuth = async () => ({ profiles: [] }); + +export function createAnthropicProvider(): ProviderPlugin { + return { + id: "anthropic", + label: "Anthropic", + docsPath: "/providers/models", + hookAliases: ["claude-cli"], + envVars: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY"], + auth: [ + { + id: "cli", + kind: "custom", + label: "Claude CLI", + hint: "Reuse a local Claude CLI login and switch model selection to claude-cli/*", + run: noopAuth, + wizard: { + choiceId: "anthropic-cli", + choiceLabel: "Anthropic Claude CLI", + choiceHint: "Reuse a local Claude CLI login on this host", + groupId: "anthropic", + groupLabel: "Anthropic", + groupHint: "Claude CLI + API key", + }, + }, + { + id: "setup-token", + kind: "token", + label: "Anthropic setup-token", + hint: "Manual bearer token path", + run: noopAuth, + wizard: { + choiceId: "setup-token", + choiceLabel: "Anthropic setup-token", + choiceHint: "Manual token path", + groupId: "anthropic", + groupLabel: "Anthropic", + groupHint: "Claude CLI + API key + token", + }, + }, + { + id: "api-key", + kind: "api_key", + label: "Anthropic API key", + hint: "Direct Anthropic API key", + run: noopAuth, + wizard: { + choiceId: "apiKey", + choiceLabel: "Anthropic API key", + groupId: "anthropic", + groupLabel: "Anthropic", + groupHint: "Claude CLI + API key", + }, + }, + ], + }; +} diff --git a/extensions/fal/index.ts b/extensions/fal/index.ts index 87cedfadfc2..3d38d821272 100644 --- a/extensions/fal/index.ts +++ b/extensions/fal/index.ts @@ -1,7 +1,6 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry"; -import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; import { buildFalImageGenerationProvider } from "./image-generation-provider.js"; -import { applyFalConfig, FAL_DEFAULT_IMAGE_MODEL_REF } from "./onboard.js"; +import { createFalProvider } from "./provider-registration.js"; import { buildFalVideoGenerationProvider } from "./video-generation-provider.js"; const PROVIDER_ID = "fal"; @@ -11,36 +10,7 @@ export default definePluginEntry({ name: "fal Provider", description: "Bundled fal image and video generation provider", register(api) { - api.registerProvider({ - id: PROVIDER_ID, - label: "fal", - docsPath: "/providers/models", - envVars: ["FAL_KEY"], - auth: [ - createProviderApiKeyAuthMethod({ - providerId: PROVIDER_ID, - methodId: "api-key", - label: "fal API key", - hint: "Image and video generation API key", - optionKey: "falApiKey", - flagName: "--fal-api-key", - envVar: "FAL_KEY", - promptMessage: "Enter fal API key", - defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF, - expectedProviders: ["fal"], - applyConfig: (cfg) => applyFalConfig(cfg), - wizard: { - choiceId: "fal-api-key", - choiceLabel: "fal API key", - choiceHint: "Image and video generation API key", - groupId: "fal", - groupLabel: "fal", - groupHint: "Image and video generation", - onboardingScopes: ["image-generation"], - }, - }), - ], - }); + api.registerProvider(createFalProvider()); api.registerImageGenerationProvider(buildFalImageGenerationProvider()); api.registerVideoGenerationProvider(buildFalVideoGenerationProvider()); }, diff --git a/extensions/fal/provider-contract-api.ts b/extensions/fal/provider-contract-api.ts new file mode 100644 index 00000000000..66fbf2fd532 --- /dev/null +++ b/extensions/fal/provider-contract-api.ts @@ -0,0 +1,31 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +const PROVIDER_ID = "fal"; +const FAL_DEFAULT_IMAGE_MODEL_REF = "fal/fal-ai/flux/dev"; + +export function createFalProvider(): ProviderPlugin { + return { + id: PROVIDER_ID, + label: "fal", + docsPath: "/providers/models", + envVars: ["FAL_KEY"], + auth: [ + { + id: "api-key", + kind: "api_key", + label: "fal API key", + hint: "Image and video generation API key", + run: async () => ({ profiles: [], defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF }), + wizard: { + choiceId: "fal-api-key", + choiceLabel: "fal API key", + choiceHint: "Image and video generation API key", + groupId: "fal", + groupLabel: "fal", + groupHint: "Image and video generation", + onboardingScopes: ["image-generation"], + }, + }, + ], + }; +} diff --git a/extensions/fal/provider-registration.ts b/extensions/fal/provider-registration.ts new file mode 100644 index 00000000000..d62c879f444 --- /dev/null +++ b/extensions/fal/provider-registration.ts @@ -0,0 +1,38 @@ +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; +import { applyFalConfig, FAL_DEFAULT_IMAGE_MODEL_REF } from "./onboard.js"; + +const PROVIDER_ID = "fal"; + +export function createFalProvider(): ProviderPlugin { + return { + id: PROVIDER_ID, + label: "fal", + docsPath: "/providers/models", + envVars: ["FAL_KEY"], + auth: [ + createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "fal API key", + hint: "Image and video generation API key", + optionKey: "falApiKey", + flagName: "--fal-api-key", + envVar: "FAL_KEY", + promptMessage: "Enter fal API key", + defaultModel: FAL_DEFAULT_IMAGE_MODEL_REF, + expectedProviders: ["fal"], + applyConfig: (cfg) => applyFalConfig(cfg), + wizard: { + choiceId: "fal-api-key", + choiceLabel: "fal API key", + choiceHint: "Image and video generation API key", + groupId: "fal", + groupLabel: "fal", + groupHint: "Image and video generation", + onboardingScopes: ["image-generation"], + }, + }), + ], + }; +} diff --git a/extensions/google/provider-contract-api.ts b/extensions/google/provider-contract-api.ts new file mode 100644 index 00000000000..50150a7d1c8 --- /dev/null +++ b/extensions/google/provider-contract-api.ts @@ -0,0 +1,61 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +const noopAuth = async () => ({ profiles: [] }); + +export function createGoogleProvider(): ProviderPlugin { + return { + id: "google", + label: "Google AI Studio", + docsPath: "/providers/models", + hookAliases: ["google-antigravity", "google-vertex"], + envVars: ["GEMINI_API_KEY", "GOOGLE_API_KEY"], + auth: [ + { + id: "api-key", + kind: "api_key", + label: "Google Gemini API key", + hint: "AI Studio / Gemini API key", + run: noopAuth, + wizard: { + choiceId: "gemini-api-key", + choiceLabel: "Google Gemini API key", + groupId: "google", + groupLabel: "Google", + groupHint: "Gemini API key + OAuth", + }, + }, + ], + }; +} + +export function createGoogleGeminiCliProvider(): ProviderPlugin { + return { + id: "google-gemini-cli", + label: "Gemini CLI OAuth", + docsPath: "/providers/models", + aliases: ["gemini-cli"], + envVars: [ + "OPENCLAW_GEMINI_OAUTH_CLIENT_ID", + "OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET", + "GEMINI_CLI_OAUTH_CLIENT_ID", + "GEMINI_CLI_OAUTH_CLIENT_SECRET", + ], + auth: [ + { + id: "oauth", + kind: "oauth", + label: "Google OAuth", + hint: "PKCE + localhost callback", + run: noopAuth, + }, + ], + wizard: { + setup: { + choiceId: "google-gemini-cli", + choiceLabel: "Gemini CLI OAuth", + choiceHint: "Google OAuth with project-aware token payload", + methodId: "oauth", + }, + }, + }; +} diff --git a/extensions/minimax/provider-contract-api.ts b/extensions/minimax/provider-contract-api.ts new file mode 100644 index 00000000000..dd7bead4f83 --- /dev/null +++ b/extensions/minimax/provider-contract-api.ts @@ -0,0 +1,84 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +const noopAuth = async () => ({ profiles: [] }); +const wizardGroup = { + groupId: "minimax", + groupLabel: "MiniMax", + groupHint: "M2.7 (recommended)", +} as const; + +export function createMinimaxProvider(): ProviderPlugin { + return { + id: "minimax", + label: "MiniMax", + hookAliases: ["minimax-cn"], + docsPath: "/providers/minimax", + envVars: ["MINIMAX_API_KEY"], + auth: [ + { + id: "api-global", + kind: "api_key", + label: "MiniMax API key (Global)", + hint: "Global endpoint - api.minimax.io", + run: noopAuth, + wizard: { + choiceId: "minimax-global-api", + choiceLabel: "MiniMax API key (Global)", + choiceHint: "Global endpoint - api.minimax.io", + ...wizardGroup, + }, + }, + { + id: "api-cn", + kind: "api_key", + label: "MiniMax API key (CN)", + hint: "CN endpoint - api.minimaxi.com", + run: noopAuth, + wizard: { + choiceId: "minimax-cn-api", + choiceLabel: "MiniMax API key (CN)", + choiceHint: "CN endpoint - api.minimaxi.com", + ...wizardGroup, + }, + }, + ], + }; +} + +export function createMinimaxPortalProvider(): ProviderPlugin { + return { + id: "minimax-portal", + label: "MiniMax", + hookAliases: ["minimax-portal-cn"], + docsPath: "/providers/minimax", + envVars: ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"], + auth: [ + { + id: "oauth", + kind: "device_code", + label: "MiniMax OAuth (Global)", + hint: "Global endpoint - api.minimax.io", + run: noopAuth, + wizard: { + choiceId: "minimax-global-oauth", + choiceLabel: "MiniMax OAuth (Global)", + choiceHint: "Global endpoint - api.minimax.io", + ...wizardGroup, + }, + }, + { + id: "oauth-cn", + kind: "device_code", + label: "MiniMax OAuth (CN)", + hint: "CN endpoint - api.minimaxi.com", + run: noopAuth, + wizard: { + choiceId: "minimax-cn-oauth", + choiceLabel: "MiniMax OAuth (CN)", + choiceHint: "CN endpoint - api.minimaxi.com", + ...wizardGroup, + }, + }, + ], + }; +} diff --git a/extensions/moonshot/provider-contract-api.ts b/extensions/moonshot/provider-contract-api.ts new file mode 100644 index 00000000000..cf45ad17727 --- /dev/null +++ b/extensions/moonshot/provider-contract-api.ts @@ -0,0 +1,33 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +const noopAuth = async () => ({ profiles: [] }); + +export function createMoonshotProvider(): ProviderPlugin { + return { + id: "moonshot", + label: "Moonshot", + docsPath: "/providers/moonshot", + auth: [ + { + id: "api-key", + kind: "api_key", + label: "Kimi API key (.ai)", + hint: "Kimi K2.5 + Kimi", + run: noopAuth, + wizard: { + groupLabel: "Moonshot AI (Kimi K2.5)", + }, + }, + { + id: "api-key-cn", + kind: "api_key", + label: "Kimi API key (.cn)", + hint: "Kimi K2.5 + Kimi", + run: noopAuth, + wizard: { + groupLabel: "Moonshot AI (Kimi K2.5)", + }, + }, + ], + }; +} diff --git a/extensions/openai/provider-contract-api.ts b/extensions/openai/provider-contract-api.ts new file mode 100644 index 00000000000..fbfdac6f459 --- /dev/null +++ b/extensions/openai/provider-contract-api.ts @@ -0,0 +1,54 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +const noopAuth = async () => ({ profiles: [] }); + +export function createOpenAICodexProvider(): ProviderPlugin { + return { + id: "openai-codex", + label: "OpenAI Codex", + docsPath: "/providers/models", + auth: [ + { + id: "oauth", + kind: "oauth", + label: "ChatGPT OAuth", + hint: "Browser sign-in", + run: noopAuth, + }, + ], + wizard: { + setup: { + choiceId: "openai-codex", + choiceLabel: "OpenAI Codex (ChatGPT OAuth)", + choiceHint: "Browser sign-in", + methodId: "oauth", + }, + }, + }; +} + +export function createOpenAIProvider(): ProviderPlugin { + return { + id: "openai", + label: "OpenAI", + hookAliases: ["azure-openai", "azure-openai-responses"], + docsPath: "/providers/models", + envVars: ["OPENAI_API_KEY"], + auth: [ + { + id: "api-key", + kind: "api_key", + label: "OpenAI API key", + hint: "Direct OpenAI API key", + run: noopAuth, + wizard: { + choiceId: "openai-api-key", + choiceLabel: "OpenAI API key", + groupId: "openai", + groupLabel: "OpenAI", + groupHint: "Codex OAuth + API key", + }, + }, + ], + }; +} diff --git a/extensions/openrouter/provider-contract-api.ts b/extensions/openrouter/provider-contract-api.ts new file mode 100644 index 00000000000..792f0ec5b0f --- /dev/null +++ b/extensions/openrouter/provider-contract-api.ts @@ -0,0 +1,26 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +export function createOpenrouterProvider(): ProviderPlugin { + return { + id: "openrouter", + label: "OpenRouter", + docsPath: "/providers/models", + envVars: ["OPENROUTER_API_KEY"], + auth: [ + { + id: "api-key", + kind: "api_key", + label: "OpenRouter API key", + hint: "API key", + run: async () => ({ profiles: [] }), + wizard: { + choiceId: "openrouter-api-key", + choiceLabel: "OpenRouter API key", + groupId: "openrouter", + groupLabel: "OpenRouter", + groupHint: "API key", + }, + }, + ], + }; +} diff --git a/extensions/xai/provider-contract-api.ts b/extensions/xai/provider-contract-api.ts new file mode 100644 index 00000000000..94c58105f47 --- /dev/null +++ b/extensions/xai/provider-contract-api.ts @@ -0,0 +1,22 @@ +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; + +export function createXaiProvider(): ProviderPlugin { + return { + id: "xai", + label: "xAI", + aliases: ["x-ai"], + docsPath: "/providers/xai", + auth: [ + { + id: "api-key", + kind: "api_key", + label: "xAI API key", + hint: "API key", + run: async () => ({ profiles: [] }), + wizard: { + groupLabel: "xAI (Grok)", + }, + }, + ], + }; +} diff --git a/test/helpers/plugins/provider-contract.ts b/test/helpers/plugins/provider-contract.ts index 00caf0e7646..a907bf422bf 100644 --- a/test/helpers/plugins/provider-contract.ts +++ b/test/helpers/plugins/provider-contract.ts @@ -2,19 +2,87 @@ import { describe, expect, it } from "vitest"; import { pluginRegistrationContractRegistry, providerContractLoadError, - requireProviderContractProvider, resolveProviderContractProvidersForPluginIds, } from "../../../src/plugins/contracts/registry.js"; +import { loadBundledPluginPublicArtifactModuleSync } from "../../../src/plugins/public-surface-loader.js"; +import type { ProviderPlugin } from "../../../src/plugins/types.js"; import { installProviderPluginContractSuite } from "./provider-contract-suites.js"; +type ProviderContractEntry = { + pluginId: string; + provider: ProviderPlugin; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isProviderPlugin(value: unknown): value is ProviderPlugin { + return ( + isRecord(value) && + typeof value.id === "string" && + typeof value.label === "string" && + Array.isArray(value.auth) + ); +} + +function resolveProviderContractProvidersFromPublicArtifact( + pluginId: string, +): ProviderContractEntry[] | null { + let mod: Record; + try { + mod = loadBundledPluginPublicArtifactModuleSync>({ + dirName: pluginId, + artifactBasename: "provider-contract-api.js", + }); + } catch (error) { + if ( + error instanceof Error && + error.message.startsWith("Unable to resolve bundled plugin public surface ") + ) { + return null; + } + throw error; + } + + const providers: ProviderContractEntry[] = []; + for (const [name, exported] of Object.entries(mod).toSorted(([left], [right]) => + left.localeCompare(right), + )) { + if ( + typeof exported !== "function" || + exported.length !== 0 || + !name.startsWith("create") || + !name.endsWith("Provider") + ) { + continue; + } + const provider = exported(); + if (isProviderPlugin(provider)) { + providers.push({ pluginId, provider }); + } + } + return providers.length > 0 ? providers : null; +} + export function describeProviderContracts(pluginId: string) { const providerIds = pluginRegistrationContractRegistry.find((entry) => entry.pluginId === pluginId)?.providerIds ?? []; + const resolveProviderEntries = (): ProviderContractEntry[] => { + const publicArtifactProviders = resolveProviderContractProvidersFromPublicArtifact(pluginId); + if (publicArtifactProviders) { + return publicArtifactProviders; + } + return resolveProviderContractProvidersForPluginIds([pluginId]).map((provider) => ({ + pluginId, + provider, + })); + }; describe(`${pluginId} provider contract registry load`, () => { it("loads bundled providers without import-time registry failure", () => { - const providers = resolveProviderContractProvidersForPluginIds([pluginId]); + const providers = resolveProviderEntries(); expect(providerContractLoadError).toBeUndefined(); expect(providers.length).toBeGreaterThan(0); }); @@ -25,7 +93,13 @@ export function describeProviderContracts(pluginId: string) { // Resolve provider entries lazily so the non-isolated extension runner // does not race provider contract collection against other file imports. installProviderPluginContractSuite({ - provider: () => requireProviderContractProvider(providerId), + provider: () => { + const entry = resolveProviderEntries().find((entry) => entry.provider.id === providerId); + if (!entry) { + throw new Error(`provider contract entry missing for ${pluginId}:${providerId}`); + } + return entry.provider; + }, }); }); }