diff --git a/extensions/clawrouter/index.test.ts b/extensions/clawrouter/index.test.ts index 0f1bd0c8ff2..c281479b487 100644 --- a/extensions/clawrouter/index.test.ts +++ b/extensions/clawrouter/index.test.ts @@ -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[0]> = []; + const baseStreamFn: StreamFn = (model) => { + calls.push(model); + return {} as ReturnType; + }; + 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?.({ diff --git a/extensions/clawrouter/index.ts b/extensions/clawrouter/index.ts index e45ea461da1..8c085074b47 100644 --- a/extensions/clawrouter/index.ts +++ b/extensions/clawrouter/index.ts @@ -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); diff --git a/extensions/clawrouter/provider-catalog.test.ts b/extensions/clawrouter/provider-catalog.test.ts index 0b96aac93f4..6486cd29bab 100644 --- a/extensions/clawrouter/provider-catalog.test.ts +++ b/extensions/clawrouter/provider-catalog.test.ts @@ -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; } { const fetchGuardMock: MockedFunction = 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"); }); }); diff --git a/extensions/clawrouter/provider-catalog.ts b/extensions/clawrouter/provider-catalog.ts index 252915f602d..016dc184969 100644 --- a/extensions/clawrouter/provider-catalog.ts +++ b/extensions/clawrouter/provider-catalog.ts @@ -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; + baseUrl: string; upstreamModel?: string; }; -type CatalogSnapshot = { - apiBaseUrl: string; - authorizationHeader: string; - modelsByRoute: Map; - nativeModelIds: Map; -}; - -let catalogSnapshot: CatalogSnapshot | undefined; - function readRecord(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -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; 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(); - const modelsByRoute = new Map(); - const nativeModelIds = new Map(); 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; -} diff --git a/extensions/clawrouter/stream.ts b/extensions/clawrouter/stream.ts new file mode 100644 index 00000000000..e6b32e678f7 --- /dev/null +++ b/extensions/clawrouter/stream.ts @@ -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 | undefined, + apiKey: string, +): Record { + const next: Record = {}; + 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, + ); + }; +}