From 62c1c4031c79dabc02da14e55439e2892af21a44 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 20 Apr 2026 14:13:49 +0200 Subject: [PATCH] fix(coding-agent): add OpenRouter attribution headers closes #3414 --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/src/core/sdk.ts | 24 ++- packages/coding-agent/src/core/telemetry.ts | 13 ++ .../src/modes/interactive/interactive-mode.ts | 11 +- .../test/sdk-openrouter-attribution.test.ts | 181 ++++++++++++++++++ 5 files changed, 220 insertions(+), 10 deletions(-) create mode 100644 packages/coding-agent/src/core/telemetry.ts create mode 100644 packages/coding-agent/test/sdk-openrouter-attribution.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9a6f12bf..faae5dee 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 5831e1ff..1646bed2 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -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, + settingsManager: SettingsManager, +): Record | 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) => { diff --git a/packages/coding-agent/src/core/telemetry.ts b/packages/coding-agent/src/core/telemetry.ts new file mode 100644 index 00000000..b563d34b --- /dev/null +++ b/packages/coding-agent/src/core/telemetry.ts @@ -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(); +} diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 3bf3fbba..8ed2923e 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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 | 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; } diff --git a/packages/coding-agent/test/sdk-openrouter-attribution.test.ts b/packages/coding-agent/test/sdk-openrouter-attribution.test.ts new file mode 100644 index 00000000..7daa510d --- /dev/null +++ b/packages/coding-agent/test/sdk-openrouter-attribution.test.ts @@ -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 { + 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, + options: { + telemetryEnabled?: boolean; + providerHeaders?: Record; + requestHeaders?: Record; + } = {}, + ): Promise | 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"); + }); +});