Skip to content

Commit 203be31

Browse files
committed
🤖 feat: add AI app attribution headers
Change-Id: I6eb85de8f5c9b204e0836755c3041f0d6d6d98ad Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent d78c608 commit 203be31

File tree

3 files changed

+87
-0
lines changed

3 files changed

+87
-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 the mux application (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: 35 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,33 @@ 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+
* OpenRouter attribution docs: https://openrouter.ai/docs/app-attribution
246+
*
247+
* Exported for testing.
248+
*/
249+
export function buildAppAttributionHeaders(
250+
existingHeaders: Record<string, string> | undefined
251+
): Record<string, string> {
252+
// Clone to avoid mutating caller-provided objects.
253+
const headers: Record<string, string> = existingHeaders ? { ...existingHeaders } : {};
254+
255+
// Header names are case-insensitive. Preserve user-provided values by never overwriting.
256+
const existingLowercaseKeys = new Set(Object.keys(headers).map((key) => key.toLowerCase()));
257+
258+
if (!existingLowercaseKeys.has("http-referer")) {
259+
headers["HTTP-Referer"] = MUX_APP_ATTRIBUTION_URL;
260+
}
261+
262+
if (!existingLowercaseKeys.has("x-title")) {
263+
headers["X-Title"] = MUX_APP_ATTRIBUTION_TITLE;
264+
}
265+
266+
return headers;
267+
}
268+
241269
/**
242270
* Preload AI SDK provider modules to avoid race conditions in concurrent test environments.
243271
* This function loads @ai-sdk/anthropic, @ai-sdk/openai, and ollama-ai-provider-v2 eagerly
@@ -435,6 +463,13 @@ export class AIService extends EventEmitter {
435463
? { ...configWithoutBaseUrl, baseURL: baseUrl }
436464
: configWithoutBaseUrl;
437465

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

0 commit comments

Comments
 (0)