mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 09:39:25 +00:00
fix(openrouter): attribute every OpenRouter request with one header set
This commit is contained in:
parent
c34e838e49
commit
55824fae49
16 changed files with 73 additions and 82 deletions
|
|
@ -354,14 +354,15 @@ openclaw models auth login --provider openrouter --method oauth
|
|||
openclaw models auth login --provider openrouter --method api-key
|
||||
```
|
||||
|
||||
On verified OpenRouter requests (`https://openrouter.ai/api/v1`), OpenClaw adds
|
||||
OpenRouter's documented app-attribution headers:
|
||||
On requests to OpenRouter endpoints (`openrouter.ai`), OpenClaw adds OpenRouter's
|
||||
documented app-attribution headers. This applies to the bundled `openrouter`
|
||||
provider and to custom provider ids whose `baseUrl` points at OpenRouter:
|
||||
|
||||
| Header | Value |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| `HTTP-Referer` | `https://openclaw.ai` |
|
||||
| `X-OpenRouter-Title` | `OpenClaw` |
|
||||
| `X-OpenRouter-Categories` | `cli-agent,cloud-agent,programming-app,creative-writing,writing-assistant,general-chat,personal-agent` |
|
||||
| Header | Value |
|
||||
| ------------------------- | -------------------------- |
|
||||
| `HTTP-Referer` | `https://openclaw.ai` |
|
||||
| `X-OpenRouter-Title` | `OpenClaw` |
|
||||
| `X-OpenRouter-Categories` | `personal-agent,cli-agent` |
|
||||
|
||||
<Warning>
|
||||
If you repoint the OpenRouter provider at some other proxy or base URL, OpenClaw
|
||||
|
|
|
|||
|
|
@ -32,8 +32,6 @@ export async function resolveOpenRouterGenerationRequestContext(params: {
|
|||
defaultHeaders: {
|
||||
Authorization: `Bearer ${auth.apiKey}`,
|
||||
...(params.jsonContentType ? { "Content-Type": "application/json" } : {}),
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-OpenRouter-Title": "OpenClaw",
|
||||
},
|
||||
request: sanitizeConfiguredModelProviderRequest(
|
||||
params.cfg.models?.providers?.openrouter?.request,
|
||||
|
|
|
|||
|
|
@ -133,8 +133,6 @@ describe("openrouter image generation provider", () => {
|
|||
allowPrivateNetwork: false,
|
||||
defaultHeaders: {
|
||||
Authorization: "Bearer openrouter-key",
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-OpenRouter-Title": "OpenClaw",
|
||||
},
|
||||
request: undefined,
|
||||
provider: "openrouter",
|
||||
|
|
@ -146,8 +144,6 @@ describe("openrouter image generation provider", () => {
|
|||
const headers = requireHeaders(request.headers);
|
||||
expect(Object.fromEntries(headers.entries())).toEqual({
|
||||
authorization: "Bearer openrouter-key",
|
||||
"http-referer": "https://openclaw.ai",
|
||||
"x-openrouter-title": "OpenClaw",
|
||||
});
|
||||
expect(request).toEqual({
|
||||
url: "https://custom.openrouter.test/api/v1/chat/completions",
|
||||
|
|
|
|||
|
|
@ -757,8 +757,6 @@ describe("openrouter provider hooks", () => {
|
|||
const options = baseStreamFn.mock.calls[0]?.[2] as { headers?: HeadersInit } | undefined;
|
||||
const headers = new Headers(options?.headers);
|
||||
expect(headers.get("authorization")).toBe("Bearer or-test-key");
|
||||
expect(headers.get("http-referer")).toBe("https://openclaw.ai");
|
||||
expect(headers.get("x-openrouter-title")).toBe("OpenClaw");
|
||||
});
|
||||
|
||||
it("merges resolved OpenRouter model params into transport params", async () => {
|
||||
|
|
|
|||
|
|
@ -91,8 +91,6 @@ describe("openrouter media understanding provider", () => {
|
|||
defaultHeaders: {
|
||||
Authorization: "Bearer sk-openrouter",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-OpenRouter-Title": "OpenClaw",
|
||||
},
|
||||
provider: "openrouter",
|
||||
api: "openrouter-stt",
|
||||
|
|
@ -121,8 +119,6 @@ describe("openrouter media understanding provider", () => {
|
|||
throw new Error("expected OpenRouter request headers");
|
||||
}
|
||||
expect(headers.get("authorization")).toBe("Bearer sk-openrouter");
|
||||
expect(headers.get("http-referer")).toBe("https://openclaw.ai");
|
||||
expect(headers.get("x-openrouter-title")).toBe("OpenClaw");
|
||||
expect(release).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -115,8 +115,6 @@ async function transcribeOpenRouterAudio(
|
|||
defaultHeaders: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-OpenRouter-Title": "OpenClaw",
|
||||
},
|
||||
provider: "openrouter",
|
||||
api: "openrouter-stt",
|
||||
|
|
|
|||
|
|
@ -121,8 +121,6 @@ describe("openrouter speech provider", () => {
|
|||
defaultHeaders: {
|
||||
Authorization: "Bearer sk-openrouter",
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-OpenRouter-Title": "OpenClaw",
|
||||
},
|
||||
provider: "openrouter",
|
||||
capability: "audio",
|
||||
|
|
@ -137,8 +135,6 @@ describe("openrouter speech provider", () => {
|
|||
expect(Object.fromEntries(headers.entries())).toEqual({
|
||||
authorization: "Bearer sk-openrouter",
|
||||
"content-type": "application/json",
|
||||
"http-referer": "https://openclaw.ai",
|
||||
"x-openrouter-title": "OpenClaw",
|
||||
});
|
||||
expect(request).toEqual({
|
||||
url: "https://openrouter.ai/api/v1/audio/speech",
|
||||
|
|
|
|||
|
|
@ -34,10 +34,6 @@ export function buildOpenRouterSpeechProvider(): SpeechProviderPlugin {
|
|||
defaultResponseFormat: "mp3",
|
||||
voiceCompatibleResponseFormats: ["mp3"],
|
||||
baseUrlPolicy: { kind: "canonical", aliases: ["https://openrouter.ai/v1"], allowCustom: true },
|
||||
extraHeaders: {
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-OpenRouter-Title": "OpenClaw",
|
||||
},
|
||||
apiErrorLabel: "OpenRouter TTS API error",
|
||||
missingApiKeyError: "OpenRouter API key missing",
|
||||
readExtraConfig: (raw) => ({ provider: asOptionalRecord(raw?.provider) }),
|
||||
|
|
|
|||
|
|
@ -50,12 +50,6 @@ function mergeOpenRouterAuthHeaders(options: Parameters<StreamFn>[2]): Parameter
|
|||
if (!headers.has("authorization")) {
|
||||
headers.set("Authorization", `Bearer ${apiKey}`);
|
||||
}
|
||||
if (!headers.has("http-referer")) {
|
||||
headers.set("HTTP-Referer", "https://openclaw.ai");
|
||||
}
|
||||
if (!headers.has("x-openrouter-title")) {
|
||||
headers.set("X-OpenRouter-Title", "OpenClaw");
|
||||
}
|
||||
return {
|
||||
...options,
|
||||
headers: Object.fromEntries(headers.entries()),
|
||||
|
|
|
|||
|
|
@ -218,8 +218,6 @@ function resolveOpenRouterVideoCatalogRequest(params: {
|
|||
defaultBaseUrl: OPENROUTER_BASE_URL,
|
||||
defaultHeaders: {
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-OpenRouter-Title": "OpenClaw",
|
||||
},
|
||||
request,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -337,8 +337,7 @@ describe("provider attribution", () => {
|
|||
).toEqual({
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-OpenRouter-Title": "OpenClaw",
|
||||
"X-OpenRouter-Categories":
|
||||
"cli-agent,cloud-agent,programming-app,creative-writing,writing-assistant,general-chat,personal-agent",
|
||||
"X-OpenRouter-Categories": "personal-agent,cli-agent",
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -684,7 +683,7 @@ describe("provider attribution", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("gates documented OpenRouter attribution to known OpenRouter endpoints", () => {
|
||||
it("gates documented OpenRouter attribution to OpenRouter endpoints for any provider id", () => {
|
||||
expectRecordFields(
|
||||
resolveProviderRequestPolicy({
|
||||
provider: "openrouter",
|
||||
|
|
@ -701,6 +700,20 @@ describe("provider attribution", () => {
|
|||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
resolveProviderRequestPolicy({
|
||||
provider: "or",
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
transport: "stream",
|
||||
capability: "llm",
|
||||
}).attributionHeaders,
|
||||
).toEqual({
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-OpenRouter-Title": "OpenClaw",
|
||||
"X-OpenRouter-Categories": "personal-agent,cli-agent",
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveProviderRequestPolicy({
|
||||
provider: "openrouter",
|
||||
|
|
|
|||
|
|
@ -148,8 +148,8 @@ function readCompatBoolean(
|
|||
|
||||
const OPENCLAW_ATTRIBUTION_PRODUCT = "OpenClaw";
|
||||
const OPENCLAW_ATTRIBUTION_ORIGINATOR = "openclaw";
|
||||
const OPENROUTER_ATTRIBUTION_CATEGORIES =
|
||||
"cli-agent,cloud-agent,programming-app,creative-writing,writing-assistant,general-chat,personal-agent";
|
||||
// OpenRouter honors at most two recognized categories per request and silently drops the rest.
|
||||
const OPENROUTER_ATTRIBUTION_CATEGORIES = "personal-agent,cli-agent";
|
||||
|
||||
const LOCAL_ENDPOINT_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
||||
const OPENAI_RESPONSES_APIS = new Set([
|
||||
|
|
@ -308,7 +308,8 @@ function resolveProviderAttributionPolicy(
|
|||
return {
|
||||
...policy,
|
||||
docsUrl: "https://openrouter.ai/docs/app-attribution",
|
||||
reviewNote: "Documented app attribution headers. Verified in OpenClaw runtime wrapper.",
|
||||
reviewNote:
|
||||
"Documented app attribution headers. Applied on OpenRouter endpoints regardless of configured provider id.",
|
||||
headers: {
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-OpenRouter-Title": policy.product,
|
||||
|
|
@ -399,12 +400,6 @@ export function resolveProviderRequestPolicy(
|
|||
let attributionProvider: string | undefined;
|
||||
if (provider === "openai" && usesVerifiedOpenAIAttributionHost) {
|
||||
attributionProvider = "openai";
|
||||
} else if (provider === "openrouter" && policy?.enabledByDefault) {
|
||||
// OpenRouter attribution is documented, but only apply it to known
|
||||
// OpenRouter endpoints or the default (unset) baseUrl path.
|
||||
if (endpointClass === "openrouter" || endpointClass === "default") {
|
||||
attributionProvider = "openrouter";
|
||||
}
|
||||
} else if (provider === "xai" && policy?.enabledByDefault) {
|
||||
// Default (unset baseUrl) maps to api.x.ai; custom baseUrls are treated as proxies and withheld.
|
||||
if (endpointClass === "xai-native" || endpointClass === "default") {
|
||||
|
|
@ -419,6 +414,14 @@ export function resolveProviderRequestPolicy(
|
|||
// A custom baseUrl is a proxy and must not inherit OpenClaw attribution.
|
||||
attributionProvider = "opencode-go";
|
||||
}
|
||||
// OpenRouter attribution follows the endpoint, so custom provider ids pointed at
|
||||
// openrouter.ai are attributed too; custom proxy baseUrls are withheld.
|
||||
if (
|
||||
!attributionProvider &&
|
||||
(endpointClass === "openrouter" || (provider === "openrouter" && endpointClass === "default"))
|
||||
) {
|
||||
attributionProvider = "openrouter";
|
||||
}
|
||||
if (!attributionProvider && endpointClass === "nvidia-native") {
|
||||
attributionProvider = "nvidia";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -526,8 +526,7 @@ describe("provider request config", () => {
|
|||
expect(resolved).toEqual({
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-OpenRouter-Title": "OpenClaw",
|
||||
"X-OpenRouter-Categories":
|
||||
"cli-agent,cloud-agent,programming-app,creative-writing,writing-assistant,general-chat,personal-agent",
|
||||
"X-OpenRouter-Categories": "personal-agent,cli-agent",
|
||||
"X-Custom": "1",
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -486,28 +486,29 @@ describe("createAgentSession attribution headers", () => {
|
|||
expect(options.headers).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps OpenRouter attribution headers for provider and endpoint matches", async () => {
|
||||
const providerOptions = await createSessionAndStreamModel({
|
||||
...testModel,
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://example.test",
|
||||
});
|
||||
const endpointOptions = await createSessionAndStreamModel({
|
||||
...testModel,
|
||||
provider: "custom-openai",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
});
|
||||
it("forwards OpenRouter attribution for openrouter.ai endpoints with telemetry off, but not proxies", async () => {
|
||||
vi.stubEnv("OPENCLAW_TELEMETRY", "0");
|
||||
try {
|
||||
const proxyOptions = await createSessionAndStreamModel({
|
||||
...testModel,
|
||||
provider: "openrouter",
|
||||
baseUrl: "https://example.test",
|
||||
});
|
||||
const endpointOptions = await createSessionAndStreamModel({
|
||||
...testModel,
|
||||
provider: "custom-openai",
|
||||
baseUrl: "https://openrouter.ai/api/v1",
|
||||
});
|
||||
|
||||
expect(providerOptions.headers).toMatchObject({
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-OpenRouter-Title": "OpenClaw",
|
||||
"X-OpenRouter-Categories": "cli-agent",
|
||||
});
|
||||
expect(endpointOptions.headers).toMatchObject({
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-OpenRouter-Title": "OpenClaw",
|
||||
"X-OpenRouter-Categories": "cli-agent",
|
||||
});
|
||||
expect(proxyOptions.headers).toBeUndefined();
|
||||
expect(endpointOptions.headers).toMatchObject({
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-OpenRouter-Title": "OpenClaw",
|
||||
"X-OpenRouter-Categories": "personal-agent,cli-agent",
|
||||
});
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Cloudflare attribution headers for provider and endpoint matches", async () => {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { registerResolvedAgentDir } from "../agent-dir-registry.js";
|
|||
import { sanitizeCompactionReplayMessages } from "../compaction-replay.js";
|
||||
import { getAgentDirResolution } from "../config.js";
|
||||
import { projectModelThinkingCompat } from "../model-catalog-lookup.js";
|
||||
import { resolveProviderRequestPolicy } from "../provider-attribution.js";
|
||||
import {
|
||||
Agent,
|
||||
type AgentMessage,
|
||||
|
|
@ -190,20 +191,24 @@ function getAttributionHeaders(
|
|||
model: Model,
|
||||
settingsManager: SettingsManager,
|
||||
): Record<string, string> | undefined {
|
||||
// Transports outside the OpenAI families do not consult the attribution policy,
|
||||
// so forward its OpenRouter header set as caller headers. Like the transport-side
|
||||
// policy, OpenRouter app attribution does not depend on install telemetry.
|
||||
const { attributionProvider, attributionHeaders } = resolveProviderRequestPolicy({
|
||||
provider: model.provider,
|
||||
api: model.api,
|
||||
baseUrl: model.baseUrl,
|
||||
});
|
||||
if (attributionProvider === "openrouter") {
|
||||
return attributionHeaders;
|
||||
}
|
||||
|
||||
if (!isInstallTelemetryEnabled(settingsManager)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const baseUrl = model.baseUrl ?? "";
|
||||
|
||||
if (model.provider === "openrouter" || baseUrl.includes("openrouter.ai")) {
|
||||
return {
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-OpenRouter-Title": "OpenClaw",
|
||||
"X-OpenRouter-Categories": "cli-agent",
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
model.provider === "cloudflare-workers-ai" ||
|
||||
model.provider === "cloudflare-ai-gateway" ||
|
||||
|
|
|
|||
|
|
@ -63,8 +63,7 @@ describe("proxy stream wrappers", () => {
|
|||
headers: {
|
||||
"HTTP-Referer": "https://openclaw.ai",
|
||||
"X-OpenRouter-Title": "OpenClaw",
|
||||
"X-OpenRouter-Categories":
|
||||
"cli-agent,cloud-agent,programming-app,creative-writing,writing-assistant,general-chat,personal-agent",
|
||||
"X-OpenRouter-Categories": "personal-agent,cli-agent",
|
||||
"X-Custom": "1",
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue