Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/constants/appAttribution.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// App attribution values for AI provider requests.
//
// These are used by OpenRouter (and other compatible platforms) to attribute
// requests to mux (e.g., for leaderboards).

export const MUX_APP_ATTRIBUTION_TITLE = "mux";
export const MUX_APP_ATTRIBUTION_URL = "https://mux.coder.com";
45 changes: 45 additions & 0 deletions src/node/services/aiService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import {
AIService,
normalizeAnthropicBaseURL,
buildAnthropicHeaders,
buildAppAttributionHeaders,
ANTHROPIC_1M_CONTEXT_HEADER,
} from "./aiService";
import { HistoryService } from "./historyService";
import { PartialService } from "./partialService";
import { InitStateManager } from "./initStateManager";
import { Config } from "@/node/config";
import { MUX_APP_ATTRIBUTION_TITLE, MUX_APP_ATTRIBUTION_URL } from "@/constants/appAttribution";

describe("AIService", () => {
let service: AIService;
Expand Down Expand Up @@ -117,3 +119,46 @@ describe("buildAnthropicHeaders", () => {
expect(result).toEqual({ "anthropic-beta": ANTHROPIC_1M_CONTEXT_HEADER });
});
});

describe("buildAppAttributionHeaders", () => {
it("adds both headers when no headers exist", () => {
expect(buildAppAttributionHeaders(undefined)).toEqual({
"HTTP-Referer": MUX_APP_ATTRIBUTION_URL,
"X-Title": MUX_APP_ATTRIBUTION_TITLE,
});
});

it("adds only the missing header when one is present", () => {
const existing = { "HTTP-Referer": "https://example.com" };
const result = buildAppAttributionHeaders(existing);
expect(result).toEqual({
"HTTP-Referer": "https://example.com",
"X-Title": MUX_APP_ATTRIBUTION_TITLE,
});
});

it("does not overwrite existing values (case-insensitive)", () => {
const existing = { "http-referer": "https://example.com", "X-TITLE": "My App" };
const result = buildAppAttributionHeaders(existing);
expect(result).toEqual(existing);
});

it("preserves unrelated headers", () => {
const existing = { "x-custom": "value" };
const result = buildAppAttributionHeaders(existing);
expect(result).toEqual({
"x-custom": "value",
"HTTP-Referer": MUX_APP_ATTRIBUTION_URL,
"X-Title": MUX_APP_ATTRIBUTION_TITLE,
});
});

it("does not mutate the input object", () => {
const existing = { "x-custom": "value" };
const existingSnapshot = { ...existing };

buildAppAttributionHeaders(existing);

expect(existing).toEqual(existingSnapshot);
});
});
37 changes: 37 additions & 0 deletions src/node/services/aiService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { EnvHttpProxyAgent, type Dispatcher } from "undici";
import { getPlanFilePath } from "@/common/utils/planStorage";
import { getPlanModeInstruction } from "@/common/utils/ui/modeUtils";
import type { UIMode } from "@/common/types/mode";
import { MUX_APP_ATTRIBUTION_TITLE, MUX_APP_ATTRIBUTION_URL } from "@/constants/appAttribution";
import { readPlanFile } from "@/node/utils/runtime/helpers";

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

/**
* Build app attribution headers used by OpenRouter (and other compatible platforms).
*
* Attribution docs:
* - OpenRouter: https://openrouter.ai/docs/app-attribution
* - Vercel AI Gateway: https://vercel.com/docs/ai-gateway/app-attribution
*
* Exported for testing.
*/
export function buildAppAttributionHeaders(
existingHeaders: Record<string, string> | undefined
): Record<string, string> {
// Clone to avoid mutating caller-provided objects.
const headers: Record<string, string> = existingHeaders ? { ...existingHeaders } : {};

// Header names are case-insensitive. Preserve user-provided values by never overwriting.
const existingLowercaseKeys = new Set(Object.keys(headers).map((key) => key.toLowerCase()));

if (!existingLowercaseKeys.has("http-referer")) {
headers["HTTP-Referer"] = MUX_APP_ATTRIBUTION_URL;
}

if (!existingLowercaseKeys.has("x-title")) {
headers["X-Title"] = MUX_APP_ATTRIBUTION_TITLE;
}

return headers;
}

/**
* Preload AI SDK provider modules to avoid race conditions in concurrent test environments.
* This function loads @ai-sdk/anthropic, @ai-sdk/openai, and ollama-ai-provider-v2 eagerly
Expand Down Expand Up @@ -435,6 +465,13 @@ export class AIService extends EventEmitter {
? { ...configWithoutBaseUrl, baseURL: baseUrl }
: configWithoutBaseUrl;

// Inject app attribution headers (used by OpenRouter and other compatible platforms).
// We never overwrite user-provided values (case-insensitive header matching).
providerConfig = {
...providerConfig,
headers: buildAppAttributionHeaders(providerConfig.headers),
};

// Handle Anthropic provider
if (providerName === "anthropic") {
// Anthropic API key can come from:
Expand Down
Loading