Skip to content

Commit 3e9740e

Browse files
authored
🤖 feat: add AI app attribution headers (#1141)
Adds default app attribution headers (`HTTP-Referer`, `X-Title`) to all AI SDK provider requests, enabling OpenRouter (and compatible gateways) to attribute mux usage. - Implemented `buildAppAttributionHeaders` (case-insensitive merge, never overwrites user-provided values) - Applied during model creation so all `streamText()` calls inherit it - Added unit tests --- <details> <summary>📋 Implementation Plan</summary> # 🤖 Plan: Add app attribution headers to AI SDK streaming (OpenRouter leaderboard) ## Goal Ensure mux’s `streamText()` calls include **app attribution** so OpenRouter (and any other compatible gateway) can attribute traffic to mux (e.g., for leaderboards). For OpenRouter specifically, this means sending these request headers: - `HTTP-Referer`: a stable URL for the app (recommended by OpenRouter) - `X-Title`: a human-readable app name ## Recommended approach (A): Inject attribution headers for **all providers** in `AIService.createModel()` (default-on) **Net new product LoC:** ~30–55 ### Why this approach - Covers **every** provider (OpenRouter + others) without needing to touch `streamText()` call sites. - Future-proof: any future non-streaming usage will automatically carry the same attribution. - Allows user overrides via `providers.jsonc` `headers` (we never overwrite existing `HTTP-Referer` / `X-Title`, case-insensitive). ### Implementation steps 1. **Add centralized constants** for mux attribution values - Add `src/constants/appAttribution.ts` exporting: - `MUX_APP_ATTRIBUTION_TITLE` = `"mux"` - `MUX_APP_ATTRIBUTION_URL` = `"https://mux.coder.com"` 2. **Add a small, tested header-merge helper** (defensive + case-insensitive) - In `src/node/services/aiService.ts`, export: - `buildAppAttributionHeaders(existing?: Record<string, string>): Record<string, string>` - Behavior: - Preserve all unrelated headers. - Add missing headers using canonical casing (`HTTP-Referer`, `X-Title`). - Never overwrite existing values (match keys case-insensitively). 3. **Wire into model creation once** - In `AIService.createModel()` (after baseUrl → baseURL normalization), apply: - `providerConfig.headers = buildAppAttributionHeaders(providerConfig.headers)` - This automatically affects OpenRouter and every other provider branch because they all pass `headers` through to the provider factory. 4. **Unit tests** - Extend `src/node/services/aiService.test.ts` with `describe("buildAppAttributionHeaders", ...)`. - Test cases: - adds both headers when no headers exist - adds only the missing header when one is present - does not overwrite existing values (including different casing like `http-referer`, `X-TITLE`) - preserves unrelated headers ### Validation - `make typecheck` - `bun test src/node/services/aiService.test.ts` - Manual: run a request through any provider (especially OpenRouter) and confirm the headers appear upstream. ## Rollout / compatibility notes - This should be a non-breaking behavioral addition. - We must ensure we **never** overwrite user-provided `HTTP-Referer` / `X-Title` (case-insensitive check). </details> --- _Generated with `mux` • Model: "unknown" • Thinking: "unknown"_ Signed-off-by: Thomas Kosiewski <tk@coder.com>
1 parent fe3c94d commit 3e9740e

File tree

3 files changed

+89
-0
lines changed

3 files changed

+89
-0
lines changed

src/constants/appAttribution.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
// App attribution values for AI provider requests.
2+
//
3+
// These are used by OpenRouter (and other compatible platforms) to attribute
4+
// requests to mux (e.g., for leaderboards).
5+
6+
export const MUX_APP_ATTRIBUTION_TITLE = "mux";
7+
export const MUX_APP_ATTRIBUTION_URL = "https://mux.coder.com";

src/node/services/aiService.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@ import {
77
AIService,
88
normalizeAnthropicBaseURL,
99
buildAnthropicHeaders,
10+
buildAppAttributionHeaders,
1011
ANTHROPIC_1M_CONTEXT_HEADER,
1112
} from "./aiService";
1213
import { HistoryService } from "./historyService";
1314
import { PartialService } from "./partialService";
1415
import { InitStateManager } from "./initStateManager";
1516
import { Config } from "@/node/config";
17+
import { MUX_APP_ATTRIBUTION_TITLE, MUX_APP_ATTRIBUTION_URL } from "@/constants/appAttribution";
1618

1719
describe("AIService", () => {
1820
let service: AIService;
@@ -117,3 +119,46 @@ describe("buildAnthropicHeaders", () => {
117119
expect(result).toEqual({ "anthropic-beta": ANTHROPIC_1M_CONTEXT_HEADER });
118120
});
119121
});
122+
123+
describe("buildAppAttributionHeaders", () => {
124+
it("adds both headers when no headers exist", () => {
125+
expect(buildAppAttributionHeaders(undefined)).toEqual({
126+
"HTTP-Referer": MUX_APP_ATTRIBUTION_URL,
127+
"X-Title": MUX_APP_ATTRIBUTION_TITLE,
128+
});
129+
});
130+
131+
it("adds only the missing header when one is present", () => {
132+
const existing = { "HTTP-Referer": "https://example.com" };
133+
const result = buildAppAttributionHeaders(existing);
134+
expect(result).toEqual({
135+
"HTTP-Referer": "https://example.com",
136+
"X-Title": MUX_APP_ATTRIBUTION_TITLE,
137+
});
138+
});
139+
140+
it("does not overwrite existing values (case-insensitive)", () => {
141+
const existing = { "http-referer": "https://example.com", "X-TITLE": "My App" };
142+
const result = buildAppAttributionHeaders(existing);
143+
expect(result).toEqual(existing);
144+
});
145+
146+
it("preserves unrelated headers", () => {
147+
const existing = { "x-custom": "value" };
148+
const result = buildAppAttributionHeaders(existing);
149+
expect(result).toEqual({
150+
"x-custom": "value",
151+
"HTTP-Referer": MUX_APP_ATTRIBUTION_URL,
152+
"X-Title": MUX_APP_ATTRIBUTION_TITLE,
153+
});
154+
});
155+
156+
it("does not mutate the input object", () => {
157+
const existing = { "x-custom": "value" };
158+
const existingSnapshot = { ...existing };
159+
160+
buildAppAttributionHeaders(existing);
161+
162+
expect(existing).toEqual(existingSnapshot);
163+
});
164+
});

src/node/services/aiService.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ import { EnvHttpProxyAgent, type Dispatcher } from "undici";
6060
import { getPlanFilePath } from "@/common/utils/planStorage";
6161
import { getPlanModeInstruction } from "@/common/utils/ui/modeUtils";
6262
import type { UIMode } from "@/common/types/mode";
63+
import { MUX_APP_ATTRIBUTION_TITLE, MUX_APP_ATTRIBUTION_URL } from "@/constants/appAttribution";
6364
import { readPlanFile } from "@/node/utils/runtime/helpers";
6465

6566
// Export a standalone version of getToolsForModel for use in backend
@@ -238,6 +239,35 @@ export function buildAnthropicHeaders(
238239
return { "anthropic-beta": ANTHROPIC_1M_CONTEXT_HEADER };
239240
}
240241

242+
/**
243+
* Build app attribution headers used by OpenRouter (and other compatible platforms).
244+
*
245+
* Attribution docs:
246+
* - OpenRouter: https://openrouter.ai/docs/app-attribution
247+
* - Vercel AI Gateway: https://vercel.com/docs/ai-gateway/app-attribution
248+
*
249+
* Exported for testing.
250+
*/
251+
export function buildAppAttributionHeaders(
252+
existingHeaders: Record<string, string> | undefined
253+
): Record<string, string> {
254+
// Clone to avoid mutating caller-provided objects.
255+
const headers: Record<string, string> = existingHeaders ? { ...existingHeaders } : {};
256+
257+
// Header names are case-insensitive. Preserve user-provided values by never overwriting.
258+
const existingLowercaseKeys = new Set(Object.keys(headers).map((key) => key.toLowerCase()));
259+
260+
if (!existingLowercaseKeys.has("http-referer")) {
261+
headers["HTTP-Referer"] = MUX_APP_ATTRIBUTION_URL;
262+
}
263+
264+
if (!existingLowercaseKeys.has("x-title")) {
265+
headers["X-Title"] = MUX_APP_ATTRIBUTION_TITLE;
266+
}
267+
268+
return headers;
269+
}
270+
241271
/**
242272
* Preload AI SDK provider modules to avoid race conditions in concurrent test environments.
243273
* This function loads @ai-sdk/anthropic, @ai-sdk/openai, and ollama-ai-provider-v2 eagerly
@@ -435,6 +465,13 @@ export class AIService extends EventEmitter {
435465
? { ...configWithoutBaseUrl, baseURL: baseUrl }
436466
: configWithoutBaseUrl;
437467

468+
// Inject app attribution headers (used by OpenRouter and other compatible platforms).
469+
// We never overwrite user-provided values (case-insensitive header matching).
470+
providerConfig = {
471+
...providerConfig,
472+
headers: buildAppAttributionHeaders(providerConfig.headers),
473+
};
474+
438475
// Handle Anthropic provider
439476
if (providerName === "anthropic") {
440477
// Anthropic API key can come from:

0 commit comments

Comments
 (0)