fix: credit Paperclip for managed OpenRouter requests (#16850)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - AI connections choose credentials and routes independently of the
agent harness.
> - OpenRouter assigns app credit from the final request headers.
> - Harness defaults can assign Paperclip work to the underlying
harness.
> - This pull request applies one public Paperclip identity to managed
OpenRouter requests.
> - The benefit is consistent app credit across supported harnesses and
internal model calls.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

App attribution for existing OpenRouter connections and explicit
provider configurations.

**Current behavior**

Requests can omit attribution or carry the harness identity. Paperclip
does not receive consistent credit in OpenRouter rankings.

**Proposed behavior**

Use `https://paperclip.ing`, the title `Paperclip`, and the coding
categories `cloud-agent,programming-app` for managed requests to
OpenRouter. Keep credentials, routing, and unrelated providers intact.

**Reason and benefit**

The provider URL identifies the app across personal and shared API keys.
A search of public issues and pull requests found no duplicate
attribution work. This improves existing AI connections and does not add
a separate roadmap feature.

## What Changed

- Add a shared attribution policy and a parity-checked copy for the
standalone Runner.
- Apply it to managed Codex, Claude, OpenCode, and Hermes connections,
plus native Pi and explicit legacy provider configs.
- Preserve attribution across Codex config filtering, Claude environment
filtering, OpenCode proxy requests, and model-level overrides.
- Attribute direct fast-response and decision-model requests, including
custom connections to the OpenRouter endpoint.
- Add positive and negative regression tests and provider attribution
requirements to the connector playbook.
- Advance native Pi to profile 24, preserving historical identities and
binding the attribution source in its declaration and release checks.

## Verification

- Final head `6df282d2a58c2698e41127443903d6fc85b4b72f`: all CI/status
checks passed or were intentionally skipped; Greptile is **5/5**, with
zero unresolved review threads. [Final CI
run](https://github.com/paperclipai/paperclip/actions/runs/39274938069).
- Focused server/shared/config suites: 132 tests passed before the final
edge-case additions. Expanded adapter suites pass 48 tests; the final
OpenCode merge passes 15 tests; global/EU/US endpoint and policy-parity
suites pass 26 tests.
- Focused Runner suites: 85 tests passed, including model overrides and
recovery/config boundaries. Profile installation/environment/config
tests subsequently passed 72 tests (one optional distribution test
skipped); historical decoding passed 11 tests and updated recovery
coverage passed 27 tests. The Rust profile admission test and all five
native Pi launch/recovery tests passed.
- All 114 managed AI connection tests pass together. They include
custom-header preservation for legacy and native Claude runs, both
OpenRouter and other gateways, inherited headers, and unresolved secret
references.
- Actual requests from installed Codex 0.160.0, Claude Code 2.1.245,
OpenCode 1.18.34, Pi 0.74.0 and 1.0.0, and the managed Hermes runtime
carried all four Paperclip headers. A local endpoint used dummy
credentials and returned an intentional 401 after capture. Codex and
Claude retry requests retained attribution. This proves outgoing header
precedence; it is not a successful model-inference test. The first
Claude capture had an argument-separation error and sent no request;
correcting the command produced the captured requests.
- A live request through `fastResponseModel` completed with `attribution
verified`. OpenRouter recorded generation
`gen-1792420273-Ez1GUTCc7YuZ1FJMCGob` under app ID `3118063`, at a cost
of $0.00000375. The public app record maps that ID to
`https://paperclip.ing/`.
- Full `pnpm -r typecheck` and `pnpm build` passed, including a repeat
after the review fixes. The final server build stamp records commit
`6df282d2a58c2698e41127443903d6fc85b4b72f`. `pnpm check:token-gates`
passed.
- CI first caught the stale Pi source attestation; it is now a new
immutable profile. Greptile caught custom-header loss, now fixed and
covered by the complete managed-connection suite. The next CI run caught
the missing Docker allowlist entry for profile 24; the allowlist and
release smoke pins are now synchronized. Local Docker verification
cannot run because its daemon socket is absent; The exact Docker context
check passed in CI on the final head.
- The initial local `pnpm test:run` did **not** pass. It ran across
source/build edits, reported chat/tool-access collection failures, three
workspace-runtime failures, two new managed-header failures, one Dot
Runner readiness failure, and two native-session-resume failures, and
was interrupted after the final-head CI server coverage passed. Its
secrets suite also reported 91 cases skipped. The isolated tool-access
suite passed all 428 tests, and the complete managed-connection suite
passed all 114 tests. These retries establish recovery, not the cause of
the original local failures; no claim of a successful full local run is
made. Final acceptance uses immutable final-head CI plus the focused
local and live checks above.
- Final-head CI encountered one unchanged UI assertion in
`FileViewerSheet.copy.test.tsx` (missing “Copied link” feedback after
clipboard completion). That shard passed on the earlier head with
identical UI code, and all six clipboard tests passed locally. A timing
race around the asynchronous React update is suspected but not
conclusively established. The single CI retry passed, with no
product/test code changed for it. The earlier failure remains part of
this verification record.

## Risks

- Native Pi profile 24 requires a fresh provider session when
encountering a profile 23 identity; recovery tests cover that boundary.
- Harness upgrades can change header precedence. The playbook requires
actual request capture when changing provider paths.
- Externally managed agents, unmanaged harness settings, and gateways
that replace headers need separate qualification. This change does not
intercept arbitrary model calls made by task scripts.
- The existing OpenRouter entry for the Paperclip URL still displays an
old title, “PrettyFly Opus Testing,” and no categories on the final
public-page recheck. The live request received credit under that URL's
app ID. Public title propagation and leaderboard placement remain
controlled by OpenRouter.
- Global, EU, and US OpenRouter hosts are recognized explicitly; other
subdomains are not treated as OpenRouter. No database migration,
credential ownership change, or Paperclip telemetry event is involved.
Attribution contains public product constants only.

## Model Used

OpenAI GPT-6 through Codex, with reasoning, repository editing, shell
execution, web research, and live API verification. The exact deployment
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 listed
above; the unsuccessful full local attempt is explicitly recorded, with
full final-head coverage supplied by CI
- [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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-10-19 10:28:53 -05:00
1 parent a7504784e5
commit 9179d8dcc3
41 files changed
+571 -57

No files matched your search

+1
View File
@@ -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
+59
View File
@@ -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
@@ -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<Record<string, ReadonlySet<stri
"ANTHROPIC_AUTH_TOKEN",
"CLAUDE_CODE_OAUTH_TOKEN",
"ANTHROPIC_BASE_URL",
"ANTHROPIC_CUSTOM_HEADERS",
"ANTHROPIC_MODEL",
"ANTHROPIC_SMALL_FAST_MODEL",
"CLAUDE_CONFIG_DIR",
@@ -2071,6 +2073,10 @@ async function buildRuntime(input: {
// are absent from tempKeysApplied and keep their compatibility protection.
if (!scratchKeys.has(key) || value !== scratch.dir) resolvedAdapterEnv[key] = value;
}
if (acpxAgent === "claude" && isOpenRouterUrl(env.ANTHROPIC_BASE_URL ?? process.env.ANTHROPIC_BASE_URL)) {
env.ANTHROPIC_CUSTOM_HEADERS = openRouterClaudeHeaders(env.ANTHROPIC_CUSTOM_HEADERS ?? process.env.ANTHROPIC_CUSTOM_HEADERS);
resolvedAdapterEnv.ANTHROPIC_CUSTOM_HEADERS = env.ANTHROPIC_CUSTOM_HEADERS;
}
if (authToken) env.PAPERCLIP_API_KEY = authToken;
// For the claude agent, set model via ANTHROPIC_MODEL at startup rather than
// via session/set_config_option — the ACP server's set_config_option handler
@@ -0,0 +1 @@
export * from "@paperclipai/shared/openrouter-attribution";
@@ -1,3 +1,4 @@
import { isOpenRouterUrl, openRouterClaudeHeaders } from "@paperclipai/shared/openrouter-attribution";
import { createProviderStoppedBoundary } from "@paperclipai/adapter-utils/provider-stopped-boundary";
import { createUsageCheckpointLog } from "@paperclipai/adapter-utils/usage-checkpoint";
import fs from "node:fs/promises";
@@ -307,6 +308,9 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
env[key] = value;
}
if (isOpenRouterUrl(env.ANTHROPIC_BASE_URL ?? process.env.ANTHROPIC_BASE_URL)) {
env.ANTHROPIC_CUSTOM_HEADERS = openRouterClaudeHeaders(env.ANTHROPIC_CUSTOM_HEADERS ?? process.env.ANTHROPIC_CUSTOM_HEADERS);
}
if (authToken) {
env.PAPERCLIP_API_KEY = authToken;
}
@@ -41,6 +41,29 @@ const BIFROST_PROVIDERS = {
};
describe("prepareCodexRuntimeConfig", () => {
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 });
@@ -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<string, string> : {});
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(
@@ -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<string>();
@@ -31,6 +32,27 @@ async function makeConfigHome(initialConfig?: Record<string, unknown>) {
}
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, { models?: Record<string, unknown> }> };
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<string, { models?: Record<string, unknown> }> };
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<string, unknown>;
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();
});
});
@@ -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<PreparedOpenCodeRuntimeConfig> {
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<string, unknown> = {
...existingConfig,
permission: "allow",
...(skipPermissions ? { permission: "allow" } : {}),
};
const inlineContent = input.env.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT;
let inlineConfig: Record<string, unknown> | 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<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = { 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<string, string> : {};
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<string, string> : {}) } : model,
])) : undefined;
result[name] = { ...value, options: { ...options, headers: openRouterAttributionHeaders(headers) }, ...(models ? { models } : {}) };
}
return result;
}
function mergeConfigObjects(base: Record<string, unknown>, override: Record<string, unknown>): Record<string, unknown> {
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) };
}
@@ -22,6 +22,26 @@ async function readModelsJson(agentConfigDir: string): Promise<Record<string, un
}
describe("preparePiRuntimeConfig", () => {
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<string, any> };
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" } });
@@ -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<string, string> : {});
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<string, string> : {});
}
}
}
const agentConfigDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-pi-agent-config-"));
try {
await fs.writeFile(
+2 -2
View File
@@ -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": {
@@ -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();
@@ -29,7 +29,7 @@ pub(crate) fn acpx_release_profile(agent: &str) -> Option<AcpxReleaseProfile> {
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 {
@@ -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()
@@ -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);
@@ -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<Record<QualifiedAcpxAgent, readonly
claude: ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", "AWS_BEARER_TOKEN_BEDROCK"],
codex: ["OPENAI_API_KEY", "CODEX_API_KEY", "PAPERCLIP_AI_PROVIDER_KEY"],
};
export const CLAUDE_ROUTING_ENV_KEYS = ["ANTHROPIC_BASE_URL", "CLAUDE_CODE_USE_BEDROCK", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_EC2_METADATA_DISABLED", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL"] as const;
export const CLAUDE_ROUTING_ENV_KEYS = ["ANTHROPIC_BASE_URL", "ANTHROPIC_CUSTOM_HEADERS", "CLAUDE_CODE_USE_BEDROCK", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_EC2_METADATA_DISABLED", "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL"] as const;
/** Keep the task projection consistent when excluding general IAM from Pi. */
function removePiIamCredentials(environment: NodeJS.ProcessEnv): void {
@@ -149,6 +150,9 @@ export function createSanitizedAcpxSpawnInput(
result[key] = value;
}
if (agent === "pi") removePiIamCredentials(result);
if (agent === "claude" && isOpenRouterUrl(result.ANTHROPIC_BASE_URL)) {
result.ANTHROPIC_CUSTOM_HEADERS = openRouterClaudeHeaders(result.ANTHROPIC_CUSTOM_HEADERS);
}
return Object.freeze({
env: Object.freeze(result),
}) as SanitizedAcpxSpawnInput;
@@ -22,12 +22,12 @@ export const QUALIFIED_ACPX_PROFILE_DATA = {
"protocolVersion": 1,
"acpxVersion": "0.13.1",
"agent": "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",
"permissionPolicy": "interactive"
},
"cursor": {
@@ -110,11 +110,19 @@ describe("Pi installation factory", () => {
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");
}
@@ -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();
});
@@ -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<string>([
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<string, Record<string, unknown>>;
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<string, string> | 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<string, unknown>)[key])}`).join(",")}}`
@@ -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"]) {
@@ -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<Record<string, readonly HistoricalAcpxProfileVersion[]>> = {
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],
};
@@ -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"],
@@ -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",
);
@@ -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",
@@ -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<string, string> = {}): Record<string, string> {
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<string, string> = {};
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`;
}
@@ -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();
@@ -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<s
|| (url.protocol !== "https:" && !(url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)))) {
throw new Error("Invalid managed Codex provider URL");
}
return `model_provider = "paperclip"\n[model_providers.paperclip]\nname = "Paperclip connection"\nbase_url = ${JSON.stringify(provider.base_url)}\nwire_api = "responses"\nrequires_openai_auth = false\n${provider.env_key ? 'env_key = "PAPERCLIP_AI_PROVIDER_KEY"\n' : ""}`;
return `model_provider = "paperclip"\n[model_providers.paperclip]\nname = "Paperclip connection"\nbase_url = ${JSON.stringify(provider.base_url)}\nwire_api = "responses"\nrequires_openai_auth = false\n${provider.env_key ? 'env_key = "PAPERCLIP_AI_PROVIDER_KEY"\n' : ""}${isOpenRouterUrl(provider.base_url) ? openRouterCodexHeaders() : ""}`;
}
export async function prepareIsolatedCodexHome(input: {
@@ -0,0 +1,51 @@
{
"commandDigest": "sha256:347feab6eed4bc0f4ab099aae3292694e73d08e05148483ac612b0db958b0e39",
"declaration": {
"schema": "paperclip.acpx_profile_declaration.v1",
"agent": "pi",
"agentProfileVersion": 24,
"acpxVersion": "0.13.1",
"agentServerVersion": "0.0.33",
"agentRuntimeVersion": "1.0.0",
"nodeVersion": "24.21.0",
"closure": {
"darwin-arm64": "282022db10150c6632b3444df421342e7d534bdf5d5fb1097a2e79d0625a2bcf",
"darwin-x64": "64e251e19009f755c0b04f73ce2138246faab71a961b0f13d75ebfcc34bef12e",
"linux-x64": "713b1fdff42fb56a1518bdc084f181d70bee8ebadc3e4b1d76321ed9108c8410"
},
"wrapperSha256": "9d129b3d38772e93e97080aa6c4e574ac5df4e47ce5bc331a484a9fbf8188b36",
"helperSha256": "41e0490b617da0d60e0c8ec58ef311236f945129d99f816cff6897f78da7f91d",
"extensionSha256": "f5818573a355702388072b28926897db546a5cf346b5171f646582ce5f9102a4",
"agentFilesBinding": "registered-runtime-context-v1",
"nativeQuestions": "select-confirm-input-editor-v4-root-field-types",
"pathPolicy": "sdk-normalization-v1",
"mcpToolNames": "provider-safe-exact-source-v1",
"acpxPatchSha256": "00d6af17216cb0cad902b48633eb19bf0ec6b05837a984cb24661fca593f09c1",
"nativeAssistantMessages": "pi1-rpc-deltas-v3-bound-message-and-tool-receipts",
"nativeNotices": "session-extension-v2-bounded-runtime-failure",
"messageProjectionSha256": "17d1e91b948dfa6a56e1905645add52b502041ad843c8d50a13b07956f22aadd",
"noticeProjectionSha256": "014485adc690998e395d334ea3ae67f72be6060a2fc78a1185df7b8d1da28fbf",
"sharedRuntimeContract": "paperclip.acpx-runtime-contract.v1",
"nativeInputDisposition": "queued-only-v1",
"cacheWarming": "disabled-owned-decision-v1",
"thinkingLevel": {
"supported": [
"off",
"low",
"high",
"max"
],
"newConfigurationDefault": "low",
"admission": "explicit-native-effective-before-prompt-v1",
"recovery": "mode-bound-v1"
},
"modelSelection": "caller-selected-native-verified-v1",
"providerConfigurationSourceSha256": "75a9a13f13f224dc092bd8ebf49e8747162172943761e44476c8eb18d80c52d4",
"credentialEnvironmentSourceSha256": "b29464dd2fcbd1bff3a9cdb4ba97a00d9c4a07a6cdd878f1baaa8a4948d020ca",
"runtimeSandboxSourceSha256": "4c0e8b3dca5ecaf60edbf5db26bb55a00bdf800f4523a072c69ed8a92d67f8ca",
"recoveryIdentitySourceSha256": "61eed640ede944fcd07b25b8a2c48adb30d0e881043ca5549ce2f846ac8652ae",
"nativeCancellation": "acknowledged-cancelled-terminal-preserves-usage-v1",
"dependencySecurityPatchSha256": "5273a195b952f233336122842faaf650193c04f36b9359d20d4d63079b0ce814",
"openRouterAttributionSourceSha256": "1211191529d6073b733b311c3d69cada4973eede0f3e540c46e261f78e0b90f9"
}
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { isOpenRouterUrl, openRouterAttributionHeaders, openRouterClaudeHeaders } from "./openrouter-attribution.js";
describe("OpenRouter attribution", () => {
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");
});
});
@@ -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<string, string> = {}): Record<string, string> {
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<string, string> = {};
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`;
}
+3 -3
View File
@@ -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 }); }
@@ -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;
@@ -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");
@@ -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);
}
@@ -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/);
});
});
+10 -1
View File
@@ -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 });
}
+6 -1
View File
@@ -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 };
}
@@ -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<DecisionProviderOutcome> {
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<Experimental_DecisionModel, string> = {
@@ -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");