fix(openrouter): isolate custom proxy credentials and transport security (#118773)

* fix(openrouter): confine custom endpoint credentials and transport policy

* test(openrouter): preserve typed fetch arguments in proxy fixtures
This commit is contained in:
Peter Steinberger
2026-08-03 10:32:06 -07:00
committed by GitHub
parent 616b0c9408
commit 830ab164ef
8 changed files with 856 additions and 28 deletions
+130
View File
@@ -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<typeof fetch>(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();
+9 -2
View File
@@ -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,
}),
@@ -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({
+81 -4
View File
@@ -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<typeof resolveProviderHttpRequestConfig>,
"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<ModelProviderConfig> {
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);
+83 -1
View File
@@ -21,7 +21,8 @@ const {
})),
}));
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
vi.mock("openclaw/plugin-sdk/provider-http", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/provider-http")>()),
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();
+1 -1
View File
@@ -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",
+263
View File
@@ -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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(
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",
+68 -20
View File
@@ -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<unknown>
async function fetchEndpoint(params: {
path: "credits" | "key";
token: string;
baseUrl: string;
headers: Headers;
ssrfPolicy: ReturnType<typeof resolveOpenRouterSsrfPolicy>;
dispatcherPolicy: ReturnType<typeof resolveProviderHttpRequestConfig>["dispatcherPolicy"];
timeoutMs: number;
fetchFn: typeof fetch;
}): Promise<EndpointResult> {
let response: Response;
let guardedResponse: Awaited<ReturnType<typeof fetchWithSsrFGuard>>;
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<ProviderUsageSnapshot> {
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 =