revert(providers): remove ClawRouter provider

This commit is contained in:
Vincent Koc
2026-06-17 11:55:12 +08:00
committed by Vincent Koc
parent 97b9bd1d81
commit 04255b247c
22 changed files with 12 additions and 1292 deletions
-5
View File
@@ -381,11 +381,6 @@
- changed-files:
- any-glob-to-any-file:
- "extensions/anthropic/**"
"extensions: clawrouter":
- changed-files:
- any-glob-to-any-file:
- "extensions/clawrouter/**"
- "docs/providers/clawrouter.md"
"extensions: cloudflare-ai-gateway":
- changed-files:
- any-glob-to-any-file:
-1
View File
@@ -1418,7 +1418,6 @@
"providers/cerebras",
"providers/chutes",
"providers/claude-max-api-proxy",
"providers/clawrouter",
"providers/cloudflare-ai-gateway",
"providers/comfy",
"providers/deepgram",
+1 -3
View File
@@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description.
## Core npm package
91 plugins
90 plugins
- **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint.
@@ -75,8 +75,6 @@ Each entry lists the package, distribution route, and description.
- **[chutes](/plugins/reference/chutes)** (`@openclaw/chutes-provider`) - included in OpenClaw. Adds Chutes model provider support to OpenClaw.
- **[clawrouter](/plugins/reference/clawrouter)** (`@openclaw/clawrouter-provider`) - included in OpenClaw. Adds ClawRouter model provider support to OpenClaw.
- **[clickclack](/plugins/reference/clickclack)** (`@openclaw/clickclack`) - included in OpenClaw. Adds the Clickclack channel surface for sending and receiving OpenClaw messages.
- **[cloudflare-ai-gateway](/plugins/reference/cloudflare-ai-gateway)** (`@openclaw/cloudflare-ai-gateway-provider`) - included in OpenClaw. Adds Cloudflare AI Gateway model provider support to OpenClaw.
+1 -1
View File
@@ -15,5 +15,5 @@ This page is generated from `extensions/*/package.json` and
pnpm plugins:inventory:gen
```
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 128
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 127
generated plugin reference pages by distribution, package, and description.
-23
View File
@@ -1,23 +0,0 @@
---
summary: "Adds ClawRouter model provider support to OpenClaw."
read_when:
- You are installing, configuring, or auditing the clawrouter plugin
title: "ClawRouter plugin"
---
# ClawRouter plugin
Adds ClawRouter model provider support to OpenClaw.
## Distribution
- Package: `@openclaw/clawrouter-provider`
- Install route: included in OpenClaw
## Surface
providers: clawrouter
## Related docs
- [clawrouter](/providers/clawrouter)
-58
View File
@@ -1,58 +0,0 @@
---
summary: "Use one managed ClawRouter key to access approved providers in OpenClaw"
title: "ClawRouter"
read_when:
- You have a ClawRouter proxy key
- Your team centrally manages provider access and grants
- You want OpenClaw to discover only the models you can use
---
ClawRouter gives OpenClaw one managed credential for approved model providers.
The upstream API keys, OAuth grants, and subscription credentials stay in
ClawRouter.
## Setup
Authenticate with the proxy key issued by your ClawRouter administrator:
```bash
openclaw onboard --auth-choice clawrouter-api-key
```
Or provide it through the environment:
```bash
export CLAWROUTER_API_KEY="clawrouter-live-..."
```
OpenClaw asks `https://clawrouter.openclaw.ai/v1/catalog` for the models granted
to that key and caches the credential-scoped result for a short period. Model
references keep the ClawRouter provider prefix:
```bash
openclaw models list --provider clawrouter
openclaw models set clawrouter/openai/gpt-5.5-mini
```
ClawRouter publishes the real transport for each model. OpenClaw uses the
unified OpenAI route when available and the provider-native Anthropic or Gemini
route when required.
## Custom deployment
For a self-hosted ClawRouter, configure its API base URL:
```json5
{
models: {
providers: {
clawrouter: {
baseUrl: "https://clawrouter.example/v1",
},
},
},
}
```
The proxy key still determines which providers and models are visible and
usable. Grant changes appear after the short discovery cache expires.
-1
View File
@@ -33,7 +33,6 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
- [BytePlus (International)](/concepts/model-providers#byteplus-international)
- [Cerebras](/providers/cerebras)
- [Chutes](/providers/chutes)
- [ClawRouter](/providers/clawrouter)
- [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
- [ComfyUI](/providers/comfy)
- [DeepSeek](/providers/deepseek)
-228
View File
@@ -1,228 +0,0 @@
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import { capturePluginRegistration } from "openclaw/plugin-sdk/plugin-test-runtime";
import { clearLiveCatalogCacheForTests } from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const providerAuthRuntimeMocks = vi.hoisted(() => ({
resolveApiKeyForProvider: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => providerAuthRuntimeMocks);
import plugin from "./index.js";
const LIVE_CATALOG = {
providers: [
{
id: "openai",
displayName: "OpenAI",
openaiCompatible: true,
nativeBaseUrl: "/v1/native/openai",
routes: [
{
path: "/v1/responses",
methods: ["POST"],
requestFormat: "openai.responses",
},
],
models: [
{
id: "openai/gpt-5.5-mini",
upstream: "gpt-5.5-mini",
capabilities: ["llm.responses"],
},
],
},
],
};
describe("clawrouter provider plugin", () => {
beforeEach(() => {
clearLiveCatalogCacheForTests();
providerAuthRuntimeMocks.resolveApiKeyForProvider.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("registers managed proxy-key auth and transport 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),
buildReplayPolicy: expect.any(Function),
normalizeResolvedModel: expect.any(Function),
sanitizeReplayHistory: expect.any(Function),
wrapSimpleCompletionStreamFn: expect.any(Function),
wrapStreamFn: expect.any(Function),
});
expect(provider?.auth[0]).toMatchObject({
id: "api-key",
label: "ClawRouter proxy key",
kind: "api_key",
});
expect(provider?.wrapSimpleCompletionStreamFn).toBe(provider?.wrapStreamFn);
});
it("attaches the resolved proxy key only when dispatching a request", () => {
const provider = capturePluginRegistration(plugin).providers[0];
const calls: Array<Parameters<StreamFn>[0]> = [];
const baseStreamFn: StreamFn = (model) => {
calls.push(model);
return {} as ReturnType<StreamFn>;
};
const wrapped = provider?.wrapStreamFn?.({
provider: "clawrouter",
modelId: "anthropic/default",
streamFn: baseStreamFn,
} as never);
void wrapped?.(
{
provider: "clawrouter",
api: "anthropic-messages",
id: "anthropic/default",
headers: { "X-Request-ID": "request-1" },
params: {
clawrouterRoute: {
api: "anthropic-messages",
baseUrl: "https://clawrouter.example/v1/native/anthropic",
upstreamModel: "claude-sonnet-4-5-20250929",
},
},
} as never,
{} as never,
{ apiKey: "runtime-proxy-key" } as never,
);
void wrapped?.(
{
provider: "clawrouter",
api: "anthropic-messages",
id: "anthropic/default",
params: {
clawrouterRoute: {
api: "anthropic-messages",
baseUrl: "https://clawrouter.example/v1/native/anthropic",
upstreamModel: "claude-sonnet-4-5-20250929",
},
},
} as never,
{} as never,
{ apiKey: "CLAWROUTER_API_KEY" } as never,
);
expect(calls[0]?.headers).toEqual({
"X-Request-ID": "request-1",
Authorization: "Bearer runtime-proxy-key",
});
expect(calls[0]?.id).toBe("claude-sonnet-4-5-20250929");
expect(calls[0]?.params).toBeUndefined();
expect(calls[1]?.headers).toBeUndefined();
expect(calls[1]?.id).toBe("claude-sonnet-4-5-20250929");
expect(calls[1]?.params).toBeUndefined();
});
it("resolves managed secret refs before credential-scoped discovery", async () => {
providerAuthRuntimeMocks.resolveApiKeyForProvider.mockResolvedValue({
apiKey: "resolved-proxy-key",
mode: "api-key",
source: "models.json secretref",
});
const fetchMock = vi.fn(async () => Response.json(LIVE_CATALOG));
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const provider = capturePluginRegistration(plugin).providers[0];
const result = await provider?.catalog?.run({
config: { models: {} },
agentDir: "/agent",
workspaceDir: "/workspace",
env: {},
resolveProviderAuth: () => ({
apiKey: "secretref-managed",
discoveryApiKey: undefined,
mode: "api_key",
source: "profile",
profileId: "clawrouter-profile",
}),
resolveProviderApiKey: () => ({
apiKey: "secretref-managed",
discoveryApiKey: undefined,
}),
});
if (!result || !("provider" in result)) {
throw new Error("expected ClawRouter catalog provider result");
}
expect(result.provider.apiKey).toBe("secretref-managed");
expect(result.provider.models.map((model) => model.id)).toEqual(["openai/gpt-5.5-mini"]);
expect(providerAuthRuntimeMocks.resolveApiKeyForProvider).toHaveBeenCalledWith({
provider: "clawrouter",
cfg: { models: {} },
agentDir: "/agent",
workspaceDir: "/workspace",
profileId: "clawrouter-profile",
lockedProfile: true,
});
const fetchCall = fetchMock.mock.calls[0] as unknown as [string, RequestInit] | undefined;
expect(new Headers(fetchCall?.[1]?.headers).get("Authorization")).toBe(
"Bearer resolved-proxy-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",
});
});
it("keeps replay handling aligned with each discovered transport", () => {
const provider = capturePluginRegistration(plugin).providers[0];
const buildReplayPolicy = provider?.buildReplayPolicy;
expect(
buildReplayPolicy?.({
provider: "clawrouter",
modelApi: "anthropic-messages",
modelId: "anthropic/default",
} as never),
).toMatchObject({
preserveNativeAnthropicToolUseIds: true,
preserveSignatures: true,
validateAnthropicTurns: true,
});
expect(
buildReplayPolicy?.({
provider: "clawrouter",
modelApi: "google-generative-ai",
modelId: "google/gemini-default",
} as never),
).toMatchObject({
validateGeminiTurns: true,
});
expect(
buildReplayPolicy?.({
provider: "clawrouter",
modelApi: "openai-responses",
modelId: "openai/gpt-5.5-mini",
} as never),
).toMatchObject({
validateGeminiTurns: false,
validateAnthropicTurns: false,
});
});
});
-126
View File
@@ -1,126 +0,0 @@
// 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 {
buildGoogleGeminiReplayPolicy,
buildNativeAnthropicReplayPolicyForModel,
buildPassthroughGeminiSanitizingReplayPolicy,
resolveTaggedReasoningOutputMode,
sanitizeGoogleGeminiReplayHistory,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
buildClawRouterProviderConfig,
normalizeClawRouterApiBaseUrl,
normalizeClawRouterResolvedModel,
} from "./provider-catalog.js";
import { wrapClawRouterProviderStream } from "./stream.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.resolveProviderAuth(PROVIDER_ID);
let discoveryApiKey = auth.discoveryApiKey;
if (!discoveryApiKey) {
try {
const { resolveApiKeyForProvider } =
await import("openclaw/plugin-sdk/provider-auth-runtime");
discoveryApiKey = (
await resolveApiKeyForProvider({
provider: PROVIDER_ID,
cfg: ctx.config,
...(ctx.agentDir ? { agentDir: ctx.agentDir } : {}),
...(ctx.workspaceDir ? { workspaceDir: ctx.workspaceDir } : {}),
...(auth.profileId ? { profileId: auth.profileId, lockedProfile: true } : {}),
})
)?.apiKey;
} catch {
return null;
}
}
const apiKey = auth.apiKey ?? discoveryApiKey;
if (!apiKey || !discoveryApiKey) {
return null;
}
const configuredBaseUrl = ctx.config.models?.providers?.[PROVIDER_ID]?.baseUrl;
try {
return {
provider: await buildClawRouterProviderConfig({
apiKey,
discoveryApiKey,
baseUrl: configuredBaseUrl,
}),
};
} catch {
return null;
}
},
},
normalizeConfig: ({ providerConfig }) => {
const baseUrl = normalizeClawRouterApiBaseUrl(providerConfig.baseUrl);
return baseUrl !== providerConfig.baseUrl ? { ...providerConfig, baseUrl } : undefined;
},
normalizeResolvedModel: ({ model }) => normalizeClawRouterResolvedModel(model),
wrapSimpleCompletionStreamFn: wrapClawRouterProviderStream,
wrapStreamFn: wrapClawRouterProviderStream,
buildReplayPolicy: ({ modelApi, modelId }) => {
if (modelApi === "anthropic-messages") {
return buildNativeAnthropicReplayPolicyForModel(modelId);
}
if (modelApi === "google-generative-ai") {
return buildGoogleGeminiReplayPolicy();
}
if (modelApi === "openai-completions" || modelApi === "openai-responses") {
return buildPassthroughGeminiSanitizingReplayPolicy(modelId);
}
return undefined;
},
sanitizeReplayHistory: (ctx) =>
ctx.modelApi === "google-generative-ai"
? sanitizeGoogleGeminiReplayHistory(ctx)
: undefined,
resolveReasoningOutputMode: (ctx) =>
ctx.modelApi === "google-generative-ai" ? resolveTaggedReasoningOutputMode() : undefined,
isModernModelRef: () => true,
});
},
});
@@ -1,37 +0,0 @@
{
"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": {}
}
}
-15
View File
@@ -1,15 +0,0 @@
{
"name": "@openclaw/clawrouter-provider",
"version": "2026.6.9",
"private": true,
"description": "OpenClaw ClawRouter provider plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}
@@ -1,266 +0,0 @@
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,
normalizeClawRouterResolvedModel,
prepareClawRouterRequestModel,
} 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",
},
{
path: "/v1beta/models/${model}:streamGenerateContent",
methods: ["POST"],
requestFormat: "google.generate_content",
responseFormat: "google.generate_content",
},
],
models: [
{
id: "google/gemini-default",
upstream: "gemini",
capabilities: ["llm.generate", "llm.stream"],
},
],
},
{
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(catalog: unknown = CATALOG): {
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();
});
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",
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,
baseUrl: provider.baseUrl,
provider: "clawrouter",
} as ProviderRuntimeModel);
expect(normalized).toMatchObject({
id: "anthropic/default",
api: "anthropic-messages",
baseUrl: "https://clawrouter.example/v1/native/anthropic",
});
expect(prepareClawRouterRequestModel(normalized as ProviderRuntimeModel)).toMatchObject({
id: "claude-sonnet-4-5-20250929",
params: undefined,
});
const gemini = provider.models.find((model) => model.id === "google/gemini-default");
const normalizedGemini = normalizeClawRouterResolvedModel({
...gemini,
baseUrl: provider.baseUrl,
provider: "clawrouter",
} as ProviderRuntimeModel);
expect(normalizedGemini).toMatchObject({
id: "google/gemini-default",
api: "google-generative-ai",
baseUrl: "https://clawrouter.example/v1/native/google-gemini/v1beta",
});
expect(prepareClawRouterRequestModel(normalizedGemini as ProviderRuntimeModel)).toMatchObject({
id: "gemini",
params: undefined,
});
expect(JSON.stringify(provider.models)).not.toContain("clawrouter-test-key");
});
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("does not advertise Gemini models without an explicit streaming route", async () => {
const generateOnlyCatalog = structuredClone(CATALOG);
generateOnlyCatalog.providers[2].routes = generateOnlyCatalog.providers[2].routes.filter(
(route) => !route.path.includes(":streamGenerateContent"),
);
generateOnlyCatalog.providers[2].models[0].capabilities = ["llm.generate"];
const { fetchGuard } = buildFetchGuard(generateOnlyCatalog);
const provider = await buildClawRouterProviderConfig({
apiKey: "clawrouter-test-key",
baseUrl: "https://clawrouter.example",
fetchGuard,
});
expect(provider.models.map((model) => model.id)).not.toContain("google/gemini-default");
});
it("keeps credential-scoped route metadata isolated on each catalog result", async () => {
const firstCatalog = structuredClone(CATALOG);
firstCatalog.providers[1].models[0].upstream = "first-upstream";
const first = buildFetchGuard(firstCatalog);
const firstProvider = await buildClawRouterProviderConfig({
apiKey: "first-key",
baseUrl: "https://clawrouter.example",
fetchGuard: first.fetchGuard,
});
const firstAnthropic = firstProvider.models.find((model) => model.id === "anthropic/default");
const secondCatalog = structuredClone(CATALOG);
secondCatalog.providers[1].models[0].upstream = "second-upstream";
const second = buildFetchGuard(secondCatalog);
const secondProvider = await buildClawRouterProviderConfig({
apiKey: "second-key",
baseUrl: "https://clawrouter.example",
fetchGuard: second.fetchGuard,
});
const secondAnthropic = secondProvider.models.find((model) => model.id === "anthropic/default");
expect(
prepareClawRouterRequestModel(
normalizeClawRouterResolvedModel({
...firstAnthropic,
baseUrl: firstProvider.baseUrl,
provider: "clawrouter",
} as ProviderRuntimeModel) as ProviderRuntimeModel,
).id,
).toBe("first-upstream");
expect(
prepareClawRouterRequestModel(
normalizeClawRouterResolvedModel({
...secondAnthropic,
baseUrl: secondProvider.baseUrl,
provider: "clawrouter",
} as ProviderRuntimeModel) as ProviderRuntimeModel,
).id,
).toBe("second-upstream");
});
});
-328
View File
@@ -1,328 +0,0 @@
// 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 ROUTE_METADATA_KEY = "clawrouterRoute";
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;
};
type RouteMetadata = {
api: NonNullable<ModelDefinitionConfig["api"]>;
baseUrl: string;
upstreamModel?: string;
};
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 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: NonNullable<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.stream") &&
provider.routes.find(
(route) =>
route.methods.includes("POST") &&
route.requestFormat === "google.generate_content" &&
route.path.includes(":streamGenerateContent"),
);
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,
params: {
[ROUTE_METADATA_KEY]: {
api,
baseUrl,
...(upstreamModel ? { upstreamModel } : {}),
} satisfies RouteMetadata,
},
},
};
}
function buildDiscoveredModels(
rootUrl: string,
providers: CatalogProvider[],
): ModelDefinitionConfig[] {
const models = new Map<string, ModelDefinitionConfig>();
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);
}
}
return [...models.values()].toSorted((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,
models: buildDiscoveredModels(rootUrl, providers),
};
}
function readRouteMetadata(params: ProviderRuntimeModel["params"]): RouteMetadata | undefined {
const row = readRecord(params?.[ROUTE_METADATA_KEY]);
const baseUrl = readString(row?.baseUrl);
const api = readString(row?.api);
if (
!baseUrl ||
(api !== "openai-responses" &&
api !== "openai-completions" &&
api !== "anthropic-messages" &&
api !== "google-generative-ai")
) {
return undefined;
}
return {
api,
baseUrl,
...(readString(row?.upstreamModel) ? { upstreamModel: readString(row?.upstreamModel) } : {}),
};
}
function stripRouteMetadata(
params: ProviderRuntimeModel["params"],
): ProviderRuntimeModel["params"] {
if (!params || !(ROUTE_METADATA_KEY in params)) {
return params;
}
const { [ROUTE_METADATA_KEY]: _routeMetadata, ...remaining } = params;
return Object.keys(remaining).length > 0 ? remaining : undefined;
}
export function normalizeClawRouterResolvedModel(
model: ProviderRuntimeModel,
): ProviderRuntimeModel | undefined {
const route = readRouteMetadata(model.params);
if (!route) {
return undefined;
}
return {
...model,
api: route.api,
baseUrl: route.baseUrl,
};
}
export function prepareClawRouterRequestModel(model: ProviderRuntimeModel): ProviderRuntimeModel {
const route = readRouteMetadata(model.params);
if (!route) {
return model;
}
return {
...model,
params: stripRouteMetadata(model.params),
...(route.upstreamModel && route.upstreamModel !== model.id ? { id: route.upstreamModel } : {}),
};
}
-46
View File
@@ -1,46 +0,0 @@
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
import { prepareClawRouterRequestModel } from "./provider-catalog.js";
const ENV_API_KEY_MARKER = "CLAWROUTER_API_KEY";
function withBearerAuthorization(
headers: Record<string, string> | undefined,
apiKey: string,
): Record<string, string> {
const next: Record<string, string> = {};
for (const [name, value] of Object.entries(headers ?? {})) {
if (name.toLowerCase() !== "authorization") {
next[name] = value;
}
}
next.Authorization = `Bearer ${apiKey}`;
return next;
}
function createClawRouterStreamWrapper(underlying: StreamFn | undefined): StreamFn | undefined {
if (!underlying) {
return undefined;
}
return (model, context, options) => {
const apiKey = options?.apiKey?.trim();
const preparedModel = prepareClawRouterRequestModel(model);
if (!apiKey || apiKey === ENV_API_KEY_MARKER) {
return underlying(preparedModel, context, options);
}
return underlying(
{
...preparedModel,
headers: withBearerAuthorization(preparedModel.headers, apiKey),
},
context,
options,
);
};
}
export function wrapClawRouterProviderStream(
ctx: ProviderWrapStreamFnContext,
): StreamFn | undefined {
return createClawRouterStreamWrapper(ctx.streamFn);
}
-16
View File
@@ -1,16 +0,0 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}
-6
View File
@@ -491,12 +491,6 @@ importers:
specifier: workspace:*
version: link:../../packages/plugin-sdk
extensions/clawrouter:
devDependencies:
'@openclaw/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
extensions/clickclack:
dependencies:
ws:
@@ -109,7 +109,6 @@ function humanizeId(value) {
["byteplus", "BytePlus"],
["codex", "Codex"],
["cli", "CLI"],
["clawrouter", "ClawRouter"],
["comfy", "ComfyUI"],
["dashscope", "DashScope"],
["deepgram", "Deepgram"],
-1
View File
@@ -1198,7 +1198,6 @@ describe("runBtwSideQuestion", () => {
expect(result).toEqual({ text: "Ollama Cloud answer." });
const registerParams = expectRecordFields(mockArg(registerProviderStreamForModelMock, 0, 0), {
workspaceDir: "/tmp/workspace",
applyProviderWrapper: true,
});
expectRecordFields(registerParams.model, {
provider: "ollama",
-1
View File
@@ -719,7 +719,6 @@ export async function runBtwSideQuestion(
agentDir: params.agentDir,
workspaceDir,
env: process.env,
applyProviderWrapper: true,
});
const streamFn = resolveEmbeddedAgentStreamFn({
currentStreamFn: streamSimple,
-93
View File
@@ -1,93 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Model } from "../llm/types.js";
const mocks = vi.hoisted(() => ({
ensureCustomApiRegistered: vi.fn(),
createBoundaryAwareStreamFnForModel: vi.fn(),
createTransportAwareStreamFnForModel: vi.fn(),
resolveProviderStreamFn: vi.fn(),
wrapProviderStreamFn: vi.fn(),
}));
vi.mock("../plugins/provider-runtime.js", () => ({
resolveProviderStreamFn: mocks.resolveProviderStreamFn,
wrapProviderStreamFn: mocks.wrapProviderStreamFn,
}));
vi.mock("./custom-api-registry.js", () => ({
ensureCustomApiRegistered: mocks.ensureCustomApiRegistered,
}));
vi.mock("./provider-transport-stream.js", () => ({
createBoundaryAwareStreamFnForModel: mocks.createBoundaryAwareStreamFnForModel,
createTransportAwareStreamFnForModel: mocks.createTransportAwareStreamFnForModel,
}));
import { registerProviderStreamForModel } from "./provider-stream.js";
const MODEL = {
id: "anthropic/default",
name: "Anthropic default",
api: "anthropic-messages",
provider: "clawrouter",
baseUrl: "https://clawrouter.example/v1/native/anthropic",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 32_768,
} satisfies Model<"anthropic-messages">;
describe("registerProviderStreamForModel", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("applies an opted-in provider wrapper after selecting the boundary transport", () => {
const baseStream = vi.fn();
const wrappedStream = vi.fn();
mocks.createBoundaryAwareStreamFnForModel.mockReturnValue(baseStream);
mocks.wrapProviderStreamFn.mockReturnValue(wrappedStream);
expect(
registerProviderStreamForModel({
model: MODEL,
cfg: { models: {} },
agentDir: "/agent",
workspaceDir: "/workspace",
applyProviderWrapper: true,
}),
).toBe(wrappedStream);
expect(mocks.createBoundaryAwareStreamFnForModel).toHaveBeenCalledWith(MODEL, {
cfg: { models: {} },
agentDir: "/agent",
workspaceDir: "/workspace",
env: undefined,
});
expect(mocks.wrapProviderStreamFn).toHaveBeenCalledWith({
provider: "clawrouter",
config: { models: {} },
workspaceDir: "/workspace",
env: undefined,
context: {
config: { models: {} },
agentDir: "/agent",
workspaceDir: "/workspace",
provider: "clawrouter",
modelId: "anthropic/default",
model: MODEL,
streamFn: baseStream,
},
});
expect(mocks.ensureCustomApiRegistered).toHaveBeenCalledWith(
"anthropic-messages",
wrappedStream,
);
});
it("leaves existing callers unwrapped by default", () => {
const baseStream = vi.fn();
mocks.resolveProviderStreamFn.mockReturnValue(baseStream);
expect(registerProviderStreamForModel({ model: MODEL })).toBe(baseStream);
expect(mocks.wrapProviderStreamFn).not.toHaveBeenCalled();
});
});
+10 -35
View File
@@ -5,12 +5,9 @@
*/
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { Api, Model } from "../llm/types.js";
import { resolveProviderStreamFn, wrapProviderStreamFn } from "../plugins/provider-runtime.js";
import { resolveProviderStreamFn } from "../plugins/provider-runtime.js";
import { ensureCustomApiRegistered } from "./custom-api-registry.js";
import {
createBoundaryAwareStreamFnForModel,
createTransportAwareStreamFnForModel,
} from "./provider-transport-stream.js";
import { createTransportAwareStreamFnForModel } from "./provider-transport-stream.js";
import type { StreamFn } from "./runtime/index.js";
/** Resolves and registers the stream function for a provider-backed model. */
@@ -21,15 +18,8 @@ export function registerProviderStreamForModel<TApi extends Api>(params: {
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
allowRuntimePluginLoad?: boolean;
applyProviderWrapper?: boolean;
}): StreamFn | undefined {
const transportContext = {
cfg: params.cfg,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
env: params.env,
};
const baseStreamFn =
const streamFn =
resolveProviderStreamFn({
provider: params.model.provider,
config: params.cfg,
@@ -45,30 +35,15 @@ export function registerProviderStreamForModel<TApi extends Api>(params: {
model: params.model,
},
}) ??
createTransportAwareStreamFnForModel(params.model, transportContext) ??
(params.applyProviderWrapper
? createBoundaryAwareStreamFnForModel(params.model, transportContext)
: undefined);
if (!baseStreamFn) {
createTransportAwareStreamFnForModel(params.model, {
cfg: params.cfg,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
env: params.env,
});
if (!streamFn) {
return undefined;
}
const streamFn = params.applyProviderWrapper
? (wrapProviderStreamFn({
provider: params.model.provider,
config: params.cfg,
workspaceDir: params.workspaceDir,
env: params.env,
context: {
config: params.cfg,
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
provider: params.model.provider,
modelId: params.model.id,
model: params.model,
streamFn: baseStreamFn,
},
}) ?? baseStreamFn)
: baseStreamFn;
// Register custom APIs only after a concrete stream exists, so later callers
// can route by model.api without reloading provider runtime hooks.
ensureCustomApiRegistered(params.model.api, streamFn);
-1
View File
@@ -433,7 +433,6 @@ const BUILT_IN_MODEL_PROVIDER_OVERLAY_IDS = new Set([
"byteplus-plan",
"cerebras",
"chutes",
"clawrouter",
"cloudflare-ai-gateway",
"codex",
"comfy",