feat(providers): add ClawRouter routing and quotas (#99658)

* feat(providers): add ClawRouter routing and quotas

* docs(plugins): refresh Ollama inventory

* test(plugins): register ClawRouter boundary coverage

* docs(i18n): add ClawRouter glossary terms

* docs(providers): expand ClawRouter setup guide
This commit is contained in:
Peter Steinberger 2026-07-03 18:29:14 -07:00 committed by GitHub
parent 28bbb439f6
commit 4a354f76c1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1552 additions and 4 deletions

5
.github/labeler.yml vendored
View file

@ -359,6 +359,11 @@
- any-glob-to-any-file:
- "extensions/cerebras/**"
- "docs/providers/cerebras.md"
"extensions: clawrouter":
- changed-files:
- any-glob-to-any-file:
- "extensions/clawrouter/**"
- "docs/providers/clawrouter.md"
"extensions: deepseek":
- changed-files:
- any-glob-to-any-file:

View file

@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
### Changes
- **ClawRouter routing and quotas:** add the bundled ClawRouter provider plugin with credential-scoped dynamic model discovery, OpenAI-compatible and native Anthropic/Gemini transports, and managed budget reporting across OpenClaw usage surfaces. (#99658)
- **Model and provider coverage:** add GPT-5.6 support, use Nemotron Super's 1M context window, and preserve explicit OpenRouter authentication headers. (#98333, #98726, #98187) Thanks @steipete-oai, @eleqtrizit, @sunlit-deng, and @laurencebrown.
- **CLI and node workflows:** add `openclaw attach`, node context-path support, actionable device-approval recovery guidance, and clearer plugin install exit diagnostics. (#96454, #97679, #98115, #98146, #98497) Thanks @anagnorisis2peripeteia, @obviyus, @wm0018, @welfo-beo, @RomneyDa, @Sanjays2402, and @vincentkoc.
- **Cron and usage:** add exit-triggered schedules, detached session-targeted runs, an in-flight job doctor warning, and a built-in full usage footer. (#92037, #98755, #98620, #92657, #92877) Thanks @anagnorisis2peripeteia, @obviyus, @EthanSK, @masatohoshino, and @Marvinthebored.

View file

@ -43,6 +43,22 @@
"source": "ClawHub",
"target": "ClawHub"
},
{
"source": "ClawRouter (managed multi-provider routing)",
"target": "ClawRouter托管式多提供商路由"
},
{
"source": "ClawRouter",
"target": "ClawRouter"
},
{
"source": "ClawRouter plugin",
"target": "ClawRouter 插件"
},
{
"source": "clawrouter",
"target": "clawrouter"
},
{
"source": "OpenAI",
"target": "OpenAI"

View file

@ -283,6 +283,7 @@ messages and normalizes `stats.cached` into `cacheRead`; legacy
| Provider | Id | Auth env | Example model |
| --------------------------------------- | -------------------------------- | ---------------------------------------------------- | ---------------------------------------------------------- |
| BytePlus | `byteplus` / `byteplus-plan` | `BYTEPLUS_API_KEY` | `byteplus-plan/ark-code-latest` |
| ClawRouter | `clawrouter` | `CLAWROUTER_API_KEY` | `clawrouter/anthropic/claude-sonnet-4-6` |
| Cohere | `cohere` | `COHERE_API_KEY` | `cohere/command-a-03-2025` |
| GitHub Copilot | `github-copilot` | `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN` | - |
| Hugging Face Inference | `huggingface` | `HUGGINGFACE_HUB_TOKEN` or `HF_TOKEN` | `huggingface/deepseek-ai/DeepSeek-R1` |

View file

@ -1428,6 +1428,7 @@
"providers/azure-speech",
"providers/cerebras",
"providers/chutes",
"providers/clawrouter",
"providers/cohere",
"providers/claude-max-api-proxy",
"providers/cloudflare-ai-gateway",

View file

@ -5857,6 +5857,15 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Surface
- H2: Related docs
## plugins/reference/clawrouter.md
- Route: /plugins/reference/clawrouter
- Headings:
- H1: ClawRouter plugin
- H2: Distribution
- H2: Surface
- H2: Related docs
## plugins/reference/clickclack.md
- Route: /plugins/reference/clickclack
@ -7272,6 +7281,18 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Notes
- H2: Related
## providers/clawrouter.md
- Route: /providers/clawrouter
- Headings:
- H2: Getting started
- H2: Model discovery
- H2: Protocol and provider plugins
- H2: Quotas and usage
- H2: Troubleshooting
- H2: Security behavior
- H2: Related
## providers/cloudflare-ai-gateway.md
- Route: /providers/cloudflare-ai-gateway

View file

@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description.
## Core npm package
59 plugins
60 plugins
- **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint.
@ -69,6 +69,8 @@ Each entry lists the package, distribution route, and description.
- **[canvas](/plugins/reference/canvas)** (`@openclaw/canvas-plugin`) - included in OpenClaw. Experimental Canvas control and A2UI rendering surfaces for paired nodes.
- **[clawrouter](/plugins/reference/clawrouter)** (`@openclaw/clawrouter`) - included in OpenClaw. Adds ClawRouter model provider support to OpenClaw.
- **[codex-supervisor](/plugins/reference/codex-supervisor)** (`@openclaw/codex-supervisor`) - included in OpenClaw. Supervise Codex app-server sessions from OpenClaw.
- **[cohere](/plugins/reference/cohere)** (`@openclaw/cohere-provider`) - included in OpenClaw; npm; ClawHub: `clawhub:@openclaw/cohere-provider`. OpenClaw Cohere provider plugin.

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 129
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 130
generated plugin reference pages by distribution, package, and description.

View file

@ -0,0 +1,23 @@
---
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`
- Install route: included in OpenClaw
## Surface
providers: clawrouter
## Related docs
- [clawrouter](/providers/clawrouter)

View file

@ -16,7 +16,7 @@ Adds Ollama, Ollama Cloud model provider support to OpenClaw.
## Surface
providers: ollama, ollama-cloud; contracts: memoryEmbeddingProviders, webSearchProviders
providers: ollama, ollama-cloud; contracts: memoryEmbeddingProviders, tools, webSearchProviders
## Related docs

View file

@ -0,0 +1,148 @@
---
summary: "Route credential-scoped models through ClawRouter and show managed quotas"
title: "ClawRouter"
read_when:
- You want one managed key for multiple model providers
- You need ClawRouter model discovery or quota reporting in OpenClaw
---
ClawRouter gives OpenClaw one policy-scoped key for multiple upstream model
providers. The bundled plugin discovers only the models allowed for that key,
routes each model through its declared protocol, and reports the key's budget
and aggregate usage on OpenClaw usage surfaces.
You do not install or authenticate each upstream provider plugin on the
OpenClaw host. Upstream credentials and provider-specific forwarding stay in
ClawRouter. OpenClaw needs only the bundled `@openclaw/clawrouter` plugin and an
issued ClawRouter credential.
| Property | Value |
| ------------- | ---------------------------------------- |
| Provider | `clawrouter` |
| Package | `@openclaw/clawrouter` |
| Auth | `CLAWROUTER_API_KEY` |
| Default URL | `https://clawrouter.openclaw.ai` |
| Model catalog | Credential-scoped via `/v1/catalog` |
| Quotas | Monthly budget and usage via `/v1/usage` |
## Getting started
<Steps>
<Step title="Get a scoped credential">
Ask your ClawRouter administrator for a credential whose policy includes
the providers, models, and monthly budget you should use. Credentials are
revealed once when issued.
</Step>
<Step title="Configure OpenClaw">
```bash
export CLAWROUTER_API_KEY="..."
openclaw onboard --auth-choice clawrouter-api-key
```
The plugin is included with OpenClaw and enabled by default. For a custom
deployment, set `models.providers.clawrouter.baseUrl` to the ClawRouter
origin; the default is `https://clawrouter.openclaw.ai`.
</Step>
<Step title="List granted models">
```bash
openclaw models list --all --provider clawrouter
```
Use the returned model refs exactly as shown. They retain the upstream
namespace, such as `clawrouter/openai/...`, `clawrouter/anthropic/...`, or
`clawrouter/google/...`.
</Step>
<Step title="Select a model">
```bash
openclaw models set clawrouter/<provider>/<model>
```
You can also select a returned model for one run with
`openclaw agent --model clawrouter/<provider>/<model> --message "..."`.
</Step>
</Steps>
## Model discovery
`GET /v1/catalog` is the source of truth. OpenClaw does not ship a second,
fixed list of ClawRouter models. A model configured in ClawRouter appears when:
- the credential's policy grants its provider;
- the provider connection is enabled and ready;
- the catalog model advertises a supported LLM capability; and
- the catalog exposes a transport contract supported by the plugin.
Adding another model to a supported ClawRouter provider therefore does not
require an OpenClaw release or another provider plugin. The next catalog
refresh discovers it. A model that needs a new wire protocol requires support
in the ClawRouter plugin before OpenClaw advertises it.
## Protocol and provider plugins
You do not need to install every upstream company's auth plugin. ClawRouter
owns upstream credentials; its catalog tells OpenClaw which transport to use.
The plugin supports:
| Catalog route | OpenClaw transport |
| ------------------------------ | ---------------------- |
| OpenAI-compatible chat | `openai-completions` |
| OpenAI-compatible Responses | `openai-responses` |
| Native Anthropic Messages | `anthropic-messages` |
| Native Google Gemini streaming | `google-generative-ai` |
The plugin also applies the matching replay and tool-schema policies for those
families. Catalog rows using another request/stream format are intentionally
not advertised as OpenClaw text models. Normalize those providers to one of the
supported contracts in ClawRouter rather than sending an incompatible payload.
## Quotas and usage
ClawRouter's `/v1/usage` response feeds the normal OpenClaw provider-usage
surfaces. `/status` and related dashboard status show the monthly budget window
when the key has a limit, plus request, token, and spend totals. Unmetered keys
still show aggregate usage without a percentage window.
Quota lookup uses the same scoped key as model discovery. A failed quota lookup
does not block model execution.
Check the live snapshot with:
```bash
openclaw status --usage
openclaw models status
```
The same provider snapshot is available to `/status` in chat and OpenClaw's
usage UI. The budget is policy-wide, so requests made by another client using
the same ClawRouter policy can change the remaining percentage.
## Troubleshooting
| Symptom | Check |
| ---------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| No ClawRouter models | Confirm the credential is active, its policy grants at least one ready model provider, and `CLAWROUTER_API_KEY` is available to the OpenClaw process. |
| A configured ClawRouter model is missing | Inspect its `/v1/catalog` capability and route format. Unsupported transport contracts are intentionally filtered. |
| `401` or `403` from catalog or usage | Reissue or re-scope the ClawRouter credential; OpenClaw does not fall back to upstream provider keys. |
| Model call fails after discovery | Check the provider connection and upstream health in ClawRouter, then retry after its readiness state recovers. |
| Usage has totals but no percentage | The policy is unmetered; add a monthly budget in ClawRouter to expose a percentage window. |
## Security behavior
- Catalog discovery is scoped to the configured proxy key and cached per key.
- The proxy key is attached only at request dispatch; it is not stored in model metadata.
- Native Anthropic and Gemini model ids are rewritten to their upstream ids only at dispatch.
- Unsupported or ungranted catalog rows fail closed and are not selectable.
## Related
<CardGroup cols={2}>
<Card title="Model providers" href="/concepts/model-providers" icon="layers">
Provider configuration and model selection.
</Card>
<Card title="Usage tracking" href="/concepts/usage-tracking" icon="chart-line">
OpenClaw usage and status surfaces.
</Card>
</CardGroup>

View file

@ -33,6 +33,7 @@ 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 (managed multi-provider routing)](/providers/clawrouter)
- [Cohere](/providers/cohere)
- [Cloudflare AI Gateway](/providers/cloudflare-ai-gateway)
- [ComfyUI](/providers/comfy)

View file

@ -0,0 +1,5 @@
# ClawRouter
Managed multi-provider model routing and quota reporting for OpenClaw.
See the [ClawRouter provider docs](../../docs/providers/clawrouter.md).

View file

@ -0,0 +1,205 @@
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import { registerSingleProviderPlugin } 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: [],
models: [
{
id: "openai/gpt-5.5",
upstream: "gpt-5.5",
capabilities: ["llm.responses"],
},
],
},
],
};
describe("ClawRouter plugin", () => {
beforeEach(() => {
clearLiveCatalogCacheForTests();
providerAuthRuntimeMocks.resolveApiKeyForProvider.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("registers catalog, transport compatibility, and quota hooks", async () => {
const provider = await registerSingleProviderPlugin(plugin);
expect(provider).toMatchObject({
id: "clawrouter",
label: "ClawRouter",
docsPath: "/providers/clawrouter",
envVars: ["CLAWROUTER_API_KEY"],
buildReplayPolicy: expect.any(Function),
fetchUsageSnapshot: expect.any(Function),
inspectToolSchemas: expect.any(Function),
normalizeResolvedModel: expect.any(Function),
normalizeToolSchemas: expect.any(Function),
resolveUsageAuth: 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 proxy key and native upstream id only at request dispatch", async () => {
const provider = await registerSingleProviderPlugin(plugin);
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/claude-sonnet-4-6",
streamFn: baseStreamFn,
} as never);
void wrapped?.(
{
provider: "clawrouter",
api: "anthropic-messages",
id: "anthropic/claude-sonnet-4-6",
headers: { "X-Request-ID": "request-1" },
params: {
clawrouterRoute: {
api: "anthropic-messages",
baseUrl: "https://clawrouter.example/v1/native/anthropic",
upstreamModel: "claude-sonnet-4-6",
},
},
} as never,
{} as never,
{ apiKey: "runtime-proxy-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-6");
expect(calls[0]?.params).toBeUndefined();
});
it("resolves managed secret refs before 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 = await registerSingleProviderPlugin(plugin);
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"]);
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("surfaces catalog authentication failures", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("Unauthorized", { status: 401 })),
);
const provider = await registerSingleProviderPlugin(plugin);
await expect(
provider?.catalog?.run({
config: { models: {} },
env: { CLAWROUTER_API_KEY: "invalid-proxy-key" },
resolveProviderAuth: () => ({
apiKey: "invalid-proxy-key",
discoveryApiKey: "invalid-proxy-key",
mode: "api_key",
source: "env",
}),
resolveProviderApiKey: () => ({
apiKey: "invalid-proxy-key",
discoveryApiKey: "invalid-proxy-key",
}),
}),
).rejects.toThrow(/401/u);
});
it("dispatches replay and tool policies by upstream protocol family", async () => {
const provider = await registerSingleProviderPlugin(plugin);
expect(
provider?.buildReplayPolicy?.({
provider: "clawrouter",
modelApi: "anthropic-messages",
modelId: "anthropic/claude-sonnet-4-6",
} as never),
).toMatchObject({ preserveNativeAnthropicToolUseIds: true, validateAnthropicTurns: true });
expect(
provider?.buildReplayPolicy?.({
provider: "clawrouter",
modelApi: "google-generative-ai",
modelId: "google/gemini-3.5-flash",
} as never),
).toMatchObject({ validateGeminiTurns: true });
expect(
provider?.buildReplayPolicy?.({
provider: "clawrouter",
modelApi: "openai-completions",
modelId: "deepseek/deepseek-v4-flash",
} as never),
).toMatchObject({ sanitizeToolCallIds: true });
});
});

View file

@ -0,0 +1,161 @@
// ClawRouter plugin entrypoint registers credential-scoped model routing and quota reporting.
import { definePluginEntry, type ProviderAuthMethod } from "openclaw/plugin-sdk/plugin-entry";
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
import { buildProviderReplayFamilyHooks } from "openclaw/plugin-sdk/provider-model-shared";
import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools";
import {
buildClawRouterProviderConfig,
normalizeClawRouterApiBaseUrl,
normalizeClawRouterResolvedModel,
} from "./provider-catalog.js";
import { wrapClawRouterProviderStream } from "./stream.js";
import { fetchClawRouterUsage } from "./usage.js";
const PROVIDER_ID = "clawrouter";
const ENV_VAR = "CLAWROUTER_API_KEY";
const openAiReplay = buildProviderReplayFamilyHooks({
family: "openai-compatible",
dropReasoningFromHistory: false,
});
const anthropicReplay = buildProviderReplayFamilyHooks({
family: "native-anthropic-by-model",
});
const googleReplay = buildProviderReplayFamilyHooks({ family: "google-gemini" });
const openAiTools = buildProviderToolCompatFamilyHooks("openai");
const deepSeekTools = buildProviderToolCompatFamilyHooks("deepseek");
const geminiTools = buildProviderToolCompatFamilyHooks("gemini");
function buildApiKeyAuth(): ProviderAuthMethod {
return createProviderApiKeyAuthMethod({
providerId: PROVIDER_ID,
methodId: "api-key",
label: "ClawRouter proxy key",
hint: "Credential-scoped access to approved models and budgets",
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 models through one managed key",
groupId: PROVIDER_ID,
groupLabel: "ClawRouter",
groupHint: "Managed model access and quotas",
},
});
}
function configuredBaseUrl(config: {
models?: { providers?: Record<string, { baseUrl?: unknown }> };
}): string | undefined {
const value = config.models?.providers?.[PROVIDER_ID]?.baseUrl;
return typeof value === "string" ? value : undefined;
}
function resolveToolFamily(modelId: string) {
const normalized = modelId.toLowerCase();
if (normalized.startsWith("deepseek/")) {
return deepSeekTools;
}
if (normalized.startsWith("google/")) {
return geminiTools;
}
return openAiTools;
}
export default definePluginEntry({
id: PROVIDER_ID,
name: "ClawRouter",
description: "Managed multi-provider model routing and quotas",
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;
}
return {
provider: await buildClawRouterProviderConfig({
apiKey,
discoveryApiKey,
baseUrl: configuredBaseUrl(ctx.config),
}),
};
},
},
normalizeConfig: ({ providerConfig }) => {
const baseUrl = normalizeClawRouterApiBaseUrl(providerConfig.baseUrl);
return baseUrl !== providerConfig.baseUrl ? { ...providerConfig, baseUrl } : undefined;
},
normalizeResolvedModel: ({ model }) => normalizeClawRouterResolvedModel(model),
wrapSimpleCompletionStreamFn: wrapClawRouterProviderStream,
wrapStreamFn: wrapClawRouterProviderStream,
buildReplayPolicy: (ctx) => {
if (ctx.modelApi === "anthropic-messages") {
return anthropicReplay.buildReplayPolicy?.(ctx);
}
if (ctx.modelApi === "google-generative-ai") {
return googleReplay.buildReplayPolicy?.(ctx);
}
return openAiReplay.buildReplayPolicy?.(ctx);
},
sanitizeReplayHistory: (ctx) =>
ctx.modelApi === "google-generative-ai"
? googleReplay.sanitizeReplayHistory?.(ctx)
: undefined,
resolveReasoningOutputMode: (ctx) =>
ctx.modelApi === "google-generative-ai"
? googleReplay.resolveReasoningOutputMode?.(ctx)
: undefined,
normalizeToolSchemas: (ctx) => resolveToolFamily(ctx.modelId ?? "").normalizeToolSchemas(ctx),
inspectToolSchemas: (ctx) => resolveToolFamily(ctx.modelId ?? "").inspectToolSchemas(ctx),
isModernModelRef: () => true,
resolveUsageAuth: async (ctx) => {
const apiKey = ctx.resolveApiKeyFromConfigAndStore({
envDirect: [ctx.env[ENV_VAR]],
});
return apiKey ? { token: apiKey } : null;
},
fetchUsageSnapshot: async (ctx) =>
await fetchClawRouterUsage({
token: ctx.token,
baseUrl: configuredBaseUrl(ctx.config),
timeoutMs: ctx.timeoutMs,
fetchFn: ctx.fetchFn,
}),
});
},
});

