mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 00:05:49 +02:00
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:
1 parent
18e9ca7277
commit
35c75b56d4
5 files changed
+165
-3
No files matched your search
@@ -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 }) };
|
||||
}
|
||||
Reference in new issue
Block a user