fix(openrouter): attribute every OpenRouter request with one header set

This commit is contained in:
Ayaan Zaidi 2026-09-29 09:20:23 +05:30
parent c34e838e49
commit 55824fae49
No known key found for this signature in database
16 changed files with 73 additions and 82 deletions

View file

@ -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

View file

@ -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,

View file

@ -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",

View file

@ -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 () => {

View file

@ -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();
});

View file

@ -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",

View file

@ -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",

View file

@ -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) }),

View file

@ -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()),

View file

@ -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,
}),

View file

@ -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",

View file

@ -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";
}

View file

@ -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",
});
});

View file

@ -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 () => {

View file

@ -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" ||

View file

@ -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",
},
},