View file

@ -0,0 +1,12 @@
{
"name": "@openclaw/clawrouter",
"version": "2026.6.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/clawrouter",
"version": "2026.6.11"
}
}
}

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 models through one managed key",
"groupId": "clawrouter",
"groupLabel": "ClawRouter",
"groupHint": "Managed model access and quotas",
"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",
"version": "2026.6.11",
"private": true,
"description": "OpenClaw ClawRouter plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
]
}
}

View file

@ -0,0 +1,249 @@
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 PRICING = {
inputMicrosPerMillion: 3_000_000,
outputMicrosPerMillion: 15_000_000,
cachedInputMicrosPerMillion: 300_000,
cacheWrite5mInputMicrosPerMillion: 3_750_000,
maxInputTokens: 1_000_000,
defaultMaxOutputTokens: 64_000,
};
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",
},
],
models: [
{
id: "openai/gpt-5.5",
upstream: "gpt-5.5",
capabilities: ["llm.responses", "llm.chat"],
pricing: PRICING,
},
],
},
{
id: "deepseek",
displayName: "DeepSeek",
openaiCompatible: true,
nativeBaseUrl: "/v1/native/deepseek",
routes: [],
models: [
{
id: "deepseek/deepseek-v4-flash",
upstream: "deepseek-v4-flash",
capabilities: ["llm.chat"],
},
],
},
{
id: "anthropic",
displayName: "Anthropic",
openaiCompatible: false,
nativeBaseUrl: "/v1/native/anthropic",
routes: [
{
path: "/v1/messages",
methods: ["POST"],
requestFormat: "anthropic.messages",
},
],
models: [
{
id: "anthropic/claude-sonnet-4-6",
upstream: "claude-sonnet-4-6",
capabilities: ["llm.messages"],
pricing: PRICING,
},
],
},
{
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",
},
{
path: "/v1beta/models/${model}:streamGenerateContent",
methods: ["POST"],
requestFormat: "google.generate_content",
},
],
models: [
{
id: "google/gemini-3.5-flash",
upstream: "gemini-3.5-flash",
capabilities: ["llm.generate", "llm.stream"],
},
],
},
{
id: "cohere",
displayName: "Cohere",
openaiCompatible: false,
nativeBaseUrl: "/v1/native/cohere",
routes: [
{
path: "/v2/chat",
methods: ["POST"],
requestFormat: "cohere.chat",
},
],
models: [
{
id: "cohere/command-a-plus-05-2026",
upstream: "command-a-plus-05-2026",
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 every supported catalog protocol to its OpenClaw transport", async () => {
const { fetchGuard, fetchGuardMock } = buildFetchGuard();
const provider = await buildClawRouterProviderConfig({
apiKey: "clawrouter-test-key",
baseUrl: "https://clawrouter.example/v1",
fetchGuard,
});
expect(fetchGuardMock).toHaveBeenCalledOnce();
expect(provider.models.map((model) => model.id)).toEqual([
"anthropic/claude-sonnet-4-6",
"deepseek/deepseek-v4-flash",
"google/gemini-3.5-flash",
"openai/gpt-5.5",
]);
expect(provider.models.find((model) => model.id === "openai/gpt-5.5")).toMatchObject({
api: "openai-responses",
baseUrl: "https://clawrouter.example/v1",
reasoning: true,
input: ["text", "image"],
cost: { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
contextWindow: 1_000_000,
maxTokens: 64_000,
});
expect(
provider.models.find((model) => model.id === "deepseek/deepseek-v4-flash"),
).toMatchObject({ api: "openai-completions" });
expect(
provider.models.find((model) => model.id === "anthropic/claude-sonnet-4-6"),
).toMatchObject({
api: "anthropic-messages",
baseUrl: "https://clawrouter.example/v1/native/anthropic",
});
expect(provider.models.find((model) => model.id === "google/gemini-3.5-flash")).toMatchObject({
api: "google-generative-ai",
baseUrl: "https://clawrouter.example/v1/native/google-gemini/v1beta",
});
expect(provider.models.map((model) => model.id)).not.toContain("cohere/command-a-plus-05-2026");
});
it("rewrites only native protocol model ids at the request boundary", async () => {
const provider = await buildClawRouterProviderConfig({
apiKey: "clawrouter-test-key",
baseUrl: "https://clawrouter.example",
fetchGuard: buildFetchGuard().fetchGuard,
});
const anthropic = provider.models.find((model) => model.id === "anthropic/claude-sonnet-4-6");
const normalized = normalizeClawRouterResolvedModel({
...anthropic,
baseUrl: provider.baseUrl,
provider: "clawrouter",
} as ProviderRuntimeModel);
expect(normalized).toMatchObject({
id: "anthropic/claude-sonnet-4-6",
api: "anthropic-messages",
});
expect(prepareClawRouterRequestModel(normalized as ProviderRuntimeModel)).toMatchObject({
id: "claude-sonnet-4-6",
params: undefined,
});
const openai = provider.models.find((model) => model.id === "openai/gpt-5.5");
const normalizedOpenAi = normalizeClawRouterResolvedModel({
...openai,
baseUrl: provider.baseUrl,
provider: "clawrouter",
} as ProviderRuntimeModel);
expect(prepareClawRouterRequestModel(normalizedOpenAi as ProviderRuntimeModel).id).toBe(
"openai/gpt-5.5",
);
});
it("caches catalog rows per credential scope", 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 without a streaming route", async () => {
const catalog = structuredClone(CATALOG);
catalog.providers[3].routes = catalog.providers[3].routes.filter(
(route) => !route.path.includes(":streamGenerateContent"),
);
catalog.providers[3].models[0].capabilities = ["llm.generate"];
const provider = await buildClawRouterProviderConfig({
apiKey: "clawrouter-test-key",
fetchGuard: buildFetchGuard(catalog).fetchGuard,
});
expect(provider.models.map((model) => model.id)).not.toContain("google/gemini-3.5-flash");
});
});

