mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-28 22:33:35 +00:00
fix(providers): isolate ClawRouter runtime credentials
This commit is contained in:
@@ -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?.({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
41
extensions/clawrouter/stream.ts
Normal file
41
extensions/clawrouter/stream.ts
Normal 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,
|
||||
);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user