Files
Peter Steinberger 830ab164ef 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
2026-08-03 10:32:06 -07:00

342 lines
12 KiB
TypeScript

import {
clearLiveCatalogCacheForTests,
type LiveModelCatalogFetchGuard,
} from "openclaw/plugin-sdk/provider-catalog-live-runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { buildOpenrouterLiveProvider, buildOpenrouterProvider } from "./provider-catalog.js";
describe("OpenRouter provider catalog", () => {
beforeEach(() => {
clearLiveCatalogCacheForTests();
});
it("discovers text models and preserves bundled routes", async () => {
const release = vi.fn(async () => undefined);
const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async ({ url }) => ({
response: Response.json({
data: [
{
id: "google/gemini-3.6-flash",
name: "Google: Gemini 3.6 Flash",
architecture: {
input_modalities: ["text", "image", "audio", "video"],
output_modalities: ["text"],
},
supported_parameters: ["reasoning", "tools"],
context_length: 1_048_576,
top_provider: {
context_length: 1_048_576,
max_completion_tokens: 65_536,
},
pricing: {
prompt: "0.0000015",
completion: "0.0000075",
input_cache_read: "0.00000015",
},
},
{
id: "google/gemini-3.5-flash-lite",
architecture: { modality: "text+image->text" },
supported_parameters: ["include_reasoning"],
context_length: 1_048_576,
max_completion_tokens: 65_536,
pricing: { prompt: "0.0000003", completion: "0.0000025" },
},
{
id: "google/gemini-3.1-flash-image",
architecture: { modality: "text+image->image" },
context_length: 65_536,
},
],
}),
finalUrl: url,
release,
}));
const provider = await buildOpenrouterLiveProvider({
apiKey: "OPENROUTER_API_KEY",
discoveryApiKey: "resolved-openrouter-key",
fetchGuard,
});
expect(provider.apiKey).toBe("OPENROUTER_API_KEY");
expect(provider.models.map((model) => model.id)).toEqual(
expect.arrayContaining([
"openrouter/auto",
"google/gemini-3.5-flash-lite",
"google/gemini-3.6-flash",
]),
);
expect(provider.models.map((model) => model.id)).not.toContain("google/gemini-3.1-flash-image");
expect(provider.models.find((model) => model.id === "google/gemini-3.6-flash")).toMatchObject({
name: "Google: Gemini 3.6 Flash",
reasoning: true,
input: ["text", "image"],
contextWindow: 1_048_576,
maxTokens: 65_536,
cost: { input: 1.5, output: 7.5, cacheRead: 0.15, cacheWrite: 0 },
});
expect(
new Headers(vi.mocked(fetchGuard).mock.calls[0]?.[0].init?.headers).get("authorization"),
).toBe("Bearer resolved-openrouter-key");
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({
data: [
{
id: "google/gemini-3.6-flash",
architecture: { modality: "text->text" },
},
],
}),
finalUrl: url,
release: async () => undefined,
}));
await buildOpenrouterLiveProvider({
apiKey: "runtime-a",
discoveryApiKey: "discovery-a",
fetchGuard,
});
await buildOpenrouterLiveProvider({
apiKey: "runtime-b",
discoveryApiKey: "discovery-a",
fetchGuard,
});
expect(fetchGuard).toHaveBeenCalledOnce();
clearLiveCatalogCacheForTests();
vi.mocked(fetchGuard).mockRejectedValueOnce(new Error("network unavailable"));
const fallback = await buildOpenrouterLiveProvider({
apiKey: "runtime-a",
discoveryApiKey: "discovery-a",
fetchGuard,
});
expect(fallback.models).toEqual(buildOpenrouterProvider().models);
});
});