View file

@ -0,0 +1,387 @@
// 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 CatalogPricing = {
inputMicrosPerMillion?: number;
outputMicrosPerMillion?: number;
cachedInputMicrosPerMillion?: number;
cacheWrite5mInputMicrosPerMillion?: number;
cacheWrite1hInputMicrosPerMillion?: number;
maxInputTokens?: number;
defaultMaxOutputTokens?: number;
};
type CatalogModel = {
id: string;
upstream: string;
capabilities: string[];
pricing?: CatalogPricing;
};
type CatalogProvider = {
id: string;
displayName: string;
openaiCompatible: boolean;
nativeBaseUrl: string;
routes: CatalogRoute[];
models: CatalogModel[];
};
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 readNonNegativeNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
}
function readPositiveSafeInteger(value: unknown): number | undefined {
return Number.isSafeInteger(value) && Number(value) > 0 ? Number(value) : undefined;
}
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 parseCatalogPricing(value: unknown): CatalogPricing | undefined {
const row = readRecord(value);
if (!row) {
return undefined;
}
return {
inputMicrosPerMillion: readNonNegativeNumber(row.inputMicrosPerMillion),
outputMicrosPerMillion: readNonNegativeNumber(row.outputMicrosPerMillion),
cachedInputMicrosPerMillion: readNonNegativeNumber(row.cachedInputMicrosPerMillion),
cacheWrite5mInputMicrosPerMillion: readNonNegativeNumber(row.cacheWrite5mInputMicrosPerMillion),
cacheWrite1hInputMicrosPerMillion: readNonNegativeNumber(row.cacheWrite1hInputMicrosPerMillion),
maxInputTokens: readPositiveSafeInteger(row.maxInputTokens),
defaultMaxOutputTokens: readPositiveSafeInteger(row.defaultMaxOutputTokens),
};
}
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),
pricing: parseCatalogPricing(row?.pricing),
};
}
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 inferReasoning(providerId: string, modelId: string): boolean {
const id = `${providerId}/${modelId}`.toLowerCase();
return /(?:claude-|gemini-|gpt-5|gpt-oss|deepseek-v|reasoner|glm-5|grok-4|minimax-m)/u.test(id);
}
function inferInput(providerId: string, modelId: string): Array<"text" | "image"> {
const id = `${providerId}/${modelId}`.toLowerCase();
return /(?:claude-|gemini-|gpt-4o|gpt-5)/u.test(id) ? ["text", "image"] : ["text"];
}
function microsPerMillionToCost(value: number | undefined): number {
return value === undefined ? 0 : value / 1_000_000;
}
function modelCost(pricing: CatalogPricing | undefined): ModelDefinitionConfig["cost"] {
if (!pricing) {
return DEFAULT_COST;
}
return {
input: microsPerMillionToCost(pricing.inputMicrosPerMillion),
output: microsPerMillionToCost(pricing.outputMicrosPerMillion),
cacheRead: microsPerMillionToCost(pricing.cachedInputMicrosPerMillion),
cacheWrite: microsPerMillionToCost(
pricing.cacheWrite5mInputMicrosPerMillion ?? pricing.cacheWrite1hInputMicrosPerMillion,
),
};
}
function buildRoutedModel(
rootUrl: string,
provider: CatalogProvider,
model: CatalogModel,
): ModelDefinitionConfig | 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 {
id: model.id,
name: `${provider.displayName} · ${model.id}`,
api,
baseUrl,
reasoning: inferReasoning(provider.id, model.id),
input: inferInput(provider.id, model.id),
cost: modelCost(model.pricing),
contextWindow: model.pricing?.maxInputTokens ?? DEFAULT_CONTEXT_WINDOW,
maxTokens: model.pricing?.defaultMaxOutputTokens ?? 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.id)) {
continue;
}
models.set(routed.id, routed);
}
}
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;
}
const upstreamModel = readString(row?.upstreamModel);
return {
api,
baseUrl,
...(upstreamModel ? { 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 } : {}),
};
}

