mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
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:
1 parent
a7504784e5
commit
9179d8dcc3
41 files changed
+571
-57
No files matched your search
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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`;
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user