fix(coding-agent): add OpenRouter attribution headers

closes #3414
This commit is contained in:
Mario Zechner
2026-04-20 14:13:49 +02:00
parent 23569e304b
commit 62c1c4031c
5 changed files with 220 additions and 10 deletions

View File

@@ -5,6 +5,7 @@
### Fixed
- Fixed skill resolution to dedupe symlinked aliases by canonical path, so `pi config` no longer shows duplicate skill entries when `~/.pi/agent/skills` points to `~/.agents/skills` ([#3405](https://github.com/badlogic/pi-mono/issues/3405))
- Fixed OpenRouter request attribution to include Pi app headers (`HTTP-Referer: https://pi.dev`, `X-OpenRouter-Title: pi`, `X-OpenRouter-Categories: cli-agent`) when sessions are created through the coding-agent SDK and install telemetry is enabled ([#3414](https://github.com/badlogic/pi-mono/issues/3414))
## [0.67.68] - 2026-04-17

View File

@@ -13,6 +13,7 @@ import type { ResourceLoader } from "./resource-loader.js";
import { DefaultResourceLoader } from "./resource-loader.js";
import { getDefaultSessionDir, SessionManager } from "./session-manager.js";
import { SettingsManager } from "./settings-manager.js";
import { isInstallTelemetryEnabled } from "./telemetry.js";
import { time } from "./timings.js";
import {
allTools,
@@ -131,6 +132,23 @@ function getDefaultAgentDir(): string {
return getAgentDir();
}
function getOpenRouterAttributionHeaders(
model: Model<any>,
settingsManager: SettingsManager,
): Record<string, string> | undefined {
if (!isInstallTelemetryEnabled(settingsManager)) {
return undefined;
}
if (model.provider !== "openrouter" && !model.baseUrl.includes("openrouter.ai")) {
return undefined;
}
return {
"HTTP-Referer": "https://pi.dev",
"X-OpenRouter-Title": "pi",
"X-OpenRouter-Categories": "cli-agent",
};
}
/**
* Create an AgentSession with the specified options.
*
@@ -301,10 +319,14 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
if (!auth.ok) {
throw new Error(auth.error);
}
const openRouterAttributionHeaders = getOpenRouterAttributionHeaders(model, settingsManager);
return streamSimple(model, context, {
...options,
apiKey: auth.apiKey,
headers: auth.headers || options?.headers ? { ...auth.headers, ...options?.headers } : undefined,
headers:
openRouterAttributionHeaders || auth.headers || options?.headers
? { ...openRouterAttributionHeaders, ...auth.headers, ...options?.headers }
: undefined,
});
},
onPayload: async (payload, _model) => {

View File

@@ -0,0 +1,13 @@
import type { SettingsManager } from "./settings-manager.js";
function isTruthyEnvFlag(value: string | undefined): boolean {
if (!value) return false;
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
}
export function isInstallTelemetryEnabled(
settingsManager: SettingsManager,
telemetryEnv: string | undefined = process.env.PI_TELEMETRY,
): boolean {
return telemetryEnv !== undefined ? isTruthyEnvFlag(telemetryEnv) : settingsManager.getEnableInstallTelemetry();
}

View File

@@ -65,6 +65,7 @@ import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../cor
import { type SessionContext, SessionManager } from "../../core/session-manager.js";
import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.js";
import type { SourceInfo } from "../../core/source-info.js";
import { isInstallTelemetryEnabled } from "../../core/telemetry.js";
import type { TruncationResult } from "../../core/tools/truncate.js";
import { getChangelogPath, getNewEntries, parseChangelog } from "../../utils/changelog.js";
import { copyToClipboard } from "../../utils/clipboard.js";
@@ -154,11 +155,6 @@ function isAnthropicSubscriptionAuthKey(apiKey: string | undefined): boolean {
return typeof apiKey === "string" && apiKey.startsWith("sk-ant-oat");
}
function isTruthyEnvFlag(value: string | undefined): boolean {
if (!value) return false;
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
}
function isUnknownModel(model: Model<any> | undefined): boolean {
return !!model && model.provider === "unknown" && model.id === "unknown" && model.api === "unknown";
}
@@ -844,10 +840,7 @@ export class InteractiveMode {
return;
}
const telemetryEnv = process.env.PI_TELEMETRY;
const telemetryEnabled =
telemetryEnv !== undefined ? isTruthyEnvFlag(telemetryEnv) : this.settingsManager.getEnableInstallTelemetry();
if (!telemetryEnabled) {
if (!isInstallTelemetryEnabled(this.settingsManager)) {
return;
}

View File

@@ -0,0 +1,181 @@
import { existsSync, mkdirSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
type Api,
type AssistantMessage,
createAssistantMessageEventStream,
type Model,
type SimpleStreamOptions,
} from "@mariozechner/pi-ai";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { AuthStorage } from "../src/core/auth-storage.js";
import { ModelRegistry } from "../src/core/model-registry.js";
import { createAgentSession } from "../src/core/sdk.js";
import { SessionManager } from "../src/core/session-manager.js";
import { SettingsManager } from "../src/core/settings-manager.js";
describe("createAgentSession OpenRouter attribution headers", () => {
let tempDir: string;
let cwd: string;
let agentDir: string;
let originalTelemetryEnv: string | undefined;
beforeEach(() => {
tempDir = join(tmpdir(), `pi-sdk-openrouter-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
cwd = join(tempDir, "project");
agentDir = join(tempDir, "agent");
mkdirSync(cwd, { recursive: true });
mkdirSync(agentDir, { recursive: true });
originalTelemetryEnv = process.env.PI_TELEMETRY;
delete process.env.PI_TELEMETRY;
});
afterEach(() => {
if (originalTelemetryEnv === undefined) {
delete process.env.PI_TELEMETRY;
} else {
process.env.PI_TELEMETRY = originalTelemetryEnv;
}
if (tempDir && existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true });
}
});
function createModel(provider: string, baseUrl: string): Model<Api> {
return {
id: `${provider}-test-model`,
name: `${provider} Test Model`,
api: "openai-completions",
provider,
baseUrl,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 4096,
};
}
function createDoneStream() {
const stream = createAssistantMessageEventStream();
const message: AssistantMessage = {
role: "assistant",
content: [{ type: "text", text: "ok" }],
api: "openai-completions",
provider: "capture-provider",
model: "capture-model",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
};
stream.end(message);
return stream;
}
async function captureHeaders(
model: Model<Api>,
options: {
telemetryEnabled?: boolean;
providerHeaders?: Record<string, string>;
requestHeaders?: Record<string, string>;
} = {},
): Promise<Record<string, string> | undefined> {
const settingsManager = SettingsManager.create(cwd, agentDir);
if (options.telemetryEnabled === false) {
settingsManager.setEnableInstallTelemetry(false);
}
const authStorage = AuthStorage.create(join(agentDir, "auth.json"));
authStorage.setRuntimeApiKey(model.provider, "test-api-key");
const modelRegistry = ModelRegistry.create(authStorage, join(agentDir, "models.json"));
const registeredProviders = ["capture-provider"];
let capturedOptions: SimpleStreamOptions | undefined;
modelRegistry.registerProvider("capture-provider", {
api: "openai-completions",
streamSimple: (_model, _context, providerOptions) => {
capturedOptions = providerOptions;
return createDoneStream();
},
});
if (options.providerHeaders) {
modelRegistry.registerProvider(model.provider, { headers: options.providerHeaders });
registeredProviders.push(model.provider);
}
const { session } = await createAgentSession({
cwd,
agentDir,
model,
authStorage,
modelRegistry,
settingsManager,
sessionManager: SessionManager.inMemory(cwd),
});
try {
await session.agent.streamFn(
model,
{ messages: [] },
options.requestHeaders ? { headers: options.requestHeaders } : undefined,
);
return capturedOptions?.headers;
} finally {
session.dispose();
for (const provider of registeredProviders.reverse()) {
modelRegistry.unregisterProvider(provider);
}
}
}
it("adds default attribution headers for OpenRouter models", async () => {
const headers = await captureHeaders(createModel("openrouter", "https://openrouter.ai/api/v1"));
expect(headers?.["HTTP-Referer"]).toBe("https://pi.dev");
expect(headers?.["X-OpenRouter-Title"]).toBe("pi");
expect(headers?.["X-OpenRouter-Categories"]).toBe("cli-agent");
});
it("does not add attribution headers when telemetry is disabled", async () => {
const headers = await captureHeaders(createModel("openrouter", "https://openrouter.ai/api/v1"), {
telemetryEnabled: false,
});
expect(headers?.["HTTP-Referer"]).toBeUndefined();
expect(headers?.["X-OpenRouter-Title"]).toBeUndefined();
expect(headers?.["X-OpenRouter-Categories"]).toBeUndefined();
});
it("adds attribution headers for custom providers routed through OpenRouter", async () => {
const headers = await captureHeaders(createModel("custom-openrouter", "https://openrouter.ai/api/v1"));
expect(headers?.["HTTP-Referer"]).toBe("https://pi.dev");
expect(headers?.["X-OpenRouter-Title"]).toBe("pi");
expect(headers?.["X-OpenRouter-Categories"]).toBe("cli-agent");
});
it("lets provider and request headers override the defaults", async () => {
const headers = await captureHeaders(createModel("openrouter", "https://openrouter.ai/api/v1"), {
providerHeaders: {
"HTTP-Referer": "https://provider.example",
"X-OpenRouter-Categories": "provider-category",
},
requestHeaders: {
"X-OpenRouter-Title": "request-title",
},
});
expect(headers?.["HTTP-Referer"]).toBe("https://provider.example");
expect(headers?.["X-OpenRouter-Title"]).toBe("request-title");
expect(headers?.["X-OpenRouter-Categories"]).toBe("provider-category");
});
});