View file

@ -0,0 +1,46 @@
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);
}

View file

@ -0,0 +1,16 @@
{
"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"
]
}

View file

@ -0,0 +1,85 @@
import { describe, expect, it, vi } from "vitest";
import { fetchClawRouterUsage } from "./usage.js";
describe("ClawRouter usage", () => {
it("maps the managed monthly budget and usage totals", async () => {
const fetchFn = vi.fn(async () =>
Response.json({
budget: {
configured: true,
ledger: "durable_object",
windowKey: "default/test-policy/2026-07",
limitMicros: 100_000_000,
spentMicros: 25_000_000,
remainingMicros: 75_000_000,
},
usage: {
summary: {
requestCount: 12,
totalTokens: 34_567,
actualCostMicros: 25_000_000,
},
},
}),
);
const snapshot = await fetchClawRouterUsage({
token: "proxy-key",
baseUrl: "https://clawrouter.example/v1",
timeoutMs: 5000,
fetchFn: fetchFn as unknown as typeof fetch,
});
expect(snapshot).toEqual({
provider: "clawrouter",
displayName: "ClawRouter",
windows: [
{
label: "Monthly budget",
usedPercent: 25,
resetAt: Date.UTC(2026, 7, 1),
},
],
summary: "12 requests · 34,567 tokens · $25.00 used",
plan: "Managed monthly budget",
});
expect(fetchFn).toHaveBeenCalledWith(
"https://clawrouter.example/v1/usage",
expect.objectContaining({
headers: {
Accept: "application/json",
Authorization: "Bearer proxy-key",
},
}),
);
});
it("shows aggregate usage for an unmetered key", async () => {
const snapshot = await fetchClawRouterUsage({
token: "proxy-key",
timeoutMs: 5000,
fetchFn: vi.fn(async () =>
Response.json({
budget: { configured: false, ledger: "unmetered" },
usage: { summary: { requestCount: 0, totalTokens: 0, actualCostMicros: 0 } },
}),
) as unknown as typeof fetch,
});
expect(snapshot.windows).toEqual([]);
expect(snapshot.summary).toBe("0 requests · 0 tokens · $0.00 used");
expect(snapshot.plan).toBe("Unmetered proxy key");
});
it("does not expose an upstream error body", async () => {
await expect(
fetchClawRouterUsage({
token: "proxy-key",
timeoutMs: 5000,
fetchFn: vi.fn(
async () => new Response("secret details", { status: 403 }),
) as unknown as typeof fetch,
}),
).rejects.toThrow("ClawRouter usage request failed (HTTP 403)");
});
});

