mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
fix(evals): price explicit response model identities (#17808)
## 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
This commit is contained in:
1 parent
8373d78679
commit
051b425fdf
7 files changed
+167
-13
No files matched your search
@@ -2866,6 +2866,19 @@ The caller supplies an explicit campaign ceiling, deadline, request count, and
|
||||
concurrency. Monetary decisions use integer nanodollars. Model list prices remain
|
||||
estimates with a pinned version; they do not become provider invoices.
|
||||
|
||||
A versioned pricecard may declare `responseModels`, a list of one to sixteen
|
||||
additional exact response model identities with the same reviewed rates. Request
|
||||
admission still requires the exact `model`; the list only permits those response
|
||||
identities for usage estimates and reviewed reservation bounds. Entries must be
|
||||
unique, valid ledger identities and distinct from `model`. No prefix matching or
|
||||
date removal is performed. Add or change mappings only with a new price version
|
||||
and verified provider provenance. The reviewed bound digest includes the mapping;
|
||||
changing it invalidates old bound evidence. An unlisted response remains unpriced
|
||||
and disqualifies the bound. Historical receipts and ledgers remain unchanged.
|
||||
The existing supported request cache policy also uses that exact mapping when
|
||||
the pricecard provider and request model match. It does not repair missing write
|
||||
counters, malformed TTL details or an unqualified cache policy.
|
||||
|
||||
The journal retains setup/agent/judge/tool/infrastructure attribution separately
|
||||
from heartbeat IDs. Multiple sources for the same request are retained but only
|
||||
one cost contributes to the total: reconciled, reported, estimated, then allocated.
|
||||
|
||||
@@ -86,12 +86,14 @@ it("stops at the campaign cap before any upstream request", async () => {
|
||||
await expect(forwardEvalRequest({ ...f, transport })).rejects.toThrow("spending cap");
|
||||
expect(transport).not.toHaveBeenCalled();
|
||||
});
|
||||
it.each(["direct", "braintrust"] as const)("retains request-policy cost provenance without changing the %s cache policy", async mode => {
|
||||
it.each((["direct", "braintrust"] as const).flatMap(mode => (["alias", "snapshot"] as const).map(model => ({ mode, model }))))(
|
||||
"retains request-policy cost provenance for $model responses without changing the $mode cache policy", async ({ mode, model }) => {
|
||||
const f = await fixture();
|
||||
const pricedRoute = { ...route, mode, price: { ...route.price, model: "gpt-5.6-sol", inputUsdPerMillion: "4", cachedReadUsdPerMillion: "0.4",
|
||||
outputUsdPerMillion: "20", cacheWrite30mUsdPerMillion: "5", cacheWrite12hUsdPerMillion: "10" } };
|
||||
outputUsdPerMillion: "20", cacheWrite30mUsdPerMillion: "5", cacheWrite12hUsdPerMillion: "10", responseModels: ["gpt-5.6-sol-reviewed-snapshot"] } };
|
||||
const payload = { ...f.payload, model: pricedRoute.price.model, prompt_cache_retention: "24h", stream: true };
|
||||
const body = { ...usage, model: pricedRoute.price.model, usage: { ...usage.usage, input_tokens_details: { cache_write_tokens: 6, cached_tokens: 2 } } };
|
||||
const body = { ...usage, model: model === "alias" ? pricedRoute.price.model : pricedRoute.price.responseModels[0],
|
||||
usage: { ...usage.usage, input_tokens_details: { cache_write_tokens: 6, cached_tokens: 2 } } };
|
||||
const transport = vi.fn<typeof fetch>(async () => new Response(`data: ${JSON.stringify({ type: "response.completed", response: body })}\n\n`, {
|
||||
headers: { "content-type": "text/event-stream", "x-bt-used-endpoint": "OPENAI_API_KEY", "x-bt-cached": "MISS" },
|
||||
}));
|
||||
@@ -101,6 +103,7 @@ it.each(["direct", "braintrust"] as const)("retains request-policy cost provenan
|
||||
cacheWriteAttribution: { source: "request_policy", version: "openai-prompt-cache-2026-10-20", ttl: "30m" } } });
|
||||
expect(f.receipts.at(-1)).toEqual(receipt);
|
||||
expect(f.ledger.snapshot()).toMatchObject({ knownUsd: "0.000138800", heldUsd: "0.000000000", inflight: 0 });
|
||||
expect((await readEvalCostLedger(f.file)).state.snapshot()).toMatchObject({ knownUsd: "0.000138800", heldUsd: "0.000000000", unknownRequests: 0 });
|
||||
});
|
||||
it("retains cancelled and provider-error requests as unknown, including repeated retries", async () => {
|
||||
const f = await fixture();
|
||||
@@ -451,6 +454,66 @@ it("refuses missing or unreviewed attribution checkpoints before request dispatc
|
||||
const provisionalBound = { version: 1 as const, scope: "full_lifecycle" as const, evidenceSha256: "a".repeat(64),
|
||||
configurationSha256: costBoundConfigurationSha256({ provider: route.provider, mode: route.mode, price: route.price,
|
||||
maximumInputTokens: route.maximumInputTokens, maximumOutputTokens: route.maximumOutputTokens, reservation: evalRequestReservation(route) }) };
|
||||
it.each(["json", "sse"] as const)("prices a declared exact response snapshot from forwarded %s bytes and replays the ledger", async format => {
|
||||
const f = await fixture();
|
||||
const selected = { ...route, price: { ...route.price, model: "gpt-5.4-mini", responseModels: ["gpt-5.4-mini-2026-03-17"], version: "snapshot-fixture-2" } };
|
||||
const bound = { ...provisionalBound, configurationSha256: costBoundConfigurationSha256({
|
||||
provider: selected.provider, mode: selected.mode, price: selected.price,
|
||||
maximumInputTokens: selected.maximumInputTokens, maximumOutputTokens: selected.maximumOutputTokens, reservation: evalRequestReservation(selected),
|
||||
}) };
|
||||
const returned = { ...usage, model: selected.price.responseModels[0] };
|
||||
const wire = Buffer.from(format === "sse" ? `data: ${JSON.stringify({ type: "response.completed", response: returned })}\r\n\r\ndata: [DONE]\r\n\r\n` : JSON.stringify(returned));
|
||||
const forwarded: Uint8Array[] = [];
|
||||
const transport = vi.fn<typeof fetch>(async (_url, init) => {
|
||||
expect(JSON.parse(init!.body as string).model).toBe(selected.price.model);
|
||||
expect((await readEvalCostLedger(f.file)).state.snapshot().inflight).toBe(1);
|
||||
return new Response(new ReadableStream<Uint8Array>({ start(controller) {
|
||||
for (let index = 0; index < wire.length; index += 3) controller.enqueue(wire.subarray(index, index + 3));
|
||||
controller.close();
|
||||
} }), { headers: { "content-type": format === "sse" ? "text/event-stream" : "application/json", "x-bt-used-endpoint": selected.endpointName! } });
|
||||
});
|
||||
const receipt = await forwardEvalRequest({ ...f, route: { ...selected, provisionalBound: bound },
|
||||
payload: { ...f.payload, model: selected.price.model }, transport, onChunk: async chunk => { forwarded.push(chunk); } });
|
||||
expect(Buffer.concat(forwarded)).toEqual(wire);
|
||||
expect(receipt).toMatchObject({ outcome: "succeeded", costUsd: "0.000068000", usageComplete: true, final: true, usage: { model: returned.model } });
|
||||
expect(f.receipts.at(-1)).toEqual(receipt);
|
||||
const replay = (await readEvalCostLedger(f.file)).state;
|
||||
expect(replay.snapshot()).toMatchObject({ knownUsd: "0.000068000", heldUsd: "0.000000000", unknownRequests: 0, boundedUnresolvedRequests: 0, inflight: 0 });
|
||||
const request = [...replay.requests.values()][0]!;
|
||||
expect(request.boundInvalidated).not.toBe(true);
|
||||
expect(request.observations).toEqual(expect.arrayContaining([expect.objectContaining({
|
||||
classification: "estimated", model: returned.model, costUsd: "0.000068000", pricingVersion: selected.price.version,
|
||||
})]));
|
||||
expect(transport).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(["listed-partial", "unlisted-snapshot", "unlisted-suffix", "no-mapping"] as const)("preserves unknown usage and the correct bound for %s response bytes", async reason => {
|
||||
const f = await fixture();
|
||||
const selected = { ...route, price: { ...route.price, model: "gpt-5.4-mini",
|
||||
...(reason === "no-mapping" ? {} : { responseModels: ["gpt-5.4-mini-2026-03-17"] }) } };
|
||||
const bound = { ...provisionalBound, configurationSha256: costBoundConfigurationSha256({
|
||||
provider: selected.provider, mode: selected.mode, price: selected.price,
|
||||
maximumInputTokens: selected.maximumInputTokens, maximumOutputTokens: selected.maximumOutputTokens, reservation: evalRequestReservation(selected),
|
||||
}) };
|
||||
const model = reason === "unlisted-snapshot" ? "gpt-5.4-mini-2026-03-18"
|
||||
: reason === "unlisted-suffix" ? "gpt-5.4-mini-2026-03-17-extra" : "gpt-5.4-mini-2026-03-17";
|
||||
const returned = { ...usage, model, ...(reason === "listed-partial" ? { status: "in_progress", usage: undefined } : {}) };
|
||||
const receipt = await forwardEvalRequest({ ...f, route: { ...selected, provisionalBound: bound }, payload: { ...f.payload, model: selected.price.model },
|
||||
transport: async () => new Response(JSON.stringify(returned), { headers: { "x-bt-used-endpoint": selected.endpointName! } }) });
|
||||
expect(receipt.costUsd).toBeNull();
|
||||
const replay = (await readEvalCostLedger(f.file)).state;
|
||||
expect(replay.snapshot()).toMatchObject({ knownUsd: "0.000000000", heldUsd: "0.003000000", unknownRequests: 1,
|
||||
boundedUnresolvedRequests: reason === "listed-partial" ? 1 : 0, inflight: 0 });
|
||||
expect([...replay.requests.values()][0]!.boundInvalidated === true).toBe(reason !== "listed-partial");
|
||||
});
|
||||
|
||||
it("does not admit a declared response snapshot as the request model", async () => {
|
||||
const f = await fixture(), transport = vi.fn<typeof fetch>(), persist = vi.fn();
|
||||
const selected = { ...route, price: { ...route.price, responseModels: ["fixture-2026-03-17"] } };
|
||||
await expect(forwardEvalRequest({ ...f, route: selected, payload: { ...f.payload, model: selected.price.responseModels[0] }, transport, persist })).rejects.toThrow("unqualified_model");
|
||||
expect(transport).not.toHaveBeenCalled(); expect(persist).not.toHaveBeenCalled();
|
||||
expect(f.ledger.snapshot().requests).toHaveLength(0);
|
||||
});
|
||||
it("retains a reviewed model bound through a partial response and durable reload without releasing the hold", async () => {
|
||||
const f = await fixture();
|
||||
const receipt = await forwardEvalRequest({ ...f, route: { ...route, provisionalBound }, transport: async () => {
|
||||
@@ -476,9 +539,10 @@ it.each(["no-headers", "route", "failover", "model", "input-limit", "output-limi
|
||||
expect([...replay.requests.values()][0]!.boundInvalidated).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a changed model limit or rate with a stale reviewed bound before dispatch", async () => {
|
||||
it("refuses a changed model limit, rate or response mapping with a stale reviewed bound before dispatch", async () => {
|
||||
const f = await fixture(), transport = vi.fn<typeof fetch>();
|
||||
for (const changed of [{ ...route, maximumInputTokens: 500 }, { ...route, price: { ...route.price, inputUsdPerMillion: "20" } }]) {
|
||||
for (const changed of [{ ...route, maximumInputTokens: 500 }, { ...route, price: { ...route.price, inputUsdPerMillion: "20" } },
|
||||
{ ...route, price: { ...route.price, responseModels: ["fixture-2026-03-17"] } }]) {
|
||||
await expect(forwardEvalRequest({ ...f, route: { ...changed, provisionalBound }, transport })).rejects.toThrow("configuration changed");
|
||||
}
|
||||
expect(transport).not.toHaveBeenCalled();
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:ht
|
||||
import { once } from "node:events";
|
||||
import { braintrustGatewayRequestParent } from "./braintrust-gateway-context.js";
|
||||
import { usdToUnits } from "../../packages/shared/src/money.js";
|
||||
import { estimateProviderCost, ProviderUsageAccumulator, type EvalModelPrice, type ProviderUsageReceipt } from "./provider-usage.js";
|
||||
import { estimateProviderCost, matchesPriceResponseModel, ProviderUsageAccumulator, validatePriceResponseModels, type EvalModelPrice, type ProviderUsageReceipt } from "./provider-usage.js";
|
||||
import { EvalAdmissionError, validEvalLedgerId, type EvalChargeIdentity, type openEvalCostLedger } from "./cost-ledger.js";
|
||||
|
||||
type Ledger = Awaited<ReturnType<typeof openEvalCostLedger>>;
|
||||
@@ -34,6 +34,7 @@ const row = (value: unknown): Record<string, unknown> => value && typeof value =
|
||||
class EvalRequestError extends Error {}
|
||||
|
||||
export function evalRequestReservation(route: EvalGatewayRoute): string {
|
||||
validatePriceResponseModels(route.price);
|
||||
if (route.provisionalBound !== undefined) validateCostBoundEvidence(route.provisionalBound);
|
||||
if (![route.maximumInputTokens, route.maximumOutputTokens, route.timeoutMs].every(n => Number.isSafeInteger(n) && n > 0)
|
||||
|| route.timeoutMs > 300_000 || route.provider !== route.price.provider || !validEvalLedgerId(route.price.version) || !validEvalLedgerId(route.price.model)) throw new Error("Invalid eval gateway limits or price identity");
|
||||
@@ -150,7 +151,7 @@ export async function forwardEvalRequest(input: {
|
||||
receipt.clientDisconnected = true; receipt.outcome = "cancelled"; receipt.errorCode ??= "client_disconnected";
|
||||
return true;
|
||||
};
|
||||
const usage = new ProviderUsageAccumulator(route.provider, body);
|
||||
const usage = new ProviderUsageAccumulator(route.provider, body, route.price);
|
||||
const bound = modelReservationBound(route, reservation);
|
||||
await ledger.reserve(requestId, input.identity, reservation, bound);
|
||||
const abort = new AbortController();
|
||||
@@ -222,7 +223,7 @@ export async function forwardEvalRequest(input: {
|
||||
receipt.errorCode ??= "invalid_usage";
|
||||
}
|
||||
const observedInput = observed.tokenBounds?.input ?? 0;
|
||||
if (bound && (observed.tokenBounds?.valid === false || receipt.errorCode === "invalid_usage" || observed.model !== undefined && observed.model !== route.price.model
|
||||
if (bound && (observed.tokenBounds?.valid === false || receipt.errorCode === "invalid_usage" || observed.model !== undefined && !matchesPriceResponseModel(route.price, observed.model)
|
||||
|| !Number.isSafeInteger(observedInput) || observedInput > route.maximumInputTokens
|
||||
|| (observed.tokenBounds?.output ?? 0) > route.maximumOutputTokens
|
||||
|| route.mode === "braintrust" && (!response || receipt.endpoint !== route.endpointName
|
||||
|
||||
@@ -33,6 +33,26 @@ it("rejects credentials in the manifest and unknown pricing/billing policy field
|
||||
expect(() => validateEvalOperationsManifest(changed, [cell], 0)).toThrow("fields");
|
||||
expect(() => validateEvalOperationsManifest({ ...manifest(), route: "braintrust" }, [cell], 0)).toThrow("project UUID");
|
||||
});
|
||||
it("admits a bounded exact response mapping without widening the selected request model", () => {
|
||||
const value = manifest();
|
||||
value.models[0]!.price.responseModels = [`${cell.profile.model}-2026-03-17`];
|
||||
expect(validateEvalOperationsManifest(value, [cell], 0)).toBe(value);
|
||||
value.models[0]!.price.responseModels = Array.from({ length: 16 }, (_, index) => `${cell.profile.model}-snapshot-${index}`);
|
||||
expect(validateEvalOperationsManifest(value, [cell], 0)).toBe(value);
|
||||
const changed = structuredClone(value); changed.models[0]!.price.model = "other";
|
||||
changed.models[0]!.price.responseModels = [cell.profile.model];
|
||||
expect(() => validateEvalOperationsManifest(changed, [cell], 0)).toThrow("Missing exact model");
|
||||
});
|
||||
it.each([null, "fixture", [], [""], [" leading-space"], ["fixture?query"], [4], ["a", "a"],
|
||||
new Array(1), Array.from({ length: 17 }, (_, index) => `fixture-${index}`),
|
||||
].map(responseModels => ({ responseModels })))("rejects malformed or excessive response model mappings: $responseModels", ({ responseModels }) => {
|
||||
const value = manifest(); Object.assign(value.models[0]!.price, { responseModels });
|
||||
expect(() => validateEvalOperationsManifest(value, [cell], 0)).toThrow("response model");
|
||||
});
|
||||
it("rejects a response mapping that repeats the exact request identity", () => {
|
||||
const value = manifest(); value.models[0]!.price.responseModels = [cell.profile.model];
|
||||
expect(() => validateEvalOperationsManifest(value, [cell], 0)).toThrow("response model");
|
||||
});
|
||||
|
||||
it("rejects an unaffordable selected request before opening a ledger or checking credentials", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "eval-unaffordable-preflight-"));
|
||||
|
||||
@@ -37,7 +37,7 @@ export function validateEvalOperationsManifest(value: EvalOperationsManifest, ex
|
||||
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", "version", "sourceUrl", "longContext", ...rateFields]);
|
||||
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");
|
||||
|
||||
@@ -15,6 +15,20 @@ it("never turns missing usage or incompatible model/pricing into a free charge",
|
||||
expect(estimateProviderCost(zero, price)).toBe("0.000000000");
|
||||
expect(estimateProviderCost(zero, { ...price, model: "other" })).toBeNull();
|
||||
});
|
||||
it("prices only exact response identities declared by the versioned pricecard", () => {
|
||||
const selected = { ...price, model: "gpt-5.4-mini", responseModels: ["gpt-5.4-mini-2026-03-17"] };
|
||||
const receipt = normalizeProviderUsage("openai", { model: selected.responseModels[0], usage: {
|
||||
input_tokens: 100, input_tokens_details: { cache_write_tokens: 0, cached_tokens: 40 }, output_tokens: 20,
|
||||
} });
|
||||
expect(estimateProviderCost(receipt, selected)).toBe("0.000360000");
|
||||
expect(estimateProviderCost({ ...receipt, model: selected.model }, selected)).toBe("0.000360000");
|
||||
expect(estimateProviderCost(receipt, { ...selected, provider: "anthropic" })).toBeNull();
|
||||
expect(estimateProviderCost(receipt, { ...selected, responseModels: undefined })).toBeNull();
|
||||
expect(estimateProviderCost(receipt, { ...selected, responseModels: selected.responseModels[0] as never })).toBeNull();
|
||||
for (const model of [undefined, "gpt-5.4-mini-2026-03-18", "gpt-5.4-mini-2026-03-17-extra", "gpt-5.4", "GPT-5.4-mini-2026-03-17"]) {
|
||||
expect(estimateProviderCost({ ...receipt, model }, selected)).toBeNull();
|
||||
}
|
||||
});
|
||||
it("does not add Anthropic cumulative output counters and prices cache writes by TTL", () => {
|
||||
const stream = new ProviderUsageAccumulator("anthropic");
|
||||
stream.observe({ type: "message_start", message: { id: "message", model: "fixture", usage: { input_tokens: 10, output_tokens: 1, cache_read_input_tokens: 20, cache_creation_input_tokens: 30, cache_creation: { ephemeral_5m_input_tokens: 20, ephemeral_1h_input_tokens: 10 } } } });
|
||||
@@ -111,6 +125,29 @@ it("prefers provider TTL splits and never repairs malformed or partial provider
|
||||
expect(estimateProviderCost(receipt, policyPrice)).toBeNull();
|
||||
}
|
||||
});
|
||||
it.each([
|
||||
{ name: "exact reviewed mapping", mapped: true, expected: "0.077600000" },
|
||||
{ name: "no mapping", mapped: false, expected: null },
|
||||
{ name: "unlisted snapshot", mapped: true, responseModel: "unlisted-snapshot", expected: null },
|
||||
{ name: "wrong price provider", mapped: true, provider: "anthropic" as const, expected: null },
|
||||
{ name: "wrong price request model", mapped: true, model: "another-request", expected: null },
|
||||
{ name: "unqualified cache policy", mapped: true, request: { prompt_cache_options: { mode: "explicit", ttl: "12h" } }, expected: null },
|
||||
{ name: "missing write counter", mapped: true, details: { cache_write_tokens: undefined }, expected: null },
|
||||
{ name: "malformed TTL counters", mapped: true, details: { cache_write_tokens_details: null }, expected: null },
|
||||
])("uses a reviewed snapshot for cache attribution only with $name", test => {
|
||||
const snapshot = "gpt-5.6-sol-reviewed-snapshot";
|
||||
const selected: EvalModelPrice = { ...policyPrice, model: test.model ?? policyPrice.model, provider: test.provider ?? "openai",
|
||||
...(test.mapped ? { responseModels: [snapshot] } : {}) };
|
||||
const stream = new ProviderUsageAccumulator("openai", { model: policyPrice.model, input: "hello",
|
||||
prompt_cache_options: { mode: "implicit", ttl: "12h" }, ...test.request }, selected);
|
||||
stream.observe({ type: "response.completed", response: { model: test.responseModel ?? snapshot, usage: {
|
||||
input_tokens: 12_000, input_tokens_details: { cached_tokens: 4000, cache_write_tokens: 7000, ...test.details }, output_tokens: 100,
|
||||
} } });
|
||||
const receipt = stream.receipt();
|
||||
expect(estimateProviderCost(receipt, selected)).toBe(test.expected);
|
||||
expect(receipt.cacheWriteAttribution).toEqual(test.expected === null ? undefined
|
||||
: { source: "request_policy", version: "openai-prompt-cache-2026-10-20", ttl: "12h" });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["openai", { input_tokens: 1001, output_tokens: 1 }, 1001],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { usdToUnits } from "../../packages/shared/src/money.js";
|
||||
import type { EvalCostObservation } from "./cost-ledger.js";
|
||||
import { validEvalLedgerId, type EvalCostObservation } from "./cost-ledger.js";
|
||||
|
||||
type Tokens = NonNullable<EvalCostObservation["tokens"]>;
|
||||
type Row = Record<string, unknown>;
|
||||
@@ -96,7 +96,8 @@ export class ProviderUsageAccumulator {
|
||||
private usage: Row = {};
|
||||
private final = false;
|
||||
private readonly cachePolicy: ReturnType<typeof openAiRequestCachePolicy>;
|
||||
constructor(readonly provider: "openai" | "anthropic", request?: unknown) {
|
||||
constructor(readonly provider: "openai" | "anthropic", request?: unknown,
|
||||
private readonly price?: Pick<EvalModelPrice, "provider" | "model" | "responseModels">) {
|
||||
this.cachePolicy = provider === "openai" ? openAiRequestCachePolicy(request) : undefined;
|
||||
}
|
||||
observe(event: unknown) {
|
||||
@@ -124,7 +125,9 @@ export class ProviderUsageAccumulator {
|
||||
receipt() {
|
||||
const receipt = normalizeProviderUsage(this.provider, { ...this.body, usage: this.usage });
|
||||
const details = row(this.usage.input_tokens_details);
|
||||
if (this.cachePolicy && receipt.model === this.cachePolicy.model && receipt.complete && receipt.tokens.cachedWrite! > 0
|
||||
const policyMatchesResponse = this.cachePolicy && (receipt.model === this.cachePolicy.model
|
||||
|| this.price?.provider === this.provider && this.price.model === this.cachePolicy.model && matchesPriceResponseModel(this.price, receipt.model));
|
||||
if (this.cachePolicy && policyMatchesResponse && receipt.complete && receipt.tokens.cachedWrite! > 0
|
||||
&& !("cache_write_tokens_details" in details)) {
|
||||
receipt.cacheWrite30m = this.cachePolicy.ttl === "30m" ? receipt.tokens.cachedWrite : 0;
|
||||
receipt.cacheWrite12h = this.cachePolicy.ttl === "12h" ? receipt.tokens.cachedWrite : 0;
|
||||
@@ -146,13 +149,29 @@ export interface EvalTokenRates {
|
||||
export interface EvalModelPrice extends EvalTokenRates {
|
||||
provider: "openai" | "anthropic";
|
||||
model: string;
|
||||
responseModels?: string[];
|
||||
version: string;
|
||||
sourceUrl: string;
|
||||
longContext?: { aboveInputTokens: number; rates: EvalTokenRates };
|
||||
}
|
||||
|
||||
export function validatePriceResponseModels(price: Pick<EvalModelPrice, "model" | "responseModels">): void {
|
||||
const models = price.responseModels;
|
||||
if (models === undefined) return;
|
||||
if (!Array.isArray(models) || models.length < 1 || models.length > 16 || new Set(models).size !== models.length) {
|
||||
throw new Error("Invalid eval price response model mapping");
|
||||
}
|
||||
for (const model of models) {
|
||||
if (!validEvalLedgerId(model) || model === price.model) throw new Error("Invalid eval price response model mapping");
|
||||
}
|
||||
}
|
||||
|
||||
export function matchesPriceResponseModel(price: Pick<EvalModelPrice, "model" | "responseModels">, model: unknown): boolean {
|
||||
return typeof model === "string" && (model === price.model || Array.isArray(price.responseModels) && price.responseModels.includes(model));
|
||||
}
|
||||
|
||||
export function estimateProviderCost(receipt: ProviderUsageReceipt, price: EvalModelPrice): string | null {
|
||||
if (!receipt.complete || receipt.model !== price.model || receipt.provider !== price.provider) return null;
|
||||
if (!receipt.complete || !matchesPriceResponseModel(price, receipt.model) || receipt.provider !== price.provider) return null;
|
||||
const { tokens } = receipt;
|
||||
const totalInput = tokens.input! + tokens.cachedRead! + tokens.cachedWrite!;
|
||||
if (!Number.isSafeInteger(totalInput)) return null;
|
||||
|
||||
Reference in new issue
Block a user