Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

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,264 @@
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),
prepareDynamicModel: expect.any(Function),
resolveDynamicModel: 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("resolves configured catalog models through a stored auth profile", async () => {
providerAuthRuntimeMocks.resolveApiKeyForProvider.mockResolvedValue({
apiKey: "resolved-proxy-key",
mode: "api-key",
source: "auth profile",
});
vi.stubGlobal(
"fetch",
vi.fn(async () => Response.json(LIVE_CATALOG)),
);
const provider = await registerSingleProviderPlugin(plugin);
const context = {
config: { models: {} },
agentDir: "/agent",
workspaceDir: "/workspace",
provider: "clawrouter",
modelId: "openai/gpt-5.5",
modelRegistry: { find: vi.fn(() => null) },
authProfileId: "clawrouter-profile",
authProfileMode: "api_key",
};
expect(provider?.resolveDynamicModel?.(context as never)).toBeUndefined();
await provider?.prepareDynamicModel?.(context as never);
expect(provider?.resolveDynamicModel?.(context as never)).toMatchObject({
id: "openai/gpt-5.5",
provider: "clawrouter",
api: "openai-responses",
baseUrl: "https://clawrouter.openclaw.ai/v1",
params: {
clawrouterRoute: {
api: "openai-responses",
baseUrl: "https://clawrouter.openclaw.ai/v1",
},
},
});
expect(providerAuthRuntimeMocks.resolveApiKeyForProvider).toHaveBeenCalledWith({
provider: "clawrouter",
cfg: { models: {} },
agentDir: "/agent",
workspaceDir: "/workspace",
profileId: "clawrouter-profile",
lockedProfile: true,
});
expect(
provider?.resolveDynamicModel?.({
...context,
authProfileId: "another-profile",
} as never),
).toBeUndefined();
providerAuthRuntimeMocks.resolveApiKeyForProvider.mockResolvedValue(undefined);
await provider?.prepareDynamicModel?.(context as never);
expect(provider?.resolveDynamicModel?.(context as never)).toBeUndefined();
});
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,226 @@
// ClawRouter plugin entrypoint registers credential-scoped model routing and quota reporting.
import {
definePluginEntry,
type ProviderAuthMethod,
type ProviderResolveDynamicModelContext,
type ProviderRuntimeModel,
} 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,
normalizeClawRouterRootUrl,
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 }> } } | null | undefined,
): string | undefined {
const value = config?.models?.providers?.[PROVIDER_ID]?.baseUrl;
return typeof value === "string" ? value : undefined;
}
function dynamicModelScope(ctx: ProviderResolveDynamicModelContext): string {
return JSON.stringify([
ctx.agentDir ?? "",
ctx.workspaceDir ?? "",
ctx.authProfileId ?? "",
normalizeClawRouterRootUrl(ctx.providerConfig?.baseUrl ?? configuredBaseUrl(ctx.config)),
]);
}
function buildRuntimeModels(
providerConfig: Awaited<ReturnType<typeof buildClawRouterProviderConfig>>,
): Map<string, ProviderRuntimeModel> {
const models = new Map<string, ProviderRuntimeModel>();
for (const model of providerConfig.models) {
const api = model.api ?? providerConfig.api;
const baseUrl = model.baseUrl ?? providerConfig.baseUrl;
if (!api || !baseUrl) {
continue;
}
models.set(model.id, {
...model,
api,
baseUrl,
provider: PROVIDER_ID,
input: model.input.filter(
(entry): entry is "text" | "image" => entry === "text" || entry === "image",
),
});
}
return models;
}
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) {
const dynamicModels = new Map<string, Map<string, ProviderRuntimeModel>>();
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),
}),
};
},
},
resolveDynamicModel: (ctx) => dynamicModels.get(dynamicModelScope(ctx))?.get(ctx.modelId),
prepareDynamicModel: async (ctx) => {
const scope = dynamicModelScope(ctx);
dynamicModels.delete(scope);
const { resolveApiKeyForProvider } =
await import("openclaw/plugin-sdk/provider-auth-runtime");
const apiKey = (
await resolveApiKeyForProvider({
provider: PROVIDER_ID,
cfg: ctx.config,
...(ctx.agentDir ? { agentDir: ctx.agentDir } : {}),
...(ctx.workspaceDir ? { workspaceDir: ctx.workspaceDir } : {}),
...(ctx.authProfileId ? { profileId: ctx.authProfileId, lockedProfile: true } : {}),
})
)?.apiKey;
if (!apiKey) {
return;
}
const providerConfig = await buildClawRouterProviderConfig({
apiKey,
discoveryApiKey: apiKey,
baseUrl: ctx.providerConfig?.baseUrl ?? configuredBaseUrl(ctx.config),
});
dynamicModels.set(scope, buildRuntimeModels(providerConfig));
},
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",
};
}