diff --git a/extensions/clawrouter/index.test.ts b/extensions/clawrouter/index.test.ts new file mode 100644 index 000000000000..d1c249703212 --- /dev/null +++ b/extensions/clawrouter/index.test.ts @@ -0,0 +1,40 @@ +import { capturePluginRegistration } from "openclaw/plugin-sdk/plugin-test-runtime"; +import { describe, expect, it } from "vitest"; +import plugin from "./index.js"; + +describe("clawrouter provider plugin", () => { + it("registers managed proxy-key auth and dynamic routing hooks", () => { + const captured = capturePluginRegistration(plugin); + const provider = captured.providers[0]; + + expect(provider).toMatchObject({ + id: "clawrouter", + label: "ClawRouter", + docsPath: "/providers/clawrouter", + envVars: ["CLAWROUTER_API_KEY"], + isModernModelRef: expect.any(Function), + normalizeResolvedModel: expect.any(Function), + resolveDynamicModel: expect.any(Function), + }); + expect(provider?.auth[0]).toMatchObject({ + id: "api-key", + label: "ClawRouter proxy key", + kind: "api_key", + }); + }); + + it("normalizes configured ClawRouter roots to the API base URL", () => { + const provider = capturePluginRegistration(plugin).providers[0]; + const normalized = provider?.normalizeConfig?.({ + provider: "clawrouter", + providerConfig: { + baseUrl: "https://clawrouter.example/", + models: [], + }, + } as never); + + expect(normalized).toMatchObject({ + baseUrl: "https://clawrouter.example/v1", + }); + }); +}); diff --git a/extensions/clawrouter/index.ts b/extensions/clawrouter/index.ts new file mode 100644 index 000000000000..57d4f3ac541a --- /dev/null +++ b/extensions/clawrouter/index.ts @@ -0,0 +1,88 @@ +// ClawRouter plugin entrypoint registers credential-scoped model routing. +import { definePluginEntry, type ProviderAuthMethod } from "openclaw/plugin-sdk/plugin-entry"; +import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; +import { PASSTHROUGH_GEMINI_REPLAY_HOOKS } from "openclaw/plugin-sdk/provider-model-shared"; +import { + buildClawRouterProviderConfig, + normalizeClawRouterApiBaseUrl, + normalizeClawRouterResolvedModel, + resolveDiscoveredClawRouterModel, +} from "./provider-catalog.js"; + +const PROVIDER_ID = "clawrouter"; +const ENV_VAR = "CLAWROUTER_API_KEY"; + +function buildApiKeyAuth(): ProviderAuthMethod { + return createProviderApiKeyAuthMethod({ + providerId: PROVIDER_ID, + methodId: "api-key", + label: "ClawRouter proxy key", + hint: "Credential-scoped access to approved providers", + 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 providers through one managed key", + groupId: PROVIDER_ID, + groupLabel: "ClawRouter", + groupHint: "Managed provider access", + }, + }); +} + +export default definePluginEntry({ + id: PROVIDER_ID, + name: "ClawRouter Provider", + description: "Bundled ClawRouter provider plugin", + register(api) { + api.registerProvider({ + id: PROVIDER_ID, + label: "ClawRouter", + docsPath: "/providers/clawrouter", + envVars: [ENV_VAR], + auth: [buildApiKeyAuth()], + catalog: { + order: "simple", + run: async (ctx) => { + const auth = ctx.resolveProviderApiKey(PROVIDER_ID); + const apiKey = auth.apiKey ?? auth.discoveryApiKey; + if (!apiKey) { + return null; + } + const configuredBaseUrl = ctx.config.models?.providers?.[PROVIDER_ID]?.baseUrl; + try { + return { + provider: await buildClawRouterProviderConfig({ + apiKey, + discoveryApiKey: auth.discoveryApiKey, + baseUrl: configuredBaseUrl, + }), + }; + } catch { + return null; + } + }, + }, + normalizeConfig: ({ providerConfig }) => { + const baseUrl = normalizeClawRouterApiBaseUrl(providerConfig.baseUrl); + return baseUrl !== providerConfig.baseUrl ? { ...providerConfig, baseUrl } : undefined; + }, + resolveDynamicModel: ({ modelId, providerConfig }) => + resolveDiscoveredClawRouterModel({ + baseUrl: providerConfig?.baseUrl, + modelId, + }), + normalizeResolvedModel: ({ model }) => normalizeClawRouterResolvedModel(model), + ...PASSTHROUGH_GEMINI_REPLAY_HOOKS, + isModernModelRef: () => true, + }); + }, +}); diff --git a/extensions/clawrouter/openclaw.plugin.json b/extensions/clawrouter/openclaw.plugin.json new file mode 100644 index 000000000000..941b21f188f8 --- /dev/null +++ b/extensions/clawrouter/openclaw.plugin.json @@ -0,0 +1,37 @@ +{ + "id": "clawrouter", + "activation": { + "onStartup": false + }, + "enabledByDefault": true, + "providers": ["clawrouter"], + "setup": { + "providers": [ + { + "id": "clawrouter", + "envVars": ["CLAWROUTER_API_KEY"] + } + ] + }, + "providerAuthChoices": [ + { + "provider": "clawrouter", + "method": "api-key", + "choiceId": "clawrouter-api-key", + "choiceLabel": "ClawRouter proxy key", + "choiceHint": "Approved providers through one managed key", + "groupId": "clawrouter", + "groupLabel": "ClawRouter", + "groupHint": "Managed provider access", + "optionKey": "clawrouterApiKey", + "cliFlag": "--clawrouter-api-key", + "cliOption": "--clawrouter-api-key ", + "cliDescription": "ClawRouter proxy key" + } + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/clawrouter/package.json b/extensions/clawrouter/package.json new file mode 100644 index 000000000000..621334ab442f --- /dev/null +++ b/extensions/clawrouter/package.json @@ -0,0 +1,15 @@ +{ + "name": "@openclaw/clawrouter-provider", + "version": "2026.6.2", + "private": true, + "description": "OpenClaw ClawRouter provider plugin", + "type": "module", + "devDependencies": { + "@openclaw/plugin-sdk": "workspace:*" + }, + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/clawrouter/provider-catalog.test.ts b/extensions/clawrouter/provider-catalog.test.ts new file mode 100644 index 000000000000..d99d22919ad4 --- /dev/null +++ b/extensions/clawrouter/provider-catalog.test.ts @@ -0,0 +1,226 @@ +import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry"; +import { + clearLiveCatalogCacheForTests, + type LiveModelCatalogFetchGuard, +} from "openclaw/plugin-sdk/provider-catalog-live-runtime"; +import { beforeEach, describe, expect, it, vi, type MockedFunction } from "vitest"; +import { + buildClawRouterProviderConfig, + clearClawRouterCatalogForTests, + normalizeClawRouterResolvedModel, + resolveDiscoveredClawRouterModel, +} from "./provider-catalog.js"; + +const CATALOG = { + version: "clawrouter.client-catalog.v1", + providers: [ + { + id: "openai", + displayName: "OpenAI", + openAiCompatible: true, + nativeBaseUrl: "/v1/native/openai", + routes: [ + { + path: "/v1/responses", + methods: ["POST"], + requestFormat: "openai.responses", + responseFormat: "openai.responses", + }, + ], + models: [ + { + id: "openai/gpt-5.5-mini", + upstream: "gpt-5.5-mini", + capabilities: ["llm.responses", "llm.chat"], + }, + ], + }, + { + id: "anthropic", + displayName: "Anthropic", + openAiCompatible: false, + nativeBaseUrl: "/v1/native/anthropic", + routes: [ + { + path: "/v1/messages", + methods: ["POST"], + requestFormat: "anthropic.messages", + responseFormat: "anthropic.messages", + }, + ], + models: [ + { + id: "anthropic/default", + upstream: "claude-sonnet-4-5-20250929", + capabilities: ["llm.messages"], + }, + ], + }, + { + id: "google-gemini", + displayName: "Google Gemini", + openAiCompatible: false, + nativeBaseUrl: "/v1/native/google-gemini", + routes: [ + { + path: "/v1beta/models/${model}:generateContent", + methods: ["POST"], + requestFormat: "google.generate_content", + responseFormat: "google.generate_content", + }, + ], + models: [ + { + id: "google/gemini-default", + upstream: "gemini", + capabilities: ["llm.generate"], + }, + ], + }, + { + id: "cohere", + displayName: "Cohere", + openAiCompatible: false, + nativeBaseUrl: "/v1/native/cohere", + routes: [ + { + path: "/v2/chat", + methods: ["POST"], + requestFormat: "cohere.chat", + responseFormat: "cohere.chat", + }, + ], + models: [ + { + id: "cohere/default", + upstream: "command-a", + capabilities: ["llm.chat"], + }, + ], + }, + ], +}; + +function buildFetchGuard(): { + fetchGuard: LiveModelCatalogFetchGuard; + fetchGuardMock: MockedFunction; +} { + const fetchGuardMock: MockedFunction = vi.fn(async () => ({ + response: new Response(JSON.stringify(CATALOG)), + finalUrl: "https://clawrouter.example/v1/catalog", + release: async () => undefined, + })); + return { fetchGuard: fetchGuardMock, fetchGuardMock }; +} + +describe("clawrouter provider catalog", () => { + beforeEach(() => { + clearLiveCatalogCacheForTests(); + clearClawRouterCatalogForTests(); + }); + + it("maps credential-scoped catalog rows to their real provider transports", async () => { + const { fetchGuard, fetchGuardMock } = buildFetchGuard(); + const provider = await buildClawRouterProviderConfig({ + apiKey: "clawrouter-test-key", + baseUrl: "https://clawrouter.example/v1", + fetchGuard, + }); + + expect(fetchGuardMock).toHaveBeenCalledOnce(); + expect(provider).toMatchObject({ + api: "openai-responses", + apiKey: "clawrouter-test-key", + authHeader: true, + baseUrl: "https://clawrouter.example/v1", + }); + expect(provider.models.map((model) => model.id)).toEqual([ + "anthropic/default", + "google/gemini-default", + "openai/gpt-5.5-mini", + ]); + + expect(provider.models.find((model) => model.id === "openai/gpt-5.5-mini")).toMatchObject({ + api: "openai-responses", + baseUrl: "https://clawrouter.example/v1", + }); + expect(provider.models.find((model) => model.id === "anthropic/default")).toMatchObject({ + api: "anthropic-messages", + baseUrl: "https://clawrouter.example/v1/native/anthropic", + }); + expect(provider.models.find((model) => model.id === "google/gemini-default")).toMatchObject({ + api: "google-generative-ai", + baseUrl: "https://clawrouter.example/v1/native/google-gemini/v1beta", + }); + + const anthropic = provider.models.find((model) => model.id === "anthropic/default"); + const normalized = normalizeClawRouterResolvedModel({ + ...anthropic, + provider: "clawrouter", + } as ProviderRuntimeModel); + expect(normalized?.id).toBe("claude-sonnet-4-5-20250929"); + + const dynamic = resolveDiscoveredClawRouterModel({ + baseUrl: provider.baseUrl, + modelId: "google/gemini-default", + }); + expect(dynamic).toMatchObject({ + id: "google/gemini-default", + provider: "clawrouter", + api: "google-generative-ai", + }); + }); + + it("caches the auth-scoped catalog for the discovery TTL", async () => { + const { fetchGuard, fetchGuardMock } = buildFetchGuard(); + const params = { + apiKey: "clawrouter-test-key", + baseUrl: "https://clawrouter.example", + fetchGuard, + }; + + await buildClawRouterProviderConfig(params); + await buildClawRouterProviderConfig(params); + + expect(fetchGuardMock).toHaveBeenCalledOnce(); + const headers = fetchGuardMock.mock.calls[0]?.[0].init?.headers; + expect(headers).toBeInstanceOf(Headers); + expect((headers as Headers).get("authorization")).toBe("Bearer clawrouter-test-key"); + }); + + it("replaces stale discovery state when the active catalog changes", async () => { + const first = buildFetchGuard(); + const provider = await buildClawRouterProviderConfig({ + apiKey: "first-key", + baseUrl: "https://first.example", + fetchGuard: first.fetchGuard, + }); + const anthropic = provider.models.find((model) => model.id === "anthropic/default"); + + const second = buildFetchGuard(); + await buildClawRouterProviderConfig({ + apiKey: "second-key", + baseUrl: "https://second.example", + fetchGuard: second.fetchGuard, + }); + + expect( + resolveDiscoveredClawRouterModel({ + baseUrl: "https://first.example/v1", + modelId: "openai/gpt-5.5-mini", + }), + ).toBeUndefined(); + expect( + resolveDiscoveredClawRouterModel({ + baseUrl: "https://second.example/v1", + modelId: "openai/gpt-5.5-mini", + }), + ).toBeDefined(); + expect( + normalizeClawRouterResolvedModel({ + ...anthropic, + provider: "clawrouter", + } as ProviderRuntimeModel), + ).toBeUndefined(); + }); +}); diff --git a/extensions/clawrouter/provider-catalog.ts b/extensions/clawrouter/provider-catalog.ts new file mode 100644 index 000000000000..489d6d93b03c --- /dev/null +++ b/extensions/clawrouter/provider-catalog.ts @@ -0,0 +1,306 @@ +// ClawRouter provider catalog maps credential-scoped routes to OpenClaw transports. +import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry"; +import { + getCachedLiveProviderModelRows, + type LiveModelCatalogFetchGuard, +} from "openclaw/plugin-sdk/provider-catalog-live-runtime"; +import type { + ModelDefinitionConfig, + ModelProviderConfig, +} from "openclaw/plugin-sdk/provider-model-shared"; + +export const CLAWROUTER_DEFAULT_BASE_URL = "https://clawrouter.openclaw.ai"; + +const PROVIDER_ID = "clawrouter"; +const CATALOG_CACHE_TTL_MS = 60_000; +const DEFAULT_CONTEXT_WINDOW = 200_000; +const DEFAULT_MAX_TOKENS = 32_768; +const DEFAULT_COST = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, +}; + +type CatalogRoute = { + path: string; + requestFormat: string; + methods: string[]; +}; + +type CatalogModel = { + id: string; + upstream: string; + capabilities: string[]; +}; + +type CatalogProvider = { + id: string; + displayName: string; + openAiCompatible: boolean; + nativeBaseUrl: string; + routes: CatalogRoute[]; + models: CatalogModel[]; +}; + +type RoutedModel = { + definition: ModelDefinitionConfig; + upstreamModel?: string; +}; + +type CatalogSnapshot = { + apiBaseUrl: string; + modelsByRoute: Map; + nativeModelIds: Map; +}; + +let catalogSnapshot: CatalogSnapshot | undefined; + +function readRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function readString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +function readStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.map(readString).filter((entry): entry is string => Boolean(entry)) + : []; +} + +function readCatalogRows(body: unknown): readonly unknown[] { + const providers = readRecord(body)?.providers; + if (!Array.isArray(providers)) { + throw new Error("ClawRouter catalog response must contain providers[]"); + } + return providers; +} + +function parseCatalogRoute(value: unknown): CatalogRoute | undefined { + const row = readRecord(value); + const path = readString(row?.path); + const requestFormat = readString(row?.requestFormat); + if (!path || !requestFormat) { + return undefined; + } + return { + path, + requestFormat, + methods: readStringArray(row?.methods).map((method) => method.toUpperCase()), + }; +} + +function parseCatalogModel(value: unknown): CatalogModel | undefined { + const row = readRecord(value); + const id = readString(row?.id); + const upstream = readString(row?.upstream); + if (!id || !upstream) { + return undefined; + } + return { + id, + upstream, + capabilities: readStringArray(row?.capabilities), + }; +} + +function parseCatalogProvider(value: unknown): CatalogProvider | undefined { + const row = readRecord(value); + const id = readString(row?.id); + const nativeBaseUrl = readString(row?.nativeBaseUrl); + if (!id || !nativeBaseUrl || !nativeBaseUrl.startsWith("/v1/native/")) { + return undefined; + } + return { + id, + displayName: readString(row?.displayName) ?? id, + openAiCompatible: row?.openAiCompatible === true, + nativeBaseUrl, + routes: Array.isArray(row?.routes) + ? row.routes.map(parseCatalogRoute).filter((route): route is CatalogRoute => Boolean(route)) + : [], + models: Array.isArray(row?.models) + ? row.models.map(parseCatalogModel).filter((model): model is CatalogModel => Boolean(model)) + : [], + }; +} + +function trimTrailingSlashes(value: string): string { + return value.replace(/\/+$/, ""); +} + +export function normalizeClawRouterRootUrl(baseUrl: string | undefined): string { + const normalized = trimTrailingSlashes(baseUrl?.trim() || CLAWROUTER_DEFAULT_BASE_URL); + return normalized.endsWith("/v1") ? normalized.slice(0, -3) : normalized; +} + +export function normalizeClawRouterApiBaseUrl(baseUrl: string | undefined): string { + return `${normalizeClawRouterRootUrl(baseUrl)}/v1`; +} + +function routeKey(baseUrl: string, modelId: string): string { + return `${trimTrailingSlashes(baseUrl)}\0${modelId}`; +} + +function supportsCapability(model: CatalogModel, ...capabilities: string[]): boolean { + return capabilities.some((capability) => model.capabilities.includes(capability)); +} + +function findNativeRoute( + provider: CatalogProvider, + requestFormat: string, +): CatalogRoute | undefined { + return provider.routes.find( + (route) => route.methods.includes("POST") && route.requestFormat === requestFormat, + ); +} + +function googleNativeBaseUrl(rootUrl: string, provider: CatalogProvider, route: CatalogRoute) { + const modelPathIndex = route.path.indexOf("/models/${model}"); + if (modelPathIndex <= 0) { + return undefined; + } + return `${rootUrl}${provider.nativeBaseUrl}${route.path.slice(0, modelPathIndex)}`; +} + +function buildRoutedModel( + rootUrl: string, + provider: CatalogProvider, + model: CatalogModel, +): RoutedModel | undefined { + let api: ModelDefinitionConfig["api"]; + let baseUrl: string; + let upstreamModel: string | undefined; + + if (provider.openAiCompatible && supportsCapability(model, "llm.responses")) { + api = "openai-responses"; + baseUrl = `${rootUrl}/v1`; + } else if (provider.openAiCompatible && supportsCapability(model, "llm.chat")) { + api = "openai-completions"; + baseUrl = `${rootUrl}/v1`; + } else if ( + supportsCapability(model, "llm.messages") && + findNativeRoute(provider, "anthropic.messages") + ) { + api = "anthropic-messages"; + baseUrl = `${rootUrl}${provider.nativeBaseUrl}`; + upstreamModel = model.upstream; + } else { + const googleRoute = + supportsCapability(model, "llm.generate", "llm.stream") && + findNativeRoute(provider, "google.generate_content"); + const googleBaseUrl = googleRoute + ? googleNativeBaseUrl(rootUrl, provider, googleRoute) + : undefined; + if (!googleBaseUrl) { + return undefined; + } + api = "google-generative-ai"; + baseUrl = googleBaseUrl; + upstreamModel = model.upstream; + } + + return { + definition: { + id: model.id, + name: `${provider.displayName}: ${model.id}`, + api, + baseUrl, + reasoning: false, + input: ["text"], + cost: DEFAULT_COST, + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + }, + upstreamModel, + }; +} + +function updateDiscoveredModels( + rootUrl: string, + providers: CatalogProvider[], +): ModelDefinitionConfig[] { + const models = new Map(); + const modelsByRoute = new Map(); + const nativeModelIds = new Map(); + for (const provider of providers) { + for (const model of provider.models) { + const routed = buildRoutedModel(rootUrl, provider, model); + if (!routed || models.has(routed.definition.id)) { + continue; + } + models.set(routed.definition.id, routed.definition); + const key = routeKey(routed.definition.baseUrl ?? `${rootUrl}/v1`, routed.definition.id); + modelsByRoute.set(key, routed.definition); + modelsByRoute.set(routeKey(`${rootUrl}/v1`, routed.definition.id), routed.definition); + if (routed.upstreamModel) { + nativeModelIds.set(key, routed.upstreamModel); + } + } + } + // Discovery owns one active provider config, so replace the whole snapshot. + // Keeping older credential-scoped catalogs would leak stale grants and grow forever. + catalogSnapshot = { + apiBaseUrl: `${rootUrl}/v1`, + modelsByRoute, + nativeModelIds, + }; + return [...models.values()].sort((left, right) => left.id.localeCompare(right.id)); +} + +export async function buildClawRouterProviderConfig(params: { + apiKey: string; + discoveryApiKey?: string; + baseUrl?: string; + fetchGuard?: LiveModelCatalogFetchGuard; +}): Promise { + const rootUrl = normalizeClawRouterRootUrl(params.baseUrl); + const rows = await getCachedLiveProviderModelRows({ + providerId: PROVIDER_ID, + endpoint: `${rootUrl}/v1/catalog`, + apiKey: params.apiKey, + discoveryApiKey: params.discoveryApiKey, + fetchGuard: params.fetchGuard, + readRows: readCatalogRows, + ttlMs: CATALOG_CACHE_TTL_MS, + shouldCacheRows: (providers) => providers.length > 0, + auditContext: "clawrouter-model-discovery", + }); + const providers = rows + .map(parseCatalogProvider) + .filter((provider): provider is CatalogProvider => Boolean(provider)); + return { + baseUrl: `${rootUrl}/v1`, + api: "openai-responses", + apiKey: params.apiKey, + authHeader: true, + models: updateDiscoveredModels(rootUrl, providers), + }; +} + +export function resolveDiscoveredClawRouterModel(params: { + baseUrl?: string; + modelId: string; +}): ProviderRuntimeModel | undefined { + const apiBaseUrl = normalizeClawRouterApiBaseUrl(params.baseUrl); + if (catalogSnapshot?.apiBaseUrl !== apiBaseUrl) { + return undefined; + } + const model = catalogSnapshot.modelsByRoute.get(routeKey(apiBaseUrl, params.modelId)); + return model ? { ...model, provider: PROVIDER_ID } : undefined; +} + +export function normalizeClawRouterResolvedModel( + model: ProviderRuntimeModel, +): ProviderRuntimeModel | undefined { + const upstreamModel = catalogSnapshot?.nativeModelIds.get(routeKey(model.baseUrl, model.id)); + return upstreamModel && upstreamModel !== model.id ? { ...model, id: upstreamModel } : undefined; +} + +export function clearClawRouterCatalogForTests(): void { + catalogSnapshot = undefined; +} diff --git a/extensions/clawrouter/tsconfig.json b/extensions/clawrouter/tsconfig.json new file mode 100644 index 000000000000..f7da347f46c1 --- /dev/null +++ b/extensions/clawrouter/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../tsconfig.package-boundary.base.json", + "compilerOptions": { + "rootDir": "." + }, + "include": ["./*.ts"], + "exclude": ["./**/*.test.ts", "./dist/**", "./node_modules/**"] +} diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 7aa6a06cf213..6186b9763436 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -433,6 +433,7 @@ const BUILT_IN_MODEL_PROVIDER_OVERLAY_IDS = new Set([ "byteplus-plan", "cerebras", "chutes", + "clawrouter", "cloudflare-ai-gateway", "codex", "comfy",