fix(providers): isolate ClawRouter runtime credentials

This commit is contained in:
Vincent Koc
2026-06-17 07:52:27 +08:00
committed by Vincent Koc
parent c4e0d27ade
commit 8a4d92d362
5 changed files with 161 additions and 108 deletions

View File

@@ -1,9 +1,10 @@
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import { capturePluginRegistration } from "openclaw/plugin-sdk/plugin-test-runtime";
import { describe, expect, it } from "vitest";
import plugin from "./index.js";
describe("clawrouter provider plugin", () => {
it("registers managed proxy-key auth and dynamic routing hooks", () => {
it("registers managed proxy-key auth and transport routing hooks", () => {
const captured = capturePluginRegistration(plugin);
const provider = captured.providers[0];
@@ -15,8 +16,8 @@ describe("clawrouter provider plugin", () => {
isModernModelRef: expect.any(Function),
buildReplayPolicy: expect.any(Function),
normalizeResolvedModel: expect.any(Function),
resolveDynamicModel: expect.any(Function),
sanitizeReplayHistory: expect.any(Function),
wrapStreamFn: expect.any(Function),
});
expect(provider?.auth[0]).toMatchObject({
id: "api-key",
@@ -25,6 +26,46 @@ describe("clawrouter provider plugin", () => {
});
});
it("attaches the resolved proxy key only when dispatching a request", () => {
const provider = capturePluginRegistration(plugin).providers[0];
const calls: Array<Parameters<StreamFn>[0]> = [];
const baseStreamFn: StreamFn = (model) => {
calls.push(model);
return {} as ReturnType<StreamFn>;
};
const wrapped = provider?.wrapStreamFn?.({
provider: "clawrouter",
modelId: "anthropic/default",
streamFn: baseStreamFn,
} as never);
wrapped?.(
{
provider: "clawrouter",
api: "anthropic-messages",
id: "anthropic/default",
headers: { "X-Request-ID": "request-1" },
} as never,
{} as never,
{ apiKey: "runtime-proxy-key" } as never,
);
wrapped?.(
{
provider: "clawrouter",
api: "anthropic-messages",
id: "anthropic/default",
} as never,
{} as never,
{ apiKey: "CLAWROUTER_API_KEY" } as never,
);
expect(calls[0]?.headers).toEqual({
"X-Request-ID": "request-1",
Authorization: "Bearer runtime-proxy-key",
});
expect(calls[1]?.headers).toBeUndefined();
});
it("normalizes configured ClawRouter roots to the API base URL", () => {
const provider = capturePluginRegistration(plugin).providers[0];
const normalized = provider?.normalizeConfig?.({

View File

@@ -12,8 +12,8 @@ import {
buildClawRouterProviderConfig,
normalizeClawRouterApiBaseUrl,
normalizeClawRouterResolvedModel,
resolveDiscoveredClawRouterModel,
} from "./provider-catalog.js";
import { wrapClawRouterProviderStream } from "./stream.js";
const PROVIDER_ID = "clawrouter";
const ENV_VAR = "CLAWROUTER_API_KEY";
@@ -81,12 +81,8 @@ export default definePluginEntry({
const baseUrl = normalizeClawRouterApiBaseUrl(providerConfig.baseUrl);
return baseUrl !== providerConfig.baseUrl ? { ...providerConfig, baseUrl } : undefined;
},
resolveDynamicModel: ({ modelId, providerConfig }) =>
resolveDiscoveredClawRouterModel({
baseUrl: providerConfig?.baseUrl,
modelId,
}),
normalizeResolvedModel: ({ model }) => normalizeClawRouterResolvedModel(model),
wrapStreamFn: wrapClawRouterProviderStream,
buildReplayPolicy: ({ modelApi, modelId }) => {
if (modelApi === "anthropic-messages") {
return buildNativeAnthropicReplayPolicyForModel(modelId);

View File

@@ -6,9 +6,7 @@ import {
import { beforeEach, describe, expect, it, vi, type MockedFunction } from "vitest";
import {
buildClawRouterProviderConfig,
clearClawRouterCatalogForTests,
normalizeClawRouterResolvedModel,
resolveDiscoveredClawRouterModel,
} from "./provider-catalog.js";
const CATALOG = {
@@ -101,12 +99,12 @@ const CATALOG = {
],
};
function buildFetchGuard(): {
function buildFetchGuard(catalog: unknown = CATALOG): {
fetchGuard: LiveModelCatalogFetchGuard;
fetchGuardMock: MockedFunction<LiveModelCatalogFetchGuard>;
} {
const fetchGuardMock: MockedFunction<LiveModelCatalogFetchGuard> = vi.fn(async () => ({
response: new Response(JSON.stringify(CATALOG)),
response: new Response(JSON.stringify(catalog)),
finalUrl: "https://clawrouter.example/v1/catalog",
release: async () => undefined,
}));
@@ -116,7 +114,6 @@ function buildFetchGuard(): {
describe("clawrouter provider catalog", () => {
beforeEach(() => {
clearLiveCatalogCacheForTests();
clearClawRouterCatalogForTests();
});
it("maps credential-scoped catalog rows to their real provider transports", async () => {
@@ -131,7 +128,6 @@ describe("clawrouter provider catalog", () => {
expect(provider).toMatchObject({
api: "openai-responses",
apiKey: "clawrouter-test-key",
authHeader: true,
baseUrl: "https://clawrouter.example/v1",
});
expect(provider.models.map((model) => model.id)).toEqual([
@@ -163,20 +159,9 @@ describe("clawrouter provider catalog", () => {
id: "claude-sonnet-4-5-20250929",
api: "anthropic-messages",
baseUrl: "https://clawrouter.example/v1/native/anthropic",
headers: {
Authorization: "Bearer clawrouter-test-key",
},
});
const dynamic = resolveDiscoveredClawRouterModel({
baseUrl: provider.baseUrl,
modelId: "google/gemini-default",
});
expect(dynamic).toMatchObject({
id: "google/gemini-default",
provider: "clawrouter",
api: "google-generative-ai",
});
expect(normalized?.params).toBeUndefined();
expect(JSON.stringify(provider.models)).not.toContain("clawrouter-test-key");
});
it("caches the auth-scoped catalog for the discovery TTL", async () => {
@@ -196,40 +181,40 @@ describe("clawrouter provider catalog", () => {
expect((headers as Headers).get("authorization")).toBe("Bearer clawrouter-test-key");
});
it("replaces stale discovery state when the active catalog changes", async () => {
const first = buildFetchGuard();
const provider = await buildClawRouterProviderConfig({
it("keeps credential-scoped route metadata isolated on each catalog result", async () => {
const firstCatalog = structuredClone(CATALOG);
firstCatalog.providers[1].models[0].upstream = "first-upstream";
const first = buildFetchGuard(firstCatalog);
const firstProvider = await buildClawRouterProviderConfig({
apiKey: "first-key",
baseUrl: "https://first.example",
baseUrl: "https://clawrouter.example",
fetchGuard: first.fetchGuard,
});
const anthropic = provider.models.find((model) => model.id === "anthropic/default");
const firstAnthropic = firstProvider.models.find((model) => model.id === "anthropic/default");
const second = buildFetchGuard();
await buildClawRouterProviderConfig({
const secondCatalog = structuredClone(CATALOG);
secondCatalog.providers[1].models[0].upstream = "second-upstream";
const second = buildFetchGuard(secondCatalog);
const secondProvider = await buildClawRouterProviderConfig({
apiKey: "second-key",
baseUrl: "https://second.example",
baseUrl: "https://clawrouter.example",
fetchGuard: second.fetchGuard,
});
const secondAnthropic = secondProvider.models.find((model) => model.id === "anthropic/default");
expect(
resolveDiscoveredClawRouterModel({
baseUrl: "https://first.example/v1",
modelId: "openai/gpt-5.5-mini",
}),
).toBeUndefined();
expect(
resolveDiscoveredClawRouterModel({
baseUrl: "https://second.example/v1",
modelId: "openai/gpt-5.5-mini",
}),
).toBeDefined();
normalizeClawRouterResolvedModel({
...firstAnthropic,
baseUrl: firstProvider.baseUrl,
provider: "clawrouter",
} as ProviderRuntimeModel)?.id,
).toBe("first-upstream");
expect(
normalizeClawRouterResolvedModel({
...anthropic,
baseUrl: provider.baseUrl,
...secondAnthropic,
baseUrl: secondProvider.baseUrl,
provider: "clawrouter",
} as ProviderRuntimeModel),
).toBeUndefined();
} as ProviderRuntimeModel)?.id,
).toBe("second-upstream");
});
});

View File

@@ -13,6 +13,7 @@ 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 = {
@@ -45,18 +46,14 @@ type CatalogProvider = {
type RoutedModel = {
definition: ModelDefinitionConfig;
};
type RouteMetadata = {
api: NonNullable<ModelDefinitionConfig["api"]>;
baseUrl: string;
upstreamModel?: string;
};
type CatalogSnapshot = {
apiBaseUrl: string;
authorizationHeader: string;
modelsByRoute: Map<string, ModelDefinitionConfig>;
nativeModelIds: Map<string, string>;
};
let catalogSnapshot: CatalogSnapshot | undefined;
function readRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
@@ -143,10 +140,6 @@ export function normalizeClawRouterApiBaseUrl(baseUrl: string | undefined): stri
return `${normalizeClawRouterRootUrl(baseUrl)}/v1`;
}
function routeKey(baseUrl: string, modelId: string): string {
return `${trimTrailingSlashes(baseUrl)}\0${modelId}`;
}
function supportsCapability(model: CatalogModel, ...capabilities: string[]): boolean {
return capabilities.some((capability) => model.capabilities.includes(capability));
}
@@ -173,7 +166,7 @@ function buildRoutedModel(
provider: CatalogProvider,
model: CatalogModel,
): RoutedModel | undefined {
let api: ModelDefinitionConfig["api"];
let api: NonNullable<ModelDefinitionConfig["api"]>;
let baseUrl: string;
let upstreamModel: string | undefined;
@@ -216,19 +209,22 @@ function buildRoutedModel(
cost: DEFAULT_COST,
contextWindow: DEFAULT_CONTEXT_WINDOW,
maxTokens: DEFAULT_MAX_TOKENS,
params: {
[ROUTE_METADATA_KEY]: {
api,
baseUrl,
...(upstreamModel ? { upstreamModel } : {}),
} satisfies RouteMetadata,
},
},
upstreamModel,
};
}
function updateDiscoveredModels(
function buildDiscoveredModels(
rootUrl: string,
providers: CatalogProvider[],
apiKey: string,
): ModelDefinitionConfig[] {
const models = new Map<string, ModelDefinitionConfig>();
const modelsByRoute = new Map<string, ModelDefinitionConfig>();
const nativeModelIds = new Map<string, string>();
for (const provider of providers) {
for (const model of provider.models) {
const routed = buildRoutedModel(rootUrl, provider, model);
@@ -236,22 +232,8 @@ function updateDiscoveredModels(
continue;
}
models.set(routed.definition.id, routed.definition);
const key = routeKey(routed.definition.baseUrl ?? `${rootUrl}/v1`, routed.definition.id);
modelsByRoute.set(key, routed.definition);
modelsByRoute.set(routeKey(`${rootUrl}/v1`, routed.definition.id), routed.definition);
if (routed.upstreamModel) {
nativeModelIds.set(key, routed.upstreamModel);
}
}
}
// Discovery owns one active provider config, so replace the whole snapshot.
// Keeping older credential-scoped catalogs would leak stale grants and grow forever.
catalogSnapshot = {
apiBaseUrl: `${rootUrl}/v1`,
authorizationHeader: `Bearer ${apiKey}`,
modelsByRoute,
nativeModelIds,
};
return [...models.values()].sort((left, right) => left.id.localeCompare(right.id));
}
@@ -280,44 +262,52 @@ export async function buildClawRouterProviderConfig(params: {
baseUrl: `${rootUrl}/v1`,
api: "openai-responses",
apiKey: params.apiKey,
authHeader: true,
models: updateDiscoveredModels(rootUrl, providers, params.apiKey),
models: buildDiscoveredModels(rootUrl, providers),
};
}
export function resolveDiscoveredClawRouterModel(params: {
baseUrl?: string;
modelId: string;
}): ProviderRuntimeModel | undefined {
const apiBaseUrl = normalizeClawRouterApiBaseUrl(params.baseUrl);
if (catalogSnapshot?.apiBaseUrl !== apiBaseUrl) {
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 model = catalogSnapshot.modelsByRoute.get(routeKey(apiBaseUrl, params.modelId));
return model ? { ...model, provider: PROVIDER_ID } : undefined;
return {
api,
baseUrl,
...(readString(row?.upstreamModel) ? { upstreamModel: readString(row?.upstreamModel) } : {}),
};
}
function stripRouteMetadata(
params: ProviderRuntimeModel["params"],
): ProviderRuntimeModel["params"] {
if (!params || !(ROUTE_METADATA_KEY in params)) {
return params;
}
const { [ROUTE_METADATA_KEY]: _routeMetadata, ...remaining } = params;
return Object.keys(remaining).length > 0 ? remaining : undefined;
}
export function normalizeClawRouterResolvedModel(
model: ProviderRuntimeModel,
): ProviderRuntimeModel | undefined {
const discovered = catalogSnapshot?.modelsByRoute.get(routeKey(model.baseUrl, model.id));
if (!catalogSnapshot || !discovered) {
const route = readRouteMetadata(model.params);
if (!route) {
return undefined;
}
const discoveredBaseUrl = discovered.baseUrl ?? catalogSnapshot.apiBaseUrl;
const upstreamModel = catalogSnapshot.nativeModelIds.get(routeKey(discoveredBaseUrl, model.id));
return {
...model,
api: discovered.api ?? model.api,
baseUrl: discoveredBaseUrl,
headers: {
...model.headers,
Authorization: catalogSnapshot.authorizationHeader,
},
...(upstreamModel && upstreamModel !== model.id ? { id: upstreamModel } : {}),
api: route.api,
baseUrl: route.baseUrl,
params: stripRouteMetadata(model.params),
...(route.upstreamModel && route.upstreamModel !== model.id ? { id: route.upstreamModel } : {}),
};
}
export function clearClawRouterCatalogForTests(): void {
catalogSnapshot = undefined;
}

View File

@@ -0,0 +1,41 @@
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
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;
}
export function wrapClawRouterProviderStream(
ctx: ProviderWrapStreamFnContext,
): StreamFn | undefined {
const underlying = ctx.streamFn;
if (!underlying) {
return undefined;
}
return (model, context, options) => {
const apiKey = options?.apiKey?.trim();
if (!apiKey || apiKey === ENV_API_KEY_MARKER) {
return underlying(model, context, options);
}
return underlying(
{
...model,
headers: withBearerAuthorization(model.headers, apiKey),
},
context,
options,
);
};
}