mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
## 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>
474 lines
14 KiB
TypeScript
474 lines
14 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
||
import type { AiConnectionMetadata } from "@paperclipai/shared";
|
||
import {
|
||
fastResponsePrompt,
|
||
fastResponseReceipt,
|
||
runFastResponseProvider,
|
||
} from "../services/fast-response-provider.js";
|
||
const ack =
|
||
"I’ll check the settings panel’s border styling and work on the fix.";
|
||
const chatResponse = () => ({
|
||
id: "receipt-1",
|
||
model: "openai/gpt-oss-120b",
|
||
created: 1,
|
||
choices: [
|
||
{
|
||
index: 0,
|
||
finish_reason: "stop",
|
||
message: { role: "assistant", content: ack },
|
||
},
|
||
],
|
||
usage: {
|
||
prompt_tokens: 100,
|
||
completion_tokens: 15,
|
||
total_tokens: 115,
|
||
cost: 0.00001,
|
||
},
|
||
});
|
||
const response = (body: unknown) =>
|
||
new Response(JSON.stringify(body), {
|
||
headers: { "content-type": "application/json" },
|
||
});
|
||
const input = (metadata: AiConnectionMetadata) => ({
|
||
metadata,
|
||
model: "test-model",
|
||
credential: "fixture-key",
|
||
prompt: fastResponsePrompt({ agentName: "Alex", message: "Fix the border" }),
|
||
signal: AbortSignal.timeout(3000),
|
||
});
|
||
const connections: AiConnectionMetadata[] = [
|
||
...(["openrouter", "openai", "anthropic", "google", "xai"] as const).map(
|
||
(provider) => ({ provider, method: "api_key" as const }),
|
||
),
|
||
{
|
||
provider: "anthropic",
|
||
method: "api_key",
|
||
routing: {
|
||
kind: "bedrock",
|
||
protocol: "bedrock",
|
||
region: "us-west-2",
|
||
auth: "bearer",
|
||
models: [],
|
||
},
|
||
},
|
||
...(["chat", "responses", "messages"] as const).map((protocol) => ({
|
||
provider: "openai" as const,
|
||
method: "api_key" as const,
|
||
routing: {
|
||
kind: "gateway" as const,
|
||
protocol,
|
||
baseUrl: "https://gateway.example/v1",
|
||
auth: "bearer" as const,
|
||
models: [],
|
||
},
|
||
})),
|
||
{
|
||
provider: "openai",
|
||
method: "api_key",
|
||
routing: {
|
||
kind: "local",
|
||
protocol: "chat",
|
||
baseUrl: "http://localhost:8080/v1",
|
||
auth: "none",
|
||
models: [],
|
||
},
|
||
},
|
||
...(["responses", "messages"] as const).map((protocol) => ({
|
||
provider: "openrouter" as const,
|
||
method: "api_key" as const,
|
||
routing: {
|
||
kind: "openrouter" as const,
|
||
protocol,
|
||
auth: "bearer" as const,
|
||
models: [],
|
||
},
|
||
})),
|
||
];
|
||
describe("fast response provider", () => {
|
||
it("rejects copied JSON output while retaining the provider charge", async () => {
|
||
const body = chatResponse();
|
||
body.choices[0].message.content = '{"message":"I’ll check the border."}';
|
||
const result = await runFastResponseProvider(input({ provider: "openrouter", method: "api_key" }), {
|
||
fetch: vi.fn(async () => response(body)),
|
||
});
|
||
expect(result.errorCode).toBe("invalid_output");
|
||
expect(result.text).toBeUndefined();
|
||
expect(result.receipt.costStatus).toBe("reported");
|
||
});
|
||
it("bounds unicode input and drops old context first", () => {
|
||
const prompt = fastResponsePrompt({
|
||
agentName: "Alex",
|
||
message: "🦄".repeat(10000),
|
||
recent: ["OLD-CONTEXT".repeat(1000)],
|
||
});
|
||
expect(Buffer.byteLength(prompt)).toBeLessThanOrEqual(4096);
|
||
expect(prompt).not.toContain("OLD-CONTEXT");
|
||
expect(prompt).toContain("[truncated]");
|
||
});
|
||
it("bounds escaped metadata and multibyte attachment names without losing the current request", () => {
|
||
const prompt = fastResponsePrompt({
|
||
agentName: "\u0001".repeat(100),
|
||
title: "🦄".repeat(200),
|
||
attachments: Array(5).fill("\u0001".repeat(80)),
|
||
message: "Fix the border",
|
||
recent: ["old".repeat(2000)],
|
||
});
|
||
expect(Buffer.byteLength(prompt)).toBeLessThanOrEqual(4096);
|
||
expect(prompt).toContain("Fix the border");
|
||
});
|
||
it("calls OpenRouter through the SDK with latency routing and records the charge", async () => {
|
||
const fetch = vi.fn(async () => response(chatResponse()));
|
||
const result = await runFastResponseProvider(
|
||
input({ provider: "openrouter", method: "api_key" }),
|
||
{ fetch },
|
||
);
|
||
expect(result.text).toBe(ack);
|
||
expect(result.receipt).toMatchObject({
|
||
inputTokens: 100,
|
||
outputTokens: 15,
|
||
costStatus: "reported",
|
||
costCents: "0.0010000",
|
||
});
|
||
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",
|
||
max_tokens: 256,
|
||
provider: { sort: "latency" },
|
||
});
|
||
expect(body.tools).toBeUndefined();
|
||
expect(fetch).toHaveBeenCalledTimes(1);
|
||
});
|
||
it.each([
|
||
[
|
||
{
|
||
provider: "openai",
|
||
method: "api_key",
|
||
routing: {
|
||
kind: "gateway",
|
||
protocol: "chat",
|
||
baseUrl: "https://gateway.example/v1",
|
||
auth: "bearer",
|
||
models: [],
|
||
},
|
||
},
|
||
"https://gateway.example/v1/chat/completions",
|
||
"Bearer fixture-key",
|
||
],
|
||
[
|
||
{
|
||
provider: "openai",
|
||
method: "api_key",
|
||
routing: {
|
||
kind: "local",
|
||
protocol: "chat",
|
||
baseUrl: "http://localhost:8080/v1",
|
||
auth: "none",
|
||
models: [],
|
||
},
|
||
},
|
||
"http://localhost:8080/v1/chat/completions",
|
||
null,
|
||
],
|
||
] as const)(
|
||
"honors saved chat endpoint/auth %j",
|
||
async (metadata, expectedUrl, auth) => {
|
||
const fetch = vi.fn(async () => response(chatResponse()));
|
||
const result = await runFastResponseProvider(
|
||
input(metadata as AiConnectionMetadata),
|
||
{ fetch },
|
||
);
|
||
expect(result.text).toBe(ack);
|
||
const [url, init] = fetch.mock.calls[0] as unknown as [
|
||
string,
|
||
RequestInit,
|
||
];
|
||
expect(url).toBe(expectedUrl);
|
||
expectAttribution(url, init);
|
||
expect(new Headers(init.headers).get("authorization")).toBe(auth);
|
||
},
|
||
);
|
||
it.each([
|
||
[
|
||
{ provider: "openai", method: "api_key" },
|
||
"https://api.openai.com/v1/responses",
|
||
],
|
||
[{ provider: "xai", method: "api_key" }, "https://api.x.ai/v1/responses"],
|
||
[
|
||
{
|
||
provider: "openai",
|
||
method: "api_key",
|
||
routing: {
|
||
kind: "openrouter",
|
||
protocol: "responses",
|
||
auth: "bearer",
|
||
models: [],
|
||
},
|
||
},
|
||
"https://openrouter.ai/api/v1/responses",
|
||
],
|
||
[
|
||
{
|
||
provider: "openai",
|
||
method: "api_key",
|
||
routing: {
|
||
kind: "gateway",
|
||
protocol: "responses",
|
||
baseUrl: "https://gateway.example/v1",
|
||
auth: "bearer",
|
||
models: [],
|
||
},
|
||
},
|
||
"https://gateway.example/v1/responses",
|
||
],
|
||
] as const)("supports Responses %j", async (metadata, url) => {
|
||
const fetch = vi.fn(async () =>
|
||
response({
|
||
id: "resp_1",
|
||
object: "response",
|
||
created_at: 1,
|
||
model: "test-model",
|
||
status: "completed",
|
||
output: [
|
||
{
|
||
type: "message",
|
||
id: "msg_1",
|
||
role: "assistant",
|
||
status: "completed",
|
||
content: [{ type: "output_text", text: ack, annotations: [] }],
|
||
},
|
||
],
|
||
usage: { input_tokens: 100, output_tokens: 15, total_tokens: 115 },
|
||
}),
|
||
);
|
||
const result = await runFastResponseProvider(
|
||
input(metadata as AiConnectionMetadata),
|
||
{ fetch },
|
||
);
|
||
expect(result.text).toBe(ack);
|
||
expect(result.receipt).toMatchObject({
|
||
inputTokens: 100,
|
||
outputTokens: 15,
|
||
});
|
||
const [actual, init] = fetch.mock.calls[0] as unknown as [
|
||
string,
|
||
RequestInit,
|
||
];
|
||
expect(actual).toBe(url);
|
||
expect(new Headers(init.headers).get("authorization")).toBe(
|
||
"Bearer fixture-key",
|
||
);
|
||
expect(JSON.parse(String(init.body))).toMatchObject({
|
||
model: "test-model",
|
||
max_output_tokens: 256,
|
||
});
|
||
});
|
||
it.each([
|
||
[
|
||
{ provider: "anthropic", method: "api_key" },
|
||
"https://api.anthropic.com/v1/messages",
|
||
"x-api-key",
|
||
"fixture-key",
|
||
],
|
||
[
|
||
{
|
||
provider: "anthropic",
|
||
method: "api_key",
|
||
routing: {
|
||
kind: "openrouter",
|
||
protocol: "messages",
|
||
auth: "bearer",
|
||
models: [],
|
||
},
|
||
},
|
||
"https://openrouter.ai/api/v1/messages",
|
||
"authorization",
|
||
"Bearer fixture-key",
|
||
],
|
||
[
|
||
{
|
||
provider: "anthropic",
|
||
method: "api_key",
|
||
routing: {
|
||
kind: "gateway",
|
||
protocol: "messages",
|
||
baseUrl: "https://messages.example/v1",
|
||
auth: "bearer",
|
||
models: [],
|
||
},
|
||
},
|
||
"https://messages.example/v1/messages",
|
||
"authorization",
|
||
"Bearer fixture-key",
|
||
],
|
||
] as const)("supports Messages %j", async (metadata, url, header, key) => {
|
||
const fetch = vi.fn(async () =>
|
||
response({
|
||
id: "msg_1",
|
||
type: "message",
|
||
role: "assistant",
|
||
model: "test-model",
|
||
content: [{ type: "text", text: ack }],
|
||
stop_reason: "end_turn",
|
||
stop_sequence: null,
|
||
usage: { input_tokens: 100, output_tokens: 15 },
|
||
}),
|
||
);
|
||
const result = await runFastResponseProvider(
|
||
input(metadata as AiConnectionMetadata),
|
||
{ fetch },
|
||
);
|
||
expect(result.text).toBe(ack);
|
||
expect(result.receipt).toMatchObject({
|
||
inputTokens: 100,
|
||
outputTokens: 15,
|
||
});
|
||
const [actual, init] = fetch.mock.calls[0] as unknown as [
|
||
string,
|
||
RequestInit,
|
||
];
|
||
expect(actual).toBe(url);
|
||
expect(new Headers(init.headers).get(header)).toBe(key);
|
||
expect(JSON.parse(String(init.body))).toMatchObject({
|
||
model: "test-model",
|
||
max_tokens: 256,
|
||
});
|
||
});
|
||
it("supports Google generation and auth", async () => {
|
||
const fetch = vi.fn(async () =>
|
||
response({
|
||
candidates: [
|
||
{
|
||
content: { role: "model", parts: [{ text: ack }] },
|
||
finishReason: "STOP",
|
||
index: 0,
|
||
},
|
||
],
|
||
usageMetadata: {
|
||
promptTokenCount: 100,
|
||
candidatesTokenCount: 15,
|
||
totalTokenCount: 115,
|
||
},
|
||
}),
|
||
);
|
||
const result = await runFastResponseProvider(
|
||
input({ provider: "google", method: "api_key" }),
|
||
{ fetch },
|
||
);
|
||
expect(result.text).toBe(ack);
|
||
expect(result.receipt).toMatchObject({
|
||
inputTokens: 100,
|
||
outputTokens: 15,
|
||
});
|
||
const [url, init] = fetch.mock.calls[0] as unknown as [string, RequestInit];
|
||
expect(url).toBe(
|
||
"https://generativelanguage.googleapis.com/v1beta/models/test-model:generateContent",
|
||
);
|
||
expect(new Headers(init.headers).get("x-goog-api-key")).toBe("fixture-key");
|
||
expect(JSON.parse(String(init.body)).generationConfig.maxOutputTokens).toBe(
|
||
256,
|
||
);
|
||
});
|
||
it("supports Bedrock region, bearer key and Converse", async () => {
|
||
const fetch = vi.fn(async () =>
|
||
response({
|
||
output: { message: { role: "assistant", content: [{ text: ack }] } },
|
||
stopReason: "end_turn",
|
||
usage: { inputTokens: 100, outputTokens: 15, totalTokens: 115 },
|
||
metrics: { latencyMs: 50 },
|
||
}),
|
||
);
|
||
const result = await runFastResponseProvider(
|
||
input({
|
||
provider: "anthropic",
|
||
method: "api_key",
|
||
routing: {
|
||
kind: "bedrock",
|
||
protocol: "bedrock",
|
||
region: "us-west-2",
|
||
auth: "bearer",
|
||
models: [],
|
||
},
|
||
}),
|
||
{ fetch },
|
||
);
|
||
expect(result.text).toBe(ack);
|
||
expect(result.receipt).toMatchObject({
|
||
inputTokens: 100,
|
||
outputTokens: 15,
|
||
});
|
||
const [url, init] = fetch.mock.calls[0] as unknown as [string, RequestInit];
|
||
expect(url).toBe(
|
||
"https://bedrock-runtime.us-west-2.amazonaws.com/model/test-model/converse",
|
||
);
|
||
expect(new Headers(init.headers).get("authorization")).toBe(
|
||
"Bearer fixture-key",
|
||
);
|
||
expect(JSON.parse(String(init.body)).inferenceConfig.maxTokens).toBe(256);
|
||
});
|
||
it.each(connections)(
|
||
"aborts once without inventing a billing outcome for %j",
|
||
async (metadata) => {
|
||
const controller = new AbortController();
|
||
const fetch = vi.fn(async () => {
|
||
controller.abort();
|
||
throw new DOMException("aborted", "AbortError");
|
||
});
|
||
const result = await runFastResponseProvider(
|
||
{ ...input(metadata), signal: controller.signal },
|
||
{ fetch },
|
||
);
|
||
expect(result).toMatchObject({
|
||
errorCode: "timeout",
|
||
receipt: { costStatus: "unpriced" },
|
||
});
|
||
expect(fetch).toHaveBeenCalledTimes(1);
|
||
},
|
||
);
|
||
it.each(connections)(
|
||
"never retries provider rejection or retains error bodies for %j",
|
||
async (metadata) => {
|
||
const fetch = vi.fn(
|
||
async () =>
|
||
new Response(
|
||
JSON.stringify({ error: { message: "SECRET provider input" } }),
|
||
{ status: 401, headers: { "content-type": "application/json" } },
|
||
),
|
||
);
|
||
const result = await runFastResponseProvider(input(metadata), { fetch });
|
||
expect(fetch).toHaveBeenCalledTimes(1);
|
||
expect(result.errorCode).toBe("provider_auth_failed");
|
||
expect(result.noProviderWork).toBe(true);
|
||
expect(JSON.stringify(result)).not.toContain("SECRET");
|
||
},
|
||
);
|
||
it("rejects long output without discarding its charge", async () => {
|
||
const body = chatResponse();
|
||
body.choices[0].message.content = "x".repeat(321);
|
||
const result = await runFastResponseProvider(
|
||
input({ provider: "openrouter", method: "api_key" }),
|
||
{ fetch: async () => response(body) },
|
||
);
|
||
expect(result.text).toBeUndefined();
|
||
expect(result.errorCode).toBe("invalid_output");
|
||
expect(result.receipt.costStatus).toBe("reported");
|
||
});
|
||
it("does not invent a price", () => {
|
||
expect(fastResponseReceipt({ usage: { inputTokens: 5 } })).toMatchObject({
|
||
inputTokens: 5,
|
||
costCents: null,
|
||
costStatus: "unpriced",
|
||
});
|
||
});
|
||
});
|
||
|
||
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);
|
||
}
|