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 }) }; +}