fix(pi): credit Paperclip for default legacy OpenRouter requests (#16864)

## 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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-19 11:55:52 -05:00
1 parent 18e9ca7277
commit 35c75b56d4
5 files changed
+165 -3

No files matched your search

+8 -1
View File
@@ -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.
@@ -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([
{
@@ -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<AdapterExec
if (localAgentConfigDir) {
env.PI_CODING_AGENT_DIR = localAgentConfigDir;
}
let attributionExtension: Awaited<ReturnType<typeof preparePiOpenRouterExtension>> | 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<AdapterExec
onProgress: (line) => 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<AdapterExec
}
remoteRuntimeRootDir = preparedRemoteRuntime.runtimeRootDir;
remoteSkillsDir = preparedRemoteRuntime.assetDirs.skills ?? null;
const remoteAttributionDir = preparedRemoteRuntime.assetDirs.attribution;
if (!remoteAttributionDir) throw new Error("Pi attribution extension was not staged to the execution target.");
attributionExtensionPath = path.posix.join(remoteAttributionDir, "openrouter-attribution.mjs");
if (localAgentConfigDir && preparedRemoteRuntime.assetDirs.agentConfig) {
env.PI_CODING_AGENT_DIR = preparedRemoteRuntime.assetDirs.agentConfig;
}
@@ -660,6 +671,7 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
args.push("--skill", remoteSkillsDir ?? PI_AGENT_SKILLS_DIR);
if (extraArgs.length > 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<AdapterExec
}
}
} finally {
await preparedRuntimeConfig.cleanup();
await Promise.all([preparedRuntimeConfig.cleanup(), attributionExtension?.cleanup()]);
}
}
@@ -0,0 +1,75 @@
import { readFile, access } from "node:fs/promises";
import { describe, expect, it, vi } from "vitest";
import { OPENROUTER_ATTRIBUTION_HEADERS } from "@paperclipai/adapter-utils/openrouter-attribution";
import { piOpenRouterExtensionSource, preparePiOpenRouterExtension } from "./openrouter-extension.js";
async function loadExtension() {
const module = await import(`data:text/javascript;base64,${Buffer.from(piOpenRouterExtensionSource()).toString("base64")}`);
const handlers = new Map<string, (event: unknown, context: unknown) => 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();
});
});
@@ -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 }) };
}