View file

@ -0,0 +1,100 @@
import type { ProviderUsageSnapshot } from "openclaw/plugin-sdk/provider-usage";
import { normalizeClawRouterRootUrl } from "./provider-catalog.js";
type ClawRouterBudget = {
configured?: unknown;
ledger?: unknown;
windowKey?: unknown;
limitMicros?: unknown;
spentMicros?: unknown;
remainingMicros?: unknown;
};
type ClawRouterUsagePayload = {
budget?: ClawRouterBudget;
usage?: {
summary?: {
requestCount?: unknown;
totalTokens?: unknown;
actualCostMicros?: unknown;
};
};
};
function nonNegativeNumber(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
}
function formatUsd(micros: number): string {
const dollars = micros / 1_000_000;
return dollars < 0.01 && dollars > 0 ? `$${dollars.toFixed(4)}` : `$${dollars.toFixed(2)}`;
}
function formatCount(value: number): string {
return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(value);
}
function resolveMonthlyResetAt(windowKey: unknown): number | undefined {
if (typeof windowKey !== "string") {
return undefined;
}
const match = windowKey.match(/\/(\d{4})-(\d{2})$/u);
if (!match) {
return undefined;
}
const year = Number(match[1]);
const month = Number(match[2]);
return Number.isSafeInteger(year) && month >= 1 && month <= 12
? Date.UTC(year, month, 1)
: undefined;
}
function buildSummary(payload: ClawRouterUsagePayload): string | undefined {
const summary = payload.usage?.summary;
const requests = nonNegativeNumber(summary?.requestCount);
const tokens = nonNegativeNumber(summary?.totalTokens);
const costMicros = nonNegativeNumber(summary?.actualCostMicros);
const parts = [
requests === undefined ? undefined : `${formatCount(requests)} requests`,
tokens === undefined ? undefined : `${formatCount(tokens)} tokens`,
costMicros === undefined ? undefined : `${formatUsd(costMicros)} used`,
].filter((part): part is string => Boolean(part));
return parts.length > 0 ? parts.join(" · ") : undefined;
}
export async function fetchClawRouterUsage(params: {
token: string;
baseUrl?: string;
timeoutMs: number;
fetchFn: typeof fetch;
}): Promise<ProviderUsageSnapshot> {
const response = await params.fetchFn(`${normalizeClawRouterRootUrl(params.baseUrl)}/v1/usage`, {
headers: {
Accept: "application/json",
Authorization: `Bearer ${params.token}`,
},
signal: AbortSignal.timeout(params.timeoutMs),
});
if (!response.ok) {
throw new Error(`ClawRouter usage request failed (HTTP ${response.status})`);
}
const payload = (await response.json()) as ClawRouterUsagePayload;
const budget = payload.budget;
const limitMicros = nonNegativeNumber(budget?.limitMicros);
const spentMicros = nonNegativeNumber(budget?.spentMicros);
const windows = [];
if (budget?.configured === true && limitMicros !== undefined && spentMicros !== undefined) {
windows.push({
label: "Monthly budget",
usedPercent: limitMicros === 0 ? 100 : Math.min(100, (spentMicros / limitMicros) * 100),
resetAt: resolveMonthlyResetAt(budget?.windowKey),
});
}
return {
provider: "clawrouter" as ProviderUsageSnapshot["provider"],
displayName: "ClawRouter",
windows,
summary: buildSummary(payload),
plan: budget?.configured === true ? "Managed monthly budget" : "Unmetered proxy key",
};
}

