diff --git a/extensions/openrouter/index.test.ts b/extensions/openrouter/index.test.ts index 7c925700916e..32dda1705b80 100644 --- a/extensions/openrouter/index.test.ts +++ b/extensions/openrouter/index.test.ts @@ -30,6 +30,7 @@ vi.mock("openclaw/plugin-sdk/provider-stream-family", async (importOriginal) => }); import openrouterPlugin from "./index.js"; +import * as openRouterCatalog from "./provider-catalog.js"; import { buildOpenrouterProvider, isOpenRouterProxyReasoningUnsupportedModel, @@ -252,6 +253,135 @@ describe("openrouter provider hooks", () => { expect(buildOpenrouterProvider().models?.map((model) => model.id)).not.toContain("auto"); }); + it("forwards configured proxy destination and request policy into authenticated catalog discovery", async () => { + const provider = await registerSingleProviderPlugin(openrouterPlugin); + const configuredProvider = { + apiKey: "synthetic-private-proxy-key", + baseUrl: "https://private.example.invalid/router/v1///", + request: { headers: { "X-Private-Proxy-Tenant": "synthetic-tenant" } }, + models: [], + }; + const catalogSpy = vi + .spyOn(openRouterCatalog, "buildOpenrouterLiveProvider") + .mockResolvedValue(buildOpenrouterProvider()); + + try { + await provider.catalog?.run({ + config: { models: { providers: { openrouter: configuredProvider } } }, + resolveProviderApiKey: () => ({ + apiKey: "OPENROUTER_API_KEY", + discoveryApiKey: "synthetic-private-proxy-key", + }), + } as never); + + expect(catalogSpy).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: configuredProvider.baseUrl, + request: configuredProvider.request, + }), + ); + } finally { + catalogSpy.mockRestore(); + } + }); + + it("keeps dynamic proxy models on their configured credential destination", async () => { + const provider = await registerSingleProviderPlugin(openrouterPlugin); + const model = provider.resolveDynamicModel?.({ + provider: "openrouter", + modelId: "private/unknown-model", + modelRegistry: { find: vi.fn(() => null) }, + providerConfig: { baseUrl: "https://private.example.invalid/router/v1///" }, + } as never); + + expect(model?.baseUrl).toBe("https://private.example.invalid/router/v1"); + }); + + it("resolves dynamic proxy destinations from canonical provider config when runtime config is absent", async () => { + const provider = await registerSingleProviderPlugin(openrouterPlugin); + const model = provider.resolveDynamicModel?.({ + provider: "openrouter", + modelId: "private/unknown-model", + modelRegistry: { find: vi.fn(() => null) }, + config: { + models: { + providers: { + openrouter: { baseUrl: "https://private.example.invalid/router/v1/", models: [] }, + }, + }, + }, + } as never); + + expect(model?.baseUrl).toBe("https://private.example.invalid/router/v1"); + }); + + it("preserves the canonical official destination for dynamically resolved default models", async () => { + const provider = await registerSingleProviderPlugin(openrouterPlugin); + const model = provider.resolveDynamicModel?.({ + provider: "openrouter", + modelId: "openrouter/auto", + modelRegistry: { find: vi.fn(() => null) }, + providerConfig: { baseUrl: "https://openrouter.ai/v1///" }, + } as never); + + expect(model?.baseUrl).toBe("https://openrouter.ai/api/v1"); + }); + + it("forwards configured proxy destination and headers to both usage requests", async () => { + const provider = await registerSingleProviderPlugin(openrouterPlugin); + const fetchFn = vi.fn(async () => Response.json({ data: { usage: 1 } })); + + await provider.fetchUsageSnapshot?.({ + config: { + models: { + providers: { + openrouter: { + baseUrl: "https://private.example.invalid/router/v1///", + request: { headers: { "X-Private-Proxy-Tenant": "synthetic-tenant" } }, + models: [], + }, + }, + }, + }, + env: {}, + provider: "openrouter", + token: "synthetic-private-proxy-key", + timeoutMs: 5000, + fetchFn: fetchFn as unknown as typeof fetch, + }); + + expect(fetchFn.mock.calls.map(([url]) => url)).toEqual([ + "https://private.example.invalid/router/v1/credits", + "https://private.example.invalid/router/v1/key", + ]); + for (const [, options] of fetchFn.mock.calls) { + expect(new Headers(options?.headers).get("x-private-proxy-tenant")).toBe("synthetic-tenant"); + } + }); + + it("does not start authenticated catalog discovery when no credential exists", async () => { + const provider = await registerSingleProviderPlugin(openrouterPlugin); + const catalogSpy = vi.spyOn(openRouterCatalog, "buildOpenrouterLiveProvider"); + + try { + await expect( + provider.catalog?.run({ + config: { + models: { + providers: { + openrouter: { baseUrl: "https://private.example.invalid/v1", models: [] }, + }, + }, + }, + resolveProviderApiKey: () => ({}), + } as never), + ).resolves.toBeNull(); + expect(catalogSpy).not.toHaveBeenCalled(); + } finally { + catalogSpy.mockRestore(); + } + }); + it("normalizes OpenRouter API ids before capability loading and lookup", async () => { getOpenRouterModelCapabilitiesMock.mockReset(); loadOpenRouterModelCapabilitiesMock.mockClear(); diff --git a/extensions/openrouter/index.ts b/extensions/openrouter/index.ts index e9ac92123f62..ca53473ceb6c 100644 --- a/extensions/openrouter/index.ts +++ b/extensions/openrouter/index.ts @@ -32,7 +32,7 @@ import { buildOpenrouterProvider, isOpenRouterProxyReasoningUnsupportedModel, normalizeOpenRouterBaseUrl, - OPENROUTER_BASE_URL, + resolveOpenRouterApiBaseUrl, } from "./provider-catalog.js"; import { resolveOpenRouterExtraParamsForTransport } from "./provider-routing.js"; import { buildOpenRouterSpeechProvider } from "./speech-provider.js"; @@ -237,7 +237,9 @@ export default defineSingleProviderPluginEntry({ name: capabilities?.name ?? ctx.modelId, api: "openai-completions", provider: PROVIDER_ID, - baseUrl: OPENROUTER_BASE_URL, + baseUrl: resolveOpenRouterApiBaseUrl( + ctx.providerConfig?.baseUrl ?? ctx.config?.models?.providers?.openrouter?.baseUrl, + ), reasoning: (capabilities?.reasoning ?? false) && !isOpenRouterProxyReasoningUnsupportedModel(ctx.modelId), @@ -288,10 +290,13 @@ export default defineSingleProviderPluginEntry({ if (!apiKey) { return null; } + const providerConfig = ctx.config.models?.providers?.openrouter; return { provider: await buildOpenrouterLiveProvider({ apiKey, discoveryApiKey: auth.discoveryApiKey, + baseUrl: providerConfig?.baseUrl, + request: providerConfig?.request, }), }; }, @@ -340,6 +345,8 @@ export default defineSingleProviderPluginEntry({ fetchUsageSnapshot: async (ctx) => await fetchOpenRouterUsage({ token: ctx.token, + baseUrl: ctx.config.models?.providers?.openrouter?.baseUrl, + request: ctx.config.models?.providers?.openrouter?.request, timeoutMs: ctx.timeoutMs, fetchFn: ctx.fetchFn, }), diff --git a/extensions/openrouter/provider-catalog.test.ts b/extensions/openrouter/provider-catalog.test.ts index 8b33c00a0ae7..b12637b95f4a 100644 --- a/extensions/openrouter/provider-catalog.test.ts +++ b/extensions/openrouter/provider-catalog.test.ts @@ -82,6 +82,227 @@ describe("OpenRouter provider catalog", () => { expect(release).toHaveBeenCalledOnce(); }); + it("keeps custom provider credentials and request headers on the configured catalog origin", async () => { + const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async ({ url }) => ({ + response: Response.json({ data: [{ id: "custom/private-model" }] }), + finalUrl: url, + release: async () => undefined, + })); + + const provider = await buildOpenrouterLiveProvider({ + apiKey: "OPENROUTER_API_KEY", + discoveryApiKey: "synthetic-private-proxy-key", + baseUrl: "https://private.example.invalid/router/v1///", + request: { + headers: { "X-Private-Proxy-Tenant": "synthetic-tenant" }, + }, + fetchGuard, + }); + + const request = vi.mocked(fetchGuard).mock.calls[0]?.[0]; + expect(request?.url).toBe("https://private.example.invalid/router/v1/models"); + expect(provider.baseUrl).toBe("https://private.example.invalid/router/v1"); + const headers = new Headers(request?.init?.headers); + expect(headers.get("authorization")).toBe("Bearer synthetic-private-proxy-key"); + expect(headers.get("x-private-proxy-tenant")).toBe("synthetic-tenant"); + expect(request?.policy).toEqual({ + allowedOrigins: ["https://private.example.invalid"], + }); + }); + + it.each(["https://openrouter.ai/api/v1///", "https://openrouter.ai/v1/"])( + "preserves the canonical endpoint for the official alias %s", + async (baseUrl) => { + const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async ({ url }) => ({ + response: Response.json({ data: [{ id: "openrouter/auto" }] }), + finalUrl: url, + release: async () => undefined, + })); + + const provider = await buildOpenrouterLiveProvider({ + apiKey: "synthetic-official-key", + baseUrl, + fetchGuard, + }); + + expect(provider.baseUrl).toBe("https://openrouter.ai/api/v1"); + expect(vi.mocked(fetchGuard).mock.calls[0]?.[0].url).toBe( + "https://openrouter.ai/api/v1/models", + ); + }, + ); + + it.each([ + "not a URL", + "file:///tmp/openrouter", + `https://${["user", "pass"].join(":")}@private.example.invalid/v1`, + "https://private.example.invalid/v1?token=synthetic-secret", + "https://private.example.invalid/v1#synthetic-secret", + ])("rejects malformed credential destinations before fetching: %s", async (baseUrl) => { + const fetchGuard = vi.fn() as unknown as LiveModelCatalogFetchGuard; + + await expect( + buildOpenrouterLiveProvider({ apiKey: "synthetic-private-key", baseUrl, fetchGuard }), + ).rejects.toThrow("Invalid OpenRouter API base URL"); + expect(fetchGuard).not.toHaveBeenCalled(); + }); + + it("never sends non-secret API-key markers as catalog bearer credentials", async () => { + const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async ({ url }) => ({ + response: Response.json({ data: [{ id: "private/model" }] }), + finalUrl: url, + release: async () => undefined, + })); + + await buildOpenrouterLiveProvider({ + apiKey: "OPENROUTER_API_KEY", + baseUrl: "https://private.example.invalid/v1", + fetchGuard, + }); + + expect( + new Headers(vi.mocked(fetchGuard).mock.calls[0]?.[0].init?.headers).has("authorization"), + ).toBe(false); + }); + + it("isolates successful discovery caches by credential destination and request policy", async () => { + const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async ({ url }) => ({ + response: Response.json({ data: [{ id: "private/model" }] }), + finalUrl: url, + release: async () => undefined, + })); + const base = { apiKey: "synthetic-private-key", fetchGuard }; + const tenantA = { headers: { "X-Private-Proxy-Tenant": "tenant-a" } }; + const tenantB = { headers: { "X-Private-Proxy-Tenant": "tenant-b" } }; + + await buildOpenrouterLiveProvider({ + ...base, + baseUrl: "https://first.invalid/v1", + request: tenantA, + }); + await buildOpenrouterLiveProvider({ + ...base, + baseUrl: "https://first.invalid/v1", + request: tenantB, + }); + await buildOpenrouterLiveProvider({ + ...base, + baseUrl: "https://second.invalid/v1", + request: tenantA, + }); + await buildOpenrouterLiveProvider({ + ...base, + baseUrl: "https://first.invalid/v1", + request: tenantA, + }); + + expect(fetchGuard).toHaveBeenCalledTimes(3); + expect(vi.mocked(fetchGuard).mock.calls.map(([request]) => request.url)).toEqual([ + "https://first.invalid/v1/models", + "https://first.invalid/v1/models", + "https://second.invalid/v1/models", + ]); + }); + + it("honors configured proxy transport, custom auth, and explicitly denied private-network access", async () => { + const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async ({ url }) => ({ + response: Response.json({ data: [{ id: "private/model" }] }), + finalUrl: url, + release: async () => undefined, + })); + + await buildOpenrouterLiveProvider({ + apiKey: "synthetic-original-key", + baseUrl: "https://private.example.invalid/router/v1", + request: { + allowPrivateNetwork: false, + auth: { mode: "header", headerName: "X-Proxy-Key", value: "synthetic-override-key" }, + proxy: { mode: "explicit-proxy", url: "https://corporate-proxy.example.invalid" }, + }, + fetchGuard, + }); + + const request = vi.mocked(fetchGuard).mock.calls[0]?.[0]; + const headers = new Headers(request?.init?.headers); + expect(headers.get("x-proxy-key")).toBe("synthetic-override-key"); + expect(headers.has("authorization")).toBe(false); + expect(request?.policy).toEqual({}); + expect(request?.dispatcherPolicy).toMatchObject({ + mode: "explicit-proxy", + proxyUrl: "https://corporate-proxy.example.invalid", + }); + }); + + it("does not follow cross-origin catalog pagination with private credentials", async () => { + const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async ({ url }) => ({ + response: Response.json({ + data: [{ id: "private/model" }], + next: "https://attacker.example.invalid/models?page=2", + }), + finalUrl: url, + release: async () => undefined, + })); + + const provider = await buildOpenrouterLiveProvider({ + apiKey: "synthetic-private-key", + baseUrl: "https://private.example.invalid/v1", + fetchGuard, + }); + + expect(fetchGuard).toHaveBeenCalledOnce(); + expect(provider.models).toEqual(buildOpenrouterProvider().models); + }); + + it("strips private bearer and custom auth headers after a guarded cross-origin redirect", async () => { + let requestCount = 0; + const redirectedUrl = "https://redirect.example.invalid/catalog"; + const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async ({ url }) => { + requestCount += 1; + return { + response: Response.json({ + data: [{ id: `private/model-${requestCount}` }], + ...(requestCount === 1 ? { next: `${redirectedUrl}?page=2` } : {}), + }), + finalUrl: requestCount === 1 ? redirectedUrl : url, + release: async () => undefined, + }; + }); + + await buildOpenrouterLiveProvider({ + apiKey: "synthetic-private-key", + baseUrl: "https://private.example.invalid/v1", + request: { headers: { "X-Private-Proxy-Tenant": "synthetic-secret-tenant" } }, + fetchGuard, + }); + + expect(fetchGuard).toHaveBeenCalledTimes(2); + const redirectedHeaders = new Headers(vi.mocked(fetchGuard).mock.calls[1]?.[0].init?.headers); + expect(redirectedHeaders.has("authorization")).toBe(false); + expect(redirectedHeaders.has("x-private-proxy-tenant")).toBe(false); + }); + + it("fails closed before discovery when configured request secrets are unresolved", async () => { + const fetchGuard = vi.fn() as unknown as LiveModelCatalogFetchGuard; + + await expect( + buildOpenrouterLiveProvider({ + apiKey: "synthetic-private-key", + baseUrl: "https://private.example.invalid/v1", + request: { + headers: { + "X-Private-Proxy-Tenant": { + source: "env", + provider: "default", + id: "SYNTHETIC_MISSING_SECRET", + }, + }, + }, + fetchGuard, + }), + ).rejects.toThrow(); + expect(fetchGuard).not.toHaveBeenCalled(); + }); + it("caches live discovery and falls back to bundled rows", async () => { const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async ({ url }) => ({ response: Response.json({ diff --git a/extensions/openrouter/provider-catalog.ts b/extensions/openrouter/provider-catalog.ts index 28849ef97f9a..7a03ed26151d 100644 --- a/extensions/openrouter/provider-catalog.ts +++ b/extensions/openrouter/provider-catalog.ts @@ -3,10 +3,19 @@ import { buildLiveModelProviderConfig, type LiveModelCatalogFetchGuard, } from "openclaw/plugin-sdk/provider-catalog-live-runtime"; +import { + normalizeBaseUrl, + resolveProviderHttpRequestConfig, + sanitizeConfiguredModelProviderRequest, +} from "openclaw/plugin-sdk/provider-http"; import type { ModelDefinitionConfig, ModelProviderConfig, } from "openclaw/plugin-sdk/provider-model-shared"; +import { + fetchWithSsrFGuard, + ssrfPolicyFromHttpBaseUrlAllowedOrigin, +} from "openclaw/plugin-sdk/ssrf-runtime"; import { asOptionalRecord, asPositiveSafeInteger, @@ -14,7 +23,6 @@ import { } from "openclaw/plugin-sdk/string-coerce-runtime"; export const OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"; -const OPENROUTER_MODELS_ENDPOINT = `${OPENROUTER_BASE_URL}/models`; const OPENROUTER_LEGACY_BASE_URL = "https://openrouter.ai/v1"; const OPENROUTER_MODELS_CACHE_TTL_MS = 60_000; const OPENROUTER_DEFAULT_MODEL_ID = "openrouter/auto"; @@ -51,6 +59,39 @@ export function normalizeOpenRouterBaseUrl(baseUrl: string | undefined): string return undefined; } +export function resolveOpenRouterApiBaseUrl(baseUrl: string | undefined): string { + // Credentialed catalog, inference, and usage paths must share one validated provider destination. + const normalized = + normalizeOpenRouterBaseUrl(baseUrl) ?? normalizeBaseUrl(baseUrl, OPENROUTER_BASE_URL); + const parsed = URL.canParse(normalized) ? new URL(normalized) : undefined; + if ( + !parsed || + (parsed.protocol !== "http:" && parsed.protocol !== "https:") || + parsed.username || + parsed.password || + parsed.search || + parsed.hash + ) { + throw new Error("Invalid OpenRouter API base URL"); + } + return normalized; +} + +export function resolveOpenRouterSsrfPolicy( + requestConfig: Pick< + ReturnType, + "baseUrl" | "allowPrivateNetwork" + >, + request?: ModelProviderConfig["request"], +) { + // Explicit deny must override the configured-origin trust used by normal proxy requests. + return requestConfig.allowPrivateNetwork + ? { allowPrivateNetwork: true } + : request?.allowPrivateNetwork === false + ? {} + : ssrfPolicyFromHttpBaseUrlAllowedOrigin(requestConfig.baseUrl); +} + export function isOpenRouterProxyReasoningUnsupportedModel(modelId: string | undefined): boolean { const normalized = (modelId ?? "").trim().toLowerCase(); if (!normalized) { @@ -168,24 +209,60 @@ function buildOpenRouterLiveModel(row: unknown): ModelDefinitionConfig | undefin export async function buildOpenrouterLiveProvider(params: { apiKey?: string; discoveryApiKey?: string; + baseUrl?: string; + request?: ModelProviderConfig["request"]; fetchGuard?: LiveModelCatalogFetchGuard; signal?: AbortSignal; }): Promise { const fallback = buildOpenrouterProvider(); + const baseUrl = resolveOpenRouterApiBaseUrl(params.baseUrl); + const request = sanitizeConfiguredModelProviderRequest(params.request); + const resolveRequest = (apiKey?: string) => + resolveProviderHttpRequestConfig({ + provider: "openrouter", + capability: "llm", + baseUrl, + defaultBaseUrl: OPENROUTER_BASE_URL, + defaultHeaders: { + Accept: "application/json", + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), + }, + request, + }); + const requestConfig = resolveRequest(); + const endpoint = `${requestConfig.baseUrl}/models`; return await buildLiveModelProviderConfig({ providerId: "openrouter", - endpoint: OPENROUTER_MODELS_ENDPOINT, + endpoint, providerConfig: { - baseUrl: fallback.baseUrl, + baseUrl: requestConfig.baseUrl, api: fallback.api, + ...(params.request ? { request: params.request } : {}), }, models: fallback.models, apiKey: params.apiKey, discoveryApiKey: params.discoveryApiKey, - fetchGuard: params.fetchGuard, + fetchGuard: async (fetchParams) => + await (params.fetchGuard ?? fetchWithSsrFGuard)({ + ...fetchParams, + ...(requestConfig.dispatcherPolicy + ? { dispatcherPolicy: requestConfig.dispatcherPolicy } + : {}), + }), signal: params.signal, ttlMs: OPENROUTER_MODELS_CACHE_TTL_MS, auditContext: "openrouter-model-discovery", + policy: resolveOpenRouterSsrfPolicy(requestConfig, params.request), + // Destination and request policy isolate cached rows between proxy tenants and auth overrides. + cacheKeyParts: [ + "openrouter", + "model-rows", + endpoint, + params.discoveryApiKey ?? params.apiKey, + request ?? null, + ], + buildRequestHeaders: ({ apiKey, discoveryApiKey }) => + resolveRequest(discoveryApiKey ?? apiKey).headers, projectRows: (rows, fallbackProvider) => { const liveModels = rows.flatMap((row) => { const model = buildOpenRouterLiveModel(row); diff --git a/extensions/openrouter/speech-provider.test.ts b/extensions/openrouter/speech-provider.test.ts index db3dcc56fdcb..7a8d443f0a91 100644 --- a/extensions/openrouter/speech-provider.test.ts +++ b/extensions/openrouter/speech-provider.test.ts @@ -21,7 +21,8 @@ const { })), })); -vi.mock("openclaw/plugin-sdk/provider-http", () => ({ +vi.mock("openclaw/plugin-sdk/provider-http", async (importOriginal) => ({ + ...(await importOriginal()), assertOkOrThrowHttpError: assertOkOrThrowHttpErrorMock, postJsonRequest: postJsonRequestMock, readProviderBinaryResponse: readProviderBinaryResponseMock, @@ -195,6 +196,87 @@ describe("openrouter speech provider", () => { expect(release).toHaveBeenCalledOnce(); }); + it("never sends a custom model-provider credential to the public speech endpoint", async () => { + postJsonRequestMock.mockResolvedValue({ + response: new Response(new Uint8Array([1]), { status: 200 }), + release: vi.fn(async () => {}), + }); + + await buildOpenRouterSpeechProvider().synthesize({ + text: "private proxy speech", + cfg: { + models: { + providers: { + openrouter: { + apiKey: "synthetic-private-proxy-key", + baseUrl: "https://private.example.invalid/router/v1///", + }, + }, + }, + } as never, + providerConfig: {}, + target: "voice-note", + timeoutMs: 5000, + }); + + const request = requireOpenRouterPostRequest(); + expect(request.url).toBe("https://private.example.invalid/router/v1/audio/speech"); + expect(requireHeaders(request.headers).get("authorization")).toBe( + "Bearer synthetic-private-proxy-key", + ); + }); + + it("preserves a speech-specific custom destination over the model-provider base URL", async () => { + postJsonRequestMock.mockResolvedValue({ + response: new Response(new Uint8Array([1]), { status: 200 }), + release: vi.fn(async () => {}), + }); + + await buildOpenRouterSpeechProvider().synthesize({ + text: "speech-specific private proxy", + cfg: { + models: { + providers: { + openrouter: { + apiKey: "synthetic-private-proxy-key", + baseUrl: "https://model-proxy.example.invalid/v1", + }, + }, + }, + } as never, + providerConfig: { baseUrl: "https://speech-proxy.example.invalid/router/v1///" }, + target: "voice-note", + timeoutMs: 5000, + }); + + const request = requireOpenRouterPostRequest(); + expect(request.url).toBe("https://speech-proxy.example.invalid/router/v1/audio/speech"); + expect(requireHeaders(request.headers).get("authorization")).toBe( + "Bearer synthetic-private-proxy-key", + ); + }); + + it("does not synthesize when a configured private destination has no credential", async () => { + vi.stubEnv("OPENROUTER_API_KEY", ""); + + await expect( + buildOpenRouterSpeechProvider().synthesize({ + text: "missing private key", + cfg: { + models: { + providers: { + openrouter: { baseUrl: "https://private.example.invalid/v1" }, + }, + }, + } as never, + providerConfig: {}, + target: "voice-note", + timeoutMs: 5000, + }), + ).rejects.toThrow("OpenRouter API key missing"); + expect(postJsonRequestMock).not.toHaveBeenCalled(); + }); + it("defaults to a live-proven OpenRouter TTS model", () => { const provider = buildOpenRouterSpeechProvider(); diff --git a/extensions/openrouter/speech-provider.ts b/extensions/openrouter/speech-provider.ts index 5571c0f6c50c..4faa24d4e546 100644 --- a/extensions/openrouter/speech-provider.ts +++ b/extensions/openrouter/speech-provider.ts @@ -34,7 +34,7 @@ export function buildOpenRouterSpeechProvider(): SpeechProviderPlugin { responseFormats: OPENROUTER_TTS_RESPONSE_FORMATS, defaultResponseFormat: "mp3", voiceCompatibleResponseFormats: ["mp3"], - baseUrlPolicy: { kind: "canonical", aliases: ["https://openrouter.ai/v1"] }, + baseUrlPolicy: { kind: "canonical", aliases: ["https://openrouter.ai/v1"], allowCustom: true }, extraHeaders: { "HTTP-Referer": "https://openclaw.ai", "X-OpenRouter-Title": "OpenClaw", diff --git a/extensions/openrouter/usage.test.ts b/extensions/openrouter/usage.test.ts index 6340cbc473d4..c2d7f5b036c9 100644 --- a/extensions/openrouter/usage.test.ts +++ b/extensions/openrouter/usage.test.ts @@ -1,3 +1,4 @@ +import * as ssrfRuntime from "openclaw/plugin-sdk/ssrf-runtime"; import { describe, expect, it, vi } from "vitest"; import { fetchOpenRouterUsage } from "./usage.js"; @@ -54,6 +55,268 @@ describe("OpenRouter usage", () => { expect(fetchFn).toHaveBeenCalledTimes(2); }); + it("keeps custom provider credentials on the configured usage origin", async () => { + const fetchFn = vi.fn(async () => Response.json({ data: { usage: 1 } })); + + await fetchOpenRouterUsage({ + token: "synthetic-private-proxy-key", + baseUrl: "https://private.example.invalid/router/v1///", + timeoutMs: 5000, + fetchFn: fetchFn as unknown as typeof fetch, + }); + + expect(fetchFn.mock.calls.map(([url]) => url)).toEqual([ + "https://private.example.invalid/router/v1/credits", + "https://private.example.invalid/router/v1/key", + ]); + for (const [, options] of fetchFn.mock.calls) { + expect(new Headers(options?.headers).get("authorization")).toBe( + "Bearer synthetic-private-proxy-key", + ); + } + }); + + it("preserves configured request headers and custom auth for both proxy usage endpoints", async () => { + const fetchFn = vi.fn(async () => Response.json({ data: { usage: 1 } })); + + await fetchOpenRouterUsage({ + token: "synthetic-original-key", + baseUrl: "https://private.example.invalid/router/v1", + request: { + headers: { "X-Private-Proxy-Tenant": "synthetic-tenant" }, + auth: { mode: "header", headerName: "X-Proxy-Key", value: "synthetic-override-key" }, + }, + timeoutMs: 5000, + fetchFn: fetchFn as unknown as typeof fetch, + }); + + for (const [, options] of fetchFn.mock.calls) { + const headers = new Headers(options?.headers); + expect(headers.get("x-private-proxy-tenant")).toBe("synthetic-tenant"); + expect(headers.get("x-proxy-key")).toBe("synthetic-override-key"); + expect(headers.has("authorization")).toBe(false); + expect(options?.redirect).toBe("manual"); + } + }); + + it.each([ + "http://127.0.0.1:17455/private/v1", + "http://10.25.30.40:17455/private/v1", + "http://192.168.25.40:17455/private/v1", + "http://[::1]:17455/private/v1", + ])("blocks explicitly denied private usage destinations before fetch: %s", async (baseUrl) => { + const fetchFn = vi.fn(async () => Response.json({ data: { usage: 1 } })); + + const snapshot = await fetchOpenRouterUsage({ + token: "synthetic-original-key", + baseUrl, + request: { + allowPrivateNetwork: false, + auth: { mode: "header", headerName: "X-Proxy-Key", value: "synthetic-proxy-secret" }, + }, + timeoutMs: 1000, + fetchFn: fetchFn as unknown as typeof fetch, + }); + + expect(fetchFn).not.toHaveBeenCalled(); + expect(snapshot).toMatchObject({ provider: "openrouter", error: "Usage unavailable" }); + }); + + it("preserves explicitly allowed private-origin usage requests", async () => { + const fetchFn = vi.fn(async () => Response.json({ data: { usage: 1 } })); + + await fetchOpenRouterUsage({ + token: "synthetic-private-key", + baseUrl: "http://127.0.0.1:17455/private/v1", + request: { allowPrivateNetwork: true }, + timeoutMs: 1000, + fetchFn: fetchFn as unknown as typeof fetch, + }); + + expect(fetchFn.mock.calls.map(([url]) => url)).toEqual([ + "http://127.0.0.1:17455/private/v1/credits", + "http://127.0.0.1:17455/private/v1/key", + ]); + }); + + it("trusts only the operator-configured private origin when no deny policy is set", async () => { + const fetchFn = vi.fn(async () => Response.json({ data: { usage: 1 } })); + + await fetchOpenRouterUsage({ + token: "synthetic-private-key", + baseUrl: "http://127.0.0.1:17455/private/v1", + timeoutMs: 1000, + fetchFn: fetchFn as unknown as typeof fetch, + }); + + expect(fetchFn).toHaveBeenCalledTimes(2); + expect(fetchFn.mock.calls.map(([url]) => url)).toEqual([ + "http://127.0.0.1:17455/private/v1/credits", + "http://127.0.0.1:17455/private/v1/key", + ]); + }); + + it.each([ + { + name: "explicit corporate proxy", + request: { + proxy: { + mode: "explicit-proxy" as const, + url: "https://corporate-proxy.example.invalid:8443", + tls: { ca: "synthetic-proxy-ca" }, + }, + }, + dispatcherPolicy: { + mode: "explicit-proxy", + proxyUrl: "https://corporate-proxy.example.invalid:8443", + proxyTls: { ca: "synthetic-proxy-ca" }, + }, + }, + { + name: "direct mutual TLS", + request: { + tls: { + ca: "synthetic-private-ca", + cert: "synthetic-client-certificate", + key: "synthetic-client-key", + }, + }, + dispatcherPolicy: { + mode: "direct", + connect: { + ca: "synthetic-private-ca", + cert: "synthetic-client-certificate", + key: "synthetic-client-key", + }, + }, + }, + ])( + "routes $name through canonical guarded transport instead of the ambient proxy wrapper", + async ({ request, dispatcherPolicy }) => { + const ambientProxyFetch = vi.fn(async () => Response.json({ data: { usage: 1 } })); + const canonicalRuntimeFetch = vi.fn(async () => Response.json({ data: { usage: 1 } })); + const release = vi.fn(async () => undefined); + const guardedFetch = vi + .spyOn(ssrfRuntime, "fetchWithSsrFGuard") + .mockImplementation(async (params) => { + const selectedFetch = params.fetchImpl ?? canonicalRuntimeFetch; + return { + response: await selectedFetch(params.url, params.init), + finalUrl: params.url, + release, + }; + }); + + try { + await fetchOpenRouterUsage({ + token: "synthetic-private-key", + baseUrl: "https://private.example.invalid/router/v1", + request, + timeoutMs: 1000, + fetchFn: ambientProxyFetch as unknown as typeof fetch, + }); + + expect(guardedFetch).toHaveBeenCalledTimes(2); + expect(release).toHaveBeenCalledTimes(2); + expect(ambientProxyFetch).not.toHaveBeenCalled(); + expect(canonicalRuntimeFetch).toHaveBeenCalledTimes(2); + for (const [params] of guardedFetch.mock.calls) { + expect(params.dispatcherPolicy).toMatchObject(dispatcherPolicy); + expect(params.fetchImpl).toBeUndefined(); + expect(params.maxRedirects).toBe(0); + expect(params.timeoutMs).toBe(1000); + expect(params.policy).toEqual({ allowedOrigins: ["https://private.example.invalid"] }); + } + } finally { + guardedFetch.mockRestore(); + } + }, + ); + + it("surfaces blocked usage redirects without replaying private credentials", async () => { + const fetchFn = vi.fn( + async () => + new Response(null, { + status: 302, + headers: { Location: "https://attacker.example.invalid/capture" }, + }), + ); + + const snapshot = await fetchOpenRouterUsage({ + token: "synthetic-private-key", + baseUrl: "https://private.example.invalid/router/v1", + timeoutMs: 5000, + fetchFn: fetchFn as unknown as typeof fetch, + }); + + expect(snapshot).toMatchObject({ + provider: "openrouter", + windows: [], + error: "Usage unavailable", + }); + expect(fetchFn).toHaveBeenCalledTimes(2); + expect(fetchFn.mock.calls.map(([url]) => url)).toEqual([ + "https://private.example.invalid/router/v1/credits", + "https://private.example.invalid/router/v1/key", + ]); + }); + + it.each([ + { + name: "HTTP failure", + createResponse: () => new Response(null, { status: 403 }), + error: "HTTP 403", + }, + { + name: "malformed response", + createResponse: () => Response.json({ invalid: true }), + error: "Malformed usage response", + }, + ])("releases both guarded transports after $name", async ({ createResponse, error }) => { + const fetchFn = vi.fn(async () => createResponse()); + const release = vi.fn(async () => undefined); + const guardedFetch = vi + .spyOn(ssrfRuntime, "fetchWithSsrFGuard") + .mockImplementation(async (params) => { + if (!params.fetchImpl) { + throw new Error("expected the operator-owned usage fetch implementation"); + } + return { + response: await params.fetchImpl(params.url, params.init), + finalUrl: params.url, + release, + }; + }); + + try { + const snapshot = await fetchOpenRouterUsage({ + token: "synthetic-private-key", + baseUrl: "https://private.example.invalid/router/v1", + timeoutMs: 1000, + fetchFn: fetchFn as unknown as typeof fetch, + }); + + expect(snapshot.error).toContain(error); + expect(release).toHaveBeenCalledTimes(2); + } finally { + guardedFetch.mockRestore(); + } + }); + + it("rejects malformed custom destinations without invoking either usage request", async () => { + const fetchFn = vi.fn(async () => Response.json({ data: {} })); + + await expect( + fetchOpenRouterUsage({ + token: "synthetic-private-key", + baseUrl: "file:///tmp/openrouter-usage", + timeoutMs: 5000, + fetchFn: fetchFn as unknown as typeof fetch, + }), + ).rejects.toThrow("Invalid OpenRouter API base URL"); + expect(fetchFn).not.toHaveBeenCalled(); + }); + it("derives recurring budget usage from remaining credits when the period counter is absent", async () => { const snapshot = await fetchOpenRouterUsage({ token: "router-key", diff --git a/extensions/openrouter/usage.ts b/extensions/openrouter/usage.ts index dd69eee3e893..395cdfe711c0 100644 --- a/extensions/openrouter/usage.ts +++ b/extensions/openrouter/usage.ts @@ -1,13 +1,23 @@ -import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; +import { + readProviderJsonResponse, + resolveProviderHttpRequestConfig, + sanitizeConfiguredModelProviderRequest, +} from "openclaw/plugin-sdk/provider-http"; +import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; import { buildUsageHttpErrorSnapshot, parseProviderUsageNonNegativeNumber, type ProviderUsageSnapshot, } from "openclaw/plugin-sdk/provider-usage"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { asOptionalRecord } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + OPENROUTER_BASE_URL, + resolveOpenRouterApiBaseUrl, + resolveOpenRouterSsrfPolicy, +} from "./provider-catalog.js"; const OPENROUTER_USAGE_RESPONSE_MAX_BYTES = 1024 * 1024; -const OPENROUTER_API_ROOT = "https://openrouter.ai/api/v1"; type OpenRouterCreditsData = { total_credits?: unknown; @@ -88,43 +98,81 @@ async function readJson(response: Response, timeoutMs: number): Promise async function fetchEndpoint(params: { path: "credits" | "key"; - token: string; + baseUrl: string; + headers: Headers; + ssrfPolicy: ReturnType; + dispatcherPolicy: ReturnType["dispatcherPolicy"]; timeoutMs: number; fetchFn: typeof fetch; }): Promise { - let response: Response; + let guardedResponse: Awaited>; try { - response = await params.fetchFn(`${OPENROUTER_API_ROOT}/${params.path}`, { - headers: { - Accept: "application/json", - Authorization: `Bearer ${params.token}`, + guardedResponse = await fetchWithSsrFGuard({ + url: `${params.baseUrl}/${params.path}`, + // Ambient proxy fetch wrappers replace dispatchers, so configured provider transport wins. + ...(params.dispatcherPolicy + ? { dispatcherPolicy: params.dispatcherPolicy } + : { fetchImpl: params.fetchFn }), + init: { + headers: params.headers, + redirect: "error", }, - signal: AbortSignal.timeout(params.timeoutMs), + timeoutMs: params.timeoutMs, + // The shared guard controls redirects manually; zero hops preserves fail-closed usage auth. + maxRedirects: 0, + policy: params.ssrfPolicy, + auditContext: "openrouter-usage", }); } catch { return { ok: false, reason: "transport" }; } - if (!response.ok) { - await response.body?.cancel().catch(() => undefined); - return { ok: false, status: response.status }; - } try { - const root = asOptionalRecord(await readJson(response, params.timeoutMs)); - const data = asOptionalRecord(root?.data); - return data ? { ok: true, data } : { ok: false, reason: "malformed" }; - } catch { - return { ok: false, reason: "malformed" }; + const { response } = guardedResponse; + if (!response.ok) { + await response.body?.cancel().catch(() => undefined); + return { ok: false, status: response.status }; + } + try { + const root = asOptionalRecord(await readJson(response, params.timeoutMs)); + const data = asOptionalRecord(root?.data); + return data ? { ok: true, data } : { ok: false, reason: "malformed" }; + } catch { + return { ok: false, reason: "malformed" }; + } + } finally { + await guardedResponse.release(); } } export async function fetchOpenRouterUsage(params: { token: string; + baseUrl?: string; + request?: ModelProviderConfig["request"]; timeoutMs: number; fetchFn: typeof fetch; }): Promise { + const requestConfig = resolveProviderHttpRequestConfig({ + provider: "openrouter", + capability: "other", + baseUrl: resolveOpenRouterApiBaseUrl(params.baseUrl), + defaultBaseUrl: OPENROUTER_BASE_URL, + defaultHeaders: { + Accept: "application/json", + Authorization: `Bearer ${params.token}`, + }, + request: sanitizeConfiguredModelProviderRequest(params.request), + }); + const request = { + baseUrl: requestConfig.baseUrl, + headers: requestConfig.headers, + ssrfPolicy: resolveOpenRouterSsrfPolicy(requestConfig, params.request), + dispatcherPolicy: requestConfig.dispatcherPolicy, + timeoutMs: params.timeoutMs, + fetchFn: params.fetchFn, + }; const [creditsResult, keyResult] = await Promise.all([ - fetchEndpoint({ ...params, path: "credits" }), - fetchEndpoint({ ...params, path: "key" }), + fetchEndpoint({ ...request, path: "credits" }), + fetchEndpoint({ ...request, path: "key" }), ]); if (!creditsResult.ok && !keyResult.ok) { const status =