mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-31 14:16:39 +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>
94 lines
6.8 KiB
TypeScript
94 lines
6.8 KiB
TypeScript
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";
|
|
import { centsToUnits, unitsToCents, type DecisionAnswer, type DecisionProvider, type DecisionRequest } from "@paperclipai/shared";
|
|
|
|
export interface DecisionProviderReceipt {
|
|
inputTokens: number | null; outputTokens: number | null; providerRequestId: string | null;
|
|
costCents: string | null; costStatus: "reported" | "estimated" | "unpriced";
|
|
pricingProvenance: { source: "provider_reported" | "rate_card" | "unknown"; version?: string; evidence?: string; inputCentsPerMillion?: string };
|
|
}
|
|
export interface DecisionProviderOutcome { noProviderWork?: boolean; answers?: Record<string, DecisionAnswer>; errorCode?: string; receipt: DecisionProviderReceipt }
|
|
const record = (v: unknown): Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v) ? v as Record<string, unknown> : {};
|
|
const tokenCount = (v: unknown) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0 && v <= 2_147_483_647 ? v : null;
|
|
function rejectedResponseData(error: unknown) {
|
|
const value = record(error);
|
|
if (value.data) return record(value.data);
|
|
if (record(value.cause).value) return record(record(value.cause).value);
|
|
// SDK schema rejection may retain only responseBody. Read a bounded body in
|
|
// memory for accounting; never forward it to the ledger, logger, or caller.
|
|
if (typeof value.responseBody === "string" && value.responseBody.length <= 1_000_000) {
|
|
try { return record(JSON.parse(value.responseBody)); } catch { /* Not valid JSON. */ }
|
|
}
|
|
return {};
|
|
}
|
|
export function decisionReceipt(provider: DecisionProvider, result: unknown): DecisionProviderReceipt {
|
|
const value = record(result), usage = record(value.usage), metadata = record(value.providerMetadata);
|
|
const response = record(value.response);
|
|
const headers = record(response.headers);
|
|
const inputTokens = tokenCount(usage.inputTokens), outputTokens = tokenCount(usage.outputTokens);
|
|
const reported = record(record(metadata.openrouter).usage).cost;
|
|
const base = { inputTokens, outputTokens, providerRequestId: typeof response.id === "string" ? response.id.slice(0, 250) : typeof headers["x-request-id"] === "string" ? headers["x-request-id"].slice(0, 250) : null };
|
|
if (provider === "openrouter" && typeof reported === "number" && Number.isFinite(reported) && reported >= 0) {
|
|
return { ...base, costCents: unitsToCents(centsToUnits(reported * 100)), costStatus: "reported", pricingProvenance: { source: "provider_reported" } };
|
|
}
|
|
// V1 uses the official default endpoint and bounded short text requests. Decisions has its own rate, not the chat model's rate.
|
|
if (provider === "openai" && inputTokens !== null && inputTokens <= 32_000) {
|
|
return { ...base, costCents: unitsToCents(centsToUnits("10") * BigInt(inputTokens) / 1_000_000n), costStatus: "estimated",
|
|
pricingProvenance: { source: "rate_card", version: "openai-decisions-2026-10-07", inputCentsPerMillion: "10.0000000" } };
|
|
}
|
|
return { ...base, costCents: null, costStatus: "unpriced", pricingProvenance: { source: "unknown" } };
|
|
}
|
|
export async function runDecisionProvider(input: {
|
|
provider: DecisionProvider; model: string; apiKey: string; request: DecisionRequest; signal: AbortSignal;
|
|
}, 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, 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> = {
|
|
specificationVersion: "v4", provider: native.provider, modelId: native.modelId, supportedQuestionTypes: native.supportedQuestionTypes,
|
|
async doDecide(args) {
|
|
const response = await ("doDecide" in native ? native.doDecide(args) : native.doEvaluate(args));
|
|
receipt = decisionReceipt(input.provider, response);
|
|
return { ...response, warnings: [] };
|
|
},
|
|
};
|
|
try {
|
|
const result = await decide({ model, ...input.request, maxRetries: 0, abortSignal: input.signal,
|
|
telemetry: { isEnabled: false, recordInputs: false, recordOutputs: false } });
|
|
const metadata = record(result.providerMetadata);
|
|
const answers = Object.fromEntries(Object.entries(result.answers).map(([id, answer]) => {
|
|
const confidence = input.provider === "openai" ? record(record(metadata.openai).confidence)[id]
|
|
: record(record(record(metadata.openrouter).answers)[id]).confidence;
|
|
return [id, { ...answer, ...(typeof confidence === "number" && confidence >= 0 && confidence <= 1 ? { confidence } : {}) }];
|
|
}));
|
|
return { answers, receipt };
|
|
} catch (error) {
|
|
// Never expose provider error messages/bodies; they may contain inputs or credentials.
|
|
const name = error instanceof Error ? error.name : "";
|
|
const status = record(error).statusCode;
|
|
// SDKs can reject refusals or malformed answers before normalized usage.
|
|
// Keep only validated accounting fields, even in this failure path.
|
|
const data = rejectedResponseData(error);
|
|
const rawUsage = record(data.usage);
|
|
if (receipt.costStatus === "unpriced") {
|
|
receipt = decisionReceipt(input.provider, { usage: { inputTokens: rawUsage.input_tokens, outputTokens: rawUsage.output_tokens },
|
|
providerMetadata: { openrouter: { usage: { cost: rawUsage.cost } } },
|
|
response: { id: data.id, headers: record(error).responseHeaders } });
|
|
}
|
|
const rawAnswers = Array.isArray(data.answers) ? data.answers : Object.values(record(data.answers));
|
|
const refused = name.includes("Refusal") || record(data.error).type === "refusal" || rawAnswers.some(answer => record(answer).type === "refusal");
|
|
const errorCode = input.signal.aborted ? "timeout" : refused ? "refused"
|
|
: status === 401 || status === 403 ? "provider_auth_failed" : status === 429 ? "provider_rate_limited" : "provider_failed";
|
|
// An explicit authentication/validation rejection occurs before model work.
|
|
// Server errors and network failures remain unresolved: their charge is unknown.
|
|
const noProviderWork = receipt.costStatus === "unpriced" && [400, 401, 403, 404, 422, 429].includes(Number(status));
|
|
if (noProviderWork) receipt = { ...receipt, inputTokens: 0, outputTokens: 0, costCents: "0.0000000", costStatus: "estimated",
|
|
pricingProvenance: { source: "unknown", evidence: `Provider rejected request before model work (HTTP ${status})` } };
|
|
return { errorCode, receipt, noProviderWork };
|
|
}
|
|
}
|