From 5af0ccfd5f1f0cf6e6a76d50e1bcbbaffd2777bc Mon Sep 17 00:00:00 2001 From: Vincent Koc <25068+vincentkoc@users.noreply.github.com> Date: Wed, 17 Jun 2026 07:56:34 +0800 Subject: [PATCH] fix(providers): cover ClawRouter runtime auth paths --- extensions/clawrouter/index.test.ts | 93 ++++++++++++++++++++++++++++- extensions/clawrouter/index.ts | 27 +++++++-- 2 files changed, 115 insertions(+), 5 deletions(-) diff --git a/extensions/clawrouter/index.test.ts b/extensions/clawrouter/index.test.ts index c281479b487..70d5f081a17 100644 --- a/extensions/clawrouter/index.test.ts +++ b/extensions/clawrouter/index.test.ts @@ -1,9 +1,51 @@ 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 { 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: [ + { + path: "/v1/responses", + methods: ["POST"], + requestFormat: "openai.responses", + }, + ], + models: [ + { + id: "openai/gpt-5.5-mini", + upstream: "gpt-5.5-mini", + capabilities: ["llm.responses"], + }, + ], + }, + ], +}; + describe("clawrouter provider plugin", () => { + beforeEach(() => { + clearLiveCatalogCacheForTests(); + providerAuthRuntimeMocks.resolveApiKeyForProvider.mockReset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + it("registers managed proxy-key auth and transport routing hooks", () => { const captured = capturePluginRegistration(plugin); const provider = captured.providers[0]; @@ -17,6 +59,7 @@ describe("clawrouter provider plugin", () => { buildReplayPolicy: expect.any(Function), normalizeResolvedModel: expect.any(Function), sanitizeReplayHistory: expect.any(Function), + wrapSimpleCompletionStreamFn: expect.any(Function), wrapStreamFn: expect.any(Function), }); expect(provider?.auth[0]).toMatchObject({ @@ -24,6 +67,7 @@ describe("clawrouter provider plugin", () => { label: "ClawRouter proxy key", kind: "api_key", }); + expect(provider?.wrapSimpleCompletionStreamFn).toBe(provider?.wrapStreamFn); }); it("attaches the resolved proxy key only when dispatching a request", () => { @@ -66,6 +110,53 @@ describe("clawrouter provider plugin", () => { expect(calls[1]?.headers).toBeUndefined(); }); + it("resolves managed secret refs before credential-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 = capturePluginRegistration(plugin).providers[0]; + + 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-mini"]); + 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("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 8c085074b47..e021b1972d8 100644 --- a/extensions/clawrouter/index.ts +++ b/extensions/clawrouter/index.ts @@ -58,9 +58,27 @@ export default definePluginEntry({ catalog: { order: "simple", run: async (ctx) => { - const auth = ctx.resolveProviderApiKey(PROVIDER_ID); - const apiKey = auth.apiKey ?? auth.discoveryApiKey; - if (!apiKey) { + 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; } const configuredBaseUrl = ctx.config.models?.providers?.[PROVIDER_ID]?.baseUrl; @@ -68,7 +86,7 @@ export default definePluginEntry({ return { provider: await buildClawRouterProviderConfig({ apiKey, - discoveryApiKey: auth.discoveryApiKey, + discoveryApiKey, baseUrl: configuredBaseUrl, }), }; @@ -82,6 +100,7 @@ export default definePluginEntry({ return baseUrl !== providerConfig.baseUrl ? { ...providerConfig, baseUrl } : undefined; }, normalizeResolvedModel: ({ model }) => normalizeClawRouterResolvedModel(model), + wrapSimpleCompletionStreamFn: wrapClawRouterProviderStream, wrapStreamFn: wrapClawRouterProviderStream, buildReplayPolicy: ({ modelApi, modelId }) => { if (modelApi === "anthropic-messages") {