From 35c75b56d461ce4e65a35e82f1b6c288b0ea5252 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 19 Oct 2026 11:55:52 -0500 Subject: [PATCH] fix(pi): credit Paperclip for default legacy OpenRouter requests (#16864) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Legacy adapters can use host credentials and provider settings. > - OpenRouter assigns app credit from the final request headers. > - The first attribution change covered managed connections and explicit provider configs. > - Legacy Pi with only an API key bypassed those config changes. > - This pull request applies attribution at Pi's request boundary. > - The benefit is Paperclip credit without replacing the user's Pi home. ## Linked Issues or Issue Description Follow-up to #16850. A search of related OpenRouter PRs and issues found no duplicate fix for this path. **What happened?** A legacy `pi_local` agent with `OPENROUTER_API_KEY` and no `PAPERCLIP_PI_PROVIDERS` could send requests without Paperclip attribution. Existing Pi home configs could also retain the previous harness title. **Expected behavior** Paperclip receives credit for OpenRouter requests from its legacy Pi adapter. The adapter preserves credentials, routing, model settings, and other providers. **Steps to reproduce** 1. Configure a legacy Pi agent with an OpenRouter model and API key. 2. Leave `PAPERCLIP_PI_PROVIDERS` unset. 3. Start a run and inspect its outgoing headers or OpenRouter generation record. **Paperclip version or commit** Reproduced after #16850, on master commit `9179d8dcc3e908c217fde4f63c9d08ccf8132eeb`. ## What Changed - Load a temporary attribution extension for every legacy Pi run, including remote targets and retries. - Apply headers after credential and model-header resolution on Pi 0.74 and at provider dispatch on Pi 1.0. - Check the actual endpoint. Cover custom aliases and regional OpenRouter hosts. Preserve other endpoints, credentials, unrelated headers, and explicit visibility preferences. - Keep the Pi home, auth file, settings, and model catalog in place. Remove the temporary extension after the run. - Prevent repeated wrapping when Pi recomposes a provider after a registry refresh. Add regression tests and update the connector playbook. ## Verification - Final head `902399468a9033d12e3ff6ab30c8463daef50bad`: Greptile **5/5**, with no actionable comments. CI is waiting for runner assignment. - Complete legacy Pi adapter suite: **124 tests passed across 12 files**, including extension, runtime-config, and remote execution coverage. The initial focused subset passed 30 tests. - `pnpm -r typecheck` and `pnpm build` passed. After the lifecycle guard, all 124 Pi tests and the Pi package typecheck passed again. Full `pnpm test:run` is in progress. - Actual Pi 0.74 outgoing-request comparison with dummy credentials: the baseline sent no attribution headers. The patched request sent all four Paperclip headers and retained the same credential. The transport returned an intentional 401 after capture; this check does not claim successful inference. - Final-head Pi 0.74 request-capture matrix: **8/8 passed**. Cases cover bare keys, host aliases, per-model overrides, EU/US endpoints, a repointed built-in provider, an unrelated provider, and an HTTP endpoint. Credentials, custom routing headers, and visibility preferences survived. These captures used dummy credentials and intentional 401 responses. - Live request through the actual legacy adapter with Pi 0.74: response `legacy attribution verified.`; generation `gen-1792424885-G3THa6DdJXmAL3C841DI`; OpenRouter app ID **3118063**; cost **$0.0002268**. - Live request through the actual legacy adapter with the Pi 1.0 bundled CLI: response `legacy attribution verified.`; generation `gen-1792425530-LlpIbfBFyjs6XRP5l5PV` on final head `902399468a9033d12e3ff6ab30c8463daef50bad`; OpenRouter app ID **3118063**; cost **$0.0002391**. Its usage record became available on a later read. - Both live runs preserved the existing `models.json`, `settings.json`, and `auth.json` byte for byte. Pi 0.74 also retained the custom `X-Route` header while replacing the conflicting harness title. - Built-package smoke passed. `npm pack --dry-run --ignore-scripts` includes `dist/server/openrouter-extension.js`, and the compiled helper produced a working self-contained extension. The pack check used a temporary npm cache because the default cache was not writable. - Remote asset delivery is covered through the SSH execution test boundary. No live remote sandbox was used. - Initial capture attempts produced no requests because the test left stdin open. A separate source-checkout live attempt lacked the TypeScript loader and did not reach the provider. Correcting the test setup produced the evidence above. Pi 1.0's bundled transport bypassed the global-fetch capture shim; its acceptance evidence is the successful live request and provider-side app ID. ## Risks - The extension depends on Pi 0.74's request-auth registry and Pi 1.0's provider API. Harness upgrades require a fresh request capture or live credit check. - Missing extension delivery stops remote startup rather than silently losing attribution. - A private gateway that replaces headers and an externally managed harness remain outside this adapter's control. Public listing metadata still belongs to OpenRouter. - No database, API, credential ownership, or telemetry contract changes are required. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository editing, shell execution, and live API verification. The exact runtime model identifier and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass — the focused suites above; the full suite is still running - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/connections/CONNECTOR-PLAYBOOK.md | 9 ++- .../src/server/execute.remote.test.ts | 4 +- .../adapters/pi-local/src/server/execute.ts | 14 +++- .../src/server/openrouter-extension.test.ts | 75 +++++++++++++++++++ .../src/server/openrouter-extension.ts | 66 ++++++++++++++++ 5 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 packages/adapters/pi-local/src/server/openrouter-extension.test.ts create mode 100644 packages/adapters/pi-local/src/server/openrouter-extension.ts diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index f343cff4cd8..87a458b7875 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -107,7 +107,14 @@ Claude `ANTHROPIC_CUSTOM_HEADERS`, OpenCode provider `options.headers`, and Herm config allowlist, preserves Claude headers across environment isolation, sets OpenCode proxy headers at the upstream boundary, and attributes Pi's default and custom OpenRouter providers in `models.json`. Explicit legacy provider configs -for Codex, OpenCode, and Pi use the same policy. These paths must preserve model +for Codex, OpenCode, and Pi use the same policy. Legacy Pi also loads a temporary +extension at execution time, including on remote targets. It applies attribution +after credential and model-header resolution on Pi 0.74 and at provider dispatch +on Pi 1.0. This covers bare `OPENROUTER_API_KEY` setups and existing Pi home +configurations without replacing the home, credentials, settings, or model catalog. +The extension checks each request's endpoint, including custom aliases and regional +hosts; a provider named `openrouter` that points elsewhere retains its headers. +These paths must preserve model overrides, helper-model requests, retries, and recovery. The standalone Runner keeps its dependency-free copy of the policy under a parity test. diff --git a/packages/adapters/pi-local/src/server/execute.remote.test.ts b/packages/adapters/pi-local/src/server/execute.remote.test.ts index 4195a49639b..6d717d7ea0d 100644 --- a/packages/adapters/pi-local/src/server/execute.remote.test.ts +++ b/packages/adapters/pi-local/src/server/execute.remote.test.ts @@ -190,7 +190,7 @@ describe("pi remote execution", () => { }); expect(String(result.sessionId)).toContain(`${managedRemoteWorkspace}/.paperclip-runtime/pi/sessions/`); expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1); - expect(syncDirectoryToSsh).toHaveBeenCalledTimes(1); + expect(syncDirectoryToSsh).toHaveBeenCalledTimes(2); expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({ remoteDir: `${managedRemoteWorkspace}/.paperclip-runtime/pi/skills`, followSymlinks: true, @@ -206,6 +206,8 @@ describe("pi remote execution", () => { expect(call?.[2]).toContain("--session"); expect(call?.[2]).toContain("--skill"); expect(call?.[2]).toContain(`${managedRemoteWorkspace}/.paperclip-runtime/pi/skills`); + expect(call?.[2]).toContain("--extension"); + expect(call?.[2]).toContain(`${managedRemoteWorkspace}/.paperclip-runtime/pi/attribution/openrouter-attribution.mjs`); expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe(managedRemoteWorkspace); expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([ { diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index ad68313578b..bfe07590b83 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -59,6 +59,7 @@ import { isPiUnknownSessionError, parsePiJsonl, createPiJsonlParser } from "./pa import { createPiStdoutCompactor, resolvePiStdoutLogMode } from "./stdout-compaction.js"; import { ensurePiModelConfiguredAndAvailable } from "./models.js"; import { preparePiRuntimeConfig } from "./runtime-config.js"; +import { preparePiOpenRouterExtension } from "./openrouter-extension.js"; import { SANDBOX_INSTALL_COMMAND } from "../index.js"; const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -341,7 +342,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise> | undefined; try { + attributionExtension = await preparePiOpenRouterExtension(); + let attributionExtensionPath = attributionExtension.extensionPath; // Prepend installed skill `bin/` dirs to PATH so an agent's bash tool can // invoke skill binaries (e.g. `paperclip-get-issue`) by name. Without this, // any pi_local agent whose AGENTS.md calls a skill command via bash hits @@ -435,6 +439,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise onLog("stdout", line), onRuntimeProgress: ctx.onRuntimeProgress, assets: [ + { + key: "attribution", + localDir: attributionExtension.directory, + }, { key: "skills", localDir: localSkillsDir, @@ -469,6 +477,9 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0) args.push(...extraArgs); + args.push("--extension", attributionExtensionPath); // Add the user prompt as the last argument args.push(userPrompt); @@ -911,6 +923,6 @@ export async function execute(ctx: AdapterExecutionContext): Promise void>(); + module.default({ on: (event: string, handler: (event: unknown, context: unknown) => void) => handlers.set(event, handler) }); + return (registry: unknown, event = "session_start") => handlers.get(event)!({}, { modelRegistry: registry }); +} + +describe("legacy Pi OpenRouter attribution extension", () => { + it("attributes default and custom endpoints after resolving credentials and model headers", async () => { + const apply = await loadExtension(); + const result = { ok: true, apiKey: "credential", headers: { "http-referer": "https://pi.dev", "x-title": "Pi", "X-Route": "tenant", "X-OpenRouter-App-Visibility": "hidden" } }; + const resolve = vi.fn(async (_model: unknown) => result); + const registry = { getApiKeyAndHeaders: resolve }; + apply(registry); + apply(registry, "before_agent_start"); + for (const host of ["openrouter.ai", "eu.openrouter.ai", "us.openrouter.ai"]) { + expect(await registry.getApiKeyAndHeaders({ provider: "alias", baseUrl: `https://${host}/api/v1` })).toEqual({ + ...result, headers: { ...OPENROUTER_ATTRIBUTION_HEADERS, "X-Route": "tenant", "X-OpenRouter-App-Visibility": "hidden" }, + }); + } + expect(resolve).toHaveBeenCalledTimes(3); + expect(result.headers["http-referer"]).toBe("https://pi.dev"); + }); + + it("leaves other endpoints and failed authentication intact", async () => { + const apply = await loadExtension(); + const result = { ok: true, headers: { "X-Title": "Other" } }; + const registry = { getApiKeyAndHeaders: vi.fn(async (_model: unknown) => result) }; + apply(registry); + for (const baseUrl of ["https://other.example", "https://openrouter.ai.other.example", "http://openrouter.ai", "https://key@openrouter.ai"]) { + expect(await registry.getApiKeyAndHeaders({ provider: "openrouter", baseUrl })).toBe(result); + } + const failed = { ok: false, error: "credential unavailable" }; + const unavailable = { getApiKeyAndHeaders: vi.fn(async (_model: unknown) => failed) }; + apply(unavailable); + expect(await unavailable.getApiKeyAndHeaders({ baseUrl: "https://openrouter.ai/api/v1" })).toBe(failed); + }); + + it("wraps modern Pi provider dispatch without changing routing, auth, or other model requests", async () => { + const apply = await loadExtension(); + const model = { id: "model", provider: "alias", baseUrl: "https://openrouter.ai/api/v1" }; + const other = { ...model, baseUrl: "https://gateway.example/v1" }; + const stream = vi.fn((_model: unknown, _input: unknown, _options: unknown) => "stream"); + const auth = { apiKey: {} }; + let provider = { id: "alias", auth, getModels: () => [model, other], stream, streamSimple: stream }; + let registered: typeof provider | undefined; + const registry = { + getAll: () => [model], getProvider: () => ({ ...provider }), + getRegisteredNativeProvider: () => registered, + registerProvider: vi.fn((wrapped) => { provider = wrapped; registered = wrapped; }), + }; + apply(registry); + apply(registry, "model_select"); + expect(registry.registerProvider).toHaveBeenCalledTimes(1); + expect(provider.auth).toBe(auth); + const options = { apiKey: "credential", headers: { "x-title": "Pi", "X-Route": "tenant" }, signal: new AbortController().signal }; + expect(provider.streamSimple(model, {}, options)).toBe("stream"); + expect(stream).toHaveBeenLastCalledWith(model, {}, { ...options, headers: { ...OPENROUTER_ATTRIBUTION_HEADERS, "X-Route": "tenant" } }); + provider.stream(other, {}, options); + expect(stream).toHaveBeenLastCalledWith(other, {}, options); + }); + + it("writes a self-contained extension and removes its temporary directory", async () => { + const extension = await preparePiOpenRouterExtension(); + try { + expect(await readFile(extension.extensionPath, "utf8")).toBe(piOpenRouterExtensionSource()); + } finally { await extension.cleanup(); } + await expect(access(extension.directory)).rejects.toThrow(); + }); +}); diff --git a/packages/adapters/pi-local/src/server/openrouter-extension.ts b/packages/adapters/pi-local/src/server/openrouter-extension.ts new file mode 100644 index 00000000000..23db2d7fee5 --- /dev/null +++ b/packages/adapters/pi-local/src/server/openrouter-extension.ts @@ -0,0 +1,66 @@ +import { OPENROUTER_ATTRIBUTION_HEADERS } from "@paperclipai/adapter-utils/openrouter-attribution"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export function piOpenRouterExtensionSource(): string { + return `const attribution = ${JSON.stringify(OPENROUTER_ATTRIBUTION_HEADERS)}; +const names = new Set(Object.keys(attribution).map(name => name.toLowerCase())); +function isOpenRouter(value) { + try { + const url = new URL(value); + return url.protocol === "https:" && ["openrouter.ai", "eu.openrouter.ai", "us.openrouter.ai"].includes(url.hostname) && !url.username && !url.password; + } catch { return false; } +} +function headers(existing = {}) { + return { ...Object.fromEntries(Object.entries(existing).filter(([name]) => !names.has(name.toLowerCase()))), ...attribution }; +} +export default function paperclipAttribution(pi) { + const patched = new WeakSet(); + function apply(_event, context) { + const registry = context.modelRegistry; + if (typeof registry.getProvider === "function") { + for (const name of new Set(registry.getAll().map(model => model.provider))) { + const registered = registry.getRegisteredNativeProvider?.(name); + if (registered && patched.has(registered)) continue; + const provider = registry.getProvider(name); + if (!provider || patched.has(provider)) continue; + const models = provider.getModels(); + if (!models.some(model => isOpenRouter(model.baseUrl))) continue; + const wrapped = { ...provider }; + for (const method of ["stream", "streamSimple", "generateImages", "classify"]) { + if (typeof provider[method] !== "function") continue; + wrapped[method] = (model, input, options) => provider[method](model, input, + isOpenRouter(model.baseUrl) ? { ...options, headers: headers(options?.headers) } : options); + } + patched.add(wrapped); + registry.registerProvider(wrapped); + } + } else if (!patched.has(registry)) { + const resolve = registry.getApiKeyAndHeaders.bind(registry); + registry.getApiKeyAndHeaders = async model => { + const result = await resolve(model); + return result.ok && isOpenRouter(result.baseUrl ?? model.baseUrl) + ? { ...result, headers: headers(result.headers) } : result; + }; + patched.add(registry); + } + } + pi.on("session_start", apply); + pi.on("before_agent_start", apply); + pi.on("model_select", apply); +} +`; +} + +export async function preparePiOpenRouterExtension() { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-pi-attribution-")); + const extensionPath = path.join(directory, "openrouter-attribution.mjs"); + try { + await fs.writeFile(extensionPath, piOpenRouterExtensionSource(), "utf8"); + } catch (error) { + await fs.rm(directory, { recursive: true, force: true }); + throw error; + } + return { directory, extensionPath, cleanup: () => fs.rm(directory, { recursive: true, force: true }) }; +}