mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Product E2E operations reserve model cost before each provider request. > - Providers can return an exact snapshot name after a request selects a model alias. > - The estimator required the response name to equal the request alias. It left valid snapshot usage unpriced and invalidated its reviewed bound. > - This pull request lets a versioned pricecard declare a small list of exact response names that share its reviewed rates. > - The benefit is correct cost coverage while request selection and unknown-model handling stay strict. ## Linked Issues or Issue Description Refs #17765 for the shared eval gateway request policy. **What happened?** An eval request can select `gpt-5.4-mini` and receive `gpt-5.4-mini-2026-03-17`. Even with complete usage, the estimator returns `null`. The gateway also rejects that response identity as evidence for its reviewed reservation bound. **Expected behavior** A pricecard can name exact response identities with the same verified rates. Unlisted snapshots stay unknown. A declared response identity must not become an allowed request identity. **Steps to reproduce** 1. Configure a route with an alias pricecard and an exact response snapshot. 2. Return complete Responses usage for that snapshot through the gateway. 3. Observe that the old estimator leaves the charge unknown and invalidates the bound. **Paperclip version or commit** Reproduced in the shared estimator and gateway at `507dc70be26c191e372f09e58dc0965e73bf8f57`. **Deployment mode** Other: the credential-free Product E2E operations harness. The same library serves attached deployment targets. ## What Changed - Add optional `responseModels` to versioned eval pricecards. Validate one to sixteen unique exact additional identities. - Reuse one response matcher in cost estimation, reviewed bound checks and supported request-policy cache-write attribution. Cache attribution also requires the reviewed provider and request identity to match. Keep exact request-model admission unchanged. - Admit the field in the strict operations manifest. Include it in the existing whole-price bound digest. - Cover fragmented JSON and SSE forwarding, persisted usage, durable ledger replay, partial responses, unknown snapshots, invalid mappings, stale bound evidence and unchanged request admission. - Document mapping provenance, version changes and preservation of historical ledgers. ## Verification - Focused provider usage, gateway and operations tests: **185 passed, zero skipped** on the cache attribution follow-up. The exact snapshot assertions failed before the initial repair. Three positive cache-write snapshot tests also failed before the follow-up and now pass. - `pnpm test:e2e:runner:typecheck`: passed after building required workspace declarations. - `pnpm test:e2e:runner:unit`: 4,311 passed, two skipped and four failures. The same four failures reproduce on unchanged base `507dc70` (79 passed, four failed in the two affected files): two Daytona Copilot image identity assertions and two Pi catalog/profile expectation assertions. This PR does not change those files or their source inputs. - `pnpm -r typecheck` and `pnpm build`: passed again on the exact cache attribution follow-up commit. - The private Cloud consumer passed 57 offline compatibility tests again on this exact follow-up source. These cover the declared snapshot estimate, exact request selection, unknown snapshot and incomplete usage handling, ledger holds, and source export admission. No provider calls were made. - `git diff --check`: passed. - `pnpm test:run`: interrupted after repeated embedded PostgreSQL startup failures in existing native-session and other database suites. The failed attempt is preserved and does not count as a full local suite pass. Only this checkout's verified test process group was stopped; it left no processes running. The first sandboxed workspace typecheck could not open a local tsx IPC socket; the later authorized check passes. An initial runner typecheck also lacked fresh-checkout workspace build declarations; its later run passes. - All forwarding tests use local synthetic response bytes. No provider calls, releases, deployments or paid eval campaigns were run for this repair. ## Risks - An incorrect mapping can apply incorrect rates. The pricecard author must verify provider provenance and change the price version. Validation bounds the list and uses exact matching only. - Existing pricecards remain exact-name only when the new field is absent. Adding or changing a mapping invalidates old reviewed bound evidence. Historical ledgers and receipts are not rewritten. ## Model Used OpenAI Codex, GPT-6 family. The exact runtime deployment ID and context window are not exposed in this session. The agent used reasoning, source editing, terminal commands and credential-free tests. No model inference was used by the eval harness for this repair. ## 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 - [ ] I have run tests locally and they pass - [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 - [ ] 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
124 lines
9.5 KiB
TypeScript
124 lines
9.5 KiB
TypeScript
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { createHash } from "node:crypto";
|
|
import { createEvalAttemptPhase, readEvalAttemptPhase } from "./attempt-phase.js";
|
|
import { EvalSpendState, openEvalCostLedger, type EvalSpendLimits } from "./cost-ledger.js";
|
|
import { evalRequestReservation, startEvalGateway, type EvalGatewayRoute, type EvalGatewayReceipt } from "./eval-gateway.js";
|
|
import type { MatrixExecution } from "./types.js";
|
|
import { evalBraintrustConfig } from "./braintrust.js";
|
|
import { startEvalOtlpRelay } from "./eval-otlp-relay.js";
|
|
import { usdToUnits } from "../../packages/shared/src/money.js";
|
|
|
|
export interface EvalOperationsManifest {
|
|
version: 1;
|
|
limits: EvalSpendLimits;
|
|
ledgerPath: string;
|
|
executionIds: string[];
|
|
maxAttemptsPerExecution: number;
|
|
route: "direct" | "braintrust";
|
|
projectId?: string;
|
|
models: Array<Omit<EvalGatewayRoute, "apiKey" | "projectId" | "endpointName" | "mode">>;
|
|
}
|
|
const fields = (value: object, names: string[]) => {
|
|
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some(name => !names.includes(name))) throw new Error("Invalid eval operations manifest fields");
|
|
};
|
|
export function validateEvalOperationsManifest(value: EvalOperationsManifest, executions: readonly MatrixExecution[], maxRetries: number) {
|
|
fields(value, ["version", "limits", "ledgerPath", "executionIds", "maxAttemptsPerExecution", "route", "projectId", "models"]);
|
|
const initial = new EvalSpendState(value.limits).snapshot();
|
|
const initialModelAllowance = usdToUnits(value.limits.maxUsd) - usdToUnits(initial.committedUsd);
|
|
if (value.version !== 1 || !["direct", "braintrust"].includes(value.route) || !path.isAbsolute(value.ledgerPath)
|
|
|| !Array.isArray(value.executionIds) || new Set(value.executionIds).size !== value.executionIds.length
|
|
|| JSON.stringify([...value.executionIds].sort()) !== JSON.stringify(executions.map(e => e.id).sort())
|
|
|| !Number.isSafeInteger(value.maxAttemptsPerExecution) || value.maxAttemptsPerExecution < 1
|
|
|| value.limits.maxAttemptsPerExecution !== undefined && value.limits.maxAttemptsPerExecution !== value.maxAttemptsPerExecution
|
|
|| maxRetries + 1 > value.maxAttemptsPerExecution || !Array.isArray(value.models) || value.models.length === 0) throw new Error("Eval operations selection or limits mismatch");
|
|
if (value.route === "braintrust" && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value.projectId ?? "")) throw new Error("Braintrust gateway requires an explicit project UUID");
|
|
const models = new Map<string, bigint>();
|
|
for (const model of value.models) {
|
|
fields(model, ["provider", "price", "maximumInputTokens", "maximumOutputTokens", "timeoutMs", "provisionalBound"]);
|
|
const rateFields = ["inputUsdPerMillion", "cachedReadUsdPerMillion", "outputUsdPerMillion", "cacheWrite5mUsdPerMillion", "cacheWrite1hUsdPerMillion", "cacheWrite30mUsdPerMillion", "cacheWrite12hUsdPerMillion"];
|
|
fields(model.price, ["provider", "model", "responseModels", "version", "sourceUrl", "longContext", ...rateFields]);
|
|
if (model.price.longContext) { fields(model.price.longContext, ["aboveInputTokens", "rates"]); fields(model.price.longContext.rates, rateFields); }
|
|
const url = new URL(model.price.sourceUrl);
|
|
if (!["openai", "anthropic"].includes(model.provider) || url.protocol !== "https:" || url.username || url.password || url.search || url.hash) throw new Error("Invalid model price provenance");
|
|
const key = `${model.provider}:${model.price.model}`;
|
|
if (models.has(key)) throw new Error("Duplicate eval model price");
|
|
models.set(key, usdToUnits(evalRequestReservation({ ...model, mode: value.route, apiKey: "validation" })));
|
|
}
|
|
for (const execution of executions) {
|
|
if (execution.environment.id !== "local" || !["core-compatibility", "feedback-persistence"].includes(execution.suite.id)
|
|
|| !["runner-codex", "runner-acpx-claude", "legacy-codex", "legacy-claude"].includes(execution.profile.id)
|
|
|| execution.requiredCredentials.length !== 1) throw new Error(`Unqualified budgeted eval route: ${execution.id}`);
|
|
const provider = execution.profile.credential === "ANTHROPIC_API_KEY" ? "anthropic" : "openai";
|
|
if (!models.has(`${provider}:${execution.profile.model}`)) throw new Error(`Missing exact model pricing: ${execution.id}`);
|
|
if (models.get(`${provider}:${execution.profile.model}`)! > initialModelAllowance) {
|
|
throw new Error(`Insufficient initial model allowance for selected eval: ${execution.id}`);
|
|
}
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export async function openEvalOperations(file: string, executions: readonly MatrixExecution[], maxRetries: number, env: NodeJS.ProcessEnv) {
|
|
const manifest = validateEvalOperationsManifest(JSON.parse(await readFile(file, "utf8")), executions, maxRetries);
|
|
if (Date.now() >= Date.parse(manifest.limits.deadlineAt)) throw new Error("Eval campaign deadline reached");
|
|
if (manifest.limits.overhead && (Date.now() < Date.parse(manifest.limits.overhead.verifiedAt)
|
|
|| Date.parse(manifest.limits.deadlineAt) >= Date.parse(manifest.limits.overhead.expiresAt))) {
|
|
throw new Error("Operating overhead allowance does not cover the campaign window");
|
|
}
|
|
const required = manifest.route === "braintrust" ? ["BRAINTRUST_API_KEY"] : executions.map(execution => execution.profile.credential);
|
|
if (required.some(name => !env[name]?.trim())) throw new Error("Missing eval gateway upstream credential");
|
|
const observability = evalBraintrustConfig({ ...env, PAPERCLIP_E2E_OTLP_RELAY_URL: undefined });
|
|
if (observability && manifest.route === "braintrust" && observability.projectId !== manifest.projectId) throw new Error("Eval gateway and OTLP projects differ");
|
|
const receiptDirectory = `${manifest.ledgerPath}.requests`;
|
|
await mkdir(receiptDirectory, { recursive: true, mode: 0o700 });
|
|
const phaseDirectory = `${manifest.ledgerPath}.attempts`;
|
|
await mkdir(phaseDirectory, { recursive: true, mode: 0o700 });
|
|
const ledger = await openEvalCostLedger(manifest.ledgerPath, { ...manifest.limits, maxAttemptsPerExecution: manifest.maxAttemptsPerExecution });
|
|
const active = new Set<{ close(): Promise<void> }>();
|
|
const atomicJson = async (file: string, value: unknown) => {
|
|
await writeFile(`${file}.tmp`, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
await rename(`${file}.tmp`, file);
|
|
};
|
|
return {
|
|
manifest,
|
|
async start(execution: MatrixExecution, attemptId: string, attempt: number) {
|
|
if (!Number.isSafeInteger(attempt) || attempt < 1 || attempt > manifest.maxAttemptsPerExecution || Date.now() >= Date.parse(manifest.limits.deadlineAt)) throw new Error("Eval operations attempt/deadline limit reached");
|
|
if (!manifest.executionIds.includes(execution.id)) throw new Error("Eval operation is outside the admitted selection");
|
|
const provider = execution.profile.credential === "ANTHROPIC_API_KEY" ? "anthropic" : "openai";
|
|
const model = manifest.models.find(m => m.provider === provider && m.price.model === execution.profile.model)!;
|
|
const route: EvalGatewayRoute = { ...model, mode: manifest.route, projectId: manifest.projectId,
|
|
endpointName: provider === "openai" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY",
|
|
apiKey: env[manifest.route === "braintrust" ? "BRAINTRUST_API_KEY" : execution.profile.credential] ?? "" };
|
|
if (!route.apiKey) throw new Error("Missing eval gateway upstream credential");
|
|
await ledger.admitAttempt(execution.id, attemptId);
|
|
const identity = { executionId: execution.id, attemptId, phase: "setup" as const };
|
|
const phasePath = path.join(phaseDirectory, `${createHash("sha256").update(attemptId).digest("hex")}.json`);
|
|
await createEvalAttemptPhase(phasePath, { executionId: execution.id, attemptId });
|
|
const gateway = await startEvalGateway({ ledger, route,
|
|
identity, resolveIdentity: () => readEvalAttemptPhase(phasePath, identity), deadlineAt: Date.parse(manifest.limits.deadlineAt),
|
|
persist: (receipt: EvalGatewayReceipt) => atomicJson(path.join(receiptDirectory, `${receipt.requestId}.json`), receipt),
|
|
});
|
|
let relay: Awaited<ReturnType<typeof startEvalOtlpRelay>> | undefined;
|
|
try {
|
|
if (observability) relay = await startEvalOtlpRelay({ projectId: observability.projectId, apiKey: observability.apiKey,
|
|
campaignId: manifest.limits.campaignId, attemptId, deadlineAt: Math.min(Date.parse(manifest.limits.deadlineAt), Date.now() + 4 * 3_600_000),
|
|
receiptPath: path.join(`${manifest.ledgerPath}.otlp`, `${createHash("sha256").update(attemptId).digest("hex")}.jsonl`) });
|
|
} catch (error) { await gateway.close(); throw error; }
|
|
let closing: Promise<void> | undefined;
|
|
const endpoint = { ...gateway, phasePath, observability: relay, close: () => closing ??= (async () => {
|
|
const results = await Promise.allSettled([gateway.close(), relay?.close()]);
|
|
active.delete(endpoint);
|
|
if (results.some(result => result.status === "rejected")) throw new Error("Eval gateway or OTLP relay cleanup failed");
|
|
})() };
|
|
active.add(endpoint);
|
|
return endpoint;
|
|
},
|
|
async close() {
|
|
const results = await Promise.allSettled([...active].map(gateway => gateway.close())); active.clear();
|
|
try { await atomicJson(`${manifest.ledgerPath}.summary.json`, ledger.snapshot()); } finally { await ledger.close(); }
|
|
if (results.some(result => result.status === "rejected")) throw new Error("Eval gateway cleanup failed");
|
|
},
|
|
};
|
|
}
|
|
export type EvalOperations = Awaited<ReturnType<typeof openEvalOperations>>;
|