mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 22:24:16 +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>
73 lines
6.9 KiB
TypeScript
73 lines
6.9 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import { DECISION_MODELS, DECISION_TEST_REQUEST, decisionProviderForConnection } from "@paperclipai/shared";
|
|
import { decisionReceipt, runDecisionProvider } from "../services/decision-model-provider.js";
|
|
|
|
function openaiResponse() {
|
|
return { model: "gpt-6-luna", usage: { input_tokens: 123, output_tokens: 0 }, answers: [
|
|
{ type: "predicate", name: "billing", probability: 0.99 },
|
|
{ type: "choice", name: "team", choice: "billing", confidence: 0.9, probabilities: [{ value: "billing", probability: 0.9 }, { value: "technical", probability: 0.1 }] },
|
|
{ type: "score", name: "severity", score: 0.1, probabilities: [{ value: 0, probability: 0.9 }, { value: 1, probability: 0.1 }, { value: 2, probability: 0 }] },
|
|
] };
|
|
}
|
|
function openrouterResponse() {
|
|
return { id: "decision-fixture", model: "typesafe/jev-1.13", usage: { input_tokens: 123, output_tokens: 0, cost: 0.000005166 }, answers: {
|
|
billing: { type: "noul", noul: 0.99 }, team: { type: "choice", choice: "billing", confidence: 0.85, probabilities: { billing: 0.9, technical: 0.1 } },
|
|
severity: { type: "score", score: 0.1, probabilities: { "0": 0.9, "1": 0.1, "2": 0 } },
|
|
} };
|
|
}
|
|
describe("decision provider contract", () => {
|
|
it.each(["openai", "openrouter"] as const)("uses native %s decisions with the selected credential and preserves answers and fractional costs", async provider => {
|
|
const transport = vi.fn<typeof fetch>().mockResolvedValue(Response.json(provider === "openai" ? openaiResponse() : openrouterResponse()));
|
|
const result = await runDecisionProvider({ provider, model: DECISION_MODELS[provider].id, apiKey: "selected-credential", request: DECISION_TEST_REQUEST, signal: new AbortController().signal }, { fetch: transport });
|
|
expect(transport).toHaveBeenCalledTimes(1);
|
|
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");
|
|
expect(JSON.parse(String(options?.body)).model).toBe(DECISION_MODELS[provider].id);
|
|
});
|
|
it("keeps usage for a charged refusal and never treats it as an answer", async () => {
|
|
const transport = vi.fn<typeof fetch>().mockResolvedValue(Response.json({ ...openaiResponse(), answers: [{ type: "refusal", name: null }] }));
|
|
const result = await runDecisionProvider({ provider: "openai", model: "gpt-6-luna", apiKey: "key", request: DECISION_TEST_REQUEST, signal: new AbortController().signal }, { fetch: transport });
|
|
expect(result.answers).toBeUndefined(); expect(result.errorCode).toBe("refused"); expect(result.receipt.inputTokens).toBe(123);
|
|
});
|
|
it.each([401, 429, 500])("does not retry HTTP %i or retain its response body", async status => {
|
|
const transport = vi.fn<typeof fetch>().mockResolvedValue(Response.json({ error: { message: "PRIVATE STATE AND KEY", type: "server_error" } }, { status }));
|
|
const result = await runDecisionProvider({ provider: "openai", model: "gpt-6-luna", apiKey: "key", request: DECISION_TEST_REQUEST, signal: new AbortController().signal }, { fetch: transport });
|
|
expect(transport).toHaveBeenCalledTimes(1); expect(result.receipt.costStatus).toBe(status === 500 ? "unpriced" : "estimated"); expect(result.noProviderWork).toBe(status !== 500); expect(JSON.stringify(result)).not.toContain("PRIVATE");
|
|
});
|
|
it("preserves usage when answer validation fails", async () => {
|
|
const response = openrouterResponse(); response.answers.team.choice = "invented";
|
|
const result = await runDecisionProvider({ provider: "openrouter", model: "typesafe/jev-1.13", apiKey: "key", request: DECISION_TEST_REQUEST, signal: new AbortController().signal }, { fetch: async () => Response.json(response) });
|
|
expect(result.answers).toBeUndefined(); expect(result.receipt.costStatus).toBe("reported");
|
|
});
|
|
it.each(["openai", "openrouter"] as const)("retains the %s receipt when the SDK rejects malformed wire answers", async provider => {
|
|
const wire = provider === "openai" ? { ...openaiResponse(), answers: [{ type: "predicate", name: "billing", probability: 2 }] }
|
|
: { ...openrouterResponse(), answers: { billing: { type: "noul", noul: "not-a-probability" } } };
|
|
const result = await runDecisionProvider({ provider, model: DECISION_MODELS[provider].id, apiKey: "key", request: DECISION_TEST_REQUEST, signal: new AbortController().signal }, { fetch: async () => Response.json(wire) });
|
|
expect(result.answers).toBeUndefined(); expect(result.receipt.inputTokens).toBe(123);
|
|
expect(result.receipt.costStatus).toBe(provider === "openai" ? "estimated" : "reported");
|
|
});
|
|
it("fails closed on an OpenRouter refusal while retaining any reported charge", async () => {
|
|
const result = await runDecisionProvider({ provider: "openrouter", model: DECISION_MODELS.openrouter.id, apiKey: "key", request: DECISION_TEST_REQUEST, signal: new AbortController().signal },
|
|
{ fetch: async () => Response.json({ ...openrouterResponse(), answers: { billing: { type: "refusal" } } }) });
|
|
expect(result.answers).toBeUndefined(); expect(result.errorCode).toBe("refused"); expect(result.receipt.costStatus).toBe("reported");
|
|
});
|
|
it("does not invent prices when usage or a supported rate is missing", () => {
|
|
expect(decisionReceipt("openai", {}).costCents).toBeNull();
|
|
expect(decisionReceipt("openai", { usage: { inputTokens: 100_000 } }).costStatus).toBe("unpriced");
|
|
expect(decisionReceipt("openrouter", { usage: { inputTokens: 1 } }).costCents).toBeNull();
|
|
expect(decisionReceipt("openrouter", { providerMetadata: { openrouter: { usage: { cost: 0 } } } }).costStatus).toBe("reported");
|
|
});
|
|
it("rejects subscription, personal endpoint routing and other provider capabilities", () => {
|
|
expect(decisionProviderForConnection({ provider: "openai", method: "subscription" })).toBeNull();
|
|
expect(decisionProviderForConnection({ provider: "openai", method: "api_key", routing: { kind: "gateway", baseUrl: "https://example.com" } })).toBeNull();
|
|
expect(decisionProviderForConnection({ provider: "anthropic", method: "api_key" })).toBeNull();
|
|
expect(decisionProviderForConnection({ provider: "openrouter", method: "api_key", routing: { kind: "openrouter" } })).toBe("openrouter");
|
|
});
|
|
});
|