diff --git a/extensions/anthropic/api.ts b/extensions/anthropic/api.ts index 6fcd8f8e147..c7733cbd3f4 100644 --- a/extensions/anthropic/api.ts +++ b/extensions/anthropic/api.ts @@ -1,4 +1,5 @@ export { CLAUDE_CLI_BACKEND_ID, isClaudeCliProvider } from "./cli-shared.js"; +export { buildAnthropicProvider } from "./register.runtime.js"; export { createAnthropicBetaHeadersWrapper, createAnthropicFastModeWrapper, diff --git a/extensions/anthropic/register.runtime.ts b/extensions/anthropic/register.runtime.ts index 510ffaf2883..ae8125528bd 100644 --- a/extensions/anthropic/register.runtime.ts +++ b/extensions/anthropic/register.runtime.ts @@ -18,7 +18,10 @@ import { upsertAuthProfile, validateAnthropicSetupToken, } from "openclaw/plugin-sdk/provider-auth"; -import { cloneFirstTemplateModel } from "openclaw/plugin-sdk/provider-model-shared"; +import { + cloneFirstTemplateModel, + type ProviderPlugin, +} from "openclaw/plugin-sdk/provider-model-shared"; import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import * as claudeCliAuth from "./cli-auth-seam.js"; @@ -395,11 +398,10 @@ async function runAnthropicCliMigrationNonInteractive(ctx: { }; } -export function registerAnthropicPlugin(api: OpenClawPluginApi): void { +export function buildAnthropicProvider(): ProviderPlugin { const providerId = "anthropic"; const defaultAnthropicModel = DEFAULT_ANTHROPIC_MODEL; - api.registerCliBackend(buildAnthropicCliBackend()); - api.registerProvider({ + return { id: providerId, label: "Anthropic", docsPath: "/providers/models", @@ -505,6 +507,11 @@ export function registerAnthropicPlugin(api: OpenClawPluginApi): void { store: ctx.store, profileId: ctx.profileId, }), - }); + }; +} + +export function registerAnthropicPlugin(api: OpenClawPluginApi): void { + api.registerCliBackend(buildAnthropicCliBackend()); + api.registerProvider(buildAnthropicProvider()); api.registerMediaUnderstandingProvider(anthropicMediaUnderstandingProvider); } diff --git a/extensions/google/api.ts b/extensions/google/api.ts index bb4939bc637..fb96d515e70 100644 --- a/extensions/google/api.ts +++ b/extensions/google/api.ts @@ -24,6 +24,8 @@ export { shouldNormalizeGoogleGenerativeAiProviderConfig, shouldNormalizeGoogleProviderConfig, } from "./provider-policy.js"; +export { buildGoogleGeminiCliProvider } from "./gemini-cli-provider.js"; +export { buildGoogleProvider } from "./provider-registration.js"; export function parseGeminiAuth(apiKey: string): { headers: Record } { const parsed = apiKey.startsWith("{") ? parseGoogleOauthApiKey(apiKey) : null; diff --git a/extensions/google/gemini-cli-provider.ts b/extensions/google/gemini-cli-provider.ts index 3da20eed293..b564c716a40 100644 --- a/extensions/google/gemini-cli-provider.ts +++ b/extensions/google/gemini-cli-provider.ts @@ -4,6 +4,7 @@ import type { ProviderFetchUsageSnapshotContext, } from "openclaw/plugin-sdk/plugin-entry"; import { buildOauthProviderAuthResult } from "openclaw/plugin-sdk/provider-auth-result"; +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools"; import { fetchGeminiUsage } from "openclaw/plugin-sdk/provider-usage"; import { formatGoogleOauthApiKey, parseGoogleUsageToken } from "./oauth-token-shared.js"; @@ -29,8 +30,8 @@ async function fetchGeminiCliUsage(ctx: ProviderFetchUsageSnapshotContext) { return await fetchGeminiUsage(ctx.token, ctx.timeoutMs, ctx.fetchFn, PROVIDER_ID); } -export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) { - api.registerProvider({ +export function buildGoogleGeminiCliProvider(): ProviderPlugin { + return { id: PROVIDER_ID, label: PROVIDER_LABEL, docsPath: "/providers/models", @@ -128,5 +129,9 @@ export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) { }; }, fetchUsageSnapshot: async (ctx) => await fetchGeminiCliUsage(ctx), - }); + }; +} + +export function registerGoogleGeminiCliProvider(api: OpenClawPluginApi) { + api.registerProvider(buildGoogleGeminiCliProvider()); } diff --git a/extensions/google/provider-registration.ts b/extensions/google/provider-registration.ts index 1883a961cf1..f33d15b7c74 100644 --- a/extensions/google/provider-registration.ts +++ b/extensions/google/provider-registration.ts @@ -1,5 +1,6 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; +import type { ProviderPlugin } from "openclaw/plugin-sdk/provider-model-shared"; import { GOOGLE_GEMINI_DEFAULT_MODEL, applyGoogleGeminiModelDefault, @@ -10,8 +11,8 @@ import { import { GOOGLE_GEMINI_PROVIDER_HOOKS } from "./provider-hooks.js"; import { isModernGoogleModel, resolveGoogleGeminiForwardCompatModel } from "./provider-models.js"; -export function registerGoogleProvider(api: OpenClawPluginApi) { - api.registerProvider({ +export function buildGoogleProvider(): ProviderPlugin { + return { id: "google", label: "Google AI Studio", docsPath: "/providers/models", @@ -50,5 +51,9 @@ export function registerGoogleProvider(api: OpenClawPluginApi) { }), ...GOOGLE_GEMINI_PROVIDER_HOOKS, isModernModelRef: ({ modelId }) => isModernGoogleModel(modelId), - }); + }; +} + +export function registerGoogleProvider(api: OpenClawPluginApi) { + api.registerProvider(buildGoogleProvider()); } diff --git a/extensions/openai/api.ts b/extensions/openai/api.ts index 7f144f9aa11..96f5a56e8f5 100644 --- a/extensions/openai/api.ts +++ b/extensions/openai/api.ts @@ -10,6 +10,7 @@ export { OPENAI_DEFAULT_TTS_VOICE, } from "./default-models.js"; export { buildOpenAICodexProvider } from "./openai-codex-catalog.js"; +export { buildOpenAICodexProviderPlugin } from "./openai-codex-provider.js"; export { buildOpenAIProvider } from "./openai-provider.js"; export { buildOpenAIRealtimeTranscriptionProvider } from "./realtime-transcription-provider.js"; export { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; diff --git a/src/plugins/contracts/provider-vitest-registry.ts b/src/plugins/contracts/provider-vitest-registry.ts new file mode 100644 index 00000000000..205ff65e0c0 --- /dev/null +++ b/src/plugins/contracts/provider-vitest-registry.ts @@ -0,0 +1,28 @@ +import { buildAnthropicProvider } from "../../../extensions/anthropic/api.js"; +import { + buildGoogleGeminiCliProvider, + buildGoogleProvider, +} from "../../../extensions/google/api.js"; +import { + buildOpenAICodexProviderPlugin, + buildOpenAIProvider, +} from "../../../extensions/openai/api.js"; +import type { ProviderPlugin } from "../types.js"; + +export type ProviderContractEntry = { + pluginId: string; + provider: ProviderPlugin; +}; + +let providerContractRegistryCache: ProviderContractEntry[] | null = null; + +export function loadVitestProviderContractRegistry(): ProviderContractEntry[] { + providerContractRegistryCache ??= [ + { pluginId: "anthropic", provider: buildAnthropicProvider() }, + { pluginId: "google", provider: buildGoogleProvider() }, + { pluginId: "google", provider: buildGoogleGeminiCliProvider() }, + { pluginId: "openai", provider: buildOpenAIProvider() }, + { pluginId: "openai", provider: buildOpenAICodexProviderPlugin() }, + ]; + return providerContractRegistryCache; +} diff --git a/src/plugins/contracts/registry.retry.test.ts b/src/plugins/contracts/registry.retry.test.ts index 97e10da95e3..c61071306d8 100644 --- a/src/plugins/contracts/registry.retry.test.ts +++ b/src/plugins/contracts/registry.retry.test.ts @@ -187,6 +187,47 @@ describe("plugin contract registry scoped retries", () => { expect(loadBundledCapabilityRuntimeRegistry).toHaveBeenCalledTimes(1); }); + it("uses provider public artifacts before falling back to the bundled runtime registry", async () => { + const loadBundledCapabilityRuntimeRegistry = vi.fn(() => { + throw new Error("provider contract vitest fast path should not hit bundled runtime registry"); + }); + const loadVitestProviderContractRegistry = vi.fn(() => [ + { + pluginId: "openai", + provider: { + id: "openai", + label: "OpenAI", + docsPath: "/providers/openai", + auth: [{ id: "api-key", label: "API key", run: async () => ({ profiles: [] }) }], + } as ProviderPlugin, + }, + { + pluginId: "openai", + provider: { + id: "openai-codex", + label: "OpenAI Codex", + docsPath: "/providers/openai", + auth: [{ id: "oauth", label: "OAuth", run: async () => ({ profiles: [] }) }], + } as ProviderPlugin, + }, + ]); + + vi.doMock("../bundled-capability-runtime.js", () => ({ + loadBundledCapabilityRuntimeRegistry, + })); + vi.doMock("./provider-vitest-registry.js", () => ({ + loadVitestProviderContractRegistry, + })); + + const { resolveProviderContractProvidersForPluginIds } = await import("./registry.js"); + + expect( + resolveProviderContractProvidersForPluginIds(["openai"]).map((provider) => provider.id), + ).toEqual(["openai", "openai-codex"]); + expect(loadVitestProviderContractRegistry).toHaveBeenCalledTimes(1); + expect(loadBundledCapabilityRuntimeRegistry).not.toHaveBeenCalled(); + }); + it("retries web fetch provider loads after a transient plugin-scoped runtime error", async () => { const loadBundledCapabilityRuntimeRegistry = vi .fn() diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index 24b706879fc..369ba470770 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -17,6 +17,7 @@ import type { WebSearchProviderPlugin, } from "../types.js"; import { BUNDLED_PLUGIN_CONTRACT_SNAPSHOTS } from "./inventory/bundled-capability-metadata.js"; +import { loadVitestProviderContractRegistry } from "./provider-vitest-registry.js"; import { uniqueStrings } from "./shared.js"; import { loadVitestImageGenerationProviderContractRegistry, @@ -314,6 +315,16 @@ function loadProviderContractEntriesForPluginId(pluginId: string): ProviderContr return cached; } + if (process.env.VITEST) { + const vitestEntries = loadVitestProviderContractRegistry().filter( + (entry) => entry.pluginId === pluginId, + ); + if (vitestEntries.length > 0) { + cache.set(pluginId, vitestEntries); + return vitestEntries; + } + } + try { providerContractLoadError = undefined; const entries = loadScopedCapabilityRuntimeRegistryEntries({ @@ -344,13 +355,22 @@ function loadProviderContractRegistry(): ProviderContractEntry[] { if (!providerContractRegistryCache) { try { providerContractLoadError = undefined; - providerContractRegistryCache = loadBundledCapabilityRuntimeRegistry({ - pluginIds: resolveBundledProviderContractPluginIds(), - pluginSdkResolution: "dist", - }).providers.map((entry) => ({ - pluginId: entry.pluginId, - provider: entry.provider, - })); + const vitestEntries = process.env.VITEST ? loadVitestProviderContractRegistry() : []; + const coveredPluginIds = new Set(vitestEntries.map((entry) => entry.pluginId)); + const remainingPluginIds = resolveBundledProviderContractPluginIds().filter( + (pluginId) => !coveredPluginIds.has(pluginId), + ); + const runtimeEntries = + remainingPluginIds.length > 0 + ? loadBundledCapabilityRuntimeRegistry({ + pluginIds: remainingPluginIds, + pluginSdkResolution: "dist", + }).providers.map((entry) => ({ + pluginId: entry.pluginId, + provider: entry.provider, + })) + : []; + providerContractRegistryCache = [...vitestEntries, ...runtimeEntries]; } catch (error) { providerContractLoadError = error instanceof Error ? error : new Error(String(error)); providerContractRegistryCache = [];