6
pnpm-lock.yaml generated
View file

@ -488,6 +488,12 @@ 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:

View file

@ -107,6 +107,7 @@ function humanizeId(value) {
["aws", "AWS"],
["azure", "Azure"],
["byteplus", "BytePlus"],
["clawrouter", "ClawRouter"],
["codex", "Codex"],
["cli", "CLI"],
["comfy", "ComfyUI"],

View file

@ -43,7 +43,7 @@ import { formatForLog } from "../ws-log.js";
import type { GatewayRequestContext, GatewayRequestHandlers } from "./types.js";
const log = createSubsystemLogger("models-auth-status");
const apiKeyUsageStatusProviders = new Set<UsageProviderId>(["deepseek"]);
const apiKeyUsageStatusProviders = new Set<UsageProviderId>(["clawrouter", "deepseek"]);
type ProviderUsageStatus = Pick<ProviderUsageSnapshot, "windows" | "summary" | "plan">;

View file

@ -10,6 +10,7 @@ describe("provider-usage.shared", () => {
});
it.each([
{ value: "clawrouter", expected: "clawrouter" },
{ value: "deepseek", expected: "deepseek" },
{ value: "zai", expected: "zai" },
{ value: "z-ai", expected: undefined },

View file

@ -8,6 +8,7 @@ export const DEFAULT_TIMEOUT_MS = 5000;
export const PROVIDER_LABELS: Record<UsageProviderId, string> = {
anthropic: "Claude",
clawrouter: "ClawRouter",
deepseek: "DeepSeek",
"github-copilot": "Copilot",
"google-gemini-cli": "Gemini",
@ -20,6 +21,7 @@ export const PROVIDER_LABELS: Record<UsageProviderId, string> = {
export const usageProviders: UsageProviderId[] = [
"anthropic",
"clawrouter",
"deepseek",
"github-copilot",
"google-gemini-cli",

View file

@ -21,6 +21,7 @@ export type UsageSummary = {
export type UsageProviderId =
| "anthropic"
| "clawrouter"
| "deepseek"
| "github-copilot"
| "google-gemini-cli"