mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
616b0c9408
commit
830ab164ef
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user