mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-09 15:59:30 +00:00
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:
parent
28bbb439f6
commit
4a354f76c1
30 changed files with 1552 additions and 4 deletions
5
.github/labeler.yml
vendored
5
.github/labeler.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -1428,6 +1428,7 @@
|
|||
"providers/azure-speech",
|
||||
"providers/cerebras",
|
||||
"providers/chutes",
|
||||
"providers/clawrouter",
|
||||
"providers/cohere",
|
||||
"providers/claude-max-api-proxy",
|
||||
"providers/cloudflare-ai-gateway",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
23
docs/plugins/reference/clawrouter.md
Normal file
23
docs/plugins/reference/clawrouter.md
Normal 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)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
148
docs/providers/clawrouter.md
Normal file
148
docs/providers/clawrouter.md
Normal 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>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
5
extensions/clawrouter/README.md
Normal file
5
extensions/clawrouter/README.md
Normal 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).
|
||||
205
extensions/clawrouter/index.test.ts
Normal file
205
extensions/clawrouter/index.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
161
extensions/clawrouter/index.ts
Normal file
161
extensions/clawrouter/index.ts
Normal 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,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
12
extensions/clawrouter/npm-shrinkwrap.json
generated
Normal file
12
extensions/clawrouter/npm-shrinkwrap.json
generated
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "@openclaw/clawrouter",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/clawrouter",
|
||||
"version": "2026.6.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
37
extensions/clawrouter/openclaw.plugin.json
Normal file
37
extensions/clawrouter/openclaw.plugin.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
15
extensions/clawrouter/package.json
Normal file
15
extensions/clawrouter/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
249
extensions/clawrouter/provider-catalog.test.ts
Normal file
249
extensions/clawrouter/provider-catalog.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
387
extensions/clawrouter/provider-catalog.ts
Normal file
387
extensions/clawrouter/provider-catalog.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
46
extensions/clawrouter/stream.ts
Normal file
46
extensions/clawrouter/stream.ts
Normal 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);
|
||||
}
|
||||
16
extensions/clawrouter/tsconfig.json
Normal file
16
extensions/clawrouter/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
85
extensions/clawrouter/usage.test.ts
Normal file
85
extensions/clawrouter/usage.test.ts
Normal 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)");
|
||||
});
|
||||
});
|
||||
100
extensions/clawrouter/usage.ts
Normal file
100
extensions/clawrouter/usage.ts
Normal 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
6
pnpm-lock.yaml
generated
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -107,6 +107,7 @@ function humanizeId(value) {
|
|||
["aws", "AWS"],
|
||||
["azure", "Azure"],
|
||||
["byteplus", "BytePlus"],
|
||||
["clawrouter", "ClawRouter"],
|
||||
["codex", "Codex"],
|
||||
["cli", "CLI"],
|
||||
["comfy", "ComfyUI"],
|
||||
|
|
|
|||
|
|
@ -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">;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export type UsageSummary = {
|
|||
|
||||
export type UsageProviderId =
|
||||
| "anthropic"
|
||||
| "clawrouter"
|
||||
| "deepseek"
|
||||
| "github-copilot"
|
||||
| "google-gemini-cli"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue