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
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:
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).
|
||||
264
extensions/clawrouter/index.test.ts
Normal file
264
extensions/clawrouter/index.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
226
extensions/clawrouter/index.ts
Normal file
226
extensions/clawrouter/index.ts
Normal 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,
|
||||
}),
|
||||
});
|
||||
},
|
||||
});
|
||||
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",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user