feat(providers): add ClawRouter managed proxy

This commit is contained in:
Vincent Koc
2026-06-17 06:59:19 +08:00
committed by Vincent Koc
parent ee2d4e1f79
commit 95dafc824e
8 changed files with 721 additions and 0 deletions

View File

@@ -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",
});
});
});

View File

@@ -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,
});
},
});

View File

@@ -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 <key>",
"cliDescription": "ClawRouter proxy key"
}
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -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"
]
}
}

View File

@@ -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<LiveModelCatalogFetchGuard>;
} {
const fetchGuardMock: MockedFunction<LiveModelCatalogFetchGuard> = 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();
});
});

View File

@@ -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<string, ModelDefinitionConfig>;
nativeModelIds: Map<string, string>;
};
let catalogSnapshot: CatalogSnapshot | undefined;
function readRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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<string, ModelDefinitionConfig>();
const modelsByRoute = new Map<string, ModelDefinitionConfig>();
const nativeModelIds = new Map<string, string>();
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<ModelProviderConfig> {
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;
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts"],
"exclude": ["./**/*.test.ts", "./dist/**", "./node_modules/**"]
}

View File

@@ -433,6 +433,7 @@ const BUILT_IN_MODEL_PROVIDER_OVERLAY_IDS = new Set([
"byteplus-plan",
"cerebras",
"chutes",
"clawrouter",
"cloudflare-ai-gateway",
"codex",
"comfy",