From b09f9b0482fac2ac7dc70095251659cd26c815f1 Mon Sep 17 00:00:00 2001 From: Alix-007 Date: Sat, 11 Jul 2026 13:01:18 +0800 Subject: [PATCH] fix(openrouter): apply request policy to video catalog requests (#102062) * fix(openrouter): apply request policy to video catalog requests * fix(openrouter): key video catalog cache by request policy * test(openrouter): preserve auth literal types * fix(openrouter): preserve catalog auth overrides --------- Co-authored-by: Peter Steinberger --- .../video-generation-provider.test.ts | 181 +++++++++++++++++- extensions/openrouter/video-model-catalog.ts | 94 +++++++-- 2 files changed, 246 insertions(+), 29 deletions(-) diff --git a/extensions/openrouter/video-generation-provider.test.ts b/extensions/openrouter/video-generation-provider.test.ts index 8cb7a95b6635..cc82a7218376 100644 --- a/extensions/openrouter/video-generation-provider.test.ts +++ b/extensions/openrouter/video-generation-provider.test.ts @@ -24,13 +24,40 @@ const { fetchWithTimeoutGuardedMock: vi.fn(), postJsonRequestMock: vi.fn(), resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "openrouter-key" })), - resolveProviderHttpRequestConfigMock: vi.fn((params: Record) => ({ - baseUrl: params.baseUrl ?? params.defaultBaseUrl ?? "https://openrouter.ai/api/v1", - allowPrivateNetwork: false, - headers: new Headers(params.defaultHeaders as HeadersInit | undefined), - dispatcherPolicy: undefined, - requestConfig: {}, - })), + resolveProviderHttpRequestConfigMock: vi.fn((params: Record) => { + const request = params.request as + | { + allowPrivateNetwork?: boolean; + auth?: + | { mode: "provider-default" } + | { mode: "authorization-bearer"; token: string } + | { + mode: "header"; + headerName: string; + value: string; + prefix?: string; + }; + headers?: Record; + } + | undefined; + const headers = new Headers(params.defaultHeaders as HeadersInit | undefined); + for (const [key, value] of Object.entries(request?.headers ?? {})) { + headers.set(key, value); + } + if (request?.auth?.mode === "authorization-bearer") { + headers.set("Authorization", `Bearer ${request.auth.token}`); + } else if (request?.auth?.mode === "header") { + headers.delete("Authorization"); + headers.set(request.auth.headerName, `${request.auth.prefix ?? ""}${request.auth.value}`); + } + return { + baseUrl: params.baseUrl ?? params.defaultBaseUrl ?? "https://openrouter.ai/api/v1", + allowPrivateNetwork: request?.allowPrivateNetwork === true, + headers, + dispatcherPolicy: undefined, + requestConfig: {}, + }; + }), waitProviderOperationPollIntervalMock: vi.fn(async () => {}), })); @@ -144,6 +171,13 @@ function expectOpenRouterFetchCall(index: number, url: string, auditContext: str ); } +function requireFetchGuardOptions(index: number): Record { + return requireRecord( + requireMockCallArg(fetchWithTimeoutGuardedMock.mock.calls, index, 4, "fetch guard options"), + "OpenRouter fetch guard options", + ); +} + function requirePostJsonParams(index = 0): Record { const call = postJsonRequestMock.mock.calls[index] as unknown[] | undefined; if (!call) { @@ -212,6 +246,10 @@ describe("openrouter video generation provider", () => { }); it("maps OpenRouter video model discovery into unified catalog rows", async () => { + const requestOverrides = { + allowPrivateNetwork: true, + headers: { "X-OpenRouter-Catalog": "enabled" }, + }; fetchWithTimeoutGuardedMock.mockResolvedValueOnce( releasedJson({ data: [ @@ -240,6 +278,7 @@ describe("openrouter video generation provider", () => { providers: { openrouter: { baseUrl: "https://custom.openrouter.test/openrouter/api/v1", + request: requestOverrides, }, }, }, @@ -252,8 +291,8 @@ describe("openrouter video generation provider", () => { resolveProviderAuth: () => ({ apiKey: "OPENROUTER_API_KEY", discoveryApiKey: "resolved-openrouter-key", - mode: "api_key", - source: "env", + mode: "api_key" as const, + source: "env" as const, }), }); @@ -267,6 +306,7 @@ describe("openrouter video generation provider", () => { defaultBaseUrl: "https://openrouter.ai/api/v1", provider: "openrouter", capability: "video", + request: requestOverrides, }, ); expectOpenRouterFetchCall( @@ -275,6 +315,8 @@ describe("openrouter video generation provider", () => { "openrouter-video-models", ); expect(requireFetchCallHeaders(0).get("authorization")).toBe("Bearer resolved-openrouter-key"); + expect(requireFetchCallHeaders(0).get("x-openrouter-catalog")).toBe("enabled"); + expect(requireFetchGuardOptions(0).ssrfPolicy).toEqual({ allowPrivateNetwork: true }); expectUnifiedModelCatalogEntries(rows, { provider: "openrouter", kind: "video_generation", @@ -313,6 +355,106 @@ describe("openrouter video generation provider", () => { }); }); + it("lets configured auth replace the OpenRouter catalog default", async () => { + fetchWithTimeoutGuardedMock.mockResolvedValueOnce(releasedJson({ data: [] })); + + await listOpenRouterVideoModelCatalog({ + config: { + models: { + providers: { + openrouter: { + request: { + auth: { + mode: "authorization-bearer", + token: ["test", "auth"].join("-"), + }, + }, + }, + }, + }, + } as never, + env: {}, + resolveProviderApiKey: () => ({ apiKey: "key", discoveryApiKey: "test-key" }), + resolveProviderAuth: () => ({ + apiKey: "key", + discoveryApiKey: "test-key", + mode: "api_key" as const, + source: "env" as const, + }), + }); + + const requestConfig = requireRecord( + requireMockCallArg(resolveProviderHttpRequestConfigMock.mock.calls, 0, 0, "request config"), + "request config", + ); + expect(requireRecord(requestConfig.defaultHeaders, "default headers").Authorization).toBe( + "Bearer test-key", + ); + expect(requireFetchCallHeaders(0).get("authorization")).toBe("Bearer test-auth"); + }); + + it("keys live OpenRouter video catalog cache by request policy", async () => { + fetchWithTimeoutGuardedMock + .mockResolvedValueOnce( + releasedJson({ + data: [{ id: "google/veo-3.1-fast", name: "Veo first policy" }], + }), + ) + .mockResolvedValueOnce( + releasedJson({ + data: [{ id: "google/veo-3.1-quality", name: "Veo second policy" }], + }), + ); + + const buildCatalogContext = (request: { + allowPrivateNetwork?: boolean; + headers?: Record; + }) => ({ + config: { + models: { + providers: { + openrouter: { + baseUrl: "https://custom.openrouter.test/openrouter/api/v1", + request, + }, + }, + }, + } as never, + env: {}, + resolveProviderApiKey: () => ({ + apiKey: "OPENROUTER_API_KEY", + discoveryApiKey: "resolved-openrouter-key", + }), + resolveProviderAuth: () => ({ + apiKey: "OPENROUTER_API_KEY", + discoveryApiKey: "resolved-openrouter-key", + mode: "api_key" as const, + source: "env" as const, + }), + }); + + const firstRows = await listOpenRouterVideoModelCatalog( + buildCatalogContext({ + allowPrivateNetwork: true, + headers: { "X-OpenRouter-Policy": "first" }, + }), + ); + const secondRows = await listOpenRouterVideoModelCatalog( + buildCatalogContext({ + allowPrivateNetwork: false, + headers: { "X-OpenRouter-Policy": "second" }, + }), + ); + + expect(fetchWithTimeoutGuardedMock).toHaveBeenCalledTimes(2); + expect(requireFetchCallHeaders(0).get("x-openrouter-policy")).toBe("first"); + expect(requireFetchCallHeaders(1).get("x-openrouter-policy")).toBe("second"); + expect(requireFetchGuardOptions(0).ssrfPolicy).toEqual({ allowPrivateNetwork: true }); + expect(requireFetchGuardOptions(1).ssrfPolicy).toBeUndefined(); + expect(firstRows?.[0]?.model).toBe("google/veo-3.1-fast"); + expect(secondRows?.[0]?.model).toBe("google/veo-3.1-quality"); + }); + it("cancels oversized OpenRouter video catalog success bodies", async () => { const oversized = releasedOversizedJsonStream(); fetchWithTimeoutGuardedMock.mockResolvedValueOnce(oversized); @@ -337,7 +479,7 @@ describe("openrouter video generation provider", () => { apiKey: "OPENROUTER_API_KEY", discoveryApiKey: "resolved-openrouter-key", mode: "api_key", - source: "env", + source: "env" as const, }), }), ).rejects.toThrow( @@ -360,6 +502,10 @@ describe("openrouter video generation provider", () => { }); it("resolves live per-model capabilities for runtime overlays", async () => { + const requestOverrides = { + allowPrivateNetwork: true, + headers: { "X-OpenRouter-Capabilities": "enabled" }, + }; fetchWithTimeoutGuardedMock.mockResolvedValueOnce( releasedJson({ data: [ @@ -384,6 +530,7 @@ describe("openrouter video generation provider", () => { providers: { openrouter: { baseUrl: "https://custom.openrouter.test/openrouter/api/v1", + request: requestOverrides, }, }, }, @@ -402,6 +549,20 @@ describe("openrouter video generation provider", () => { "https://custom.openrouter.test/openrouter/api/v1/videos/models", "openrouter-video-models", ); + expectRecordFields( + requireRecord( + requireMockCallArg(resolveProviderHttpRequestConfigMock.mock.calls, 0, 0, "request config"), + "request config", + ), + { + provider: "openrouter", + capability: "video", + baseUrl: "https://custom.openrouter.test/openrouter/api/v1", + request: requestOverrides, + }, + ); + expect(requireFetchCallHeaders(0).get("x-openrouter-capabilities")).toBe("enabled"); + expect(requireFetchGuardOptions(0).ssrfPolicy).toEqual({ allowPrivateNetwork: true }); expect(requireMockCallArg(fetchWithTimeoutGuardedMock.mock.calls, 0, 2, "fetch")).toBe(12_345); expect(requireMockCallArg(fetchWithTimeoutGuardedMock.mock.calls, 0, 3, "fetch")).toBeTypeOf( "function", diff --git a/extensions/openrouter/video-model-catalog.ts b/extensions/openrouter/video-model-catalog.ts index 1a8920564d03..653d2ee3ab29 100644 --- a/extensions/openrouter/video-model-catalog.ts +++ b/extensions/openrouter/video-model-catalog.ts @@ -9,6 +9,7 @@ import { assertOkOrThrowHttpError, readProviderJsonResponse, resolveProviderHttpRequestConfig, + sanitizeConfiguredModelProviderRequest, } from "openclaw/plugin-sdk/provider-http"; import { normalizeOptionalString, @@ -53,6 +54,12 @@ type OpenRouterVideoModelCatalogCapabilities = VideoGenerationProviderCapabiliti pricingSkus?: Readonly>; }; +type OpenRouterVideoRequestPolicyCacheKey = ReturnType< + typeof sanitizeConfiguredModelProviderRequest +>; + +type OpenRouterVideoRequestConfig = Parameters[0]; + function normalizeStringArray(value: unknown): string[] { return normalizeTrimmedStringList(value); } @@ -209,25 +216,70 @@ function projectOpenRouterVideoModelsToCatalogEntries( return entries; } +// Canonical key ordering keeps equivalent request policies on one cache entry. +function stableCacheKeyValue(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(stableCacheKeyValue); + } + if (!value || typeof value !== "object") { + return value; + } + return Object.fromEntries( + Object.entries(value) + .toSorted(([left], [right]) => left.localeCompare(right)) + .map(([key, entry]) => [key, stableCacheKeyValue(entry)]), + ); +} + +function buildRequestPolicyCacheKey(request: OpenRouterVideoRequestPolicyCacheKey): unknown { + return stableCacheKeyValue(request ?? null); +} + +function resolveOpenRouterVideoCatalogRequest(params: { + apiKey: string; + baseUrl: string | undefined; + request: OpenRouterVideoRequestConfig; +}) { + const request = sanitizeConfiguredModelProviderRequest(params.request); + return { + ...resolveProviderHttpRequestConfig({ + provider: "openrouter", + capability: "video", + baseUrl: params.baseUrl, + defaultBaseUrl: OPENROUTER_BASE_URL, + defaultHeaders: { + Authorization: `Bearer ${params.apiKey}`, + "HTTP-Referer": "https://openclaw.ai", + "X-OpenRouter-Title": "OpenClaw", + }, + request, + }), + requestPolicyCacheKey: buildRequestPolicyCacheKey(request), + }; +} + async function fetchOpenRouterVideoModels(params: { baseUrl: string; apiKey: string; + headers: Headers; + requestPolicyCacheKey: unknown; timeoutMs: number; allowPrivateNetwork: boolean; dispatcherPolicy: OpenRouterVideoDispatcherPolicy; }): Promise { return await getCachedLiveCatalogValue({ - keyParts: ["openrouter", "video-models", params.baseUrl, params.apiKey], + keyParts: [ + "openrouter", + "video-models", + params.baseUrl, + params.apiKey, + params.requestPolicyCacheKey, + ], load: async () => { - const headers = new Headers({ - Authorization: `Bearer ${params.apiKey}`, - "HTTP-Referer": "https://openclaw.ai", - "X-OpenRouter-Title": "OpenClaw", - }); const { response, release } = await fetchOpenRouterVideoGet({ url: "videos/models", baseUrl: params.baseUrl, - headers, + headers: params.headers, timeoutMs: params.timeoutMs, allowPrivateNetwork: params.allowPrivateNetwork, dispatcherPolicy: params.dispatcherPolicy, @@ -253,15 +305,17 @@ export async function listOpenRouterVideoModelCatalog( if (!apiKey) { return null; } - const { baseUrl, allowPrivateNetwork, dispatcherPolicy } = resolveProviderHttpRequestConfig({ - provider: "openrouter", - capability: "video", - baseUrl: ctx.config.models?.providers?.openrouter?.baseUrl, - defaultBaseUrl: OPENROUTER_BASE_URL, - }); + const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy, requestPolicyCacheKey } = + resolveOpenRouterVideoCatalogRequest({ + apiKey, + baseUrl: ctx.config.models?.providers?.openrouter?.baseUrl, + request: ctx.config.models?.providers?.openrouter?.request, + }); const payload = await fetchOpenRouterVideoModels({ baseUrl, apiKey, + headers, + requestPolicyCacheKey, timeoutMs: ctx.timeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS, allowPrivateNetwork, dispatcherPolicy, @@ -281,15 +335,17 @@ export async function resolveOpenRouterVideoModelCapabilities( if (!auth.apiKey) { return undefined; } - const { baseUrl, allowPrivateNetwork, dispatcherPolicy } = resolveProviderHttpRequestConfig({ - provider: "openrouter", - capability: "video", - baseUrl: ctx.cfg?.models?.providers?.openrouter?.baseUrl, - defaultBaseUrl: OPENROUTER_BASE_URL, - }); + const { baseUrl, allowPrivateNetwork, headers, dispatcherPolicy, requestPolicyCacheKey } = + resolveOpenRouterVideoCatalogRequest({ + apiKey: auth.apiKey, + baseUrl: ctx.cfg?.models?.providers?.openrouter?.baseUrl, + request: ctx.cfg?.models?.providers?.openrouter?.request, + }); const payload = await fetchOpenRouterVideoModels({ baseUrl, apiKey: auth.apiKey, + headers, + requestPolicyCacheKey, timeoutMs: ctx.timeoutMs ?? DEFAULT_HTTP_TIMEOUT_MS, allowPrivateNetwork, dispatcherPolicy,