diff --git a/.dockerignore b/.dockerignore index 8763cb8756a..62d5e970ad2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -60,6 +60,7 @@ packages/paperclip-runner/test-fixtures/pi-acp/** !packages/paperclip-runner/test-fixtures/pi-acp/profile-v21-identity.json !packages/paperclip-runner/test-fixtures/pi-acp/profile-v22-identity.json !packages/paperclip-runner/test-fixtures/pi-acp/profile-v23-identity.json +!packages/paperclip-runner/test-fixtures/pi-acp/profile-v24-identity.json !packages/paperclip-runner/docs/capability-contract.md # check:runner-workflow-traceability access()es every regression test the # stress-traceability spec names — those are src/**/*.test.ts files, so diff --git a/doc/connections/CONNECTOR-PLAYBOOK.md b/doc/connections/CONNECTOR-PLAYBOOK.md index 55b54fb27e1..f343cff4cd8 100644 --- a/doc/connections/CONNECTOR-PLAYBOOK.md +++ b/doc/connections/CONNECTOR-PLAYBOOK.md @@ -68,6 +68,65 @@ execution. Extend the provider's existing catalog entry with typed AI methods; reuse the existing login controllers. See [AI Connections](./AI-CONNECTIONS.md) for compatibility, personal defaults, resolver isolation, and legacy adoption. +## Provider app attribution + +When a provider supports application attribution, credit Paperclip for requests +that Paperclip manages. Research the provider's current attribution contract +alongside authentication. A harness may identify itself by default; setting an +API key or model alone does not replace that identity. + +For OpenRouter, use the shared policy in +`packages/shared/src/openrouter-attribution.ts`: + +```http +HTTP-Referer: https://paperclip.ing +X-OpenRouter-Title: Paperclip +X-Title: Paperclip +X-OpenRouter-Categories: cloud-agent,programming-app +``` + +The URL is the app identity. Keep it stable across companies, deployments, +personal keys, and shared keys. Send only public product identity; never put a +company name, user identifier, task, credential, or instance URL in these fields. +Preserve authentication, routing headers, and explicit visibility preferences. +Replace conflicting attribution names case-insensitively. Do not rely on header +casing or send two different identities under the legacy and current title names. +See [OpenRouter app attribution](https://openrouter.ai/docs/app-attribution). + +Apply this policy to the actual provider endpoint, including a custom connection +that points to `https://openrouter.ai` or its documented +[regional endpoints](https://openrouter.ai/docs/guides/features/in-region-routing), +`https://eu.openrouter.ai` and `https://us.openrouter.ai`. Do not infer OpenRouter from a model name, +URL substring, or a provider label whose endpoint was changed. A private gateway +must implement forwarding or attribution itself; Paperclip cannot establish the +identity of an upstream request it does not control. + +Managed AI connections project attribution into Codex provider `http_headers`, +Claude `ANTHROPIC_CUSTOM_HEADERS`, OpenCode provider `options.headers`, and Hermes +`model.default_headers`. Native Runner also regenerates Codex headers after its +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 +overrides, helper-model requests, retries, and recovery. The standalone Runner +keeps its dependency-free copy of the policy under a parity test. + +For each new or changed provider path, verify: + +- Actual outgoing headers from the supported, pinned harness or SDK. Config + snapshots alone do not prove precedence over a harness's own defaults. +- Custom aliases, conflicting mixed-case attribution, and model-level overrides. +- An unrelated provider and a repointed built-in provider retain their settings. +- The credential and unrelated headers survive; secrets never enter test logs. +- A small live request succeeds and its provider-side record carries the expected + app identity, when the provider exposes that record. Keep this evidence separate + from public leaderboard visibility, which depends on the provider's processing + and ranking rules. + +Record coverage limits explicitly. An externally managed agent, an unmanaged +harness configuration, or a gateway that replaces headers requires separate +qualification; do not claim that generating Paperclip's configuration covers it. + ## Read/write defaults and credential ownership New tool connections request the provider-documented permissions needed for their diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 425f802e153..a5032f12695 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -1,3 +1,4 @@ +import { isOpenRouterUrl, openRouterClaudeHeaders } from "@paperclipai/shared/openrouter-attribution"; import { cancellableSandboxStartup } from "./startup-cancellation.js"; import { withAdapterExecutionPhase, type AdapterExecutionPhase } from "../execution-phase.js"; import fs from "node:fs/promises"; @@ -615,6 +616,7 @@ const ACPX_INHERITED_PROVIDER_ENV_KEYS: Readonly { + it("attributes OpenRouter aliases without replacing credentials or unrelated providers", async () => { + const home = await makeCodexHome(); + const prepared = await prepareCodexRuntimeConfig({ + env: { PAPERCLIP_CODEX_PROVIDERS: JSON.stringify({ providers: { + router: { + base_url: "https://openrouter.ai/api/v1", env_key: "ROUTER_KEY", + http_headers: { "http-referer": "https://harness.example", "X-Route": "selected" }, + env_http_headers: { "x-title": "HARNESS_TITLE", "X-Tenant": "TENANT" }, + }, + other: { base_url: "https://other.example/v1", http_headers: { "X-Title": "Other" } }, + } }) }, codexHome: home, + }); + const content = await readConfigToml(home); + expect(content).toContain('HTTP-Referer = "https://paperclip.ing"'); + expect(content).toContain('env_key = "ROUTER_KEY"'); + expect(content).toContain('X-Route = "selected"'); + expect(content).toContain('X-Tenant = "TENANT"'); + expect(content).toContain('X-Title = "Other"'); + expect(content).not.toContain("HARNESS_TITLE"); + expect(content).not.toContain("harness.example"); + await prepared.cleanup(); + }); + it("is a no-op when PAPERCLIP_CODEX_PROVIDERS is unset", async () => { const home = await makeCodexHome("model = \"gpt-5.1-codex\"\n"); const prepared = await prepareCodexRuntimeConfig({ env: { FOO: "bar" }, codexHome: home }); diff --git a/packages/adapters/codex-local/src/server/runtime-config.ts b/packages/adapters/codex-local/src/server/runtime-config.ts index 978c4fdab66..912b3ef85b0 100644 --- a/packages/adapters/codex-local/src/server/runtime-config.ts +++ b/packages/adapters/codex-local/src/server/runtime-config.ts @@ -1,3 +1,4 @@ +import { isOpenRouterUrl, openRouterAttributionHeaders, OPENROUTER_ATTRIBUTION_HEADERS } from "@paperclipai/shared/openrouter-attribution"; import fs from "node:fs/promises"; import path from "node:path"; @@ -108,7 +109,15 @@ function parseCodexProvidersConfig( skipped.push(key.trim().length === 0 ? "(empty name)" : key); continue; } - providers[key] = expandEnvPlaceholders(value, resolveEnv); + const provider = expandEnvPlaceholders(value, resolveEnv); + if (isOpenRouterUrl(provider.base_url)) { + provider.http_headers = openRouterAttributionHeaders(isPlainObject(provider.http_headers) ? provider.http_headers as Record : {}); + if (isPlainObject(provider.env_http_headers)) { + const attribution = new Set(Object.keys(OPENROUTER_ATTRIBUTION_HEADERS).map(name => name.toLowerCase())); + provider.env_http_headers = Object.fromEntries(Object.entries(provider.env_http_headers).filter(([name]) => !attribution.has(name.toLowerCase()))); + } + } + providers[key] = provider; } if (Object.keys(providers).length === 0) { notes.push( diff --git a/packages/adapters/opencode-local/src/server/runtime-config.test.ts b/packages/adapters/opencode-local/src/server/runtime-config.test.ts index 598330c52cb..b75a9fa5b72 100644 --- a/packages/adapters/opencode-local/src/server/runtime-config.test.ts +++ b/packages/adapters/opencode-local/src/server/runtime-config.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import { OPENROUTER_ATTRIBUTION_HEADERS } from "@paperclipai/adapter-utils/openrouter-attribution"; import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js"; const cleanupPaths = new Set(); @@ -31,6 +32,27 @@ async function makeConfigHome(initialConfig?: Record) { } describe("prepareOpenCodeRuntimeConfig", () => { + it("attributes custom OpenRouter aliases and inline overrides without changing other endpoints", async () => { + const other = { options: { baseURL: "https://other.example/v1", headers: { "X-Title": "Other" } } }; + const configHome = await makeConfigHome({ provider: { other, openrouter: other } }); + const prepared = await prepareOpenCodeRuntimeConfig({ env: { + XDG_CONFIG_HOME: configHome, + OPENCODE_CONFIG_CONTENT: JSON.stringify({ provider: { alias: { + options: { baseURL: "https://openrouter.ai/api/v1", apiKey: "fixture-key", headers: { "http-referer": "https://harness.example", "X-Route": "selected" } }, + models: { model: { headers: { "x-title": "Harness" } } }, + } } }), + }, config: {} }); + cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); + const { alias } = JSON.parse(prepared.env.OPENCODE_CONFIG_CONTENT).provider; + expect(alias.options).toMatchObject({ apiKey: "fixture-key", headers: { ...OPENROUTER_ATTRIBUTION_HEADERS, "X-Route": "selected" } }); + expect(alias.options.headers).not.toHaveProperty("http-referer"); + expect(alias.models.model.headers).toEqual(OPENROUTER_ATTRIBUTION_HEADERS); + const config = JSON.parse(await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8")); + expect(config.provider.other).toEqual(other); + expect(config.provider.openrouter).toEqual(other); + await prepared.cleanup(); + }); + it("allows all tools and connected tools by default", async () => { const configHome = await makeConfigHome({ permission: { @@ -180,7 +202,7 @@ describe("prepareOpenCodeRuntimeConfig", () => { const runtimeConfig = JSON.parse( await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), ) as Record; - expect(runtimeConfig.provider).toBeUndefined(); + expect(runtimeConfig.provider).toMatchObject({ openrouter: { options: { headers: { "HTTP-Referer": "https://paperclip.ing" } } } }); expect(prepared.notes).toContain( "PAPERCLIP_OPENCODE_PROVIDERS contains invalid JSON; custom providers ignored.", ); @@ -197,7 +219,7 @@ describe("prepareOpenCodeRuntimeConfig", () => { const runtimeConfig = JSON.parse( await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), ) as Record; - expect(runtimeConfig.provider).toBeUndefined(); + expect(runtimeConfig.provider).toMatchObject({ openrouter: { options: { headers: { "HTTP-Referer": "https://paperclip.ing" } } } }); expect(prepared.notes).toContain( "PAPERCLIP_OPENCODE_PROVIDERS is set but is not a JSON object; custom providers ignored.", ); @@ -241,7 +263,7 @@ describe("prepareOpenCodeRuntimeConfig", () => { const runtimeConfig = JSON.parse( await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), ) as Record; - expect(runtimeConfig.provider).toBeUndefined(); + expect(runtimeConfig.provider).toMatchObject({ openrouter: { options: { headers: { "HTTP-Referer": "https://paperclip.ing" } } } }); expect(prepared.notes).toContain( "PAPERCLIP_OPENCODE_PROVIDERS: skipped provider(s) with non-object values: bifrost.", ); @@ -259,7 +281,7 @@ describe("prepareOpenCodeRuntimeConfig", () => { await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), ) as { provider?: Record }> }; expect(runtimeConfig.provider?.openrouter?.models).toEqual({ - "openai/gpt-oss-120b:nitro": {}, + "openai/gpt-oss-120b:nitro": { headers: OPENROUTER_ATTRIBUTION_HEADERS }, }); expect(prepared.notes).toContain( "Registered configured model openrouter/openai/gpt-oss-120b:nitro in the runtime OpenCode config.", @@ -288,7 +310,9 @@ describe("prepareOpenCodeRuntimeConfig", () => { const runtimeConfig = JSON.parse( await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), ) as { provider?: Record }> }; - expect(runtimeConfig.provider?.openrouter?.models).toEqual(providers.openrouter.models); + expect(runtimeConfig.provider?.openrouter?.models).toEqual(Object.fromEntries( + Object.entries(providers.openrouter.models).map(([id, model]) => [id, { ...model, headers: OPENROUTER_ATTRIBUTION_HEADERS }]), + )); expect( prepared.notes.some((note) => note.startsWith("Registered configured model")), ).toBe(false); @@ -305,19 +329,20 @@ describe("prepareOpenCodeRuntimeConfig", () => { const runtimeConfig = JSON.parse( await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), ) as Record; - expect(runtimeConfig.provider).toBeUndefined(); + expect(runtimeConfig.provider).toMatchObject({ openrouter: { options: { headers: { "HTTP-Referer": "https://paperclip.ing" } } } }); await prepared.cleanup(); }); - it("respects explicit opt-out", async () => { + it("preserves permission settings while attributing OpenRouter when skip-permissions is off", async () => { const configHome = await makeConfigHome(); const prepared = await prepareOpenCodeRuntimeConfig({ env: { XDG_CONFIG_HOME: configHome }, config: { dangerouslySkipPermissions: false }, }); - expect(prepared.env).toEqual({ XDG_CONFIG_HOME: configHome }); - expect(prepared.notes).toEqual([]); + const config = JSON.parse(await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8")); + expect(config.permission).toBeUndefined(); + expect(config.provider.openrouter.options.headers["HTTP-Referer"]).toBe("https://paperclip.ing"); await prepared.cleanup(); }); }); diff --git a/packages/adapters/opencode-local/src/server/runtime-config.ts b/packages/adapters/opencode-local/src/server/runtime-config.ts index bb51b4a8dc1..d8fe0fb4602 100644 --- a/packages/adapters/opencode-local/src/server/runtime-config.ts +++ b/packages/adapters/opencode-local/src/server/runtime-config.ts @@ -1,3 +1,4 @@ +import { isOpenRouterUrl, openRouterAttributionHeaders } from "@paperclipai/adapter-utils/openrouter-attribution"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -108,13 +109,6 @@ export async function prepareOpenCodeRuntimeConfig(input: { targetIsRemote?: boolean; }): Promise { const skipPermissions = asBoolean(input.config.dangerouslySkipPermissions, true); - if (!skipPermissions) { - return { - env: input.env, - notes: [], - cleanup: async () => {}, - }; - } // For remote execution targets the host XDG_CONFIG_HOME path is meaningless // (and actively harmful — it leaks a macOS-only path into the remote Linux @@ -150,7 +144,7 @@ export async function prepareOpenCodeRuntimeConfig(input: { const existingConfig = await readJsonObject(runtimeConfigPath); const notes = [ - "Injected runtime OpenCode config with permission=allow for all tools and connections.", + skipPermissions ? "Injected runtime OpenCode config with permission=allow for all tools and connections." : "Prepared isolated OpenCode provider configuration.", ]; // Merge gateway/custom provider definitions supplied via PAPERCLIP_OPENCODE_PROVIDERS @@ -204,8 +198,20 @@ export async function prepareOpenCodeRuntimeConfig(input: { const nextConfig: Record = { ...existingConfig, - permission: "allow", + ...(skipPermissions ? { permission: "allow" } : {}), }; + const inlineContent = input.env.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT; + let inlineConfig: Record | undefined; + if (inlineContent) { + try { + const parsed: unknown = JSON.parse(inlineContent); + if (isPlainObject(parsed)) inlineConfig = parsed; + } catch { + // OpenCode must still report invalid operator configuration. + } + } + nextProvider = attributeOpenCodeProviders(mergeConfigObjects(nextProvider, + isPlainObject(inlineConfig?.provider) ? inlineConfig.provider : {})); if (Object.keys(nextProvider).length > 0) { nextConfig.provider = nextProvider; } @@ -227,6 +233,8 @@ export async function prepareOpenCodeRuntimeConfig(input: { env: { ...input.env, XDG_CONFIG_HOME: runtimeConfigHome, + ...(inlineContent ? { OPENCODE_CONFIG_CONTENT: inlineConfig + ? JSON.stringify({ ...inlineConfig, provider: nextProvider }) : inlineContent } : {}), }, notes, cleanup: async () => { @@ -254,3 +262,28 @@ export function prepareManagedOpenCodeRemoteHomes(input: { XDG_STATE_HOME: path.posix.join(home, "state"), }); } + +/** Native provider defaults and per-model headers can both carry harness attribution. */ +function attributeOpenCodeProviders(providers: Record): Record { + const result: Record = { openrouter: {}, ...providers }; + for (const [name, value] of Object.entries(result)) { + if (!isPlainObject(value)) continue; + const options = isPlainObject(value.options) ? value.options : {}; + if (!isOpenRouterUrl(options.baseURL) && !(name === "openrouter" && options.baseURL === undefined)) continue; + const headers = isPlainObject(options.headers) ? options.headers as Record : {}; + const models = isPlainObject(value.models) ? Object.fromEntries(Object.entries(value.models).map(([id, model]) => [id, + isPlainObject(model) ? { ...model, headers: openRouterAttributionHeaders(isPlainObject(model.headers) ? model.headers as Record : {}) } : model, + ])) : undefined; + result[name] = { ...value, options: { ...options, headers: openRouterAttributionHeaders(headers) }, ...(models ? { models } : {}) }; + } + return result; +} + +function mergeConfigObjects(base: Record, override: Record): Record { + const entries = Object.entries(override).map(([key, value]) => { + const previous = base[key]; + return [key, isPlainObject(previous) && isPlainObject(value) + ? mergeConfigObjects(previous, value) : value]; + }); + return { ...base, ...Object.fromEntries(entries) }; +} diff --git a/packages/adapters/pi-local/src/server/runtime-config.test.ts b/packages/adapters/pi-local/src/server/runtime-config.test.ts index cc1310486e1..95330ba84b3 100644 --- a/packages/adapters/pi-local/src/server/runtime-config.test.ts +++ b/packages/adapters/pi-local/src/server/runtime-config.test.ts @@ -22,6 +22,26 @@ async function readModelsJson(agentConfigDir: string): Promise { + it("attributes OpenRouter aliases and model overrides while preserving routing and other providers", async () => { + const other = { baseUrl: "https://other.example/v1", headers: { "X-Title": "Other" } }; + const prepared = await preparePiRuntimeConfig({ env: { PAPERCLIP_PI_PROVIDERS: JSON.stringify({ + router: { + baseUrl: "https://openrouter.ai/api/v1", apiKey: "fixture-key", + headers: { "http-referer": "https://pi.example", "X-Route": "selected" }, + modelOverrides: { model: { headers: { "x-title": "Pi" } } }, + }, other, openrouter: other, + }) } }); + cleanupPaths.add(prepared.env.PI_CODING_AGENT_DIR); + const { providers } = await readModelsJson(prepared.env.PI_CODING_AGENT_DIR) as { providers: Record }; + expect(providers.router).toMatchObject({ apiKey: "fixture-key", headers: { "HTTP-Referer": "https://paperclip.ing", "X-Route": "selected" } }); + expect(providers.router.headers).not.toHaveProperty("http-referer"); + expect(providers.router.modelOverrides.model.headers["X-Title"]).toBe("Paperclip"); + expect(providers.router.modelOverrides.model.headers).not.toHaveProperty("x-title"); + expect(providers.other).toEqual(other); + expect(providers.openrouter).toEqual(other); + await prepared.cleanup(); + }); + it("is a no-op when PAPERCLIP_PI_PROVIDERS is unset", async () => { const prepared = await preparePiRuntimeConfig({ env: { FOO: "bar" } }); diff --git a/packages/adapters/pi-local/src/server/runtime-config.ts b/packages/adapters/pi-local/src/server/runtime-config.ts index fce2bc0e43a..2954a4c67ca 100644 --- a/packages/adapters/pi-local/src/server/runtime-config.ts +++ b/packages/adapters/pi-local/src/server/runtime-config.ts @@ -1,3 +1,4 @@ +import { isOpenRouterUrl, openRouterAttributionHeaders } from "@paperclipai/adapter-utils/openrouter-attribution"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -121,6 +122,19 @@ export async function preparePiRuntimeConfig(input: { }; } + for (const [name, provider] of Object.entries(providers)) { + if (!isPlainObject(provider)) continue; + if (isOpenRouterUrl(provider.baseUrl) || (name === "openrouter" && provider.baseUrl === undefined)) { + provider.headers = openRouterAttributionHeaders(isPlainObject(provider.headers) ? provider.headers as Record : {}); + const models = [ + ...(Array.isArray(provider.models) ? provider.models : []), + ...(isPlainObject(provider.modelOverrides) ? Object.values(provider.modelOverrides) : []), + ]; + for (const model of models) { + if (isPlainObject(model)) model.headers = openRouterAttributionHeaders(isPlainObject(model.headers) ? model.headers as Record : {}); + } + } + } const agentConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-pi-agent-config-")); try { await fs.writeFile( diff --git a/packages/paperclip-runner/acpx-profiles.json b/packages/paperclip-runner/acpx-profiles.json index 2a96ddbdd3a..33106408346 100644 --- a/packages/paperclip-runner/acpx-profiles.json +++ b/packages/paperclip-runner/acpx-profiles.json @@ -14,12 +14,12 @@ "requiresProviderPolicy": false }, "pi": { - "agentProfileVersion": 23, + "agentProfileVersion": 24, "agentServerPackage": "pi-acp", "agentServerVersion": "0.0.33", "agentRuntimePackage": "@earendil-works/pi-coding-agent", "agentRuntimeVersion": "1.0.0", - "commandDigest": "sha256:18f0f91f179745db6f63aa0d2c710018b884759fa78440d7b0d3cd18b785b4d9", + "commandDigest": "sha256:347feab6eed4bc0f4ab099aae3292694e73d08e05148483ac612b0db958b0e39", "requiresProviderPolicy": true }, "cursor": { diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs index 297b02f65d1..dbe7b83da63 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/acpx_provider_backend.rs @@ -3184,7 +3184,7 @@ mod tests { #[test] fn pi_profile_matches_published_identity_and_rejects_prior_profiles() { let published: Value = serde_json::from_str(include_str!( - "../../../../test-fixtures/pi-acp/profile-v23-identity.json" + "../../../../test-fixtures/pi-acp/profile-v24-identity.json" )) .unwrap(); let mut value = descriptor("codex"); @@ -3208,6 +3208,8 @@ mod tests { include_str!("../../../../test-fixtures/pi-acp/profile-v19-identity.json"), include_str!("../../../../test-fixtures/pi-acp/profile-v20-identity.json"), include_str!("../../../../test-fixtures/pi-acp/profile-v21-identity.json"), + include_str!("../../../../test-fixtures/pi-acp/profile-v22-identity.json"), + include_str!("../../../../test-fixtures/pi-acp/profile-v23-identity.json"), ] { let prior: Value = serde_json::from_str(prior).unwrap(); value["commandDigest"] = prior["commandDigest"].clone(); diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs index d81555e1818..0e9901daa6f 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/generated_acpx_profiles.rs @@ -29,7 +29,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option { agent_runtime_package: Some("@earendil-works/pi-coding-agent"), agent_runtime_version: Some("1.0.0"), command_digest: - "sha256:18f0f91f179745db6f63aa0d2c710018b884759fa78440d7b0d3cd18b785b4d9", + "sha256:347feab6eed4bc0f4ab099aae3292694e73d08e05148483ac612b0db958b0e39", requires_provider_policy: true, }, "cursor" => AcpxReleaseProfile { diff --git a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs index 854b1a73851..27ecdd61295 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/tests/native_provider_backend.rs @@ -17,7 +17,7 @@ use sha2::{Digest, Sha256}; const CODEX_ACPX_DIGEST: &str = "sha256:443491f4e814501a5dc47347e4510e84faeec9c9382e2d4e53bfe69386d5aabc"; const PI_ACPX_DIGEST: &str = - "sha256:18f0f91f179745db6f63aa0d2c710018b884759fa78440d7b0d3cd18b785b4d9"; + "sha256:347feab6eed4bc0f4ab099aae3292694e73d08e05148483ac612b0db958b0e39"; fn temporary_directory(label: &str) -> PathBuf { let nonce = SystemTime::now() diff --git a/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs b/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs index 0afb072cdce..2fd65420e57 100644 --- a/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs +++ b/packages/paperclip-runner/scripts/generate-acpx-profiles.mjs @@ -39,7 +39,7 @@ const { commandDigest, ...attestation } = contract; assert.equal(commandDigest, `sha256:${createHash("sha256").update(canonicalJson(attestation)).digest("hex")}`); assert.equal(contract.acpxPatchSha256, createHash("sha256") .update(await readFile(new URL("../../patches/acpx@0.13.1.patch", root))).digest("hex")); -for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v23-identity.json"], ["copilot", "test/fixtures/copilot-profile-v21-identity.json"]]) { +for (const [agent, path] of [["pi", "test-fixtures/pi-acp/profile-v24-identity.json"], ["copilot", "test/fixtures/copilot-profile-v21-identity.json"]]) { const identity = await readJson(path); assert.equal(manifest.profiles[agent].agentProfileVersion, identity.declaration.agentProfileVersion); assert.equal(manifest.profiles[agent].commandDigest, identity.commandDigest); diff --git a/packages/paperclip-runner/src/drivers/acpx/environment.ts b/packages/paperclip-runner/src/drivers/acpx/environment.ts index e6351a19725..bab0d645904 100644 --- a/packages/paperclip-runner/src/drivers/acpx/environment.ts +++ b/packages/paperclip-runner/src/drivers/acpx/environment.ts @@ -1,3 +1,4 @@ +import { isOpenRouterUrl, openRouterClaudeHeaders } from "../openrouter-attribution.js"; import { CONFIGURED_ENVIRONMENT_KEYS, configuredEnvironment, configuredEnvironmentKeys } from "../../configured-environment.js"; import { PI_CREDENTIAL_NAMES, PI_GENERAL_IAM_CREDENTIAL_NAMES, piCredentialNames } from "./pi-provider-config.js"; import { PRECONFIGURED_HOME_KEYS, usesPreconfiguredHome } from "../preconfigured-home.js"; @@ -12,7 +13,7 @@ export const ACPX_CREDENTIAL_NAMES: Readonly { expect(current.commandDigest).not.toBe(prior.commandDigest); }); + it("preserves profile 23 and binds OpenRouter attribution without changing Pi runtime bytes", async () => { + const read = async (version: number) => JSON.parse(await readFile(new URL(`../../../test-fixtures/pi-acp/profile-v${version}-identity.json`, import.meta.url), "utf8")); + const prior = await read(23), current = await read(24); + expect(Object.keys(current.declaration).filter(key => JSON.stringify(current.declaration[key]) !== JSON.stringify(prior.declaration[key])).sort()) + .toEqual(["agentProfileVersion", "credentialEnvironmentSourceSha256", "openRouterAttributionSourceSha256", "providerConfigurationSourceSha256"]); + expect(current.commandDigest).not.toBe(prior.commandDigest); + }); + it("binds the profile declaration to the reviewed patch and platform closure pins", async () => { const hash = (bytes: string | Uint8Array) => createHash("sha256").update(bytes).digest("hex"); const canonical = (value: any): string => Array.isArray(value) ? `[${value.map(canonical).join(",")}]` : value && typeof value === "object" ? `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical(value[key])}`).join(",")}}` : JSON.stringify(value); - const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v23-identity.json", import.meta.url), "utf8")); + const identity = JSON.parse(await readFile(new URL("../../../test-fixtures/pi-acp/profile-v24-identity.json", import.meta.url), "utf8")); const declaration = identity.declaration; expect(declaration.sharedRuntimeContract).toBe("paperclip.acpx-runtime-contract.v1"); expect(identity.commandDigest).toBe(`sha256:${hash(canonical(declaration))}`); @@ -125,7 +133,7 @@ describe("Pi installation factory", () => { expect(declaration.closure).toEqual(PI_DISTRIBUTION_CLOSURE_SHA256); expect(hash(await readFile(new URL("../../../../../patches/brace-expansion@5.0.9.patch", import.meta.url)))).toBe(declaration.dependencySecurityPatchSha256); expect(declaration.nodeVersion).toBe(PI_NODE_VERSION); - for (const [path, field] of [["pi-provider-config.ts", "providerConfigurationSourceSha256"], ["environment.ts", "credentialEnvironmentSourceSha256"], ["runtime-sandbox.ts", "runtimeSandboxSourceSha256"], ["recovery-identity.ts", "recoveryIdentitySourceSha256"]]) { + for (const [path, field] of [["../openrouter-attribution.ts", "openRouterAttributionSourceSha256"], ["pi-provider-config.ts", "providerConfigurationSourceSha256"], ["environment.ts", "credentialEnvironmentSourceSha256"], ["runtime-sandbox.ts", "runtimeSandboxSourceSha256"], ["recovery-identity.ts", "recoveryIdentitySourceSha256"]]) { expect(hash(await readFile(new URL(`./${path}`, import.meta.url)))).toBe(declaration[field!]); } expect(hash(await readFile(new URL("../../../../../patches/acpx@0.13.1.patch", import.meta.url)))).toBe(declaration.acpxPatchSha256); @@ -145,21 +153,21 @@ describe("Pi installation factory", () => { it("rejects legacy profiles and tampered executable identities", () => { expect(() => assertPiInstallationProfile(candidate())).not.toThrow(); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 23"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 23"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 23"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 23"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 23"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 23"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 23"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 23"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 23"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 23"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 23"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 12 })).toThrow("version 23"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 13 })).toThrow("version 23"); - expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 14 })).toThrow("version 23"); - for (const priorVersion of [15, 16, 17, 18, 19, 20, 21, 22]) expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: priorVersion })).toThrow("version 23"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 1 })).toThrow("version 24"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 2 })).toThrow("version 24"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 3 })).toThrow("version 24"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 4 })).toThrow("version 24"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 5 })).toThrow("version 24"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 6 })).toThrow("version 24"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 7 })).toThrow("version 24"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 8 })).toThrow("version 24"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 9 })).toThrow("version 24"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 10 })).toThrow("version 24"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 11 })).toThrow("version 24"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 12 })).toThrow("version 24"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 13 })).toThrow("version 24"); + expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: 14 })).toThrow("version 24"); + for (const priorVersion of [15, 16, 17, 18, 19, 20, 21, 22, 23]) expect(() => assertPiInstallationProfile({ ...candidate(), agentProfileVersion: priorVersion })).toThrow("version 24"); for (const changed of [{ agentServerVersion: "latest" }, { commandDigest: `sha256:${"0".repeat(64)}` }, { agentRuntimePackage: "ambient-pi" }]) { expect(() => assertPiInstallationProfile({ ...candidate(), ...changed })).toThrow("trusted declaration"); } diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-provider-config.test.ts b/packages/paperclip-runner/src/drivers/acpx/pi-provider-config.test.ts index 7f4fa25b501..37e28d1876b 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-provider-config.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-provider-config.test.ts @@ -69,3 +69,32 @@ describe("Pi caller-selected providers and models", () => { } finally { await rm(root, { recursive: true, force: true }); } }); }); + + +it("attributes Pi's default and custom OpenRouter providers including model overrides", () => { + const defaults = JSON.parse(piProviderConfiguration({})!.json); + expect(defaults.providers.openrouter.headers["HTTP-Referer"]).toBe("https://paperclip.ing"); + expect(piProviderConfiguration({ PAPERCLIP_PI_PROVIDERS: " " })?.json).toBe(piProviderConfiguration({})?.json); + const custom = JSON.parse(piProviderConfiguration({ PAPERCLIP_PI_PROVIDERS: JSON.stringify({ + openrouter: { headers: { "http-referer": "https://pi.example", Authorization: "Bearer fixture" }, models: [{ id: "model", headers: { "X-Title": "Pi" } }], modelOverrides: { other: { headers: { "http-referer": "https://pi.example", "X-Route": "chosen" } } } }, + alias: { baseUrl: "https://openrouter.ai/api/v1", apiKey: "CUSTOM_MODEL_KEY" }, + unrelated: { baseUrl: "https://other.example/v1", headers: { "X-Title": "Other" } }, + }) })!.json); + expect(custom.providers.openrouter.headers).toMatchObject({ "HTTP-Referer": "https://paperclip.ing", Authorization: "Bearer fixture" }); + expect(custom.providers.openrouter.headers).not.toHaveProperty("http-referer"); + expect(custom.providers.openrouter.models[0].headers["X-Title"]).toBe("Paperclip"); + expect(custom.providers.openrouter.modelOverrides.other.headers).toMatchObject({ "HTTP-Referer": "https://paperclip.ing", "X-Route": "chosen" }); + expect(custom.providers.openrouter.modelOverrides.other.headers).not.toHaveProperty("http-referer"); + expect(custom.providers.alias.headers["HTTP-Referer"]).toBe("https://paperclip.ing"); + expect(custom.providers.unrelated.headers).toEqual({ "X-Title": "Other" }); + const repointed = JSON.parse(piProviderConfiguration({ PAPERCLIP_PI_PROVIDERS: JSON.stringify({ openrouter: { baseUrl: "https://other.example/v1" } }) })!.json); + expect(repointed.providers.openrouter.headers).toBeUndefined(); +}); + +it("preserves Claude attribution through the runner environment allowlist", () => { + const result = createSanitizedAcpxSpawnInput({ ANTHROPIC_BASE_URL: "https://openrouter.ai/api", ANTHROPIC_AUTH_TOKEN: "fixture-key", ANTHROPIC_CUSTOM_HEADERS: "http-referer: https://claude.example\nX-Route: selected" }, "claude").env; + expect(result.ANTHROPIC_CUSTOM_HEADERS).toContain("HTTP-Referer: https://paperclip.ing"); + expect(result.ANTHROPIC_CUSTOM_HEADERS).toContain("X-Route: selected"); + expect(result.ANTHROPIC_AUTH_TOKEN).toBe("fixture-key"); + expect(createSanitizedAcpxSpawnInput({ ANTHROPIC_BASE_URL: "https://api.anthropic.com" }, "claude").env.ANTHROPIC_CUSTOM_HEADERS).toBeUndefined(); +}); diff --git a/packages/paperclip-runner/src/drivers/acpx/pi-provider-config.ts b/packages/paperclip-runner/src/drivers/acpx/pi-provider-config.ts index eca68f7de3b..195679daac9 100644 --- a/packages/paperclip-runner/src/drivers/acpx/pi-provider-config.ts +++ b/packages/paperclip-runner/src/drivers/acpx/pi-provider-config.ts @@ -1,3 +1,4 @@ +import { isOpenRouterUrl, openRouterAttributionHeaders } from "../openrouter-attribution.js"; import { createHash } from "node:crypto"; // Credential discovery follows the pinned Pi SDK. This is not a model catalog: @@ -70,8 +71,7 @@ const RESERVED_PI_CREDENTIAL_NAMES = new Set([ export function piProviderConfiguration(environment?: NodeJS.ProcessEnv): { json: string; digest: string; credentialNames: readonly string[]; } | undefined { - const raw = environment?.PAPERCLIP_PI_PROVIDERS; - if (raw === undefined) return undefined; + const raw = environment?.PAPERCLIP_PI_PROVIDERS?.trim() || "{}"; const invalid = () => new Error("Pi custom providers require a bounded JSON object with explicit credentials; command-based credentials are unsupported"); if (Buffer.byteLength(raw) > 64 * 1024) throw invalid(); let providers: unknown; @@ -102,6 +102,24 @@ export function piProviderConfiguration(environment?: NodeJS.ProcessEnv): { inspect(provider); } if (names.size > 64) throw invalid(); + const configured = providers as Record>; + configured.openrouter ??= {}; + for (const [name, provider] of Object.entries(configured)) { + if (isOpenRouterUrl(provider.baseUrl) || (name === "openrouter" && provider.baseUrl === undefined)) { + provider.headers = openRouterAttributionHeaders(provider.headers as Record | undefined); + // Per-model headers take precedence over provider defaults in Pi. + const modelOverrides = provider.modelOverrides; + const models = [ + ...(Array.isArray(provider.models) ? provider.models : []), + ...(modelOverrides && typeof modelOverrides === "object" && !Array.isArray(modelOverrides) ? Object.values(modelOverrides) : []), + ]; + for (const model of models) { + if (model && typeof model === "object" && !Array.isArray(model)) { + model.headers = openRouterAttributionHeaders(model.headers); + } + } + } + } const canonical = (value: unknown): string => value && typeof value === "object" ? Array.isArray(value) ? `[${value.map(canonical).join(",")}]` : `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonical((value as Record)[key])}`).join(",")}}` diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.test.ts b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.test.ts index 8f048e742aa..19b31d58f7e 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.test.ts @@ -7,6 +7,7 @@ describe("historical ACPX profile decoding", () => { expect(isSupportedAcpxProfileVersion("copilot", 17)).toBe(true); expect(isSupportedAcpxProfileVersion("pi", 17)).toBe(true); expect(isSupportedAcpxProfileVersion("pi", 18)).toBe(true); + expect(isSupportedAcpxProfileVersion("pi", 23)).toBe(true); expect(isSupportedAcpxProfileVersion("cursor", 15)).toBe(true); expect(isSupportedAcpxProfileVersion("cursor", 16)).toBe(false); for (const agent of ["pi", "copilot"]) { diff --git a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts index 9277ef267e7..5a4fec39b89 100644 --- a/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts +++ b/packages/paperclip-runner/src/drivers/acpx/profile-compatibility.ts @@ -2,12 +2,12 @@ import { QUALIFIED_ACPX_PROFILE_DATA } from "./generated-profiles.js"; /** Decodable historical profile revisions, not permission to launch them. * Exact current profile/package/digest admission is checked separately. */ -type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 22; +type HistoricalAcpxProfileVersion = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 22 | 23; type CurrentAcpxProfileVersion = typeof QUALIFIED_ACPX_PROFILE_DATA[keyof typeof QUALIFIED_ACPX_PROFILE_DATA]["agentProfileVersion"]; export type AcpxProfileVersion = HistoricalAcpxProfileVersion | CurrentAcpxProfileVersion; const historicalVersions: Readonly> = { - pi: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 22], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5], + pi: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 22, 23], claude: [1, 2, 3, 4, 5], codex: [1, 2, 3, 4, 5], grok: [1, 2, 3, 4, 5], copilot: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20], cursor: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15], }; diff --git a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts index fbd9cae0023..75e0b014beb 100644 --- a/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts +++ b/packages/paperclip-runner/src/drivers/acpx/recovery-identity.test.ts @@ -32,6 +32,7 @@ describe("ACPX recovery identity", () => { ["pi", "../../../test-fixtures/pi-acp/profile-v13-identity.json"], ["pi", "../../../test-fixtures/pi-acp/profile-v14-identity.json"], ["pi", "../../../test-fixtures/pi-acp/profile-v22-identity.json"], + ["pi", "../../../test-fixtures/pi-acp/profile-v23-identity.json"], ["cursor", "../../../test/fixtures/cursor-acp/profile-v7-identity.json"], ["cursor", "../../../test/fixtures/cursor-acp/profile-v8-identity.json"], ["cursor", "../../../test/fixtures/cursor-acp/profile-v9-identity.json"], diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts index b0fb6e21f3f..06381b79b9b 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.test.ts @@ -1993,6 +1993,9 @@ describe("OpenCodeServerDriver", () => { external_directory: { "*": "deny", [`${workspace}/**`]: "allow" }, }); expect(config.autoupdate).toBe(false); + expect(config.provider.openrouter.options.headers).toMatchObject({ + "HTTP-Referer": "https://paperclip.ing", "X-Title": "Paperclip", "X-OpenRouter-Title": "Paperclip", + }); expect(config.provider.openrouter.models).toHaveProperty( "deepseek/deepseek-v4-flash-0731", ); diff --git a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts index 99b575e48a5..8e58c6543e4 100644 --- a/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts +++ b/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts @@ -1,3 +1,4 @@ +import { isOpenRouterUrl, OPENROUTER_ATTRIBUTION_HEADERS } from "../openrouter-attribution.js"; import { spawn, type ChildProcess } from "node:child_process"; import { resolveCompletionContinuation } from "../completion-continuation.js"; import { createHash, randomBytes } from "node:crypto"; @@ -2172,7 +2173,7 @@ async function startOpenCodeProviderProxy(baseUrl: string, key: string, model: s const outgoing = (upstreamUrl.protocol === "https:" ? requestHttps : requestHttp)(upstreamUrl, { method: "POST", agent, - headers: { "Content-Type": "application/json", "Content-Length": body.length, ...(key ? { Authorization: `Bearer ${key}` } : {}) }, + headers: { ...(isOpenRouterUrl(baseUrl) ? OPENROUTER_ATTRIBUTION_HEADERS : {}), "Content-Type": "application/json", "Content-Length": body.length, ...(key ? { Authorization: `Bearer ${key}` } : {}) }, signal: AbortSignal.any([controller.signal, AbortSignal.timeout(600_000)]), }, resolve); outgoing.once("error", reject); @@ -2334,6 +2335,7 @@ async function startRuntime(input: { // provider instead of silently falling back or rejecting a newer model. provider: { [modelProvider!]: { + ...(modelProvider === "openrouter" ? { options: { headers: OPENROUTER_ATTRIBUTION_HEADERS } } : {}), ...(providerProxy ? { npm: "@ai-sdk/openai-compatible", name: "Paperclip connection", diff --git a/packages/paperclip-runner/src/drivers/openrouter-attribution.ts b/packages/paperclip-runner/src/drivers/openrouter-attribution.ts new file mode 100644 index 00000000000..2a89a2fa5ff --- /dev/null +++ b/packages/paperclip-runner/src/drivers/openrouter-attribution.ts @@ -0,0 +1,41 @@ +// Kept in sync with shared/src/openrouter-attribution.ts by the server attribution test. +// Runner is independently published and cannot depend on the control-plane packages. +/** Public product identity only: never include company, user, run, or credential data. */ +export const OPENROUTER_ATTRIBUTION_HEADERS = Object.freeze({ + "HTTP-Referer": "https://paperclip.ing", + "X-Title": "Paperclip", + "X-OpenRouter-Title": "Paperclip", + "X-OpenRouter-Categories": "cloud-agent,programming-app", +}); + +export function isOpenRouterUrl(value: unknown): boolean { + if (typeof value !== "string") return false; + 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; + } +} + +/** Replace attribution case-insensitively while preserving authentication and routing. */ +export function openRouterAttributionHeaders(headers: Record = {}): Record { + const replaced = new Set(Object.keys(OPENROUTER_ATTRIBUTION_HEADERS).map(key => key.toLowerCase())); + return { + ...Object.fromEntries(Object.entries(headers).filter(([key]) => !replaced.has(key.toLowerCase()))), + ...OPENROUTER_ATTRIBUTION_HEADERS, + }; +} + +export function openRouterClaudeHeaders(existing = ""): string { + const headers: Record = {}; + for (const line of existing.split(/\r?\n/)) { + const colon = line.indexOf(":"); + if (colon > 0) headers[line.slice(0, colon).trim()] = line.slice(colon + 1).trim(); + } + return Object.entries(openRouterAttributionHeaders(headers)).map(([name, value]) => `${name}: ${value}`).join("\n"); +} + +export function openRouterCodexHeaders(): string { + return `http_headers = { ${Object.entries(OPENROUTER_ATTRIBUTION_HEADERS).map(([key, value]) => `${JSON.stringify(key)} = ${JSON.stringify(value)}`).join(", ")} }\n`; +} diff --git a/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts b/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts index 062482ecad5..5baf82f00cb 100644 --- a/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts +++ b/packages/paperclip-runner/src/drivers/runtime-context-materializer.test.ts @@ -190,6 +190,8 @@ command = "untrusted-command" expect(config).toContain('model_provider = "paperclip"'); expect(config).toContain('base_url = "https://openrouter.ai/api/v1"'); expect(config).toContain('env_key = "PAPERCLIP_AI_PROVIDER_KEY"'); + expect(config).toContain('"HTTP-Referer" = "https://paperclip.ing"'); + expect(config).toContain('"X-OpenRouter-Title" = "Paperclip"'); expect(config).toContain("shell_snapshot = false"); expect(config).not.toMatch(/unrelated|untrusted|unassigned/); await expect(stat(join(target, "auth.json"))).rejects.toThrow(); diff --git a/packages/paperclip-runner/src/drivers/runtime-context-materializer.ts b/packages/paperclip-runner/src/drivers/runtime-context-materializer.ts index 973da0bf674..6832996fef5 100644 --- a/packages/paperclip-runner/src/drivers/runtime-context-materializer.ts +++ b/packages/paperclip-runner/src/drivers/runtime-context-materializer.ts @@ -1,3 +1,4 @@ +import { isOpenRouterUrl, openRouterCodexHeaders } from "./openrouter-attribution.js"; import { randomUUID } from "node:crypto"; import { constants } from "node:fs"; import { @@ -327,7 +328,7 @@ async function managedCodexProviderConfig(sourceHome?: string | null): Promise { + it.each(["https://openrouter.ai/api", "https://openrouter.ai/api/v1", "https://OPENROUTER.ai/api/alpha/decisions", "https://eu.openrouter.ai/api/v1", "https://us.openrouter.ai/api/v1"])("recognizes %s", url => { + expect(isOpenRouterUrl(url)).toBe(true); + }); + it.each([undefined, "", "openrouter.ai", "http://openrouter.ai/api", "https://openrouter.ai.evil.test", "https://unknown.openrouter.ai/api/v1", "https://evil.test/openrouter.ai", "https://openrouter.ai@evil.test", "https://user:secret@openrouter.ai/api", "https://api.anthropic.com"])("does not attribute other or malformed destinations: %s", url => { + expect(isOpenRouterUrl(url)).toBe(false); + }); + it("replaces mixed-case harness identity without changing credentials, routing, or privacy preferences", () => { + const existing = { "http-referer": "https://harness.example", "x-TITLE": "Harness", "X-openrouter-title": "Harness", Authorization: "Bearer fixture", "X-Route": "custom", "X-OpenRouter-App-Visibility": "hidden" }; + const result = openRouterAttributionHeaders(existing); + expect(new Headers(result).get("http-referer")).toBe("https://paperclip.ing"); + expect(new Headers(result).get("x-title")).toBe("Paperclip"); + expect(new Headers(result).get("x-openrouter-title")).toBe("Paperclip"); + expect(result).toMatchObject({ Authorization: "Bearer fixture", "X-Route": "custom", "X-OpenRouter-App-Visibility": "hidden" }); + expect(existing["http-referer"]).toBe("https://harness.example"); + }); + it("merges Claude's newline format without splitting colon-bearing values", () => { + const result = openRouterClaudeHeaders("http-referer: https://harness.example\r\nAuthorization: Bearer fixture\nX-Route: https://gateway.example:443/path"); + expect(result).toContain("HTTP-Referer: https://paperclip.ing"); + expect(result).not.toContain("harness.example"); + expect(result).toContain("Authorization: Bearer fixture"); + expect(result).toContain("X-Route: https://gateway.example:443/path"); + }); +}); diff --git a/packages/shared/src/openrouter-attribution.ts b/packages/shared/src/openrouter-attribution.ts new file mode 100644 index 00000000000..b77e887721c --- /dev/null +++ b/packages/shared/src/openrouter-attribution.ts @@ -0,0 +1,39 @@ +/** Public product identity only: never include company, user, run, or credential data. */ +export const OPENROUTER_ATTRIBUTION_HEADERS = Object.freeze({ + "HTTP-Referer": "https://paperclip.ing", + "X-Title": "Paperclip", + "X-OpenRouter-Title": "Paperclip", + "X-OpenRouter-Categories": "cloud-agent,programming-app", +}); + +export function isOpenRouterUrl(value: unknown): boolean { + if (typeof value !== "string") return false; + 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; + } +} + +/** Replace attribution case-insensitively while preserving authentication and routing. */ +export function openRouterAttributionHeaders(headers: Record = {}): Record { + const replaced = new Set(Object.keys(OPENROUTER_ATTRIBUTION_HEADERS).map(key => key.toLowerCase())); + return { + ...Object.fromEntries(Object.entries(headers).filter(([key]) => !replaced.has(key.toLowerCase()))), + ...OPENROUTER_ATTRIBUTION_HEADERS, + }; +} + +export function openRouterClaudeHeaders(existing = ""): string { + const headers: Record = {}; + for (const line of existing.split(/\r?\n/)) { + const colon = line.indexOf(":"); + if (colon > 0) headers[line.slice(0, colon).trim()] = line.slice(colon + 1).trim(); + } + return Object.entries(openRouterAttributionHeaders(headers)).map(([name, value]) => `${name}: ${value}`).join("\n"); +} + +export function openRouterCodexHeaders(): string { + return `http_headers = { ${Object.entries(OPENROUTER_ATTRIBUTION_HEADERS).map(([key, value]) => `${JSON.stringify(key)} = ${JSON.stringify(value)}`).join(", ")} }\n`; +} diff --git a/scripts/pi-public-install-probe.mjs b/scripts/pi-public-install-probe.mjs index acc101b013e..91d1c416f89 100644 --- a/scripts/pi-public-install-probe.mjs +++ b/scripts/pi-public-install-probe.mjs @@ -52,8 +52,8 @@ assert.equal(JSON.parse(await readFile(join(server, 'package.json'), 'utf8')).na const { resolvePaperclipRunnerBinary } = await import(pathToFileURL(join(server, 'dist/services/native-runtime/native-codex-runner.js'))); const { createCapabilityRunnerdCodexTransport } = await import(pathToFileURL(join(server, 'dist/vendor/paperclip-runner/live/runnerd-codex-transport.js'))); const { QUALIFIED_ACPX_PROFILES } = await import(pathToFileURL(join(server, 'dist/vendor/paperclip-runner/drivers/acpx/qualified-profiles.js'))); -assert.equal(QUALIFIED_ACPX_PROFILES.pi.agentProfileVersion, 23); -assert.equal(QUALIFIED_ACPX_PROFILES.pi.commandDigest, 'sha256:18f0f91f179745db6f63aa0d2c710018b884759fa78440d7b0d3cd18b785b4d9'); +assert.equal(QUALIFIED_ACPX_PROFILES.pi.agentProfileVersion, 24); +assert.equal(QUALIFIED_ACPX_PROFILES.pi.commandDigest, 'sha256:347feab6eed4bc0f4ab099aae3292694e73d08e05148483ac612b0db958b0e39'); assert.equal(Object.hasOwn(QUALIFIED_ACPX_PROFILES.pi, 'qualificationModel'), false); assert.equal(Object.hasOwn(QUALIFIED_ACPX_PROFILES.pi, 'reportedModelId'), false); const daemon = resolvePaperclipRunnerBinary(); @@ -97,7 +97,7 @@ try { assert.equal(state.activeTurnId, null); assert.equal(state.identity, null); assert.equal(state.providerExitUnconfirmed, false); - console.log(JSON.stringify({ schema: 'paperclip.pi.public-npm-install.v1', target: `${process.platform}-${process.arch}`, normalPackagedDaemon: true, exactPiProfile: 23, runtime: '1.0.0', credentials: 'none', promptCalls: 0, settledMs, cleanRunnerExit: true })); + console.log(JSON.stringify({ schema: 'paperclip.pi.public-npm-install.v1', target: `${process.platform}-${process.arch}`, normalPackagedDaemon: true, exactPiProfile: 24, runtime: '1.0.0', credentials: 'none', promptCalls: 0, settledMs, cleanRunnerExit: true })); } finally { try { await bundle?.transport.close(); } finally { clearTimeout(watchdog); await rm(root, { recursive: true, force: true }); } diff --git a/server/src/__tests__/ai-connections.test.ts b/server/src/__tests__/ai-connections.test.ts index 5856fa52b36..f543759e2dd 100644 --- a/server/src/__tests__/ai-connections.test.ts +++ b/server/src/__tests__/ai-connections.test.ts @@ -34,6 +34,7 @@ import { toolAccessService } from "../services/tool-access.js"; import { secretService } from "../services/secrets.js"; import { resolveExecutionRunAdapterConfig } from "../services/heartbeat.js"; import { aiConnectionBindingSchema, connectionPurposeTransportSchema, isAiConnectionCompatible } from "@paperclipai/shared"; +import { openRouterClaudeHeaders } from "@paperclipai/shared/openrouter-attribution"; import express from "express"; import request from "supertest"; import { aiConnectionRoutes, canInstallSharedAiConnectionForNewAgent, responsibleUserForAiRequest } from "../routes/ai-connections.js"; @@ -967,6 +968,35 @@ console.log(JSON.stringify({ type: "result", subtype: "success", result: "hello" await db.update(connectionGrants).set({ status: "revoked" }).where(eq(connectionGrants.id, saved.grantId)); await expect(service.select({ ...input, binding: selected, userId: "alice", adapterType: "codex_local", model: "openai/gpt-5.4" })).rejects.toThrow("Reconnect"); }); + it.each(["openrouter", "gateway"] as const)("preserves Claude custom headers for managed %s connections", async (kind) => { + const provider = kind === "openrouter" ? "openrouter" : "anthropic"; + const routing = { kind, protocol: "messages", auth: "bearer", ...(kind === "gateway" ? { baseUrl: "https://gateway.example/v1" } : {}), models: [] } as const; + const saved = await service.save(companyId, "alice", { provider, method: "api_key", name: `Headers ${kind}`, ownership: "personal", allAgents: true, agentIds: [], routing: { ...routing, models: [] } }, "fixture-credential"); + const selected = { provider, method: "api_key", mode: "delegated", ...saved } as const; + const customHeaders = "X-Gateway-Route: account-route\nx-title: Previous harness"; + const reference = { type: "secret_ref", secretId: randomUUID(), version: "latest" }; + const inherited = process.env.ANTHROPIC_CUSTOM_HEADERS; + process.env.ANTHROPIC_CUSTOM_HEADERS = "X-Gateway-Route: inherited-route"; + try { + for (const adapterType of ["claude_local", "paperclip_runner"]) { + for (const configured of [customHeaders, reference, undefined]) { + const runtime = await prepareManagedAiRuntime(db, { companyId, agentId, responsibleUserId: "alice", binding: selected, adapterType, + config: { provider: "acpx", acpxAgent: "claude", env: configured === undefined ? {} : { ANTHROPIC_CUSTOM_HEADERS: configured } } }); + try { + const effective = configured ?? process.env.ANTHROPIC_CUSTOM_HEADERS; + const expected = kind === "openrouter" && typeof effective === "string" + ? openRouterClaudeHeaders(effective) + : configured; + expect(runtime.config.env.ANTHROPIC_CUSTOM_HEADERS).toEqual(expected); + } finally { await runtime.cleanup(); } + } + } + } finally { + if (inherited === undefined) delete process.env.ANTHROPIC_CUSTOM_HEADERS; + else process.env.ANTHROPIC_CUSTOM_HEADERS = inherited; + } + }); + it("reconnects JSONB routing without changing its identity or access", async () => { const routing = { kind: "gateway", protocol: "responses", auth: "bearer", baseUrl: "https://gateway.example/v1", models: [{ id: "gateway-model" }] } as const; const input = { provider: "openai", method: "api_key", name: "Reconnect gateway", ownership: "personal", allAgents: false, agentIds: [agentId], routing: { ...routing, models: [...routing.models] } } as const; diff --git a/server/src/__tests__/decision-model-provider.test.ts b/server/src/__tests__/decision-model-provider.test.ts index d7cd89482c2..40689aaa89d 100644 --- a/server/src/__tests__/decision-model-provider.test.ts +++ b/server/src/__tests__/decision-model-provider.test.ts @@ -23,6 +23,8 @@ describe("decision provider contract", () => { const [url, options] = transport.mock.calls[0]; expect(String(url)).toBe(provider === "openai" ? "https://api.openai.com/v1/decisions" : "https://openrouter.ai/api/alpha/decisions"); expect(new Headers(options?.headers).get("authorization")).toBe("Bearer selected-credential"); + expect(new Headers(options?.headers).get("http-referer")).toBe(provider === "openrouter" ? "https://paperclip.ing" : null); + expect(new Headers(options?.headers).get("x-openrouter-title")).toBe(provider === "openrouter" ? "Paperclip" : null); expect(result.answers).toMatchObject({ billing: { type: "boolean", probability: 0.99 }, team: { type: "choice", choice: "billing", confidence: provider === "openai" ? 0.9 : 0.85 }, severity: { type: "score", score: 0.1 } }); expect(result.receipt.costCents).toBe(provider === "openai" ? "0.0012300" : "0.0005166"); expect(JSON.stringify(result.receipt)).not.toContain("customer"); diff --git a/server/src/__tests__/fast-response-provider.test.ts b/server/src/__tests__/fast-response-provider.test.ts index 88041f499c6..d43b389c240 100644 --- a/server/src/__tests__/fast-response-provider.test.ts +++ b/server/src/__tests__/fast-response-provider.test.ts @@ -131,6 +131,7 @@ describe("fast response provider", () => { }); const [url, init] = fetch.mock.calls[0] as unknown as [string, RequestInit]; expect(url).toBe("https://openrouter.ai/api/v1/chat/completions"); + expectAttribution(url, init); const body = JSON.parse(String(init.body)); expect(body).toMatchObject({ model: "test-model", @@ -185,6 +186,7 @@ describe("fast response provider", () => { RequestInit, ]; expect(url).toBe(expectedUrl); + expectAttribution(url, init); expect(new Headers(init.headers).get("authorization")).toBe(auth); }, ); @@ -461,3 +463,11 @@ describe("fast response provider", () => { }); }); }); + +function expectAttribution(url: string, init: RequestInit) { + const headers = new Headers(init.headers); + const expected = new URL(url).hostname === "openrouter.ai"; + expect(headers.get("http-referer")).toBe(expected ? "https://paperclip.ing" : null); + expect(headers.get("x-openrouter-title")).toBe(expected ? "Paperclip" : null); + expect(headers.get("x-openrouter-categories")).toBe(expected ? "cloud-agent,programming-app" : null); +} diff --git a/server/src/__tests__/openrouter-attribution.test.ts b/server/src/__tests__/openrouter-attribution.test.ts new file mode 100644 index 00000000000..5d1bee545b1 --- /dev/null +++ b/server/src/__tests__/openrouter-attribution.test.ts @@ -0,0 +1,29 @@ +import { readFile } from "node:fs/promises"; +import { describe, expect, it } from "vitest"; +import type { AiProviderRouting } from "@paperclipai/shared"; +import { OPENROUTER_ATTRIBUTION_HEADERS } from "@paperclipai/shared/openrouter-attribution"; +import { managedProviderRouting } from "../services/ai-provider-routing.js"; + +describe("Paperclip OpenRouter connection attribution", () => { + it("keeps the standalone Runner policy identical to the control-plane policy", async () => { + const shared = await readFile(new URL("../../../packages/shared/src/openrouter-attribution.ts", import.meta.url), "utf8"); + const runner = await readFile(new URL("../../../packages/paperclip-runner/src/drivers/openrouter-attribution.ts", import.meta.url), "utf8"); + expect(runner.slice(runner.indexOf("/**"))).toBe(shared); + }); + it.each(["codex_local", "claude_local", "opencode_local", "hermes_local"])("attributes managed and custom OpenRouter connections for %s", harness => { + for (const route of [ + { kind: "openrouter", protocol: "chat", auth: "bearer", models: [] }, + { kind: "gateway", protocol: "chat", auth: "bearer", models: [], baseUrl: "https://openrouter.ai/api/v1" }, + { kind: "gateway", protocol: "chat", auth: "bearer", models: [], baseUrl: "https://eu.openrouter.ai/api/v1" }, + { kind: "gateway", protocol: "chat", auth: "bearer", models: [], baseUrl: "https://us.openrouter.ai/api/v1" }, + ] satisfies AiProviderRouting[]) { + const result = managedProviderRouting(route, harness, "fixture-key", "vendor/model"); + const output = JSON.stringify(result); + for (const value of Object.values(OPENROUTER_ATTRIBUTION_HEADERS)) expect(output).toContain(value); + } + }); + it.each(["codex_local", "claude_local", "opencode_local", "hermes_local"])("does not add attribution to unrelated %s endpoints", harness => { + const result = managedProviderRouting({ kind: "gateway", protocol: "chat", auth: "bearer", models: [], baseUrl: "https://gateway.example/v1" }, harness, "fixture-key", "model"); + expect(JSON.stringify(result)).not.toMatch(/HTTP-Referer|X-Title|X-OpenRouter/); + }); +}); diff --git a/server/src/services/ai-connection-runtime.ts b/server/src/services/ai-connection-runtime.ts index 36e7704a47c..4dba2186c55 100644 --- a/server/src/services/ai-connection-runtime.ts +++ b/server/src/services/ai-connection-runtime.ts @@ -1,3 +1,4 @@ +import { OPENROUTER_ATTRIBUTION_HEADERS, openRouterClaudeHeaders } from "@paperclipai/shared/openrouter-attribution"; import { createHash } from "node:crypto"; import { HttpError, unprocessable } from "../errors.js"; import { mkdtemp, mkdir, writeFile, readFile, rm } from "node:fs/promises"; @@ -336,7 +337,7 @@ export async function prepareManagedAiRuntime( } if (!routing && input.binding.provider === "openrouter") { env.OPENCODE_CONFIG_CONTENT = JSON.stringify({ - provider: { openrouter: { options: { apiKey: value } } }, + provider: { openrouter: { options: { apiKey: value, headers: OPENROUTER_ATTRIBUTION_HEADERS } } }, }); env.OPENCODE_DISABLE_PROJECT_CONFIG = "true"; } @@ -352,7 +353,15 @@ export async function prepareManagedAiRuntime( } const projected = routing ? managedProviderRouting(routing, harness, value, typeof input.config.model === "string" ? input.config.model : "") : undefined; if (projected) { + const customHeaders = env.ANTHROPIC_CUSTOM_HEADERS ?? process.env.ANTHROPIC_CUSTOM_HEADERS; Object.assign(env, projected.env); + if (projected.env.ANTHROPIC_CUSTOM_HEADERS && customHeaders !== undefined) { + // Preserve secret references until adapter resolution; the adapter merges + // attribution after resolving them. Ordinary gateway headers survive too. + env.ANTHROPIC_CUSTOM_HEADERS = typeof customHeaders === "string" + ? openRouterClaudeHeaders(customHeaders) + : customHeaders; + } if (projected.hermesConfig) await writeFile(path.join(providerHome, "config.yaml"), projected.hermesConfig, { mode: 0o600 }); if (projected.codexConfig) await writeFile(path.join(providerHome, "config.toml"), 'cli_auth_credentials_store = "file"\n' + projected.codexConfig, { mode: 0o600 }); } diff --git a/server/src/services/ai-provider-routing.ts b/server/src/services/ai-provider-routing.ts index 33ceffb3366..22f86d6063b 100644 --- a/server/src/services/ai-provider-routing.ts +++ b/server/src/services/ai-provider-routing.ts @@ -1,3 +1,4 @@ +import { OPENROUTER_ATTRIBUTION_HEADERS, isOpenRouterUrl, openRouterClaudeHeaders, openRouterCodexHeaders } from "@paperclipai/shared/openrouter-attribution"; import { aiRoutingBaseUrl, aiRoutingModel, @@ -16,6 +17,7 @@ export function managedProviderRouting( let codexConfig = ""; let hermesConfig = ""; const baseUrl = aiRoutingBaseUrl(route, harness); + const openrouter = isOpenRouterUrl(baseUrl); if (route.kind === "bedrock") { env.CLAUDE_CODE_USE_BEDROCK = "1"; env.AWS_REGION = route.region!; @@ -25,8 +27,10 @@ export function managedProviderRouting( } else if (harness === "codex_local") { env.PAPERCLIP_AI_PROVIDER_KEY = credential; codexConfig = `model_provider = "paperclip"\n[model_providers.paperclip]\nname = "Paperclip connection"\nbase_url = ${JSON.stringify(baseUrl)}\nwire_api = "responses"\nrequires_openai_auth = false\n${route.auth === "none" ? "" : 'env_key = "PAPERCLIP_AI_PROVIDER_KEY"\n'}`; + if (openrouter) codexConfig += openRouterCodexHeaders(); } else if (harness === "claude_local") { env.ANTHROPIC_BASE_URL = baseUrl; + if (openrouter) env.ANTHROPIC_CUSTOM_HEADERS = openRouterClaudeHeaders(); env[ route.auth === "api_key" ? "ANTHROPIC_API_KEY" : "ANTHROPIC_AUTH_TOKEN" ] = credential; @@ -54,7 +58,7 @@ export function managedProviderRouting( ...(provider === "paperclip" ? { npm: "@ai-sdk/openai-compatible", name: "Paperclip connection" } : {}), - options: { baseURL: baseUrl, apiKey: credential }, + options: { baseURL: baseUrl, apiKey: credential, ...(openrouter ? { headers: OPENROUTER_ATTRIBUTION_HEADERS } : {}) }, models: id ? { [id]: { name: id } } : {}, }, }, @@ -72,5 +76,6 @@ export function managedProviderRouting( // would expose the reusable gateway key to task-controlled shell commands. hermesConfig = `model:\n provider: ${route.kind === "openrouter" ? '"openrouter"' : '"custom"'}\n default: ${JSON.stringify(model)}\n base_url: ${JSON.stringify(baseUrl)}\n api_mode: "chat_completions"\n${route.auth === "none" ? "" : ' api_key: "${OPENAI_API_KEY}"\n'}`; } + if (openrouter && hermesConfig) hermesConfig += ` default_headers: ${JSON.stringify(OPENROUTER_ATTRIBUTION_HEADERS)}\n`; return { env, config, codexConfig, hermesConfig }; } diff --git a/server/src/services/decision-model-provider.ts b/server/src/services/decision-model-provider.ts index bb5ae4ba4af..3a43e3d0f9b 100644 --- a/server/src/services/decision-model-provider.ts +++ b/server/src/services/decision-model-provider.ts @@ -1,3 +1,4 @@ +import { OPENROUTER_ATTRIBUTION_HEADERS } from "@paperclipai/shared/openrouter-attribution"; import { createOpenAI } from "@ai-sdk/openai"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; import { experimental_decide as decide, type Experimental_DecisionModel } from "ai"; @@ -44,7 +45,7 @@ export async function runDecisionProvider(input: { }, options: { fetch?: typeof fetch } = {}): Promise { const native = input.provider === "openai" ? createOpenAI({ apiKey: input.apiKey, fetch: options.fetch }).decisionModel(input.model) - : createOpenRouter({ apiKey: input.apiKey, fetch: options.fetch }).evaluationModel(input.model); + : createOpenRouter({ apiKey: input.apiKey, fetch: options.fetch, headers: OPENROUTER_ATTRIBUTION_HEADERS }).evaluationModel(input.model); let receipt = decisionReceipt(input.provider, null); // Capture only accounting metadata before SDK answer validation/refusal errors. const model: Exclude = { diff --git a/server/src/services/fast-response-provider.ts b/server/src/services/fast-response-provider.ts index 9cbcf9bd58f..6b3bd534d0b 100644 --- a/server/src/services/fast-response-provider.ts +++ b/server/src/services/fast-response-provider.ts @@ -1,3 +1,4 @@ +import { isOpenRouterUrl, OPENROUTER_ATTRIBUTION_HEADERS } from "@paperclipai/shared/openrouter-attribution"; import { generateText, type LanguageModel } from "ai"; import { createOpenAI } from "@ai-sdk/openai"; import { createAnthropic } from "@ai-sdk/anthropic"; @@ -139,6 +140,9 @@ export function fastResponseModel( route = metadata.routing; const guardedFetch: typeof fetch = async (url, init) => { const headers = new Headers(init?.headers); + if (isOpenRouterUrl(String(url))) { + for (const [name, value] of Object.entries(OPENROUTER_ATTRIBUTION_HEADERS)) headers.set(name, value); + } if (route?.auth === "none") { headers.delete("authorization"); headers.delete("x-api-key");